Compare commits

..

16 Commits

Author SHA1 Message Date
Anthony LC 86bde354a5 save1 2026-01-27 17:53:21 +01:00
Anthony LC 8ec31d75d7 save 2026-01-27 17:53:21 +01:00
Anthony LC 2c4c65b05c fixup! ️(frontend) improve prompt of some actions 2026-01-27 17:53:21 +01:00
Anthony LC 610a469a08 fixup! (back) manage streaming with the ai service 2026-01-27 17:53:20 +01:00
Anthony LC c8c58ddbdb fixup! (frontend) integrate new Blocknote AI feature 2026-01-27 17:53:20 +01:00
Anthony LC b6b0748ab3 fixup! ️(frontend) improve prompt of some actions 2026-01-27 17:53:20 +01:00
Anthony LC 79b86b069b fixup! (frontend) integrate new Blocknote AI feature 2026-01-27 17:53:20 +01:00
Manuel Raynaud 96a759400a (back) manage streaming with the ai service
We want to handle both streaming or not when interacting with the AI
backend service.
2026-01-27 17:53:19 +01:00
Anthony LC 0ec06e81d6 test-instance 2026-01-27 17:53:19 +01:00
Anthony LC b90e6271d9 🛂(frontend) bind ai_proxy abilities with AI feature
Bind ai_proxy abilities to the AI feature.
If ai_proxy is false, the AI feature will
not be available.
2026-01-27 17:53:19 +01:00
Anthony LC 980f882f2f 📄(frontend) remove AI feature when MIT
AI feature is under AGPL license, so it is removed
when the project is under MIT license.
NEXT_PUBLIC_PUBLISH_AS_MIT manage this.
2026-01-27 17:53:19 +01:00
Anthony LC 270d87b0a4 🔥(project) remove previous AI feature
We replace the previous AI feature with a new one
that uses the BlockNote AI service.
We can remove the dead codes.
2026-01-27 17:53:18 +01:00
Anthony LC dd68b5a1b3 ️(frontend) improve prompt of some actions
Some answers were a bit too concise or not detailed enough.
Improve some prompts to get better answers from the AI.
2026-01-27 17:53:18 +01:00
Anthony LC 91aa9d6acb 🔧(backend) make frontend ai bot configurable
We make the AI bot configurable with settings.
We will be able to have different AI bot name
per instance.
2026-01-27 17:53:18 +01:00
Anthony LC 08b04dea90 (frontend) integrate new Blocknote AI feature
We integrate the new Blocknote AI feature
into Docs, enhancing the document editing experience
with AI capabilities.
2026-01-27 17:53:17 +01:00
Anthony LC cc7ed88498 (backend) add ai_proxy
Add AI proxy to handle AI related requests
to the AI service.
2026-01-27 17:52:59 +01:00
804 changed files with 9098 additions and 23376 deletions
-3
View File
@@ -1,3 +0,0 @@
CVE-2026-26996
CVE-2026-27903
CVE-2026-27904
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Create empty source files
run: |
touch src/backend/locale/django.pot
@@ -48,7 +48,7 @@ jobs:
CROWDIN_BASE_PATH: "../src/"
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+4 -4
View File
@@ -20,10 +20,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
# Backend i18n
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13.3"
cache: "pip"
@@ -33,7 +33,7 @@ jobs:
run: pip install --user .
working-directory: src/backend
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -49,7 +49,7 @@ jobs:
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
# frontend i18n
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+8 -8
View File
@@ -20,16 +20,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Setup Node.js
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
- name: Install dependencies
@@ -37,7 +37,7 @@ jobs:
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Cache install frontend
if: steps.front-node_modules.outputs.cache-hit != 'true'
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
@@ -50,10 +50,10 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -61,7 +61,7 @@ jobs:
- name: Setup Node.js
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
@@ -79,7 +79,7 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
+127 -43
View File
@@ -5,66 +5,150 @@ on:
workflow_dispatch:
push:
branches:
- "main"
- 'main'
- 'refacto/blocknote-ai'
tags:
- "v*"
- 'v*'
pull_request:
branches:
- "main"
- 'main'
- 'ci/trivy-fails'
env:
DOCKER_USER: 1001:127
SHOULD_PUSH: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
permissions:
contents: read
jobs:
build-and-push-backend:
uses: ./.github/workflows/docker-publish.yml
permissions:
contents: read
secrets: inherit
with:
image_name: lasuite/impress-backend
context: .
file: Dockerfile
target: backend-production
should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
docker_user: 1001:127
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
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
with:
context: .
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-frontend:
uses: ./.github/workflows/docker-publish.yml
permissions:
contents: read
secrets: inherit
with:
image_name: lasuite/impress-frontend
context: .
file: src/frontend/Dockerfile
target: frontend-production
arm64_reuse_amd64_build_arg: "FRONTEND_IMAGE"
should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
docker_user: 1001:127
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-frontend
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
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
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-y-provider:
uses: ./.github/workflows/docker-publish.yml
permissions:
contents: read
secrets: inherit
with:
image_name: lasuite/impress-y-provider
context: .
file: src/frontend/servers/y-provider/Dockerfile
target: y-provider
should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
docker_user: 1001:127
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-y-provider
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-y-provider:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
notify-argocd:
needs:
- build-and-push-backend
- build-and-push-frontend
- build-and-push-y-provider
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
steps:
-142
View File
@@ -1,142 +0,0 @@
name: Build and Push Container Image
description: Build and push a container image based on the input arguments provided
"on":
workflow_call:
inputs:
image_name:
type: string
required: true
description: The suffix for the image name, without the registry and without the repository path.
context:
type: string
required: true
description: The path to the context to start `docker build` into.
file:
type: string
required: true
description: The path to the Dockerfile
target:
type: string
required: false
default: ""
description: The Dockerfile target stage to build the image for.
should_push:
type: boolean
required: false
default: false
description: if the image should be pushed on the docker registry
docker_user:
type: string
required: false
default: ""
description: The docker_user ARGUMENT to pass to the build step
arm64_reuse_amd64_build_arg:
type: string
required: false
default: ""
description: "Build arg name to pass first amd64 tag to arm64 build (skips arch-independent build steps)"
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: ${{ inputs.should_push }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ inputs.image_name }}
- name: Generate platform-specific tags
id: platform-tags
run: |
AMD64_TAGS=$(echo "${{ steps.meta.outputs.tags }}" | sed 's/$/-amd64/')
ARM64_TAGS=$(echo "${{ steps.meta.outputs.tags }}" | sed 's/$/-arm64/')
FIRST_AMD64_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -1)-amd64
{
echo "amd64<<EOF"
echo "$AMD64_TAGS"
echo "EOF"
echo "arm64<<EOF"
echo "$ARM64_TAGS"
echo "EOF"
echo "amd64_first=$FIRST_AMD64_TAG"
} >> "$GITHUB_OUTPUT"
# - name: Run trivy scan
# if: ${{ vars.TRIVY_SCAN_ENABLED }} == 'true'
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: "--target ${{ inputs.target }} -f ${{ inputs.file }}"
# docker-image-name: "docker.io/${{ inputs.image_name }}:${{ github.sha }}"
# trivyignores: ./.github/.trivyignore
- name: Build and push (amd64)
if: ${{ inputs.should_push }}||${{ vars.TRIVY_SCAN_ENABLED }} != 'true'
uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
file: ${{ inputs.file }}
target: ${{ inputs.target }}
platforms: linux/amd64
build-args: |
DOCKER_USER=${{ inputs.docker_user }}
PUBLISH_AS_MIT=false
push: ${{ inputs.should_push }}
provenance: false
tags: ${{ steps.platform-tags.outputs.amd64 }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push (arm64)
if: ${{ inputs.should_push }}
uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
file: ${{ inputs.file }}
target: ${{ inputs.target }}
platforms: linux/arm64
build-args: |
DOCKER_USER=${{ inputs.docker_user }}
PUBLISH_AS_MIT=false
${{ inputs.arm64_reuse_amd64_build_arg && format('{0}={1}', inputs.arm64_reuse_amd64_build_arg, steps.platform-tags.outputs.amd64_first) || '' }}
push: ${{ inputs.should_push }}
provenance: false
tags: ${{ steps.platform-tags.outputs.arm64 }}
labels: ${{ steps.meta.outputs.labels }}
- name: Create multi-arch manifests
if: ${{ inputs.should_push }}
id: create-manifest
run: |
IMAGE="${{ inputs.image_name }}"
readarray -t TAGS <<< "${{ steps.meta.outputs.tags }}"
FIRST_TAG=""
for tag in "${TAGS[@]}"; do
[ -z "$tag" ] && continue
docker buildx imagetools create -t "$tag" \
"${tag}-amd64" "${tag}-arm64"
if [ -z "$FIRST_TAG" ]; then
FIRST_TAG="$tag"
fi
done
# Get the digest of the multi-arch manifest for attestation
# Note: --format '{{.Manifest.Digest}}' is broken (docker/buildx#1175),
# so we compute it from the raw manifest JSON instead.
if [ -n "$FIRST_TAG" ]; then
DIGEST="sha256:$(docker buildx imagetools inspect "$FIRST_TAG" --raw | sha256sum | awk '{print $1}')"
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
fi
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
-157
View File
@@ -1,157 +0,0 @@
name: Build and Push to GHCR
run-name: Build and Push to GHCR
on:
workflow_dispatch:
push:
branches:
- "main"
tags:
- "v*"
env:
DOCKER_USER: 1001:127
REGISTRY: ghcr.io
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
if: github.event.repository.fork == true
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/backend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-frontend:
runs-on: ubuntu-latest
if: github.event.repository.fork == true
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/frontend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}
PUBLISH_AS_MIT=false
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-y-provider:
runs-on: ubuntu-latest
if: github.event.repository.fork == true
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/y-provider
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
-
name: Helmfile lint
shell: bash
+19 -19
View File
@@ -23,15 +23,15 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
@@ -47,14 +47,14 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
@@ -69,15 +69,15 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
@@ -111,15 +111,15 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
@@ -157,11 +157,11 @@ jobs:
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Detect relevant changes
id: changes
uses: dorny/paths-filter@v3
uses: dorny/paths-filter@v2
with:
filters: |
lock:
@@ -170,7 +170,7 @@ jobs:
- 'src/frontend/apps/impress/**'
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
@@ -178,7 +178,7 @@ jobs:
- name: Setup Node.js
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "22.x"
@@ -205,14 +205,14 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
+11 -11
View File
@@ -19,7 +19,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
@@ -27,7 +27,7 @@ jobs:
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- src/backend ':(exclude)**/impress.yml' | grep "print("
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
@@ -46,7 +46,7 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -56,7 +56,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -70,7 +70,7 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install codespell
run: pip install --user codespell
- name: Check for typos
@@ -92,9 +92,9 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13.3"
cache: "pip"
@@ -146,7 +146,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Create writable /data
run: |
@@ -154,7 +154,7 @@ jobs:
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -190,7 +190,7 @@ jobs:
mc version enable impress/impress-media-storage"
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.13.3"
cache: "pip"
@@ -202,7 +202,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
+60 -166
View File
@@ -6,117 +6,19 @@ and this project adheres to
## [Unreleased]
## [v4.8.1] - 2026-03-17
### Added
- 🔧(backend) add DB_PSYCOPG_POOL_ENABLED settings #2035
### Changed
- ⬇️(backend) downgrade django-treebeard to version < 5.0.0 #2036
## [v4.8.0] - 2026-03-13
### Added
- ✨(backend) add a is_first_connection flag to the User model #1938
- ✨(frontend) add onboarding modal with help menu button #1868
### Changed
- ♿(frontend) localize LaGaufre label fallback in Docs #1979
- ✨(backend) add a migration cleaning on-boarding document accesses #1971
- ⬆️(frontend) upgrade Next.js to v16 #1980
- ♿️(frontend) fix aria-label and landmark on document banner state #1986
- 🌐(i18n) add "new window" translation key for waffle aria-label #1984
### Fixed
- 🐛(backend) create a link_trace record for on-boarding documents #1971
- 🐛(backend) manage race condition when creating sandbox document #1971
- 🐛(frontend) fix flickering left panel #1989
- ♿️(frontend) improve doc tree keyboard navigation #1981
- 🔧(helm) allow specific env var for the backend and celery deploy
## [v4.7.0] - 2026-03-09
### Added
- ✨(helm) allow all keys in configMap as env var #1872
### Changed
- 📝(docs) improve README and add documentation hub #1870
- ♿️(frontend) restore focus to triggers after closing menus and modals #1863
- 🚸(frontend) change position elements toolbar #1957
- ♿️(frontend) add focus on open to modals #1948
### Fixed
- 🐛(frontend) analytic feature flags problem #1953
- 🐛(frontend) fix home collapsing panel #1954
- 🐛(frontend) fix disabled color on icon Dropdown #1950
- 🐛(frontend) fix zIndex table of content #1949
- 🐛(frontend) fix bug when language not supported by BN #1957
- 🐛 (backend) prevent privileged users from requesting access #1898
## [v4.6.0] - 2026-03-03
### Added
- ✨(frontend) integrate new Blocknote AI feature #1847
- 👷(docker) add arm64 platform support for image builds #1901
- ✨(tracking) add UTM parameters to shared document links #1896
- ✨(frontend) add floating bar with leftpanel collapse button #1876
- ✨(frontend) Can print a doc #1832
- ✨(backend) manage reconciliation requests for user accounts #1878
- 👷(CI) add GHCR workflow for forked repo testing #1851
- ✨(frontend) Move doc modal #1886
- ⚡️(backend) remove content from Document serializer when asked #1910
- ✨(backend) allow the duplication of subpages #1893
- ✨(backend) Onboarding docs for new users #1891
- 🩺(trivy) add trivyignore file and add minimatch CVE #1915
- 🚩 Add feature flags for the AI feature #1922
- 🍱(frontend) add icons ui-kit #1943
### Changed
- ♿️(frontend) prevent dates from being focusable #1855
- ♿️(frontend) Focus main container after navigation #1864
- 💄(frontend) align colors and logo with ui-kit v2 #1869
- 🚸(backend) sort user search results by proximity with the active user #1802
- 🚸(oidc) ignore case when fallback on email #1880
- ⚡️(CI) optimize Docker Hub workflow #1919
### Fixed
- 🐛(frontend) fix broadcast store sync #1846
- 🐛(helm) use celery resources instead of backend resources #1887
- 🐛(helm) reverse liveness and readiness for backend deployment #1887
- 🐛(y-provider) use CONVERSION_FILE_MAX_SIZE settings #1913
- 🐛(frontend) fix callout block spacing for old browsers #1914
## [v4.5.0] - 2026-01-28
### Added
### Added
- ✨(frontend) integrate configurable Waffle #1795
- ✨ Import of documents #1609
- 🚨(CI) gives warning if theme not updated #1811
- ✨(frontend) Add stat for Crisp #1824
- ✨(auth) add silent login #1690
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
- ✨(frontend) activate Find search #1834
- ✨ handle searching on subdocuments #1834
- ✨(backend) add search feature flags #1897
- ✨(frontend) integrate new Blocknote AI feature #1016
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
- ✨(backend) add field for button label in email template #1817
- ✨(backend) improve fallback logic on search endpoint #1834
### Fixed
@@ -125,19 +27,16 @@ and this project adheres to
- 🐛(frontend) add fallback for unsupported Blocknote languages #1810
- 🐛(frontend) fix emojipicker closing in tree #1808
- 🐛(frontend) display children in favorite #1782
- 🐛(frontend) preserve typed text after @ on escape #1833
### Removed
- 🔥(project) remove all code related to template #1780
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
### Security
- 🔒️(trivy) fix vulnerability about jaraco.context #1806
## [v4.4.0] - 2026-01-13
## [4.4.0] - 2026-01-13
### Added
@@ -165,7 +64,7 @@ and this project adheres to
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
## [v4.3.0] - 2026-01-05
## [4.3.0] - 2026-01-05
### Added
@@ -184,7 +83,7 @@ and this project adheres to
- 🐛(frontend) fix tables deletion #1739
- 🐛(frontend) fix children not display when first resize #1753
## [v4.2.0] - 2025-12-17
## [4.2.0] - 2025-12-17
### Added
@@ -208,7 +107,7 @@ and this project adheres to
- 🐛(frontend) Select text + Go back one page crash the app #1733
- 🐛(frontend) fix versioning conflict #1742
## [v4.1.0] - 2025-12-09
## [4.1.0] - 2025-12-09
### Added
@@ -227,7 +126,7 @@ and this project adheres to
- 🐛(nginx) fix / location to handle new static pages #1682
- 🐛(frontend) rerendering during resize window #1715
## [v4.0.0] - 2025-12-01
## [4.0.0] - 2025-12-01
### Added
@@ -250,7 +149,7 @@ and this project adheres to
- 🐛(frontend) preserve left panel width on window resize #1588
- 🐛(frontend) prevent duplicate as first character in title #1595
## [v3.10.0] - 2025-11-18
## [3.10.0] - 2025-11-18
### Added
@@ -284,7 +183,7 @@ and this project adheres to
- 🔥(backend) remove api managing templates
## [v3.9.0] - 2025-11-10
## [3.9.0] - 2025-11-10
### Added
@@ -310,13 +209,13 @@ and this project adheres to
- 🐛(frontend) button new doc UI fix #1557
- 🐛(frontend) interlinking UI fix #1557
## [v3.8.2] - 2025-10-17
## [3.8.2] - 2025-10-17
### Fixed
- 🐛(service-worker) fix sw registration and page reload logic #1500
## [v3.8.1] - 2025-10-17
## [3.8.1] - 2025-10-17
### Fixed
@@ -330,7 +229,7 @@ and this project adheres to
- 🔥(backend) remove treebeard form for the document admin #1470
## [v3.8.0] - 2025-10-14
## [3.8.0] - 2025-10-14
### Added
@@ -342,7 +241,7 @@ and this project adheres to
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
- ♻️(frontend) replace Arial font-family with token font #1411
- ♿(frontend) improve accessibility:
- ♿(frontend) enable enter key to open documents #1354
- ♿(frontend) enable enter key to open documentss #1354
- ♿(frontend) improve modal a11y: structure, labels, title #1349
- ♿improve NVDA navigation in DocShareModal #1396
- ♿ improve accessibility by adding landmark roles to layout #1394
@@ -383,7 +282,7 @@ and this project adheres to
- 🔥(frontend) remove custom DividerBlock ##1375
## [v3.7.0] - 2025-09-12
## [3.7.0] - 2025-09-12
### Added
@@ -415,7 +314,7 @@ and this project adheres to
- 🐛(frontend) fix callout emoji list #1366
## [v3.6.0] - 2025-09-04
## [3.6.0] - 2025-09-04
### Added
@@ -451,7 +350,7 @@ and this project adheres to
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
## [v3.5.0] - 2025-07-31
## [3.5.0] - 2025-07-31
### Added
@@ -479,7 +378,7 @@ and this project adheres to
- 🐛(frontend) 401 redirection overridden #1214
- 🐛(frontend) include root parent in search #1243
## [v3.4.2] - 2025-07-18
## [3.4.2] - 2025-07-18
### Changed
@@ -489,7 +388,7 @@ and this project adheres to
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
## [v3.4.1] - 2025-07-15
## [3.4.1] - 2025-07-15
### Fixed
@@ -500,7 +399,7 @@ and this project adheres to
- 🐛(frontend) fix crash share modal on grid options #1174
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
## [v3.4.0] - 2025-07-09
## [3.4.0] - 2025-07-09
### Added
@@ -544,16 +443,16 @@ and this project adheres to
- 🔥(frontend) remove Beta from logo #1095
## [v3.3.0] - 2025-05-06
## [3.3.0] - 2025-05-06
### Added
- ✨(backend) add endpoint checking media status #984
- ✨(backend) allow setting session cookie age via env var #977
- ✨(backend) allow theme customization using a configuration file #948
- ✨(backend) allow theme customnization using a configuration file #948
- ✨(frontend) Add a custom callout block to the editor #892
- 🚩(frontend) version MIT only #911
- ✨(backend) integrate malware_detection from django-lasuite #936
- ✨(backend) integrate maleware_detection from django-lasuite #936
- 🏗️(frontend) Footer configurable #959
- 🩺(CI) add lint spell mistakes #954
- ✨(frontend) create generic theme #792
@@ -576,14 +475,14 @@ and this project adheres to
- 🔥(back) remove footer endpoint #948
## [v3.2.1] - 2025-05-06
## [3.2.1] - 2025-05-06
## Fixed
- 🐛(frontend) fix list copy paste #943
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
## [v3.2.0] - 2025-05-05
## [3.2.0] - 2025-05-05
## Added
@@ -610,7 +509,7 @@ and this project adheres to
- 🐛(backend) race condition create doc #633
- 🐛(frontend) fix breaklines in custom blocks #908
## [v3.1.0] - 2025-04-07
## [3.1.0] - 2025-04-07
## Added
@@ -628,7 +527,7 @@ and this project adheres to
- 🐛(back) validate document content in serializer #822
- 🐛(frontend) fix selection click past end of content #840
## [v3.0.0] - 2025-03-28
## [3.0.0] - 2025-03-28
## Added
@@ -644,7 +543,7 @@ and this project adheres to
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801
## [v2.6.0] - 2025-03-21
## [2.6.0] - 2025-03-21
## Added
@@ -662,7 +561,7 @@ and this project adheres to
- 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [v2.5.0] - 2025-03-18
## [2.5.0] - 2025-03-18
## Added
@@ -692,7 +591,7 @@ and this project adheres to
- 🚨(helm) fix helmfile lint #736
- 🚚(frontend) redirect to 401 page when 401 error #759
## [v2.4.0] - 2025-03-06
## [2.4.0] - 2025-03-06
## Added
@@ -706,7 +605,7 @@ and this project adheres to
- 🐛(frontend) fix collaboration error #684
## [v2.3.0] - 2025-03-03
## [2.3.0] - 2025-03-03
## Added
@@ -733,7 +632,7 @@ and this project adheres to
- ♻️(frontend) improve table pdf rendering
- 🐛(email) invitation emails in receivers language
## [v2.2.0] - 2025-02-10
## [2.2.0] - 2025-02-10
## Added
@@ -752,7 +651,7 @@ and this project adheres to
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
## [v2.1.0] - 2025-01-29
## [2.1.0] - 2025-01-29
## Added
@@ -781,14 +680,14 @@ and this project adheres to
- 🔥(backend) remove "content" field from list serializer # 516
## [v2.0.1] - 2025-01-17
## [2.0.1] - 2025-01-17
## Fixed
-🐛(frontend) share modal is shown when you don't have the abilities #557
-🐛(frontend) title copy break app #564
## [v2.0.0] - 2025-01-13
## [2.0.0] - 2025-01-13
## Added
@@ -819,7 +718,7 @@ and this project adheres to
- 🐛(frontend) hide search and create doc button if not authenticated #555
- 🐛(backend) race condition creation issue #556
## [v1.10.0] - 2024-12-17
## [1.10.0] - 2024-12-17
## Added
@@ -840,7 +739,7 @@ and this project adheres to
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [v1.9.0] - 2024-12-11
## [1.9.0] - 2024-12-11
## Added
@@ -861,19 +760,19 @@ and this project adheres to
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [v1.8.2] - 2024-11-28
## [1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
## [v1.8.1] - 2024-11-27
## [1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [v1.8.0] - 2024-11-25
## [1.8.0] - 2024-11-25
## Added
@@ -901,7 +800,7 @@ and this project adheres to
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [v1.7.0] - 2024-10-24
## [1.7.0] - 2024-10-24
## Added
@@ -928,7 +827,7 @@ and this project adheres to
- 🔥(helm) remove infra related codes #366
## [v1.6.0] - 2024-10-17
## [1.6.0] - 2024-10-17
## Added
@@ -950,13 +849,13 @@ and this project adheres to
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [v1.5.1] - 2024-10-10
## [1.5.1] - 2024-10-10
## Fixed
- 🐛(db) fix users duplicate #316
## [v1.5.0] - 2024-10-09
## [1.5.0] - 2024-10-09
## Added
@@ -984,7 +883,7 @@ and this project adheres to
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [v1.4.0] - 2024-09-17
## [1.4.0] - 2024-09-17
## Added
@@ -1004,7 +903,7 @@ and this project adheres to
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [v1.3.0] - 2024-09-05
## [1.3.0] - 2024-09-05
## Added
@@ -1028,14 +927,14 @@ and this project adheres to
- 🔥(frontend) remove saving modal #213
## [v1.2.1] - 2024-08-23
## [1.2.1] - 2024-08-23
## Changed
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [v1.2.0] - 2024-08-22
## [1.2.0] - 2024-08-22
## Added
@@ -1061,7 +960,7 @@ and this project adheres to
- 🔥(helm) remove htaccess #181
## [v1.1.0] - 2024-07-15
## [1.1.0] - 2024-07-15
## Added
@@ -1076,7 +975,7 @@ and this project adheres to
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [v1.0.0] - 2024-07-02
## [1.0.0] - 2024-07-02
## Added
@@ -1114,19 +1013,14 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [v0.1.0] - 2024-05-24
## [0.1.0] - 2024-05-24
## Added
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.1...main
[v4.8.1]: https://github.com/suitenumerique/docs/releases/v4.8.1
[v4.8.0]: https://github.com/suitenumerique/docs/releases/v4.8.0
[v4.7.0]: https://github.com/suitenumerique/docs/releases/v4.7.0
[v4.6.0]: https://github.com/suitenumerique/docs/releases/v4.6.0
[v4.5.0]: https://github.com/suitenumerique/docs/releases/v4.5.0
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.4.0...main
[v4.4.0]: https://github.com/suitenumerique/docs/releases/v4.4.0
[v4.3.0]: https://github.com/suitenumerique/docs/releases/v4.3.0
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
@@ -1163,12 +1057,12 @@ and this project adheres to
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
[v1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
[v1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
[v1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
[v1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
[v1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
[v1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
[v1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
[v1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
[v0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
+2 -2
View File
@@ -95,8 +95,8 @@ Thank you for your contributions! 👍
## Contribute to BlockNote
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
If you find an issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
The project is licensed with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licensed with GNU AFFERO GENERAL PUBLIC LICENSE Version 3 and proprietary license if you are a [sponsor](https://www.blocknotejs.org/pricing).
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing).
+18 -24
View File
@@ -14,6 +14,13 @@ FROM base AS back-builder
WORKDIR /builder
# Install Rust and Cargo using Alpine's package manager
RUN apk add --no-cache \
build-base \
libffi-dev \
rust \
cargo
# Copy required python dependencies
COPY ./src/backend /builder
@@ -29,7 +36,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \
yarn build
yarn build
# ---- static link collector ----
@@ -51,7 +58,7 @@ WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
@@ -74,7 +81,7 @@ RUN apk add --no-cache \
pango \
shared-mime-info
RUN wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -91,9 +98,9 @@ COPY --from=back-builder /install /usr/local
# when python is upgraded and the path to the certificate changes.
# The space between print and the ( is intended otherwise the git lint is failing
RUN mkdir /cert && \
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
@@ -102,7 +109,7 @@ WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that
@@ -131,7 +138,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services
# name) & port
ENV DB_HOST=postgresql \
DB_PORT=5432
DB_PORT=5432
# Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
@@ -144,7 +151,7 @@ RUN rm -rf /var/cache/apk/*
ARG IMPRESS_STATIC_ROOT=/data/static
# Gunicorn - not used by default but configuration file is provided
# Gunicorn
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/impress.py /usr/local/etc/gunicorn/impress.py
@@ -158,18 +165,5 @@ COPY --from=link-collector ${IMPRESS_STATIC_ROOT} ${IMPRESS_STATIC_ROOT}
# Copy impress mails
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
# The default command runs uvicorn ASGI server in dics's main module
# WEB_CONCURRENCY: number of workers to run <=> --workers=4
ENV WEB_CONCURRENCY=4
CMD [\
"uvicorn",\
"--app-dir=/app",\
"--host=0.0.0.0",\
"--timeout-graceful-shutdown=300",\
"--limit-max-requests=20000",\
"--lifespan=off",\
"impress.asgi:application"\
]
# To run using gunicorn WSGI server use this instead:
#CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/conversations.py", "impress.wsgi:application"]
# The default command runs gunicorn WSGI server in impress's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/impress.py", "impress.wsgi:application"]
+1 -7
View File
@@ -79,16 +79,10 @@ create-env-local-files:
@touch env.d/development/kc_postgresql.local
.PHONY: create-env-local-files
generate-secret-keys:
generate-secret-keys: ## generate secret keys to be stored in common.local
@bin/generate-oidc-store-refresh-token-key.sh
.PHONY: generate-secret-keys
pre-bootstrap: \
data/media \
data/static \
create-env-local-files \
generate-secret-keys
create-env-local-files
.PHONY: pre-bootstrap
post-bootstrap: \
+136 -153
View File
@@ -3,243 +3,226 @@
<img alt="Docs" src="/docs/assets/banner-docs.png" width="100%" />
</a>
</p>
<p align="center">
<a href="https://github.com/suitenumerique/docs/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/docs" alt="">
</a>
<a href="https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md">
<img alt="PRs Welcome" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg"/>
</a>
<a href='https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md'><img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/></a>
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/>
<a href="https://github.com/suitenumerique/docs/blob/main/LICENSE">
<img alt="MIT License" src="https://img.shields.io/github/license/suitenumerique/docs"/>
</a>
</p>
<p align="center">
<a href="https://matrix.to/#/#docs-official:matrix.org">
Chat on Matrix
</a> - <a href="/docs/">
Documentation
</a> - <a href="#getting-started-">
Getting started
</a> - <a href="mailto:docs@numerique.gouv.fr">
Reach out
</a>
</p>
<p align="center">
<a href="https://matrix.to/#/#docs-official:matrix.org">Chat on Matrix</a> •
<a href="/docs/">Documentation</a> •
<a href="#try-docs">Try Docs</a> •
<a href="mailto:docs@numerique.gouv.fr">Contact us</a>
</p>
# La Suite Docs : Collaborative Text Editing
Docs, where your notes can become knowledge through live collaboration.
# La Suite Docs: Collaborative Text Editing
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
**Docs, where your notes can become knowledge through live collaboration.**
## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
Docs is an open-source collaborative editor that helps teams write, organize, and share knowledge together - in real time.
### Write
* 😌 Get simple, accessible online editing for your team.
* 💅 Create clean documents with beautiful formatting options.
* 🖌️ Focus on your content using either the in-line editor, or [the Markdown syntax](https://www.markdownguide.org/basic-syntax/).
* 🧱 Quickly design your page thanks to the many block types, accessible from the `/` slash commands, as well as keyboard shortcuts.
* 🔌 Write offline! Your edits will be synced once you're back online.
* ✨ Save time thanks to our AI actions, such as rephrasing, summarizing, fixing typos, translating, etc. You can even turn your selected text into a prompt!
![Live collaboration demo](/docs/assets/docs_live_collaboration_light.gif)
### Work together
* 🤝 Enjoy live editing! See your team collaborate in real time.
* 🔒 Keep your information secure thanks to granular access control. Only share with the right people.
* 📑 Export your content in multiple formats (`.odt`, `.docx`, `.pdf`) with customizable templates.
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
### Self-host
## What is Docs?
#### 🚀 Docs is easy to install on your own servers
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
Docs is an open-source alternative to tools like Notion or Google Docs, focused on:
#### 🌍 Known instances
We hope to see many more, here is an incomplete list of public Docs instances. Feel free to make a PR to add ones that are not listed below🙏
- Real-time collaboration
- Clean, structured documents
- Knowledge organization
- Data ownership & self-hosting
| Url | Org | Public |
| --- | --- | ------- |
| [docs.numerique.gouv.fr](https://docs.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [docs.suite.anct.gouv.fr](https://docs.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [notes.demo.opendesk.eu](https://notes.demo.opendesk.eu) | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| [notes.liiib.re](https://notes.liiib.re/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [docs.federated.nexus](https://docs.federated.nexus/) | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
| [docs.demo.mosacloud.eu](https://docs.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
***Built for public organizations, companies, and open communities.***
#### ⚠️ Advanced features
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
## Why use Docs?
## Getting started 🔧
### Writing
### Test it
- Rich-text & Markdown editing
- Slash commands & block system
- Beautiful formatting
- Offline editing
- Optional AI writing helpers (rewrite, summarize, translate, fix typos)
You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
### Collaboration
### Run Docs locally
- Live cursors & presence
- Comments & sharing
- Granular access control
> ⚠️ The methods described below for running Docs locally is **for testing purposes only**. It is based on building Docs using [Minio](https://min.io/) as an S3-compatible storage solution. Of course you can choose any S3-compatible storage solution.
### Knowledge management
**Prerequisite**
- Subpages & hierarchy
- Searchable content
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop, then type:
### Export/Import & interoperability
```shellscript
$ docker -v
- Import to `.docx` and `.md`
- Export to `.docx`, `.odt`, `.pdf`
Docker version 20.10.2, build 2291f61
## Try Docs
$ docker compose version
Experience Docs instantly - no installation required.
- 🔗 [Open a live demo document][demo]
- 🌍 [Browse public instances][instances]
[demo]: https://docs.la-suite.eu/docs/9137bbb5-3e8a-4ff7-8a36-fcc4e8bd57f4/
[instances]: /docs/instances.md
## Self-hosting
Docs supports Kubernetes, Docker Compose, and community-provided methods such as Nix and YunoHost.
Get started with self-hosting: [Installation guide](/docs/installation/README.md)
> [!WARNING]
> Some advanced features (for example: `Export as PDF`) rely on XL packages from Blocknote.
> These packages are licensed under GPL and are **not MIT-compatible**
>
> You can run Docs **without these packages** by building with:
>
> ```bash
> PUBLISH_AS_MIT=true
> ```
>
> This builds an image of Docs without non-MIT features.
>
> More details can be found in [environment variables](/docs/env.md)
## Local Development (for contributors)
Run Docs locally for development and testing.
> [!WARNING]
> This setup is intended **for development and testing only**.
> It uses Minio as an S3-compatible storage backend, but any S3-compatible service can be used.
### Prerequisites
- Docker
- Docker Compose
- GNU Make
Verify installation:
```bash
docker -v
docker compose version
Docker Compose version v2.32.4
```
> If you encounter permission errors, you may need to use `sudo`, or add your user to the `docker` group.
> ⚠️ You may need to run the following commands with `sudo`, but this can be avoided by adding your user to the local `docker` group.
### Bootstrap the project
**Project bootstrap**
The easiest way to start is using GNU Make:
The easiest way to start working on the project is to use [GNU Make](https://www.gnu.org/software/make/):
```bash
make bootstrap FLUSH_ARGS='--no-input'
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
This builds the `app-dev` and `frontend-dev` containers, installs dependencies, runs database migrations, and compiles translations.
This command builds the `app-dev` and `frontend-dev` containers, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
It is recommended to run this command after pulling new code.
Your Docker services should now be up and running 🎉
Start services:
You can access the project by going to <http://localhost:3000>.
You will be prompted to log in. The default credentials are:
```bash
make run
```
Open <https://localhost:3000>
Default credentials (development only):
```md
username: impress
password: impress
```
### Frontend development mode
📝 Note that if you need to run them afterwards, you can use the eponymous Make rule:
For frontend work, running outside Docker is often more convenient:
```bash
make frontend-development-install
make run-frontend-development
```shellscript
$ make run
```
### Backend only
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
Starting all services except the frontend container:
To do so, install the frontend dependencies with the following command:
```bash
make run-backend
```shellscript
$ make frontend-development-install
```
### Tests & Linting
And run the frontend locally in development mode with the following command:
```bash
make frontend-test
make frontend-lint
```shellscript
$ make run-frontend-development
```
Backend tests can be run without docker. This is useful to configure PyCharm or VSCode to do it.
Removing docker for testing requires to overwrite some URL and port values that are different in and out of
Docker. `env.d/development/common` contains all variables, some of them having to be overwritten by those in
`env.d/development/common.test`.
To start all the services, except the frontend container, you can use the following command:
### Demo content
Create a basic demo site:
```bash
make demo
```shellscript
$ make run-backend
```
### More Make targets
To check all available Make rules:
```bash
make help
To execute frontend tests & linting only
```shellscript
$ make frontend-test
$ make frontend-lint
```
### Django admin
**Adding content**
Create a superuser:
You can create a basic demo site by running this command:
```bash
make superuser
```shellscript
$ make demo
```
Admin UI: <http://localhost:8071/admin>
Finally, you can check all available Make rules using this command:
## Contributing
```shellscript
$ make help
```
This project is community-driven and PRs are welcome.
**Django admin**
- [Contribution guide](CONTRIBUTING.md)
- [Translations](https://crowdin.com/project/lasuite-docs)
- [Chat with us!](https://matrix.to/#/#docs-official:matrix.org)
You can access the Django admin site at:
## Roadmap
<http://localhost:8071/admin>.
Curious where Docs is headed?
You first need to create a superuser account:
Explore upcoming features, priorities and long-term direction on our [public roadmap](https://docs.numerique.gouv.fr/docs/d1d3788e-c619-41ff-abe8-2d079da2f084/).
```shellscript
$ make superuser
```
## Feedback 🙋‍♂️🙋‍♀️
We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
## Roadmap 💡
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## License 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
While Docs is a public-driven initiative, our license choice is an invitation for private sector actors to use, sell and contribute to the project.
While Docs is a public-driven initiative, our license choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
If you intend to make pull requests, see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
## Directory structure:
```markdown
docs
├── bin - executable scripts or binaries that are used for various tasks, such as setup scripts, utility scripts, or custom commands.
├── crowdin - for crowdin translations, a tool or service that helps manage translations for the project.
├── docker - Dockerfiles and related configuration files used to build Docker images for the project. These images can be used for development, testing, or production environments.
├── docs - documentation for the project, including user guides, API documentation, and other helpful resources.
├── env.d/development - environment-specific configuration files for the development environment. These files might include environment variables, configuration settings, or other setup files needed for development.
├── gitlint - configuration files for `gitlint`, a tool that enforces commit message guidelines to ensure consistency and quality in commit messages.
├── playground - experimental or temporary code, where developers can test new features or ideas without affecting the main codebase.
└── src - main source code directory, containing the core application code, libraries, and modules of the project.
```
## Credits ❤️
### Stack
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [ProseMirror](https://prosemirror.net/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction), and [Yjs](https://yjs.dev/). We thank the contributors of all these projects for their awesome work!
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/). We thank the contributors of all these projects for their awesome work!
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
---
### Gov ❤️ open source
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
Docs is the result of a joint initiative led by the French 🇫🇷 ([DINUM](https://www.numerique.gouv.fr/dinum/)) Government and German 🇩🇪 government ([ZenDiS](https://zendis.de/)).
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱), feel free to [contact us](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
<p align="center">
<img src="/docs/assets/europe_opensource.png" width="50%"/ alt="Europe Opensource">
<img src="/docs/assets/europe_opensource.png" width="50%"/>
</p>
+1 -7
View File
@@ -16,12 +16,6 @@ the following command inside your docker container:
## [Unreleased]
## [4.6.0] - 2026-02-27
- ⚠️ Some setup have changed to offer a bigger flexibility and consistency, overriding the favicon and logo are now from the theme configuration.
https://github.com/suitenumerique/docs/blob/f24b047a7cc146411412bf759b5b5248a45c3d99/src/backend/impress/configuration/theme/default.json#L129-L161
## [4.0.0] - 2025-11-26
- ⚠️ We updated `@gouvfr-lasuite/ui-kit` to `0.18.0`, so if you are customizing Docs with a css layer or with a custom template, you need to update your customization to follow the new design system structure.
@@ -68,5 +62,5 @@ service.
- AI features are now limited to users who are authenticated. Before this release, even anonymous
users who gained editor access on a document with link reach used to get AI feature.
If you want anonymous users to keep access on AI features, you must now define the
IF you want anonymous users to keep access on AI features, you must now define the
`AI_ALLOW_REACH_FROM` setting to "public".
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
# Generate the secret OIDC_STORE_REFRESH_TOKEN_KEY and store it to common.local
set -eo pipefail
COMMON_LOCAL="env.d/development/common.local"
OIDC_STORE_REFRESH_TOKEN_KEY=$(openssl rand -base64 32)
echo "" >> "${COMMON_LOCAL}"
echo "OIDC_STORE_REFRESH_TOKEN_KEY=${OIDC_STORE_REFRESH_TOKEN_KEY}" >> "${COMMON_LOCAL}"
echo "✓ OIDC_STORE_REFRESH_TOKEN_KEY generated and stored in ${COMMON_LOCAL}"
-39
View File
@@ -1,39 +0,0 @@
# Docs Documentation
Welcome to the official documentation for Docs.
This documentation is organized by topic and audience.
Use the section below to quickly find what you are looking for.
---
## Table of Contents
- Getting started
- [System requirements](system-requirements.md)
- [Installation overview](installation/README.md)
- [Docker Compose deployment](installation/compose.md)
- [Docker Compose examples](examples/compose/)
- [Kubernetes deployment](installation/kubernetes.md)
- [Helm values examples](examples/helm/)
- Configuration
- [Environment variables](env.md)
- [Customization](customization.md)
- [Language configuration](languages-configuration.md)
- [Search configuration](search.md)
- Architecture & design
- [Architecture overview](architecture.md)
- [Architectural Decision Records (ADR)](adr/)
- Usage & operations
- [Public instances](instances.md)
- [Releases & upgrades](release.md)
- [Troubleshooting](troubleshoot.md)
- Project & product
- [Roadmap](roadmap.md)
- Assets
- [Branding & visuals](assets/)
+22 -37
View File
@@ -7,29 +7,24 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_BOT | Information to give to the frontend about the AI bot | { "name": "Docs AI", "color": "#8bc6ff" }
| AI_FEATURE_ENABLED | Enable AI options | false |
| AI_FEATURE_BLOCKNOTE_ENABLED | Enable Blocknote AI options | false |
| AI_FEATURE_LEGACY_ENABLED | Enable legacyAI options | true |
| AI_MODEL | AI Model to use | |
| AI_VERCEL_SDK_VERSION | The vercel AI SDK version used | 6 |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| API_USERS_SEARCH_QUERY_MIN_LENGTH | Minimum characters to insert to search a user | 3 |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_S3_SIGNATURE_VERSION | S3 signature version (`s3v4` or `s3`) | s3v4 |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
@@ -39,17 +34,13 @@ These are the environment variables you can set for the `impress-backend` contai
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CONVERSION_FILE_MAX_SIZE | The file max size allowed when uploaded to convert it | 20971520 (20MB) |
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"] |
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"]
| CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost |
| DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 |
| DB_PSYCOPG_POOL_ENABLED | Enable or not the psycopg pool configuration in the default database options | False |
| DB_PSYCOPG_POOL_MIN_SIZE | The psycopg min pool size | 4 |
| DB_PSYCOPG_POOL_MAX_SIZE | The psycopg max pool size | None |
| DB_PSYCOPG_POOL_TIMEOUT | The default maximum time in seconds that a client can wait to receive a connection from the pool | 3 |
| DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
@@ -66,22 +57,22 @@ These are the environment variables you can set for the `impress-backend` contai
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None |
| LANGFUSE_PUBLIC_KEY | The Langfuse public key used by the sdk | None |
| LANGFUSE_BASE_URL | The Langfuse base url used by the sdk | None |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
@@ -108,9 +99,6 @@ These are the environment variables you can set for the `impress-backend` contai
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_STORE_ACCESS_TOKEN | If True stores OIDC access token in session. | false |
| OIDC_STORE_REFRESH_TOKEN | If True stores OIDC refresh token in session. | false |
| OIDC_STORE_REFRESH_TOKEN_KEY | Key to encrypt refresh token stored in session, must be a valid Fernet key | |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
@@ -120,9 +108,8 @@ These are the environment variables you can set for the `impress-backend` contai
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
| SEARCH_URL | Find application endpoint for search queries | |
| SEARCH_INDEXER_SECRET | Token required for indexation queries | |
| INDEXING_URL | Find application endpoint for indexation | |
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False |
@@ -132,12 +119,10 @@ These are the environment variables you can set for the `impress-backend` contai
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| USER_ONBOARDING_DOCUMENTS | A list of documents IDs for which a read-only access will be created for new s | [] |
| USER_ONBOARDING_SANDBOX_DOCUMENT | ID of a template sandbox document that will be duplicated for new users | |
| USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image
These are the environment variables you can set to build the `impress-frontend` image.
@@ -148,31 +133,31 @@ If you want to build the Docker image, this variable is used as an argument in t
Example:
```bash
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest
```
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest
```
If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/impress/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_` (for example, `NEXT_PUBLIC_PUBLISH_AS_MIT=false`).
Example:
```bash
```
cd src/frontend/apps/impress
NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
```
| Option | Description | default |
| -------------- | ---------------------------------------------------------------------------------- | ------- |
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
| SW_DEACTIVATED | To not install the service worker | |
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
| Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
| SW_DEACTIVATED | To not install the service worker | |
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
-1
View File
@@ -67,7 +67,6 @@ backend:
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: docs-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
+1 -1
View File
@@ -13,7 +13,7 @@ Please follow the instructions [here](/docs/installation/compose.md).
⚠️ Please keep in mind that we do not use it ourselves in production. Let us know in the issues if you run into troubles, we'll try to help.
## Other ways to install Docs
Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are too many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance.
Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are two many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance.
Here is the list of other methods in alphabetical order:
- Coop-Cloud: [code](https://git.coopcloud.tech/coop-cloud/lasuite-docs)
+3 -5
View File
@@ -134,12 +134,10 @@ DJANGO_EMAIL_URL_APP=<url used in email templates to go to the app> # e.g. "http
Built-in AI actions let users generate, summarize, translate, and correct content.
AI is disabled by default. To enable it, the following environment variables must be set in `env.d/backend`:
AI is disabled by default. To enable it, the following environment variables must be set in in `env.d/backend`:
```env
AI_FEATURE_ENABLED=true # is false by default
AI_FEATURE_BLOCKNOTE_ENABLED=true # is false by default
AI_FEATURE_LEGACY_ENABLED=true # is true by default, AI_FEATURE_ENABLED must be set to true to enable it
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=<API key>
AI_MODEL=<model used> e.g. llama
@@ -152,7 +150,7 @@ You can [customize your Docs instance](../theming.md) with your own theme and cu
The following environment variables must be set in `env.d/backend`:
```env
FRONTEND_THEME=default # name of your theme built with Cunningham
FRONTEND_THEME=default # name of your theme built with cuningham
FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css # custom css
```
@@ -206,7 +204,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
Your docs instance is now available on the domain you defined, https://docs.yourdomain.tld.
The admin interface is available on https://docs.yourdomain.tld/admin with the admin user you just created.
THe admin interface is available on https://docs.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Docs application
+1 -1
View File
@@ -250,4 +250,4 @@ minio-dev-backend-minio-api <none> docs-minio.127.0.0.1.nip.io
minio-dev-backend-minio-console <none> docs-minio-console.127.0.0.1.nip.io localhost 80, 443 8m48s
```
You can use Docs at https://docs.127.0.0.1.nip.io. The provisioning user in keycloak is docs/docs.
You can use Docs at https://docs.127.0.0.1.nip.io. The provisionning user in keycloak is docs/docs.
-77
View File
@@ -1,77 +0,0 @@
# 🌍 Public Docs Instances
This page lists known public instances of **Docs**.
These instances are operated by different organizations and may have different access policies.
If you run a public instance and would like it listed here, feel free to open a pull request.
---
## 🏛️ Public Organizations
### docs.numerique.gouv.fr
**Organization:** DINUM
**Audience:** French public agents working for central administration and extended public sphere
**Access:** ProConnect account required
<https://docs.numerique.gouv.fr/>
### docs.suite.anct.gouv.fr
**Organization:** ANCT
**Audience:** French public agents working for territorial administration and extended public sphere
**Access:** ProConnect account required
<https://docs.suite.anct.gouv.fr/>
### notes.demo.opendesk.eu
**Organization:** ZenDiS
**Type:** OpenDesk demo instance
**Access:** Request credentials
<https://notes.demo.opendesk.eu/>
---
## 🏢 Private Sector
### docs.demo.mosacloud.eu
**Organization:** mosa.cloud
**Type:** Demo instance
<https://docs.demo.mosacloud.eu/>
### notes.liiib.re
**Organization:** lasuite.coop
**Access:** Public demo
**Notes:** Content and accounts reset monthly
<https://notes.liiib.re/>
### notes.lasuite.coop
**Organization:** lasuite.coop
**Access:** Public
<https://notes.lasuite.coop/>
---
## 🤝 NGOs
### docs.federated.nexus
**Organization:** federated.nexus
**Access:** Public with account registration
<https://docs.federated.nexus/>
---
## Add your instance
To add your instance:
1. Fork the repository
2. Edit `docs/instances.md`
3. Add your instance following the existing format
4. Open a pull request
Thank you for helping grow the Docs ecosystem ❤️
+18 -29
View File
@@ -1,8 +1,8 @@
# Setup Find search for Docs
# Setup the Find search for Impress
This configuration will enable Find searches:
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexing of the document into Find.
- The `api/v1.0/documents/search/` will be used as proxy for searching documents from Find indexes.
This configuration will enable the fulltext search feature for Docs :
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
## Create an index service for Docs
@@ -15,38 +15,27 @@ See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
## Configure settings of Docs
Find uses a service provider authentication for indexing and a OIDC authentication for searching.
Add those Django settings to the Docs application to enable the feature.
Add those Django settings the Docs application to enable the feature.
```shell
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
SEARCH_INDEXER_QUERY_LIMIT=50 # Maximum number of results expected from the search endpoint
INDEXING_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_URL="http://find:8000/api/v1.0/documents/search/"
# Service provider authentication
# The token from service "docs" of Find application (development).
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
# OIDC authentication
OIDC_STORE_ACCESS_TOKEN=True # Store the access token in the session
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session
OIDC_STORE_REFRESH_TOKEN_KEY="<your-32-byte-encryption-key==>"
# Search endpoint. Uses the OIDC token for authentication
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
# Maximum number of results expected from the search endpoint
SEARCH_INDEXER_QUERY_LIMIT=50
```
`OIDC_STORE_REFRESH_TOKEN_KEY` must be a valid Fernet key (32 url-safe base64-encoded bytes).
To create one, use the `bin/generate-oidc-store-refresh-token-key.sh` command.
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
## Feature flags
The Find search integration is controlled by two feature flags:
- `flag_find_hybrid_search`
- `flag_find_full_text_search`
If a user has both flags activated the most advanced search is used (hybrid > full text > title).
A user with no flag will default to the basic title search.
Feature flags can be activated through the admin interface.
```shell
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
```
-30
View File
@@ -1,30 +0,0 @@
# User account reconciliation
It is possible to merge user accounts based on their email addresses.
Docs does not have an internal process to requests, but it allows the import of a CSV from an external form
(e.g. made with Grist) in the Django admin panel (in "Core" > "User reconciliation CSV imports" > "Add user reconciliation")
## CSV file format
The CSV must contain the following mandatory columns:
- `active_email`: the email of the user that will remain active after the process.
- `inactive_email`: the email of the user(s) that will be merged into the active user. It is possible to indicate several emails, so the user only has to make one request even if they have more than two accounts.
- `id`: a unique row id, so that entries already processed in a previous import are ignored.
The following columns are optional: `active_email_checked` and `inactive_email_checked` (both must contain `0` (False) or `1` (True), and both default to False.)
If present, it allows to indicate that the source form has a way to validate that the user making the request actually controls the email addresses, skipping the need to send confirmation emails (cf. below)
Once the CSV file is processed, this will create entries in "Core" > "User reconciliations" and send verification emails to validate that the user making the request actually controls the email addresses (unless `active_email_checked` and `inactive_email_checked` were set to `1` in the CSV)
In "Core" > "User reconciliations", an admin can then select all rows they wish to process and check the action "Process selected user reconciliations". Only rows that have the status `ready` and for which both emails have been validated will be processed.
## Settings
If there is a problem with the reconciliation attempt (e.g., one of the addresses given by the user does not match an existing account), the email signaling the error can give back the link to the reconciliation form. This is configured through the following environment variable:
```env
USER_RECONCILIATION_FORM_URL=<url used in the email for reconciliation with errors to allow a new requests>
# e.g. "https://yourgristinstance.tld/xxxx/UserReconciliationForm"
```
+7 -13
View File
@@ -48,24 +48,19 @@ LOGIN_REDIRECT_URL=http://localhost:3000
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session. Needed by search/ endpoint.
OIDC_STORE_ACCESS_TOKEN=True
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
# OIDC_STORE_ACCESS_TOKEN = True
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# User reconciliation
USER_RECONCILIATION_FORM_URL=http://localhost:3000
# AI
AI_FEATURE_ENABLED=true
AI_FEATURE_BLOCKNOTE_ENABLED=true
AI_FEATURE_LEGACY_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
@@ -87,9 +82,8 @@ DOCSPEC_API_URL=http://docspec:4000/conversion
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
# Indexer (disabled by default)
# SEARCH_INDEXER_CLASS=core.services.search_indexers.FindDocumentIndexer
# Indexer (disabled)
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
INDEXING_URL=http://find:8000/api/v1.0/documents/index/
SEARCH_URL=http://find:8000/api/v1.0/documents/search/
SEARCH_INDEXER_QUERY_LIMIT=50
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
-7
View File
@@ -1,7 +0,0 @@
# Test environment configuration for running tests without docker
# Base configuration is loaded from 'common' file
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_CONFIGURATION=Test
DB_PORT=15432
AWS_S3_ENDPOINT_URL=http://localhost:9000
+1 -1
View File
@@ -8,4 +8,4 @@ DB_HOST=postgresql
DB_NAME=impress
DB_USER=dinum
DB_PASSWORD=pass
DB_PORT=5432
DB_PORT=5432
-5
View File
@@ -53,13 +53,8 @@ LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
# User reconciliation
#USER_RECONCILIATION_FORM_URL=https://${DOCS_HOST}
# AI
#AI_FEATURE_ENABLED=true # is false by default
#AI_FEATURE_BLOCKNOTE_ENABLED=true # is false by default
#AI_FEATURE_LEGACY_ENABLED=true # is true by default, AI_FEATURE_ENABLED must be set to true to enable it
#AI_BASE_URL=https://openaiendpoint.com
#AI_API_KEY=<API key>
#AI_MODEL=<model used> e.g. llama
+5 -19
View File
@@ -32,38 +32,24 @@
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed celery versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["celery"],
"allowedVersions": "<5.6.0"
},
{
"groupName": "allowed pydantic-ai-slim versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pydantic-ai-slim"],
"allowedVersions": "<1.59.0"
},
{
"groupName": "allowed langfuse versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["langfuse"],
"allowedVersions": "<3.12.0"
},
{
"groupName": "allowed django-treebeard versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django-treebeard"],
"allowedVersions": "<5.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@next/eslint-plugin-next",
"docx",
"eslint-config-next",
"fetch-mock",
"next",
"node",
"node-fetch",
"react-resizable-panels",
"workbox-webpack-plugin"
]
}
+2 -42
View File
@@ -1,14 +1,12 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin, messages
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from core import models
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
from . import models
@admin.register(models.User)
@@ -97,44 +95,6 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.UserReconciliationCsvImport)
class UserReconciliationCsvImportAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliationCsvImport model."""
list_display = ("id", "__str__", "created_at", "status")
def save_model(self, request, obj, form, change):
"""Override save_model to trigger the import task on creation."""
super().save_model(request, obj, form, change)
if not change:
user_reconciliation_csv_import_job.delay(obj.pk)
messages.success(request, _("Import job created and queued."))
return redirect("..")
@admin.action(description=_("Process selected user reconciliations"))
def process_reconciliation(_modeladmin, _request, queryset):
"""
Admin action to process selected user reconciliations.
The action will process only entries that are ready and have both emails checked.
"""
processable_entries = queryset.filter(
status="ready", active_email_checked=True, inactive_email_checked=True
)
for entry in processable_entries:
entry.process_reconciliation_request()
@admin.register(models.UserReconciliation)
class UserReconciliationAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliation model."""
list_display = ["id", "__str__", "created_at", "status"]
actions = [process_reconciliation]
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for document accesses."""
+3 -9
View File
@@ -2,7 +2,6 @@
import unicodedata
from django.conf import settings
from django.utils.translation import gettext_lazy as _
import django_filters
@@ -47,13 +46,10 @@ class DocumentFilter(django_filters.FilterSet):
title = AccentInsensitiveCharFilter(
field_name="title", lookup_expr="unaccent__icontains", label=_("Title")
)
q = AccentInsensitiveCharFilter(
field_name="title", lookup_expr="unaccent__icontains", label=_("Search")
)
class Meta:
model = models.Document
fields = ["title", "q"]
fields = ["title"]
class ListDocumentFilter(DocumentFilter):
@@ -73,7 +69,7 @@ class ListDocumentFilter(DocumentFilter):
class Meta:
model = models.Document
fields = ["is_creator_me", "is_favorite", "title", "q"]
fields = ["is_creator_me", "is_favorite", "title"]
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
@@ -139,6 +135,4 @@ class UserSearchFilter(django_filters.FilterSet):
Custom filter for searching users.
"""
q = django_filters.CharFilter(
min_length=settings.API_USERS_SEARCH_QUERY_MIN_LENGTH, max_length=254
)
q = django_filters.CharFilter(min_length=5, max_length=254)
+38 -55
View File
@@ -17,7 +17,6 @@ from rest_framework import serializers
from core import choices, enums, models, utils, validators
from core.services import mime_types
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
Converter,
@@ -32,21 +31,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = [
"id",
"email",
"full_name",
"short_name",
"language",
"is_first_connection",
]
read_only_fields = [
"id",
"email",
"full_name",
"short_name",
"is_first_connection",
]
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
def get_full_name(self, instance):
"""Return the full name of the user."""
@@ -238,16 +224,8 @@ class DocumentSerializer(ListDocumentSerializer):
fields = super().get_fields()
request = self.context.get("request")
if request:
if request.method == "POST":
fields["id"].read_only = False
if (
serializers.BooleanField().to_internal_value(
request.query_params.get("without_content", False)
)
is True
):
del fields["content"]
if request and request.method == "POST":
fields["id"].read_only = False
return fields
@@ -612,13 +590,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
class DocumentDuplicationSerializer(serializers.Serializer):
"""
Serializer for duplicating a document.
Allows specifying whether to keep access permissions,
and whether to duplicate descendant documents as well
(deep copy) or not (shallow copy).
Allows specifying whether to keep access permissions.
"""
with_accesses = serializers.BooleanField(default=False)
with_descendants = serializers.BooleanField(default=False)
def create(self, validated_data):
"""
@@ -816,33 +791,38 @@ class VersionFilterSerializer(serializers.Serializer):
)
class AITransformSerializer(serializers.Serializer):
"""Serializer for AI transform requests."""
class AIProxySerializer(serializers.Serializer):
"""Serializer for AI proxy 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
messages = serializers.ListField(
required=True,
child=serializers.DictField(
child=serializers.CharField(required=True),
),
allow_empty=False,
)
text = serializers.CharField(required=True)
model = serializers.CharField(required=True)
def validate_text(self, value):
"""Ensure the text field is not empty."""
def validate_messages(self, messages):
"""Validate messages structure."""
# Ensure each message has the required fields
for message in messages:
if (
not isinstance(message, dict)
or "role" not in message
or "content" not in message
):
raise serializers.ValidationError(
"Each message must have 'role' and 'content' fields"
)
return messages
def validate_model(self, value):
"""Validate model value is the same than settings.AI_MODEL"""
if value != settings.AI_MODEL:
raise serializers.ValidationError(f"{value} is not a valid model")
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
@@ -1004,5 +984,8 @@ class ThreadSerializer(serializers.ModelSerializer):
class SearchDocumentSerializer(serializers.Serializer):
"""Serializer for fulltext search requests through Find application"""
q = serializers.CharField(required=True, allow_blank=True, trim_whitespace=True)
path = serializers.CharField(required=False, allow_blank=False)
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
page = serializers.IntegerField(required=False, min_value=1, default=1)
+145 -495
View File
@@ -27,24 +27,19 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.http import content_disposition_header
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
import requests
import rest_framework as drf
import waffle
from botocore.exceptions import ClientError
from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from lasuite.tools.email import get_domain_from_email
from pydantic import ValidationError as PydanticValidationError
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
from core import authentication, choices, enums, models
from core.api.filters import remove_accents
@@ -66,19 +61,10 @@ from core.services.search_indexers import (
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import (
extract_attachments,
filter_descendants,
users_sharing_documents_with,
)
from core.utils import extract_attachments, filter_descendants
from ..enums import FeatureFlag, SearchType
from . import permissions, serializers, utils
from .filters import (
DocumentFilter,
ListDocumentFilter,
UserSearchFilter,
)
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
from .throttling import (
DocumentThrottle,
UserListThrottleBurst,
@@ -234,80 +220,18 @@ class UserViewSet(
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes.
#
# Additionally results are reordered to prefer users "closer" to the current
# user: users they recently shared documents with, then same email domain.
# To achieve that without complex SQL, we build a proximity score in Python
# and return the top N results.
# For security results, users that match neither of these proximity criteria
# are not returned at all, to prevent email enumeration.
current_user = self.request.user
shared_map = users_sharing_documents_with(current_user)
# index, then only calculate precise similarity scores for sorting purposes
user_email_domain = get_domain_from_email(current_user.email) or ""
candidates = list(
return (
queryset.annotate(
sim_email=TrigramSimilarity("email", query),
sim_name=TrigramSimilarity("full_name", query),
)
.annotate(similarity=Greatest("sim_email", "sim_name"))
.filter(similarity__gt=0.2)
.order_by("-similarity")
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
)
# Keep only users that either share documents with the current user
# or have an email with the same domain as the current user.
filtered_candidates = []
for u in candidates:
candidate_domain = get_domain_from_email(u.email) or ""
if shared_map.get(u.id) or (
user_email_domain and candidate_domain == user_email_domain
):
filtered_candidates.append(u)
candidates = filtered_candidates
# Build ordering key for each candidate
def _sort_key(u):
# shared priority: most recent first
# Use shared_last_at timestamp numeric for secondary ordering when shared.
shared_last_at = shared_map.get(u.id)
if shared_last_at:
is_shared = 1
shared_score = int(shared_last_at.timestamp())
else:
is_shared = 0
shared_score = 0
# domain proximity
candidate_email_domain = get_domain_from_email(u.email) or ""
same_full_domain = (
1
if candidate_email_domain
and candidate_email_domain == user_email_domain
else 0
)
# similarity fallback
sim = getattr(u, "similarity", 0) or 0
return (
is_shared,
shared_score,
same_full_domain,
sim,
)
# Sort candidates by the key descending and return top N as a queryset-like
# list. Keep return type consistent with previous behavior (QuerySet slice
# was returned) by returning a list of model instances.
candidates.sort(key=_sort_key, reverse=True)
return candidates[: settings.API_USERS_LIST_LIMIT]
@drf.decorators.action(
detail=False,
methods=["get"],
@@ -324,78 +248,6 @@ class UserViewSet(
self.serializer_class(request.user, context=context).data
)
@drf.decorators.action(
detail=False,
methods=["post"],
url_path="onboarding-done",
permission_classes=[permissions.IsAuthenticated],
)
def onboarding_done(self, request):
"""
Allows the frontend to mark the first connection as done for the current user,
e.g. after showing an onboarding message.
"""
if request.user.is_first_connection:
request.user.is_first_connection = False
request.user.save(update_fields=["is_first_connection", "updated_at"])
return drf.response.Response(
{"detail": "Onboarding marked as done."}, status=status.HTTP_200_OK
)
class ReconciliationConfirmView(APIView):
"""API endpoint to confirm user reconciliation emails.
GET /user-reconciliations/{user_type}/{confirmation_id}/
Marks `active_email_checked` or `inactive_email_checked` to True.
"""
permission_classes = [AllowAny]
def get(self, request, user_type, confirmation_id):
"""
Check the confirmation ID and mark the corresponding email as checked.
"""
try:
# validate UUID
uuid_obj = uuid.UUID(str(confirmation_id))
except ValueError:
return drf_response.Response(
{"detail": "Badly formatted confirmation id"},
status=status.HTTP_400_BAD_REQUEST,
)
if user_type not in ("active", "inactive"):
return drf_response.Response(
{"detail": "Invalid user_type"}, status=status.HTTP_400_BAD_REQUEST
)
lookup = (
{"active_email_confirmation_id": uuid_obj}
if user_type == "active"
else {"inactive_email_confirmation_id": uuid_obj}
)
try:
rec = models.UserReconciliation.objects.get(**lookup)
except models.UserReconciliation.DoesNotExist:
return drf_response.Response(
{"detail": "Reconciliation entry not found"},
status=status.HTTP_404_NOT_FOUND,
)
field_name = (
"active_email_checked"
if user_type == "active"
else "inactive_email_checked"
)
if not getattr(rec, field_name):
setattr(rec, field_name, True)
rec.save()
return drf_response.Response({"detail": "Confirmation received"})
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
@@ -486,23 +338,7 @@ class DocumentViewSet(
9. **Media Auth**: Authorize access to document media.
Example: GET /documents/media-auth/
10. **AI Transform**: Apply a transformation action on a piece of text with AI.
Example: POST /documents/{id}/ai-transform/
Expected data:
- text (str): The input text.
- action (str): The transformation type, one of [prompt, correct, rephrase, summarize].
Returns: JSON response with the processed text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
11. **AI Translate**: Translate a piece of text with AI.
Example: POST /documents/{id}/ai-translate/
Expected data:
- text (str): The input text.
- language (str): The target language, chosen from settings.LANGUAGES.
Returns: JSON response with the translated text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
12. **AI Proxy**: Proxy an AI request to an external AI service.
10. **AI Proxy**: Proxy an AI request to an external AI service.
Example: POST /api/v1.0/documents/<resource_id>/ai-proxy
### Ordering: created_at, updated_at, is_favorite, title
@@ -542,7 +378,6 @@ class DocumentViewSet(
throttle_scope = "document"
queryset = models.Document.objects.select_related("creator").all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
all_serializer_class = serializers.ListDocumentSerializer
children_serializer_class = serializers.ListDocumentSerializer
descendants_serializer_class = serializers.ListDocumentSerializer
@@ -610,18 +445,20 @@ class DocumentViewSet(
It performs early filtering on model fields, annotates user roles, and removes
descendant documents to keep only the highest ancestors readable by the current user.
"""
user = request.user
user = self.request.user
# Not calling filter_queryset. We do our own cooking.
queryset = self.get_queryset()
filterset = ListDocumentFilter(request.GET, queryset=queryset, request=request)
filterset = ListDocumentFilter(
self.request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
filter_data = filterset.form.cleaned_data
# Filter as early as possible on fields that are available on the model
for field in ["is_creator_me", "title", "q"]:
for field in ["is_creator_me", "title"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = queryset.annotate_user_roles(user)
@@ -1088,7 +925,7 @@ class DocumentViewSet(
filter_data = filterset.form.cleaned_data
# Filter as early as possible on fields that are available on the model
for field in ["is_creator_me", "title", "q"]:
for field in ["is_creator_me", "title"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = queryset.annotate_user_roles(user)
@@ -1111,11 +948,7 @@ class DocumentViewSet(
ordering=["path"],
)
def descendants(self, request, *args, **kwargs):
"""Deprecated endpoint to list descendants of a document."""
logger.warning(
"The 'descendants' endpoint is deprecated and will be removed in a future release. "
"The search endpoint should be used for all document retrieval use cases."
)
"""Handle listing descendants of a document"""
document = self.get_object()
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
@@ -1240,7 +1073,11 @@ class DocumentViewSet(
@transaction.atomic
def duplicate(self, request, *args, **kwargs):
"""
Duplicate a document, alongside its descendants if requested.
Duplicate a document and store the links to attached files in the duplicated
document to allow cross-access.
Optionally duplicates accesses if `with_accesses` is set to true
in the payload.
"""
# Get document while checking permissions
document_to_duplicate = self.get_object()
@@ -1249,43 +1086,8 @@ class DocumentViewSet(
data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
user = request.user
duplicated_document = self._duplicate_document(
document_to_duplicate=document_to_duplicate,
serializer=serializer,
user=user,
)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
def _duplicate_document(
self,
document_to_duplicate,
serializer,
user,
new_parent=None,
):
"""
Duplicate a document and store the links to attached files in the duplicated
document to allow cross-access.
Optionally duplicates accesses if `with_accesses` is set to true
in the payload.
Optionally duplicates sub-documents if `with_descendants` is set to true in
the payload. In this case, the whole subtree of the document will be duplicated,
and the links to attached files will be stored in all duplicated documents.
The `with_accesses` option will also be applied to all duplicated documents
if `with_descendants` is set to true.
"""
with_accesses = serializer.validated_data.get("with_accesses", False)
with_descendants = serializer.validated_data.get("with_descendants", False)
user_role = document_to_duplicate.get_role(user)
user_role = document_to_duplicate.get_role(request.user)
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
base64_yjs_content = document_to_duplicate.content
@@ -1304,41 +1106,11 @@ class DocumentViewSet(
extracted_attachments & set(document_to_duplicate.attachments)
)
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
# If parent_duplicate is provided we must add the duplicated document as a child
if new_parent is not None:
duplicated_document = new_parent.add_child(
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
creator=user,
**link_kwargs,
)
# Handle access duplication for this child
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document_to_duplicate
).exclude(user=user)
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
user_id=access.user_id,
team=access.team,
role=access.role,
)
for access in original_accesses
]
if accesses_to_create:
models.DocumentAccess.objects.bulk_create(accesses_to_create)
elif not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
user_role
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
duplicated_document = models.Document.add_root(
creator=user,
creator=self.request.user,
title=title,
content=base64_yjs_content,
attachments=attachments,
@@ -1347,182 +1119,132 @@ class DocumentViewSet(
)
models.DocumentAccess.objects.create(
document=duplicated_document,
user=user,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
else:
duplicated_document = document_to_duplicate.add_sibling(
"right",
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
creator=user,
**link_kwargs,
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
# Always add the logged-in user as OWNER for root documents
if document_to_duplicate.is_root():
accesses_to_create = [
duplicated_document = document_to_duplicate.add_sibling(
"right",
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
creator=request.user,
**link_kwargs,
)
# Always add the logged-in user as OWNER for root documents
if document_to_duplicate.is_root():
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
]
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document_to_duplicate
).exclude(user=request.user)
accesses_to_create.extend(
models.DocumentAccess(
document=duplicated_document,
user=user,
role=models.RoleChoices.OWNER,
user_id=access.user_id,
team=access.team,
role=access.role,
)
]
# If accesses should be duplicated,
# add other users' accesses as per original document
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document_to_duplicate
).exclude(user=user)
accesses_to_create.extend(
models.DocumentAccess(
document=duplicated_document,
user_id=access.user_id,
team=access.team,
role=access.role,
)
for access in original_accesses
)
# Bulk create all the duplicated accesses
models.DocumentAccess.objects.bulk_create(accesses_to_create)
if with_descendants:
for child in document_to_duplicate.get_children().filter(
ancestors_deleted_at__isnull=True
):
# When duplicating descendants, attach duplicates under the duplicated_document
self._duplicate_document(
document_to_duplicate=child,
serializer=serializer,
user=user,
new_parent=duplicated_document,
for access in original_accesses
)
return duplicated_document
# Bulk create all the duplicated accesses
models.DocumentAccess.objects.bulk_create(accesses_to_create)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
def _search_simple(self, request, text):
"""
Returns a queryset filtered by the content of the document title
"""
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
queryset = self.get_queryset()
filterset = DocumentFilter({"title": text}, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.filter_queryset(queryset)
return self.get_response_for_queryset(
queryset.order_by("-updated_at"),
context={
"request": request,
},
)
def _search_fulltext(self, indexer, request, params):
"""
Returns a queryset from the results the fulltext search of Find
"""
access_token = request.session.get("oidc_access_token")
user = request.user
text = params.validated_data["q"]
queryset = models.Document.objects.all()
# Retrieve the documents ids from Find.
results = indexer.search(
text=text,
token=access_token,
visited=get_visited_document_ids_of(queryset, user),
)
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
ordered_docs = [docs_by_uuid[id] for id in results]
page = self.paginate_queryset(ordered_docs)
serializer = self.get_serializer(
page if page else ordered_docs,
many=True,
context={
"request": request,
},
)
return self.get_paginated_response(serializer.data)
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token)
def search(self, request, *args, **kwargs):
"""
Returns an ordered list of documents best matching the search query parameter 'q'.
Returns a DRF response containing the filtered, annotated and ordered document list.
It depends on a search configurable Search Indexer. If no Search Indexer is configured
or if it is not reachable, the function falls back to a basic title search.
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
Depending of the configuration it can be:
- A fulltext search through the opensearch indexation app "find" if the backend is
enabled (see SEARCH_INDEXER_CLASS)
- A filtering by the model field 'title'.
The ordering is always by the most recent first.
"""
params = serializers.SearchDocumentSerializer(data=request.query_params)
params.is_valid(raise_exception=True)
search_type = self._get_search_type()
if search_type == SearchType.TITLE:
return self._title_search(request, params.validated_data, *args, **kwargs)
indexer = get_document_indexer()
if indexer is None:
# fallback on title search if the indexer is not configured
return self._title_search(request, params.validated_data, *args, **kwargs)
try:
return self._search_with_indexer(
indexer, request, params=params, search_type=search_type
)
except requests.exceptions.RequestException as e:
logger.error("Error while searching documents with indexer: %s", e)
# fallback on title search if the indexer is not reached
return self._title_search(request, params.validated_data, *args, **kwargs)
if indexer:
return self._search_fulltext(indexer, request, params=params)
def _get_search_type(self) -> SearchType:
"""
Returns the search type to use for the search endpoint based on feature flags.
If a user has both flags activated the most advanced search is used
(HYBRID > FULL_TEXT > TITLE).
A user with no flag will default to the basic title search.
"""
if waffle.flag_is_active(self.request, FeatureFlag.FLAG_FIND_HYBRID_SEARCH):
return SearchType.HYBRID
if waffle.flag_is_active(self.request, FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH):
return SearchType.FULL_TEXT
return SearchType.TITLE
@staticmethod
def _search_with_indexer(indexer, request, params, search_type):
"""
Returns a list of documents matching the query (q) according to the configured indexer.
"""
queryset = models.Document.objects.all()
results = indexer.search(
q=params.validated_data["q"],
search_type=search_type,
token=request.session.get("oidc_access_token"),
path=(
params.validated_data["path"]
if "path" in params.validated_data
else None
),
visited=get_visited_document_ids_of(queryset, request.user),
)
return drf_response.Response(
{
"count": len(results),
"next": None,
"previous": None,
"results": results,
}
)
def _title_search(self, request, validated_data, *args, **kwargs):
"""
Fallback search method when no indexer is configured.
Only searches in the title field of documents.
"""
if not validated_data.get("path"):
return self.list(request, *args, **kwargs)
return self._list_descendants(request, validated_data)
def _list_descendants(self, request, validated_data):
"""
List all documents whose path starts with the provided path parameter.
Includes the parent document itself.
Used internally by the search endpoint when path filtering is requested.
"""
# Get parent document without access filtering
parent_path = validated_data["path"]
try:
parent = models.Document.objects.annotate_user_roles(request.user).get(
path=parent_path
)
except models.Document.DoesNotExist as exc:
raise drf.exceptions.NotFound("Document not found from path.") from exc
abilities = parent.get_abilities(request.user)
if not abilities.get("search"):
raise drf.exceptions.PermissionDenied(
"You do not have permission to search within this document."
)
# Get descendants and include the parent, ordered by path
queryset = (
parent.get_descendants(include_self=True)
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
queryset = self.filter_queryset(queryset)
# filter by title
filterset = DocumentFilter(request.GET, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.qs
return self.get_response_for_queryset(queryset)
# The indexer is not configured, we fallback on a simple icontains filter by the
# model field 'title'.
return self._search_simple(request, text=params.validated_data["q"])
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
@@ -1731,19 +1453,11 @@ class DocumentViewSet(
or serializer.validated_data["is_unsafe"]
):
extra_args.update(
{
"ContentDisposition": content_disposition_header(
as_attachment=True, filename=file_name
)
}
{"ContentDisposition": f'attachment; filename="{file_name:s}"'}
)
else:
extra_args.update(
{
"ContentDisposition": content_disposition_header(
as_attachment=False, filename=file_name
)
}
{"ContentDisposition": f'inline; filename="{file_name:s}"'}
)
file = serializer.validated_data["file"]
@@ -1919,7 +1633,7 @@ class DocumentViewSet(
methods=["post"],
name="Proxy AI requests to the AI provider",
url_path="ai-proxy",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
# throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_proxy(self, request, *args, **kwargs):
"""
@@ -1930,84 +1644,29 @@ class DocumentViewSet(
# Check permissions first
self.get_object()
if not settings.AI_FEATURE_ENABLED or not settings.AI_FEATURE_BLOCKNOTE_ENABLED:
if not settings.AI_FEATURE_ENABLED:
raise ValidationError("AI feature is not enabled.")
ai_service = AIService()
try:
stream = ai_service.stream(request)
except PydanticValidationError as err:
logger.info("pydantic validation error: %s", err)
return drf.response.Response(
{"detail": "Invalid submitted payload"},
status=drf.status.HTTP_400_BAD_REQUEST,
if settings.AI_STREAM:
stream_gen = ai_service.stream_proxy(
url=settings.AI_BASE_URL.rstrip("/") + "/chat/completions",
method="POST",
headers={"Content-Type": "application/json"},
body=json.dumps(request.data, ensure_ascii=False).encode("utf-8"),
)
return StreamingHttpResponse(
stream,
content_type="text/event-stream",
headers={
"x-vercel-ai-data-stream": "v1", # This header is used for Vercel AI streaming,
"X-Accel-Buffering": "no", # Prevent nginx buffering
},
)
resp = StreamingHttpResponse(
streaming_content=stream_gen,
content_type="text/event-stream",
status=200,
)
resp["X-Accel-Buffering"] = "no"
resp["Cache-Control"] = "no-cache"
return resp
@drf.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=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
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=drf.status.HTTP_200_OK)
def _reject_invalid_ips(self, ips):
"""
@@ -2293,7 +1952,6 @@ class DocumentAccessViewSet(
"user__full_name",
"user__email",
"user__language",
"user__is_first_connection",
"document__id",
"document__path",
"document__depth",
@@ -2593,12 +2251,6 @@ class DocumentAskForAccessViewSet(
"""Create a document ask for access resource."""
document = self.get_document_or_404()
if document.get_role(request.user) in models.PRIVILEGED_ROLES:
return drf.response.Response(
{"detail": "You already have privileged access to this document."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
serializer = serializers.DocumentAskForAccessCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -2657,9 +2309,8 @@ class ConfigView(drf.views.APIView):
array_settings = [
"AI_BOT",
"AI_FEATURE_ENABLED",
"AI_FEATURE_BLOCKNOTE_ENABLED",
"AI_FEATURE_LEGACY_ENABLED",
"API_USERS_SEARCH_QUERY_MIN_LENGTH",
"AI_MODEL",
"AI_STREAM",
"COLLABORATION_WS_URL",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
@@ -2669,7 +2320,6 @@ class ConfigView(drf.views.APIView):
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_JS_URL",
"FRONTEND_SILENT_LOGIN_ENABLED",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
+1 -22
View File
@@ -3,7 +3,7 @@ Core application enums declaration
"""
import re
from enum import Enum, StrEnum
from enum import StrEnum
from django.conf import global_settings, settings
from django.db import models
@@ -46,24 +46,3 @@ class DocumentAttachmentStatus(StrEnum):
PROCESSING = "processing"
READY = "ready"
class SearchType(str, Enum):
"""
Defines the possible search types for a document search query.
- TITLE: DRF based search in the title of the documents only.
- HYBRID and FULL_TEXT: more advanced search based on Find indexer.
"""
TITLE = "title"
HYBRID = "hybrid"
FULL_TEXT = "full-text"
class FeatureFlag(str, Enum):
"""
Defines the possible feature flags for the application.
"""
FLAG_FIND_HYBRID_SEARCH = "flag_find_hybrid_search"
FLAG_FIND_FULL_TEXT_SEARCH = "flag_find_full_text_search"
-18
View File
@@ -19,21 +19,3 @@ class ForceSessionMiddleware:
response = self.get_response(request)
return response
class SaveRawBodyMiddleware:
"""
Save the raw request body to use it later.
"""
def __init__(self, get_response):
"""Initialize the middleware."""
self.get_response = get_response
def __call__(self, request):
"""Save the raw request body in the request to use it later."""
if request.path.endswith(("/ai-proxy/", "/ai-proxy")):
request.raw_body = request.body
response = self.get_response(request)
return response
@@ -1,178 +0,0 @@
# Generated by Django 5.2.11 on 2026-02-10 15:47
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0028_remove_templateaccess_template_and_more"),
]
operations = [
migrations.CreateModel(
name="UserReconciliationCsvImport",
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",
),
),
(
"file",
models.FileField(upload_to="imports/", verbose_name="CSV file"),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
],
options={
"verbose_name": "user reconciliation CSV import",
"verbose_name_plural": "user reconciliation CSV imports",
"db_table": "impress_user_reconciliation_csv_import",
},
),
migrations.CreateModel(
name="UserReconciliation",
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",
),
),
(
"active_email",
models.EmailField(
max_length=254, verbose_name="Active email address"
),
),
(
"inactive_email",
models.EmailField(
max_length=254, verbose_name="Email address to deactivate"
),
),
("active_email_checked", models.BooleanField(default=False)),
("inactive_email_checked", models.BooleanField(default=False)),
(
"active_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"inactive_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"source_unique_id",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Unique ID in the source file",
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("ready", "Ready"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
(
"active_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="active_user",
to=settings.AUTH_USER_MODEL,
),
),
(
"inactive_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="inactive_user",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "user reconciliation",
"verbose_name_plural": "user reconciliations",
"db_table": "impress_user_reconciliation",
"ordering": ["-created_at"],
},
),
]
@@ -1,32 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-04 14:49
from django.db import migrations, models
def set_is_first_connection_false(apps, schema_editor):
"""Update all existing user.is_first_connection to False."""
user = apps.get_model("core", "User")
user.objects.update(is_first_connection=False)
class Migration(migrations.Migration):
dependencies = [
("core", "0029_userreconciliationcsvimport_userreconciliation"),
]
operations = [
migrations.AddField(
model_name="user",
name="is_first_connection",
field=models.BooleanField(
default=True,
help_text="Whether the user has completed the first connection process.",
verbose_name="first connection status",
),
),
migrations.RunPython(
set_is_first_connection_false,
reverse_code=migrations.RunPython.noop,
),
]
@@ -1,34 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-11 17:16
from django.conf import settings
from django.db import migrations
from core.models import PRIVILEGED_ROLES
def clean_onboarding_accesses(apps, schema_editor):
"""clean accesses on on-boarding documents."""
onboarding_document_ids = settings.USER_ONBOARDING_DOCUMENTS
if not onboarding_document_ids:
return
onboarding_document_ids = set(settings.USER_ONBOARDING_DOCUMENTS)
DocumentAccess = apps.get_model("core", "DocumentAccess")
DocumentAccess.objects.filter(document_id__in=onboarding_document_ids).exclude(
role__in=PRIVILEGED_ROLES
).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0030_user_is_first_connection"),
]
operations = [
migrations.RunPython(
clean_onboarding_accesses,
reverse_code=migrations.RunPython.noop,
),
]
+14 -534
View File
@@ -15,11 +15,12 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import mail
from django.core.cache import cache
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 connection, models, transaction
from django.db import models, transaction
from django.db.models.functions import Left, Length
from django.template.loader import render_to_string
from django.utils import timezone
@@ -32,14 +33,14 @@ from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
from core.choices import (
from .choices import (
PRIVILEGED_ROLES,
LinkReachChoices,
LinkRoleChoices,
RoleChoices,
get_equivalent_link_definition,
)
from core.validators import sub_validator
from .validators import sub_validator
logger = getLogger(__name__)
@@ -118,11 +119,11 @@ class UserManager(auth_models.UserManager):
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email__iexact=email)
return self.get(email=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email__iexact=email).exists()
self.filter(email=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
@@ -193,11 +194,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts."
),
)
is_first_connection = models.BooleanField(
_("first connection status"),
default=True,
help_text=_("Whether the user has completed the first connection process."),
)
objects = UserManager()
@@ -214,90 +210,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
def save(self, *args, **kwargs):
"""
If it's a new user, give its user access to the documents they were invited to.
If it's a new user, give its user access to the documents to which s.he was invited.
"""
is_adding = self._state.adding
super().save(*args, **kwargs)
if is_adding:
self._handle_onboarding_documents_access()
self._duplicate_onboarding_sandbox_document()
self._convert_valid_invitations()
def _handle_onboarding_documents_access(self):
"""
If the user is new and there are documents configured to be given to new users,
create link traces to these documents and pin them as favorites for the user.
"""
if settings.USER_ONBOARDING_DOCUMENTS:
onboarding_document_ids = set(settings.USER_ONBOARDING_DOCUMENTS)
onboarding_link_traces = []
favorite_documents = []
for document_id in onboarding_document_ids:
try:
document = Document.objects.get(id=document_id)
except Document.DoesNotExist:
logger.warning(
"Onboarding document with id %s does not exist. Skipping.",
document_id,
)
continue
if document.link_reach == LinkReachChoices.RESTRICTED:
logger.warning(
"Onboarding on a restricted document is not allowed. Must be public or "
"connected. Restricted document: %s",
document_id,
)
continue
onboarding_link_traces.append(LinkTrace(user=self, document=document))
favorite_documents.append(
DocumentFavorite(user=self, document_id=document_id)
)
LinkTrace.objects.bulk_create(onboarding_link_traces)
DocumentFavorite.objects.bulk_create(favorite_documents)
def _duplicate_onboarding_sandbox_document(self):
"""
If the user is new and there is a sandbox document configured,
duplicate the sandbox document for the user
"""
if settings.USER_ONBOARDING_SANDBOX_DOCUMENT:
# transaction.atomic is used in a context manager to avoid a transaction if
# the settings USER_ONBOARDING_SANDBOX_DOCUMENT is unused
with transaction.atomic():
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)
sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT
try:
template_document = Document.objects.get(id=sandbox_id)
except Document.DoesNotExist:
logger.warning(
"Onboarding sandbox document with id %s does not exist. Skipping.",
sandbox_id,
)
return
sandbox_document = template_document.add_sibling(
"right",
title=template_document.title,
content=template_document.content,
attachments=template_document.attachments,
duplicated_from=template_document,
creator=self,
)
DocumentAccess.objects.create(
user=self, document=sandbox_document, role=RoleChoices.OWNER
)
def _convert_valid_invitations(self):
"""
Convert valid invitations to document accesses.
@@ -331,37 +251,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
valid_invitations.delete()
def send_email(self, subject, context=None, language=None):
"""Generate and send email to the user from a template."""
emails = [self.email]
context = context or {}
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"domain": domain,
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
with override(language):
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
@cached_property
def teams(self):
@@ -372,417 +266,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
return []
class UserReconciliation(BaseModel):
"""Model to run batch jobs to replace an active user by another one"""
active_email = models.EmailField(_("Active email address"))
inactive_email = models.EmailField(_("Email address to deactivate"))
active_email_checked = models.BooleanField(default=False)
inactive_email_checked = models.BooleanField(default=False)
active_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="active_user",
)
inactive_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="inactive_user",
)
active_email_confirmation_id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, null=True
)
inactive_email_confirmation_id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, null=True
)
source_unique_id = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name=_("Unique ID in the source file"),
)
status = models.CharField(
max_length=20,
choices=[
("pending", _("Pending")),
("ready", _("Ready")),
("done", _("Done")),
("error", _("Error")),
],
default="pending",
)
logs = models.TextField(blank=True)
class Meta:
db_table = "impress_user_reconciliation"
verbose_name = _("user reconciliation")
verbose_name_plural = _("user reconciliations")
ordering = ["-created_at"]
def __str__(self):
return f"Reconciliation from {self.inactive_email} to {self.active_email}"
def save(self, *args, **kwargs):
"""
For pending queries, identify the actual users and send validation emails
"""
if self.status == "pending":
self.active_user = User.objects.filter(email=self.active_email).first()
self.inactive_user = User.objects.filter(email=self.inactive_email).first()
if self.active_user and self.inactive_user:
if not self.active_email_checked:
self.send_reconciliation_confirm_email(
self.active_user, "active", self.active_email_confirmation_id
)
if not self.inactive_email_checked:
self.send_reconciliation_confirm_email(
self.inactive_user,
"inactive",
self.inactive_email_confirmation_id,
)
self.status = "ready"
else:
self.status = "error"
self.logs = "Error: Both active and inactive users need to exist."
super().save(*args, **kwargs)
@transaction.atomic
def process_reconciliation_request(self):
"""
Process the reconciliation request as a transaction.
- Transfer document accesses from inactive to active user, updating roles as needed.
- Transfer document favorites from inactive to active user.
- Transfer link traces from inactive to active user.
- Transfer comment-related content from inactive to active user
(threads, comments and reactions)
- Activate the active user and deactivate the inactive user.
- Update the reconciliation entry itself.
"""
# Prepare the data to perform the reconciliation on
updated_accesses, removed_accesses = (
self.prepare_documentaccess_reconciliation()
)
updated_linktraces, removed_linktraces = self.prepare_linktrace_reconciliation()
update_favorites, removed_favorites = (
self.prepare_document_favorite_reconciliation()
)
updated_threads = self.prepare_thread_reconciliation()
updated_comments = self.prepare_comment_reconciliation()
updated_reactions, removed_reactions = self.prepare_reaction_reconciliation()
self.active_user.is_active = True
self.inactive_user.is_active = False
# Actually perform the bulk operations
DocumentAccess.objects.bulk_update(updated_accesses, ["user", "role"])
if removed_accesses:
ids_to_delete = [entry.id for entry in removed_accesses]
DocumentAccess.objects.filter(id__in=ids_to_delete).delete()
DocumentFavorite.objects.bulk_update(update_favorites, ["user"])
if removed_favorites:
ids_to_delete = [entry.id for entry in removed_favorites]
DocumentFavorite.objects.filter(id__in=ids_to_delete).delete()
LinkTrace.objects.bulk_update(updated_linktraces, ["user"])
if removed_linktraces:
ids_to_delete = [entry.id for entry in removed_linktraces]
LinkTrace.objects.filter(id__in=ids_to_delete).delete()
Thread.objects.bulk_update(updated_threads, ["creator"])
Comment.objects.bulk_update(updated_comments, ["user"])
# pylint: disable=C0103
ReactionThroughModel = Reaction.users.through
reactions_to_create = []
for updated_reaction in updated_reactions:
reactions_to_create.append(
ReactionThroughModel(
user_id=self.active_user.pk, reaction_id=updated_reaction.pk
)
)
if reactions_to_create:
ReactionThroughModel.objects.bulk_create(reactions_to_create)
if removed_reactions:
ids_to_delete = [entry.id for entry in removed_reactions]
ReactionThroughModel.objects.filter(
reaction_id__in=ids_to_delete, user_id=self.inactive_user.pk
).delete()
User.objects.bulk_update([self.active_user, self.inactive_user], ["is_active"])
# Wrap up the reconciliation entry
self.logs += f"""Requested update for {len(updated_accesses)} DocumentAccess items
and deletion for {len(removed_accesses)} DocumentAccess items.\n"""
self.status = "done"
self.save()
self.send_reconciliation_done_email()
def prepare_documentaccess_reconciliation(self):
"""
Prepare the reconciliation by transferring document accesses from the inactive user
to the active user.
"""
updated_accesses = []
removed_accesses = []
inactive_accesses = DocumentAccess.objects.filter(user=self.inactive_user)
# Check documents where the active user already has access
inactive_accesses_documents = inactive_accesses.values_list(
"document", flat=True
)
existing_accesses = DocumentAccess.objects.filter(user=self.active_user).filter(
document__in=inactive_accesses_documents
)
existing_roles_per_doc = dict(existing_accesses.values_list("document", "role"))
for entry in inactive_accesses:
if entry.document_id in existing_roles_per_doc:
# Update role if needed
existing_role = existing_roles_per_doc[entry.document_id]
max_role = RoleChoices.max(entry.role, existing_role)
if existing_role != max_role:
existing_access = existing_accesses.get(document=entry.document)
existing_access.role = max_role
updated_accesses.append(existing_access)
removed_accesses.append(entry)
else:
entry.user = self.active_user
updated_accesses.append(entry)
return updated_accesses, removed_accesses
def prepare_document_favorite_reconciliation(self):
"""
Prepare the reconciliation by transferring document favorites from the inactive user
to the active user.
"""
updated_favorites = []
removed_favorites = []
existing_favorites = DocumentFavorite.objects.filter(user=self.active_user)
existing_favorite_doc_ids = set(
existing_favorites.values_list("document_id", flat=True)
)
inactive_favorites = DocumentFavorite.objects.filter(user=self.inactive_user)
for entry in inactive_favorites:
if entry.document_id in existing_favorite_doc_ids:
removed_favorites.append(entry)
else:
entry.user = self.active_user
updated_favorites.append(entry)
return updated_favorites, removed_favorites
def prepare_linktrace_reconciliation(self):
"""
Prepare the reconciliation by transferring link traces from the inactive user
to the active user.
"""
updated_linktraces = []
removed_linktraces = []
existing_linktraces = LinkTrace.objects.filter(user=self.active_user)
inactive_linktraces = LinkTrace.objects.filter(user=self.inactive_user)
for entry in inactive_linktraces:
if existing_linktraces.filter(document=entry.document).exists():
removed_linktraces.append(entry)
else:
entry.user = self.active_user
updated_linktraces.append(entry)
return updated_linktraces, removed_linktraces
def prepare_thread_reconciliation(self):
"""
Prepare the reconciliation by transferring threads from the inactive user
to the active user.
"""
updated_threads = []
inactive_threads = Thread.objects.filter(creator=self.inactive_user)
for entry in inactive_threads:
entry.creator = self.active_user
updated_threads.append(entry)
return updated_threads
def prepare_comment_reconciliation(self):
"""
Prepare the reconciliation by transferring comments from the inactive user
to the active user.
"""
updated_comments = []
inactive_comments = Comment.objects.filter(user=self.inactive_user)
for entry in inactive_comments:
entry.user = self.active_user
updated_comments.append(entry)
return updated_comments
def prepare_reaction_reconciliation(self):
"""
Prepare the reconciliation by creating missing reactions for the active user
(ie, the ones that exist for the inactive user but not the active user)
and then deleting all reactions of the inactive user.
"""
inactive_reactions = Reaction.objects.filter(users=self.inactive_user)
updated_reactions = inactive_reactions.exclude(users=self.active_user)
return updated_reactions, inactive_reactions
def send_reconciliation_confirm_email(
self, user, user_type, confirmation_id, language=None
):
"""Method allowing to send confirmation email for reconciliation requests."""
language = language or get_language()
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
message = _(
"""You have requested a reconciliation of your user accounts on Docs.
To confirm that you are the one who initiated the request
and that this email belongs to you:"""
)
with override(language):
subject = _("Confirm by clicking the link to start the reconciliation")
context = {
"title": subject,
"message": message,
"link": f"{domain}/user-reconciliations/{user_type}/{confirmation_id}/",
"link_label": str(_("Click here")),
"button_label": str(_("Confirm")),
}
user.send_email(subject, context, language)
def send_reconciliation_done_email(self, language=None):
"""Method allowing to send done email for reconciliation requests."""
language = language or get_language()
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
message = _(
"""Your reconciliation request has been processed.
New documents are likely associated with your account:"""
)
with override(language):
subject = _("Your accounts have been merged")
context = {
"title": subject,
"message": message,
"link": f"{domain}/",
"link_label": str(_("Click here to see")),
"button_label": str(_("See my documents")),
}
self.active_user.send_email(subject, context, language)
class UserReconciliationCsvImport(BaseModel):
"""Model to import reconciliations requests from an external source
(eg, )"""
file = models.FileField(upload_to="imports/", verbose_name=_("CSV file"))
status = models.CharField(
max_length=20,
choices=[
("pending", _("Pending")),
("running", _("Running")),
("done", _("Done")),
("error", _("Error")),
],
default="pending",
)
logs = models.TextField(blank=True)
class Meta:
db_table = "impress_user_reconciliation_csv_import"
verbose_name = _("user reconciliation CSV import")
verbose_name_plural = _("user reconciliation CSV imports")
def __str__(self):
return f"User reconciliation CSV import {self.id}"
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email to the user from a template."""
context = context or {}
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"domain": domain,
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
with override(language):
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
def send_reconciliation_error_email(
self, recipient_email, other_email, language=None
):
"""Method allowing to send email for reconciliation requests with errors."""
language = language or get_language()
emails = [recipient_email]
message = _(
"""Your request for reconciliation was unsuccessful.
Reconciliation failed for the following email addresses:
{recipient_email}, {other_email}.
Please check for typos.
You can submit another request with the valid email addresses."""
).format(recipient_email=recipient_email, other_email=other_email)
with override(language):
subject = _("Reconciliation of your Docs accounts not completed")
context = {
"title": subject,
"message": message,
"link": settings.USER_RECONCILIATION_FORM_URL,
"link_label": str(_("Click here")),
"button_label": str(_("Make a new request")),
}
self.send_email(subject, emails, context, language)
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
@@ -1301,8 +784,6 @@ class Document(MP_Node, BaseModel):
"accesses_manage": is_owner_or_admin,
"accesses_view": has_access_role,
"ai_proxy": ai_access,
"ai_transform": ai_access,
"ai_translate": ai_access,
"attachment_upload": can_update,
"media_check": can_get,
"can_edit": can_update,
@@ -1330,7 +811,6 @@ class Document(MP_Node, BaseModel):
"versions_destroy": is_owner_or_admin,
"versions_list": has_access_role,
"versions_retrieve": has_access_role,
"search": can_get,
}
def send_email(self, subject, emails, context=None, language=None):
@@ -1343,7 +823,7 @@ class Document(MP_Node, BaseModel):
"brandname": settings.EMAIL_BRAND_NAME,
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/?utm_source=docssharelink&utm_campaign={self.id}",
"link": f"{domain}/docs/{self.id}/",
"link_label": self.title or str(_("Untitled Document")),
"button_label": _("Open"),
"logo_img": settings.EMAIL_LOGO_IMG,
@@ -1988,7 +1468,7 @@ class Invitation(BaseModel):
# Check if an identity already exists for the provided email
if (
User.objects.filter(email__iexact=self.email).exists()
User.objects.filter(email=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValidationError(
+108 -320
View File
@@ -1,40 +1,16 @@
"""AI services."""
# core/services/ai_services.py
from __future__ import annotations
import asyncio
import json
import logging
import os
import queue
import threading
from collections.abc import AsyncIterator, Iterator
from typing import Any, Dict, Union
from typing import Any, Dict, Generator
from urllib.parse import urlparse
import httpx
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from langfuse import get_client
from langfuse.openai import OpenAI as OpenAI_Langfuse
from pydantic_ai import Agent, DeferredToolRequests
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets.external import ExternalToolset
from pydantic_ai.ui import SSE_CONTENT_TYPE
from pydantic_ai.ui.vercel_ai import VercelAIAdapter
from pydantic_ai.ui.vercel_ai.request_types import RequestData, TextUIPart, UIMessage
from rest_framework.request import Request
from core import enums
if settings.LANGFUSE_PUBLIC_KEY:
OpenAI = OpenAI_Langfuse
else:
from openai import OpenAI
log = logging.getLogger(__name__)
BLOCKNOTE_TOOL_STRICT_PROMPT = """
You are editing a BlockNote document via the tool applyDocumentOperations.
BLOCKNOTE_TOOL_STRICT_PROMPT = """You are editing a BlockNote document via the tool applyDocumentOperations.
You MUST respond ONLY by calling applyDocumentOperations.
The tool input MUST be valid JSON:
@@ -64,317 +40,129 @@ IDs ALWAYS end with "$". Use ids EXACTLY as provided.
Return ONLY the JSON tool input. No prose, no markdown.
"""
AI_ACTIONS = {
"prompt": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"correct": (
"Correct grammar and spelling of the markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"rephrase": (
"Rephrase the given markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"summarize": (
"Summarize the markdown text, preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"beautify": (
"Add formatting to the text to make it more readable. "
"Do not provide any other information. "
"Preserve the language."
),
"emojify": (
"Add emojis to the important parts of the text. "
"Do not provide any other information. "
"Preserve the language."
),
}
AI_TRANSLATE = (
"Keep the same html structure and formatting. "
"Translate the content in the html to the specified language {language:s}. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
)
def convert_async_generator_to_sync(async_gen: AsyncIterator[str]) -> Iterator[str]:
"""Convert an async generator to a sync generator."""
q: queue.Queue[str | object] = queue.Queue()
sentinel = object()
exc_sentinel = object()
async def run_async_gen():
try:
async for async_item in async_gen:
q.put(async_item)
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
q.put((exc_sentinel, exc))
finally:
q.put(sentinel)
def start_async_loop():
asyncio.run(run_async_gen())
thread = threading.Thread(target=start_async_loop, daemon=True)
thread.start()
try:
while True:
item = q.get()
if item is sentinel:
break
if isinstance(item, tuple) and item[0] is exc_sentinel:
# re-raise the exception in the sync context
raise item[1]
yield item
finally:
thread.join()
def _drop_nones(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: _drop_nones(v) for k, v in obj.items() if v is not None}
if isinstance(obj, list):
return [_drop_nones(v) for v in obj]
return obj
class AIService:
"""Service class for AI-related operations."""
"""
Backward-compatible proxy service for your existing viewset:
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)
stream_proxy(provider, url, method, headers, body) -> yields bytes
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,
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": text},
],
)
Plus: hardening payload so BlockNote tool calls are valid.
"""
content = response.choices[0].message.content
def __init__(self) -> None:
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
raise ImproperlyConfigured("AI_BASE_URL and AI_API_KEY must be set")
if not content:
raise RuntimeError("AI response does not contain an answer")
self.base_url = str(settings.AI_BASE_URL).rstrip("/")
self.api_key = str(settings.AI_API_KEY)
self.allowed_host = urlparse(self.base_url).netloc
return {"answer": content}
def _assert_allowed_target(self, target_url: str) -> None:
t = urlparse(target_url)
if t.scheme not in ("http", "https"):
raise ValueError("Target URL not allowed")
if t.netloc != self.allowed_host:
raise ValueError("Target URL not allowed")
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 _filtered_headers(self, incoming_headers: Dict[str, str]) -> Dict[str, str]:
hop_by_hop = {"host", "connection", "content-length", "accept-encoding"}
out: Dict[str, str] = {}
for k, v in incoming_headers.items():
lk = k.lower()
if lk in hop_by_hop:
continue
if lk == "authorization":
# Client auth is for Django only, not upstream
continue
out[k] = v
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)
out["Authorization"] = f"Bearer {self.api_key}"
return out
@staticmethod
def inject_document_state_messages(
messages: list[UIMessage],
) -> list[UIMessage]:
"""Inject document state context before user messages.
def _normalize_tools(self, tools: list) -> list:
normalized = []
for tool in tools:
if isinstance(tool, dict) and tool.get("type") == "function":
fn = tool.get("function") or {}
if isinstance(fn, dict) and not fn.get("description"):
fn["description"] = f"Tool {fn.get('name', 'unknown')}."
tool["function"] = fn
normalized.append(_drop_nones(tool))
return normalized
Port of BlockNote's injectDocumentStateMessages.
For each user message carrying documentState metadata, an assistant
message describing the current document/selection state is prepended
so the LLM sees it as context.
"""
result: list[UIMessage] = []
for message in messages:
if (
message.role == "user"
and isinstance(message.metadata, dict)
and "documentState" in message.metadata
):
doc_state = message.metadata["documentState"]
selection = doc_state.get("selection")
blocks = doc_state.get("blocks")
def _harden_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
payload = dict(payload)
if selection:
parts = [
TextUIPart(
text=(
"This is the latest state of the selection "
"(ignore previous selections, you MUST issue "
"operations against this latest version of "
"the selection):"
),
),
TextUIPart(
text=json.dumps(doc_state.get("selectedBlocks")),
),
TextUIPart(
text=(
"This is the latest state of the entire "
"document (INCLUDING the selected text), you "
"can use this to find the selected text to "
"understand the context (but you MUST NOT "
"issue operations against this document, you "
"MUST issue operations against the selection):"
),
),
TextUIPart(text=json.dumps(blocks)),
]
else:
text = (
"There is no active selection. This is the latest "
"state of the document (ignore previous documents, "
"you MUST issue operations against this latest "
"version of the document). The cursor is BETWEEN "
"two blocks as indicated by cursor: true."
)
if doc_state.get("isEmptyDocument"):
text += (
"Because the document is empty, YOU MUST first "
"update the empty block before adding new blocks."
)
else:
text += (
"Prefer updating existing blocks over removing "
"and adding (but this also depends on the "
"user's question)."
)
parts = [
TextUIPart(text=text),
TextUIPart(text=json.dumps(blocks)),
]
# Enforce server model (important with Albert routing)
if getattr(settings, "AI_MODEL", None):
payload["model"] = settings.AI_MODEL
result.append(
UIMessage(
role="assistant",
id=f"assistant-document-state-{message.id}",
parts=parts,
)
)
# Compliance
payload["temperature"] = 0
result.append(message)
return result
# Tools normalization
if isinstance(payload.get("tools"), list):
payload["tools"] = self._normalize_tools(payload["tools"])
@staticmethod
def tool_definitions_to_toolset(
tool_definitions: Dict[str, Any],
) -> ExternalToolset:
"""Convert serialized tool definitions to a pydantic-ai ExternalToolset.
# Force tool call if tools exist
if payload.get("tools"):
payload["tool_choice"] = {"type": "function", "function": {"name": "applyDocumentOperations"}}
Port of BlockNote's toolDefinitionsToToolSet.
Builds ToolDefinition objects from the JSON-Schema-based definitions
sent by the frontend and wraps them in an ExternalToolset so that
pydantic-ai advertises them to the LLM without trying to execute them
server-side (execution is deferred to the frontend).
"""
tool_defs = [
ToolDefinition(
name=name,
description=defn.get("description", ""),
parameters_json_schema=defn.get("inputSchema", {}),
kind="external",
metadata={
"output_schema": defn.get("outputSchema"),
},
)
for name, defn in tool_definitions.items()
]
return ExternalToolset(tool_defs)
# Convert non-standard "required"
if payload.get("tool_choice") == "required":
payload["tool_choice"] = {"type": "function", "function": {"name": "applyDocumentOperations"}}
def _harden_messages(
self, run_input: RequestData, tool_definitions: Dict[str, Any]
):
"""
Harden messages if applyDocumentOperations tool is used.
We would like the system_prompt property in the Agent initialization
but for UI adapter, like vercel, the agent is ignoring it
see https://github.com/pydantic/pydantic-ai/issues/3315
# Inject strict system prompt once
msgs = payload.get("messages")
if isinstance(msgs, list):
need = True
if msgs and isinstance(msgs[0], dict) and msgs[0].get("role") == "system":
c = msgs[0].get("content") or ""
if isinstance(c, str) and "applyDocumentOperations" in c and "blocks" in c:
need = False
if need:
payload["messages"] = [{"role": "system", "content": BLOCKNOTE_TOOL_STRICT_PROMPT}] + msgs
We have to inject it in the run_input.messages if needed.
"""
for name, _defn in tool_definitions.items():
if name == "applyDocumentOperations":
run_input.messages.insert(
0,
UIMessage(
id="system-force-tool-usage",
role="system",
parts=[TextUIPart(text=BLOCKNOTE_TOOL_STRICT_PROMPT)],
),
)
return
return _drop_nones(payload)
def _build_async_stream(self, request: Request) -> AsyncIterator[str]:
"""Build the async stream from the AI provider."""
instrument_enabled = settings.LANGFUSE_PUBLIC_KEY is not None
def _maybe_harden_json_body(self, body: bytes, headers: Dict[str, str]) -> bytes:
ct = (headers.get("Content-Type") or headers.get("content-type") or "").lower()
if "application/json" not in ct:
return body
try:
payload = json.loads(body.decode("utf-8"))
except Exception:
return body
if isinstance(payload, dict):
payload = self._harden_payload(payload)
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
return body
if instrument_enabled:
langfuse = get_client()
langfuse.auth_check()
Agent.instrument_all()
def stream_proxy(
self,
*,
url: str,
method: str,
headers: Dict[str, str],
body: bytes,
) -> Generator[bytes, None, None]:
self._assert_allowed_target(url)
model = OpenAIChatModel(
settings.AI_MODEL,
provider=OpenAIProvider(
base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY
),
)
agent = Agent(model, instrument=instrument_enabled)
req_headers = self._filtered_headers(dict(headers))
req_body = self._maybe_harden_json_body(body, req_headers)
accept = request.META.get("HTTP_ACCEPT", SSE_CONTENT_TYPE)
run_input = VercelAIAdapter.build_run_input(request.raw_body)
# Inject document state context into the conversation
run_input.messages = self.inject_document_state_messages(run_input.messages)
# Build an ExternalToolset from frontend-supplied tool definitions
raw_tool_defs = (
run_input.model_extra.get("toolDefinitions")
if run_input.model_extra
else None
)
toolset = (
self.tool_definitions_to_toolset(raw_tool_defs) if raw_tool_defs else None
)
if raw_tool_defs:
self._harden_messages(run_input, raw_tool_defs)
adapter = VercelAIAdapter(
agent=agent,
run_input=run_input,
accept=accept,
sdk_version=settings.AI_VERCEL_SDK_VERSION,
)
event_stream = adapter.run_stream(
output_type=[str, DeferredToolRequests] if toolset else None,
toolsets=[toolset] if toolset else None,
)
return adapter.encode_stream(event_stream)
def stream(self, request: Request) -> Union[AsyncIterator[str], Iterator[str]]:
"""Stream AI API requests to the configured AI provider.
Returns an async iterator when running in async mode (ASGI)
or a sync iterator when running in sync mode (WSGI).
"""
async_stream = self._build_async_stream(request)
if os.environ.get("PYTHON_SERVER_MODE", "sync") == "async":
return async_stream
return convert_async_generator_to_sync(async_stream)
timeout = httpx.Timeout(connect=10.0, read=300.0, write=60.0, pool=10.0)
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
with client.stream(method.upper(), url, headers=req_headers, content=req_body) as r:
for chunk in r.iter_bytes():
if chunk:
yield chunk
+21 -95
View File
@@ -8,12 +8,12 @@ from functools import cache
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Subquery
from django.utils.module_loading import import_string
import requests
from core import models, utils
from core.enums import SearchType
logger = logging.getLogger(__name__)
@@ -69,7 +69,7 @@ def get_batch_accesses_by_users_and_teams(paths):
return dict(access_by_document_path)
def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]:
def get_visited_document_ids_of(queryset, user):
"""
Returns the ids of the documents that have a linktrace to the user and NOT owned.
It will be use to limit the opensearch responses to the public documents already
@@ -78,9 +78,7 @@ def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]:
if isinstance(user, AnonymousUser):
return []
visited_ids = models.LinkTrace.objects.filter(user=user).values_list(
"document_id", flat=True
)
qs = models.LinkTrace.objects.filter(user=user)
docs = (
queryset.exclude(accesses__user=user)
@@ -88,12 +86,12 @@ def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]:
deleted_at__isnull=True,
ancestors_deleted_at__isnull=True,
)
.filter(pk__in=visited_ids)
.filter(pk__in=Subquery(qs.values("document_id")))
.order_by("pk")
.distinct("pk")
)
return tuple(str(id) for id in docs.values_list("pk", flat=True))
return [str(id) for id in docs.values_list("pk", flat=True)]
class BaseDocumentIndexer(ABC):
@@ -109,13 +107,15 @@ class BaseDocumentIndexer(ABC):
Initialize the indexer.
"""
self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE
self.indexer_url = settings.INDEXING_URL
self.indexer_url = settings.SEARCH_INDEXER_URL
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
self.search_url = settings.SEARCH_URL
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
if not self.indexer_url:
raise ImproperlyConfigured("INDEXING_URL must be set in Django settings.")
raise ImproperlyConfigured(
"SEARCH_INDEXER_URL must be set in Django settings."
)
if not self.indexer_secret:
raise ImproperlyConfigured(
@@ -123,7 +123,9 @@ class BaseDocumentIndexer(ABC):
)
if not self.search_url:
raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.")
raise ImproperlyConfigured(
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
)
def index(self, queryset=None, batch_size=None):
"""
@@ -182,16 +184,8 @@ class BaseDocumentIndexer(ABC):
Must be implemented by subclasses.
"""
# pylint: disable=too-many-arguments, too-many-positional-arguments
def search( # noqa : PLR0913
self,
q: str,
token: str,
visited: tuple[str, ...] = (),
nb_results: int = None,
path: str = None,
search_type: SearchType = None,
):
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
def search(self, text, token, visited=(), nb_results=None):
"""
Search for documents in Find app.
Ensure the same default ordering as "Docs" list : -updated_at
@@ -199,7 +193,7 @@ class BaseDocumentIndexer(ABC):
Returns ids of the documents
Args:
q (str): user query.
text (str): Text search content.
token (str): OIDC Authentication token.
visited (list, optional):
List of ids of active public documents with LinkTrace
@@ -207,28 +201,21 @@ class BaseDocumentIndexer(ABC):
nb_results (int, optional):
The number of results to return.
Defaults to 50 if not specified.
path (str, optional):
The parent path to search descendants of.
search_type (SearchType, optional):
Type of search to perform. Can be SearchType.HYBRID or SearchType.FULL_TEXT.
If None, the backend search service will use its default search behavior.
"""
nb_results = nb_results or self.search_limit
results = self.search_query(
response = self.search_query(
data={
"q": q,
"q": text,
"visited": visited,
"services": ["docs"],
"nb_results": nb_results,
"order_by": "updated_at",
"order_direction": "desc",
"path": path,
"search_type": search_type,
},
token=token,
)
return results
return [d["_id"] for d in response]
@abstractmethod
def search_query(self, data, token) -> dict:
@@ -239,72 +226,11 @@ class BaseDocumentIndexer(ABC):
"""
class FindDocumentIndexer(BaseDocumentIndexer):
class SearchIndexer(BaseDocumentIndexer):
"""
Document indexer that indexes and searches documents with La Suite Find app.
Document indexer that pushes documents to La Suite Find app.
"""
# pylint: disable=too-many-arguments, too-many-positional-arguments
def search( # noqa : PLR0913
self,
q: str,
token: str,
visited: tuple[()] = (),
nb_results: int = None,
path: str = None,
search_type: SearchType = None,
):
"""format Find search results"""
search_results = super().search(
q=q,
token=token,
visited=visited,
nb_results=nb_results,
path=path,
search_type=search_type,
)
return [
{
**hit["_source"],
"id": hit["_id"],
"title": self.get_title(hit["_source"]),
}
for hit in search_results
]
@staticmethod
def get_title(source):
"""
Find returns the titles with an extension depending on the language.
This function extracts the title in a generic way.
Handles multiple cases:
- Localized title fields like "title.<some_extension>"
- Fallback to plain "title" field if localized version not found
- Returns empty string if no title field exists
Args:
source (dict): The _source dictionary from a search hit
Returns:
str: The extracted title or empty string if not found
Example:
>>> get_title({"title.fr": "Bonjour", "id": 1})
"Bonjour"
>>> get_title({"title": "Hello", "id": 1})
"Hello"
>>> get_title({"id": 1})
""
"""
titles = utils.get_value_by_pattern(source, r"^title\.")
for title in titles:
if title:
return title
if "title" in source:
return source["title"]
return ""
def serialize_document(self, document, accesses):
"""
Convert a Document to the JSON format expected by La Suite Find.
+2 -20
View File
@@ -4,14 +4,12 @@ Declare and configure the signals for the impress core application
from functools import partial
from django.core.cache import cache
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
from . import models
from .tasks.search import trigger_batch_document_indexer
@receiver(signals.post_save, sender=models.Document)
@@ -28,24 +26,8 @@ def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-ar
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Clear cache for the affected user.
"""
if not created:
transaction.on_commit(
partial(trigger_batch_document_indexer, instance.document)
)
# Invalidate cache for the user
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)
@receiver(signals.post_delete, sender=models.DocumentAccess)
def document_access_post_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Clear cache for the affected user when document access is deleted.
"""
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)
+5 -5
View File
@@ -63,7 +63,7 @@ def batch_document_indexer_task(timestamp):
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(document):
def trigger_batch_document_indexer(item):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
@@ -82,14 +82,14 @@ def trigger_batch_document_indexer(document):
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
document.updated_at.isoformat(),
item.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
args=[document.updated_at], countdown=countdown
args=[item.updated_at], countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", document.pk)
logger.info("Skip task for batch document %s indexation", item.pk)
else:
document_indexer_task.apply(args=[document.pk])
document_indexer_task.apply(args=[item.pk])
@@ -1,135 +0,0 @@
"""Processing tasks for user reconciliation CSV imports."""
import csv
import traceback
import uuid
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import IntegrityError
from botocore.exceptions import ClientError
from core.models import UserReconciliation, UserReconciliationCsvImport
from impress.celery_app import app
def _process_row(row, job, counters):
"""Process a single row from the CSV file."""
source_unique_id = row["id"].strip()
# Skip entries if they already exist with this source_unique_id
if UserReconciliation.objects.filter(source_unique_id=source_unique_id).exists():
counters["already_processed_source_ids"] += 1
return counters
active_email_checked = row.get("active_email_checked", "0") == "1"
inactive_email_checked = row.get("inactive_email_checked", "0") == "1"
active_email = row["active_email"]
inactive_emails = row["inactive_email"].split("|")
try:
validate_email(active_email)
except ValidationError:
job.send_reconciliation_error_email(
recipient_email=inactive_emails[0], other_email=active_email
)
job.logs += f"Invalid active email address on row {source_unique_id}."
counters["rows_with_errors"] += 1
return counters
for inactive_email in inactive_emails:
try:
validate_email(inactive_email)
except (ValidationError, ValueError):
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += f"Invalid inactive email address on row {source_unique_id}.\n"
counters["rows_with_errors"] += 1
continue
if inactive_email == active_email:
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += (
f"Error on row {source_unique_id}: "
f"{active_email} set as both active and inactive email.\n"
)
counters["rows_with_errors"] += 1
continue
_rec_entry = UserReconciliation.objects.create(
active_email=active_email,
inactive_email=inactive_email,
active_email_checked=active_email_checked,
inactive_email_checked=inactive_email_checked,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
source_unique_id=source_unique_id,
status="pending",
)
counters["rec_entries_created"] += 1
return counters
@app.task
def user_reconciliation_csv_import_job(job_id):
"""Process a UserReconciliationCsvImport job.
Creates UserReconciliation entries from the CSV file.
Does some sanity checks on the data:
- active_email and inactive_email must be valid email addresses
- active_email and inactive_email cannot be the same
Rows with errors are logged in the job logs and skipped, but do not cause
the entire job to fail or prevent the next rows from being processed.
"""
# Imports the CSV file, breaks it into UserReconciliation items
job = UserReconciliationCsvImport.objects.get(id=job_id)
job.status = "running"
job.save()
counters = {
"rec_entries_created": 0,
"rows_with_errors": 0,
"already_processed_source_ids": 0,
}
try:
with job.file.open(mode="r") as f:
reader = csv.DictReader(f)
if not {"active_email", "inactive_email", "id"}.issubset(reader.fieldnames):
raise KeyError(
"CSV is missing mandatory columns: active_email, inactive_email, id"
)
for row in reader:
counters = _process_row(row, job, counters)
job.status = "done"
job.logs += (
f"Import completed successfully. {reader.line_num} rows processed."
f" {counters['rec_entries_created']} reconciliation entries created."
f" {counters['already_processed_source_ids']} rows were already processed."
f" {counters['rows_with_errors']} rows had errors."
)
except (
csv.Error,
KeyError,
ValidationError,
ValueError,
IntegrityError,
OSError,
ClientError,
) as e:
# Catch expected I/O/CSV/model errors and record traceback in logs for debugging
job.status = "error"
job.logs += f"{e!s}\n{traceback.format_exc()}"
finally:
job.save()
@@ -68,30 +68,6 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_existing_user_via_email_case_insensitive(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email with different case,
the user should be returned (case-insensitive email matching).
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(4): # user by sub, user by mail, update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
@@ -181,39 +157,6 @@ def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate_case_insensitive(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should detect duplicate emails even with different case.
"""
klass = OIDCAuthenticationBackend()
_db_user = UserFactory(email="john.doe@example.com")
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
@@ -11,7 +11,7 @@ from django.db import transaction
import pytest
from core import factories
from core.services.search_indexers import FindDocumentIndexer
from core.services.search_indexers import SearchIndexer
@pytest.mark.django_db
@@ -19,7 +19,7 @@ from core.services.search_indexers import FindDocumentIndexer
def test_index():
"""Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory()
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
with transaction.atomic():
doc = factories.DocumentFactory()
@@ -36,7 +36,7 @@ def test_index():
str(no_title_doc.path): {"users": [user.sub]},
}
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with mock.patch.object(SearchIndexer, "push") as mock_push:
call_command("index")
push_call_args = [call.args[0] for call in mock_push.call_args_list]
+5 -3
View File
@@ -39,10 +39,12 @@ def indexer_settings_fixture(settings):
get_document_indexer.cache_clear()
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.FindDocumentIndexer"
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
settings.INDEXING_URL = "http://localhost:8081/api/v1.0/documents/index/"
settings.SEARCH_URL = "http://localhost:8081/api/v1.0/documents/search/"
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
settings.SEARCH_INDEXER_QUERY_URL = (
"http://localhost:8081/api/v1.0/documents/search/"
)
settings.SEARCH_INDEXER_COUNTDOWN = 1
yield settings
@@ -1,6 +0,0 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com","user.test41@example.com",0,0,pending,1
"user.test42@example.com","user.test43@example.com",0,1,pending,2
"user.test44@example.com","user.test45@example.com",1,0,pending,3
"user.test46@example.com","user.test47@example.com",1,1,pending,4
"user.test48@example.com","user.test49@example.com",1,1,pending,5
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com user.test41@example.com 0 0 pending 1
3 user.test42@example.com user.test43@example.com 0 1 pending 2
4 user.test44@example.com user.test45@example.com 1 0 pending 3
5 user.test46@example.com user.test47@example.com 1 1 pending 4
6 user.test48@example.com user.test49@example.com 1 1 pending 5
@@ -1,2 +0,0 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com",,0,0,pending,40
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com 0 0 pending 40
@@ -1,5 +0,0 @@
merge_accept,active_email,inactive_email,status,id
true,user.test10@example.com,user.test11@example.com|user.test12@example.com,pending,10
true,user.test30@example.com,user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com,pending,11
true,user.test20@example.com,user.test21@example.com,pending,12
true,user.test22@example.com,user.test23@example.com,pending,13
1 merge_accept active_email inactive_email status id
2 true user.test10@example.com user.test11@example.com|user.test12@example.com pending 10
3 true user.test30@example.com user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com pending 11
4 true user.test20@example.com user.test21@example.com pending 12
5 true user.test22@example.com user.test23@example.com pending 13
@@ -1,2 +0,0 @@
merge_accept,active_email,inactive_email,status,id
true,user.test20@example.com,user.test20@example.com,pending,20
1 merge_accept active_email inactive_email status id
2 true user.test20@example.com user.test20@example.com pending 20
@@ -1,6 +0,0 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status
"user.test40@example.com","user.test41@example.com",0,0,pending
"user.test42@example.com","user.test43@example.com",0,1,pending
"user.test44@example.com","user.test45@example.com",1,0,pending
"user.test46@example.com","user.test47@example.com",1,1,pending
"user.test48@example.com","user.test49@example.com",1,1,pending
1 active_email inactive_email active_email_checked inactive_email_checked status
2 user.test40@example.com user.test41@example.com 0 0 pending
3 user.test42@example.com user.test43@example.com 0 1 pending
4 user.test44@example.com user.test45@example.com 1 0 pending
5 user.test46@example.com user.test47@example.com 1 1 pending
6 user.test48@example.com user.test49@example.com 1 1 pending
@@ -245,18 +245,15 @@ def test_api_document_accesses_list_authenticated_related_privileged(
"path": access.document.path,
"depth": access.document.depth,
},
"user": (
{
"id": str(access.user.id),
"email": access.user.email,
"language": access.user.language,
"full_name": access.user.full_name,
"short_name": access.user.short_name,
"is_first_connection": access.user.is_first_connection,
}
if access.user
else None
),
"user": {
"id": str(access.user.id),
"email": access.user.email,
"language": access.user.language,
"full_name": access.user.full_name,
"short_name": access.user.short_name,
}
if access.user
else None,
"max_ancestors_role": None,
"max_role": access.role,
"team": access.team,
@@ -596,38 +596,6 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
}
def test_api_item_invitations_create_cannot_invite_existing_users_case_insensitive():
"""
It should not be possible to invite already existing users, even with different email case.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
existing_user = factories.UserFactory()
# Build an invitation to the email of an existing identity with different case
invitation_values = {
"email": existing_user.email.upper(),
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
client.force_login(user)
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() == {
"email": ["This email is already associated to a registered user."]
}
def test_api_document_invitations_create_lower_email():
"""
No matter the case, the email should be converted to lowercase.
@@ -3,7 +3,7 @@ Test AI proxy API endpoint for users in impress's core app.
"""
import random
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from django.test import override_settings
@@ -20,13 +20,9 @@ pytestmark = pytest.mark.django_db
def ai_settings(settings):
"""Fixture to set AI settings."""
settings.AI_MODEL = "llama"
settings.AI_BASE_URL = "http://localhost-ai:12345/"
settings.AI_BASE_URL = "http://example.com"
settings.AI_API_KEY = "test-key"
settings.AI_FEATURE_ENABLED = True
settings.AI_FEATURE_BLOCKNOTE_ENABLED = True
settings.AI_FEATURE_LEGACY_ENABLED = True
settings.LANGFUSE_PUBLIC_KEY = None
settings.AI_VERCEL_SDK_VERSION = 6
@override_settings(
@@ -54,6 +50,7 @@ def test_api_documents_ai_proxy_anonymous_forbidden(reach, role):
url,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
@@ -65,47 +62,81 @@ def test_api_documents_ai_proxy_anonymous_forbidden(reach, role):
@override_settings(AI_ALLOW_REACH_FROM="public")
@patch("core.services.ai_services.AIService.stream")
def test_api_documents_ai_proxy_anonymous_success(mock_stream):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_anonymous_success(mock_create):
"""
Anonymous users should be able to request AI proxy to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_stream.return_value = iter(["data: chunk1\n", "data: chunk2\n"])
mock_response = MagicMock()
mock_response.model_dump.return_value = {
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"model": "llama",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you?",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 9, "completion_tokens": 12, "total_tokens": 21},
}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = APIClient().post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream"
assert response["x-vercel-ai-data-stream"] == "v1"
assert response["X-Accel-Buffering"] == "no"
response_data = response.json()
assert response_data["id"] == "chatcmpl-123"
assert response_data["model"] == "llama"
assert len(response_data["choices"]) == 1
assert (
response_data["choices"][0]["message"]["content"]
== "Hello! How can I help you?"
)
content = b"".join(response.streaming_content).decode()
assert "chunk1" in content
assert "chunk2" in content
mock_stream.assert_called_once()
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Hello"}],
model="llama",
stream=False,
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
def test_api_documents_ai_proxy_anonymous_limited_by_setting():
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should not be able to request AI proxy to a document
if AI_ALLOW_REACH_FROM setting restricts it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Hello!"}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = APIClient().post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 401
@@ -135,8 +166,11 @@ def test_api_documents_ai_proxy_authenticated_forbidden(reach, role):
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 403
@@ -149,8 +183,8 @@ def test_api_documents_ai_proxy_authenticated_forbidden(reach, role):
("public", "editor"),
],
)
@patch("core.services.ai_services.AIService.stream")
def test_api_documents_ai_proxy_authenticated_success(mock_stream, reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_authenticated_success(mock_create, reach, role):
"""
Authenticated users should be able to request AI proxy to a document
if the link reach and role permit it.
@@ -162,18 +196,41 @@ def test_api_documents_ai_proxy_authenticated_success(mock_stream, reach, role):
document = factories.DocumentFactory(link_reach=reach, link_role=role)
mock_stream.return_value = iter(["data: response\n"])
mock_response = MagicMock()
mock_response.model_dump.return_value = {
"id": "chatcmpl-456",
"object": "chat.completion",
"model": "llama",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Hi there!"},
"finish_reason": "stop",
}
],
}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream"
mock_stream.assert_called_once()
response_data = response.json()
assert response_data["id"] == "chatcmpl-456"
assert response_data["choices"][0]["message"]["content"] == "Hi there!"
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Hello"}],
model="llama",
stream=False,
)
@pytest.mark.parametrize("via", VIA)
@@ -196,8 +253,11 @@ def test_api_documents_ai_proxy_reader(via, mock_user_teams):
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 403
@@ -205,8 +265,8 @@ def test_api_documents_ai_proxy_reader(via, mock_user_teams):
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@patch("core.services.ai_services.AIService.stream")
def test_api_documents_ai_proxy_success(mock_stream, via, role, mock_user_teams):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_success(mock_create, via, role, mock_user_teams):
"""Users with sufficient permissions should be able to request AI proxy."""
user = factories.UserFactory()
@@ -222,32 +282,45 @@ def test_api_documents_ai_proxy_success(mock_stream, via, role, mock_user_teams)
document=document, team="lasuite", role=role
)
mock_stream.return_value = iter(["data: success\n"])
mock_response = MagicMock()
mock_response.model_dump.return_value = {
"id": "chatcmpl-789",
"object": "chat.completion",
"model": "llama",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Success!"},
"finish_reason": "stop",
}
],
}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Test message"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream"
assert response["x-vercel-ai-data-stream"] == "v1"
assert response["X-Accel-Buffering"] == "no"
response_data = response.json()
assert response_data["id"] == "chatcmpl-789"
assert response_data["choices"][0]["message"]["content"] == "Success!"
content = b"".join(response.streaming_content).decode()
assert "success" in content
mock_stream.assert_called_once()
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Test message"}],
model="llama",
stream=False,
)
@pytest.mark.parametrize(
"setting_to_disable", ["AI_FEATURE_ENABLED", "AI_FEATURE_BLOCKNOTE_ENABLED"]
)
def test_api_documents_ai_proxy_ai_feature_disabled(settings, setting_to_disable):
"""When AI_FEATURE_ENABLED is False, the endpoint returns 400."""
setattr(settings, setting_to_disable, False)
def test_api_documents_ai_proxy_empty_messages():
"""The messages should not be empty when requesting AI proxy."""
user = factories.UserFactory()
client = APIClient()
@@ -255,46 +328,198 @@ def test_api_documents_ai_proxy_ai_feature_disabled(settings, setting_to_disable
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(url, {"messages": [], "model": "llama"}, format="json")
assert response.status_code == 400
assert response.json() == {"messages": ["This list may not be empty."]}
def test_api_documents_ai_proxy_missing_model():
"""The model should be required when requesting AI proxy."""
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-proxy/"
response = client.post(
f"/api/v1.0/documents/{document.id!s}/ai-proxy/",
b"{}",
content_type="application/json",
url, {"messages": [{"role": "user", "content": "Hello"}]}, format="json"
)
assert response.status_code == 400
assert response.json() == ["AI feature is not enabled."]
assert response.json() == {"model": ["This field is required."]}
def test_api_documents_ai_proxy_invalid_message_format():
"""Messages should have the correct format when requesting AI proxy."""
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-proxy/"
# Test with invalid message format (missing role)
response = client.post(
url,
{
"messages": [{"content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"messages": ["Each message must have 'role' and 'content' fields"]
}
# Test with invalid message format (missing content)
response = client.post(
url,
{
"messages": [{"role": "user"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"messages": ["Each message must have 'role' and 'content' fields"]
}
# Test with non-dict message
response = client.post(
url,
{
"messages": ["invalid"],
"model": "llama",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"messages": {"0": ['Expected a dictionary of items but got type "str".']}
}
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_stream_disabled(mock_create):
"""Stream should be automatically disabled in AI proxy requests."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Success!"}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
"stream": True, # This should be overridden to False
},
format="json",
)
assert response.status_code == 200
# Verify that stream was set to False
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Hello"}],
model="llama",
stream=False,
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_additional_parameters(mock_create):
"""AI proxy should pass through additional parameters to the AI service."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Success!"}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
"temperature": 0.7,
"max_tokens": 100,
"top_p": 0.9,
},
format="json",
)
assert response.status_code == 200
# Verify that additional parameters were passed through
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Hello"}],
model="llama",
temperature=0.7,
max_tokens=100,
top_p=0.9,
stream=False,
)
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("core.services.ai_services.AIService.stream")
def test_api_documents_ai_proxy_throttling_document(mock_stream):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI proxy endpoint.
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")
mock_stream.return_value = iter(["data: ok\n"])
mock_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Success!"}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
for _ in range(3):
mock_stream.return_value = iter(["data: ok\n"])
user = factories.UserFactory()
client.force_login(user)
response = client.post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Test message"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 200
assert response.json() == {"content": "Success!"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Test message"}],
"model": "llama",
},
)
assert response.status_code == 429
@@ -303,9 +528,72 @@ def test_api_documents_ai_proxy_throttling_document(mock_stream):
}
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_complex_conversation(mock_create):
"""AI proxy should handle complex conversations with multiple messages."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_response = MagicMock()
mock_response.model_dump.return_value = {
"id": "chatcmpl-complex",
"object": "chat.completion",
"model": "llama",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "I understand your question about Python.",
},
"finish_reason": "stop",
}
],
}
mock_create.return_value = mock_response
complex_messages = [
{"role": "system", "content": "You are a helpful programming assistant."},
{"role": "user", "content": "How do I write a for loop in Python?"},
{
"role": "assistant",
"content": "You can write a for loop using: for item in iterable:",
},
{"role": "user", "content": "Can you give me a concrete example?"},
]
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
{
"messages": complex_messages,
"model": "llama",
},
format="json",
)
assert response.status_code == 200
response_data = response.json()
assert response_data["id"] == "chatcmpl-complex"
assert (
response_data["choices"][0]["message"]["content"]
== "I understand your question about Python."
)
mock_create.assert_called_once_with(
messages=complex_messages,
model="llama",
stream=False,
)
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("core.services.ai_services.AIService.stream")
def test_api_documents_ai_proxy_throttling_user(mock_stream):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_throttling_user(mock_create):
"""
Throttling per user should be triggered on the AI proxy endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
@@ -314,14 +602,20 @@ def test_api_documents_ai_proxy_throttling_user(mock_stream):
client = APIClient()
client.force_login(user)
mock_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Success!"}
mock_create.return_value = mock_response
for _ in range(3):
mock_stream.return_value = iter(["data: ok\n"])
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 200
@@ -329,8 +623,11 @@ def test_api_documents_ai_proxy_throttling_user(mock_stream):
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 429
@@ -339,9 +636,9 @@ def test_api_documents_ai_proxy_throttling_user(mock_stream):
}
@patch("core.services.ai_services.AIService.stream")
def test_api_documents_ai_proxy_returns_streaming_response(mock_stream):
"""AI proxy should return a StreamingHttpResponse with correct headers."""
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 10, "hour": 6, "day": 10})
def test_api_documents_ai_proxy_different_models():
"""AI proxy should work with different AI models."""
user = factories.UserFactory()
client = APIClient()
@@ -349,39 +646,41 @@ def test_api_documents_ai_proxy_returns_streaming_response(mock_stream):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_stream.return_value = iter(["data: part1\n", "data: part2\n", "data: part3\n"])
models_to_test = ["gpt-3.5-turbo", "gpt-4", "claude-3", "llama-2"]
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
for model_name in models_to_test:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/ai-proxy/",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": model_name,
},
format="json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream"
assert response["x-vercel-ai-data-stream"] == "v1"
assert response["X-Accel-Buffering"] == "no"
chunks = list(response.streaming_content)
assert len(chunks) == 3
assert response.status_code == 400
assert response.json() == {"model": [f"{model_name} is not a valid model"]}
def test_api_documents_ai_proxy_invalid_payload():
"""AI Proxy should return a 400 if the payload is invalid."""
def test_api_documents_ai_proxy_ai_feature_disabled(settings):
"""When the settings AI_FEATURE_ENABLED is set to False, the endpoint is not reachable."""
settings.AI_FEATURE_ENABLED = False
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
response = client.post(
f"/api/v1.0/documents/{document.id!s}/ai-proxy/",
b'{"foo": "bar", "trigger": "submit-message"}',
content_type="application/json",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid submitted payload"}
assert response.json() == ["AI feature is not enabled."]
@@ -1,362 +0,0 @@
"""
Test AI transform API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
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
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
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@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."
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@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")
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
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",
messages=[
{
"role": "system",
"content": (
"Summarize the markdown text, preserving language and markdown formatting. "
"Do not provide any other information. Preserve the language."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_limited_by_setting(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 == 401
@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):
"""
Authenticated 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)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
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",
messages=[
{
"role": "system",
"content": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
},
{"role": "user", "content": "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
)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
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",
messages=[
{
"role": "system",
"content": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
},
{"role": "user", "content": "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")
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
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)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
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."
}
@@ -1,384 +0,0 @@
"""
Test AI translate API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
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
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",
}
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@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."
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@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")
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Ola"))]
)
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": "Ola"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Keep the same html structure and formatting. "
"Translate the content in the html to the specified language Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_limited_by_setting(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 == 401
@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):
"""
Authenticated 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)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
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",
messages=[
{
"role": "system",
"content": (
"Keep the same html structure and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": "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
)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
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",
messages=[
{
"role": "system",
"content": (
"Keep the same html structure and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": "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")
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
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)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
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."
}
@@ -9,7 +9,6 @@ import pytest
from rest_framework.test import APIClient
from core.api.serializers import UserSerializer
from core.choices import PRIVILEGED_ROLES
from core.factories import (
DocumentAskForAccessFactory,
DocumentFactory,
@@ -200,27 +199,6 @@ def test_api_documents_ask_for_access_create_authenticated_already_has_ask_for_a
assert response.json() == {"detail": "You already ask to access to this document."}
@pytest.mark.parametrize("role", PRIVILEGED_ROLES)
def test_api_documents_ask_for_access_create_authenticated_already_has_privileged_access(
role,
):
"""
Authenticated users with privileged access (owner or admin) should not be able to
create a document ask for access.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 400
assert response.json() == {
"detail": "You already have privileged access to this document."
}
## List
@@ -318,424 +318,3 @@ def test_api_documents_duplicate_reader_non_root_document():
assert duplicated_document.is_root()
assert duplicated_document.accesses.count() == 1
assert duplicated_document.accesses.get(user=user).role == "owner"
def test_api_documents_duplicate_with_descendants_simple():
"""
Duplicating a document with descendants flag should recursively duplicate all children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create document tree
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Root Document",
)
child1 = factories.DocumentFactory(
parent=root,
title="Child 1",
)
child2 = factories.DocumentFactory(
parent=root,
title="Child 2",
)
initial_count = models.Document.objects.count()
assert initial_count == 3
# Duplicate with descendants
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check that all documents were duplicated (6 total: 3 original + 3 duplicated)
assert models.Document.objects.count() == 6
# Check root duplication
assert duplicated_root.title == "Copy of Root Document"
assert duplicated_root.creator == user
assert duplicated_root.duplicated_from == root
assert duplicated_root.get_children().count() == 2
# Check children duplication
duplicated_children = duplicated_root.get_children().order_by("title")
assert duplicated_children.count() == 2
duplicated_child1 = duplicated_children.first()
assert duplicated_child1.title == "Copy of Child 1"
assert duplicated_child1.creator == user
assert duplicated_child1.duplicated_from == child1
assert duplicated_child1.get_parent() == duplicated_root
duplicated_child2 = duplicated_children.last()
assert duplicated_child2.title == "Copy of Child 2"
assert duplicated_child2.creator == user
assert duplicated_child2.duplicated_from == child2
assert duplicated_child2.get_parent() == duplicated_root
def test_api_documents_duplicate_with_descendants_multi_level():
"""
Duplicating should recursively handle multiple levels of nesting.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Level 0",
)
child = factories.DocumentFactory(
parent=root,
title="Level 1",
)
grandchild = factories.DocumentFactory(
parent=child,
title="Level 2",
)
great_grandchild = factories.DocumentFactory(
parent=grandchild,
title="Level 3",
)
initial_count = models.Document.objects.count()
assert initial_count == 4
# Duplicate with descendants
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check that all documents were duplicated
assert models.Document.objects.count() == 8
# Verify the tree structure
assert duplicated_root.depth == root.depth
dup_children = duplicated_root.get_children()
assert dup_children.count() == 1
dup_child = dup_children.first()
assert dup_child.title == "Copy of Level 1"
assert dup_child.duplicated_from == child
dup_grandchildren = dup_child.get_children()
assert dup_grandchildren.count() == 1
dup_grandchild = dup_grandchildren.first()
assert dup_grandchild.title == "Copy of Level 2"
assert dup_grandchild.duplicated_from == grandchild
dup_great_grandchildren = dup_grandchild.get_children()
assert dup_great_grandchildren.count() == 1
dup_great_grandchild = dup_great_grandchildren.first()
assert dup_great_grandchild.title == "Copy of Level 3"
assert dup_great_grandchild.duplicated_from == great_grandchild
def test_api_documents_duplicate_with_descendants_and_attachments():
"""
Duplicating with descendants should properly handle attachments in all children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents with attachments
root_id = uuid.uuid4()
child_id = uuid.uuid4()
image_key_root, image_url_root = get_image_refs(root_id)
image_key_child, image_url_child = get_image_refs(child_id)
# Create root document with attachment
ydoc = pycrdt.Doc()
fragment = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": image_url_root}),
]
)
ydoc["document-store"] = fragment
update = ydoc.get_update()
root_content = base64.b64encode(update).decode("utf-8")
root = factories.DocumentFactory(
id=root_id,
users=[(user, "owner")],
title="Root with Image",
content=root_content,
attachments=[image_key_root],
)
# Create child with different attachment
ydoc_child = pycrdt.Doc()
fragment_child = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": image_url_child}),
]
)
ydoc_child["document-store"] = fragment_child
update_child = ydoc_child.get_update()
child_content = base64.b64encode(update_child).decode("utf-8")
child = factories.DocumentFactory(
id=child_id,
parent=root,
title="Child with Image",
content=child_content,
attachments=[image_key_child],
)
# Duplicate with descendants
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check root attachments
assert duplicated_root.attachments == [image_key_root]
assert duplicated_root.content == root_content
# Check child attachments
dup_children = duplicated_root.get_children()
assert dup_children.count() == 1
dup_child = dup_children.first()
assert dup_child.attachments == [image_key_child]
assert dup_child.content == child_content
def test_api_documents_duplicate_with_descendants_and_accesses():
"""
Duplicating with descendants and accesses should propagate accesses to all children.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create document tree with accesses
root = factories.DocumentFactory(
users=[(user, "owner"), (other_user, "editor")],
title="Root",
)
child = factories.DocumentFactory(
parent=root,
title="Child",
)
factories.UserDocumentAccessFactory(document=child, user=other_user, role="reader")
# Duplicate with descendants and accesses
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True, "with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check root accesses (should be duplicated)
root_accesses = duplicated_root.accesses.order_by("user_id")
assert root_accesses.count() == 2
assert root_accesses.get(user=user).role == "owner"
assert root_accesses.get(user=other_user).role == "editor"
# Check child accesses (should be duplicated)
dup_children = duplicated_root.get_children()
dup_child = dup_children.first()
child_accesses = dup_child.accesses.order_by("user_id")
assert child_accesses.count() == 1
assert child_accesses.get(user=other_user).role == "reader"
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_with_descendants_non_root_document_becomes_root(role):
"""
When duplicating a non-root document with descendants as a reader/editor,
it should become a root document and still duplicate its children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(users=[(user, "owner")])
child = factories.DocumentFactory(
parent=parent,
users=[(user, role)],
title="Sub Document",
)
grandchild = factories.DocumentFactory(
parent=child,
title="Grandchild",
)
assert child.is_child_of(parent)
# Duplicate the child (non-root) with descendants
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_child = models.Document.objects.get(id=response.json()["id"])
assert duplicated_child.title == "Copy of Sub Document"
dup_grandchildren = duplicated_child.get_children()
assert dup_grandchildren.count() == 1
dup_grandchild = dup_grandchildren.first()
assert dup_grandchild.title == "Copy of Grandchild"
assert dup_grandchild.duplicated_from == grandchild
def test_api_documents_duplicate_without_descendants_should_not_duplicate_children():
"""
When with_descendants is not set or False, children should not be duplicated.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create document tree
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Root",
)
child = factories.DocumentFactory(
parent=root,
title="Child",
)
initial_count = models.Document.objects.count()
assert initial_count == 2
# Duplicate without descendants (default behavior)
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Only root should be duplicated, not children
assert models.Document.objects.count() == 3
assert duplicated_root.get_children().count() == 0
def test_api_documents_duplicate_with_descendants_preserves_link_configuration():
"""
Duplicating with descendants should preserve link configuration (link_reach, link_role)
for all children when with_accesses is True.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create document tree with specific link configurations
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Root",
link_reach="public",
link_role="reader",
)
child = factories.DocumentFactory(
parent=root,
title="Child",
link_reach="restricted",
link_role="editor",
)
# Duplicate with descendants and accesses
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True, "with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check root link configuration
assert duplicated_root.link_reach == root.link_reach
assert duplicated_root.link_role == root.link_role
# Check child link configuration
dup_children = duplicated_root.get_children()
dup_child = dup_children.first()
assert dup_child.link_reach == child.link_reach
assert dup_child.link_role == child.link_role
def test_api_documents_duplicate_with_descendants_complex_tree():
"""
Test duplication of a complex tree structure with multiple branches.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a complex tree:
# root
# / \
# c1 c2
# / \ \
# gc1 gc2 gc3
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Root",
)
child1 = factories.DocumentFactory(parent=root, title="Child 1")
child2 = factories.DocumentFactory(parent=root, title="Child 2")
_grandchild1 = factories.DocumentFactory(parent=child1, title="GrandChild 1")
_grandchild2 = factories.DocumentFactory(parent=child1, title="GrandChild 2")
_grandchild3 = factories.DocumentFactory(parent=child2, title="GrandChild 3")
initial_count = models.Document.objects.count()
assert initial_count == 6
# Duplicate with descendants
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# All documents should be duplicated
assert models.Document.objects.count() == 12
# Check structure is preserved
dup_children = duplicated_root.get_children().order_by("title")
assert dup_children.count() == 2
dup_child1 = dup_children.first()
assert dup_child1.title == "Copy of Child 1"
dup_grandchildren1 = dup_child1.get_children().order_by("title")
assert dup_grandchildren1.count() == 2
assert dup_grandchildren1.first().title == "Copy of GrandChild 1"
assert dup_grandchildren1.last().title == "Copy of GrandChild 2"
dup_child2 = dup_children.last()
assert dup_child2.title == "Copy of Child 2"
dup_grandchildren2 = dup_child2.get_children()
assert dup_grandchildren2.count() == 1
assert dup_grandchildren2.first().title == "Copy of GrandChild 3"
@@ -16,16 +16,7 @@ fake = Faker()
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"title_search_field",
# for integration with indexer search we must have
# the same filtering behaviour with "q" and "title" parameters
[
("title"),
("q"),
],
)
def test_api_documents_list_filter_and_access_rights(title_search_field):
def test_api_documents_list_filter_and_access_rights():
"""Filtering on querystring parameters should respect access rights."""
user = factories.UserFactory()
client = APIClient()
@@ -85,7 +76,7 @@ def test_api_documents_list_filter_and_access_rights(title_search_field):
filters = {
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
title_search_field: random.choice([None, *word_list]),
"title": random.choice([None, *word_list]),
"favorite": random.choice([None, True, False]),
"creator": random.choice([None, user, other_user]),
"ordering": random.choice(
@@ -30,8 +30,6 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": False,
@@ -59,7 +57,6 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"partial_update": document.link_role == "editor",
"restore": False,
"retrieve": True,
"search": True,
"tree": True,
"update": document.link_role == "editor",
"versions_destroy": False,
@@ -110,8 +107,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": False,
@@ -137,7 +132,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
"search": True,
"tree": True,
"update": grand_parent.link_role == "editor",
"versions_destroy": False,
@@ -220,8 +214,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": document.link_role == "editor",
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": document.link_role == "editor",
@@ -248,7 +240,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"partial_update": document.link_role == "editor",
"restore": False,
"retrieve": True,
"search": True,
"tree": True,
"update": document.link_role == "editor",
"versions_destroy": False,
@@ -307,8 +298,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": grand_parent.link_role == "editor",
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": grand_parent.link_role == "editor",
@@ -333,7 +322,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
"search": True,
"tree": True,
"update": grand_parent.link_role == "editor",
"versions_destroy": False,
@@ -533,7 +521,6 @@ def test_api_documents_retrieve_authenticated_related_parent():
"partial_update": access.role not in ["reader", "commenter"],
"restore": access.role == "owner",
"retrieve": True,
"search": True,
"tree": True,
"update": access.role not in ["reader", "commenter"],
"versions_destroy": access.role in ["administrator", "owner"],
@@ -1067,48 +1054,3 @@ def test_api_documents_retrieve_permanently_deleted_related(role, depth):
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_documents_retrieve_without_content():
"""
Test retrieve using without_content query string should remove the content in the response
"""
user = factories.UserFactory()
document = factories.DocumentFactory(creator=user, users=[(user, "owner")])
client = APIClient()
client.force_login(user)
with mock.patch("core.models.Document.content") as mock_document_content:
response = client.get(
f"/api/v1.0/documents/{document.id!s}/?without_content=true"
)
assert response.status_code == 200
payload = response.json()
assert "content" not in payload
mock_document_content.assert_not_called()
def test_api_documents_retrieve_without_content_invalid_value():
"""
Test retrieve using without_content query string but an invalid value
should return a 400
"""
user = factories.UserFactory()
document = factories.DocumentFactory(creator=user, users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/?without_content=invalid-value"
)
assert response.status_code == 400
assert response.json() == ["Must be a valid boolean."]
@@ -1,40 +1,46 @@
"""
Tests for Documents API endpoint in impress's core app: search
Tests for Documents API endpoint in impress's core app: list
"""
from unittest import mock
import random
from json import loads as json_loads
from django.test import RequestFactory
import pytest
import responses
from faker import Faker
from rest_framework import response as drf_response
from rest_framework.test import APIClient
from waffle.testutils import override_flag
from core import factories
from core.enums import FeatureFlag, SearchType
from core import factories, models
from core.services.search_indexers import get_document_indexer
fake = Faker()
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def enable_flag_find_hybrid_search():
"""Enable flag_find_hybrid_search for all tests in this module."""
with override_flag(FeatureFlag.FLAG_FIND_HYBRID_SEARCH, active=True):
yield
def build_search_url(**kwargs):
"""Build absolute uri for search endpoint with ORDERED query arguments"""
return (
RequestFactory()
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
.build_absolute_uri()
)
@mock.patch("core.services.search_indexers.FindDocumentIndexer.search_query")
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@responses.activate
def test_api_documents_search_anonymous(search_query, indexer_settings):
def test_api_documents_search_anonymous(reach, role, indexer_settings):
"""
Anonymous users should be allowed to search documents with Find.
Anonymous users should not be allowed to search documents whatever the
link reach and link role
"""
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
# mock Find response
factories.DocumentFactory(link_reach=reach, link_role=role)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
@@ -42,23 +48,7 @@ def test_api_documents_search_anonymous(search_query, indexer_settings):
status=200,
)
q = "alpha"
response = APIClient().get("/api/v1.0/documents/search/", data={"q": q})
assert search_query.call_count == 1
assert search_query.call_args[1] == {
"data": {
"q": q,
"visited": [],
"services": ["docs"],
"nb_results": 50,
"order_by": "updated_at",
"order_direction": "desc",
"path": None,
"search_type": SearchType.HYBRID,
},
"token": None,
}
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
assert response.json() == {
@@ -69,151 +59,23 @@ def test_api_documents_search_anonymous(search_query, indexer_settings):
}
@mock.patch("core.api.viewsets.DocumentViewSet.list")
def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settings):
def test_api_documents_search_endpoint_is_none(indexer_settings):
"""
When indexer is not configured and no path is provided,
should fall back on list method
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
Should fallback on title filter
"""
indexer_settings.SEARCH_URL = None
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
document = factories.DocumentFactory(title="alpha")
access = factories.UserDocumentAccessFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
mocked_response = {
"count": 0,
"next": None,
"previous": None,
"results": [{"title": "mocked list result"}],
}
mock_list.return_value = drf_response.Response(mocked_response)
q = "alpha"
response = client.get("/api/v1.0/documents/search/", data={"q": q})
assert mock_list.call_count == 1
assert mock_list.call_args[0][0].GET.get("q") == q
assert response.json() == mocked_response
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
def test_api_documents_search_fallback_on_search_list_sub_docs(
mock_list_descendants, indexer_settings
):
"""
When indexer is not configured and path parameter is provided,
should call _list_descendants() method
"""
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(title="parent", users=[user])
mocked_response = {
"count": 0,
"next": None,
"previous": None,
"results": [{"title": "mocked _list_descendants result"}],
}
mock_list_descendants.return_value = drf_response.Response(mocked_response)
q = "alpha"
response = client.get(
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
)
assert mock_list_descendants.call_count == 1
assert mock_list_descendants.call_args[0][0].GET.get("q") == q
assert mock_list_descendants.call_args[0][0].GET.get("path") == parent.path
assert response.json() == mocked_response
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
def test_api_documents_search_indexer_crashes(mock_title_search, indexer_settings):
"""
When indexer is configured but crashes -> falls back on title_search
"""
# indexer is properly configured
indexer_settings.SEARCH_URL = None
assert get_document_indexer() is None
# but returns an error when the query is sent
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[{"error": "Some indexer error"}],
status=404,
)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mocked_response = {
"count": 0,
"next": None,
"previous": None,
"results": [{"title": "mocked title_search result"}],
}
mock_title_search.return_value = drf_response.Response(mocked_response)
parent = factories.DocumentFactory(title="parent", users=[user])
q = "alpha"
response = client.get(
"/api/v1.0/documents/search/", data={"q": "alpha", "path": parent.path}
)
# the search endpoint did not crash
assert response.status_code == 200
# fallback on title_search
assert mock_title_search.call_count == 1
assert mock_title_search.call_args[0][0].GET.get("q") == q
assert mock_title_search.call_args[0][0].GET.get("path") == parent.path
assert response.json() == mocked_response
@responses.activate
def test_api_documents_search_invalid_params(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/")
assert response.status_code == 400
assert response.json() == {"q": ["This field is required."]}
@responses.activate
def test_api_documents_search_success(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
document = {"id": "doc-123", "title": "alpha", "path": "path/to/alpha.pdf"}
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[
{
"_id": str(document["id"]),
"_source": {"title": document["title"], "path": document["path"]},
},
],
status=200,
)
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
@@ -223,6 +85,341 @@ def test_api_documents_search_success(indexer_settings):
"next": None,
"previous": None,
}
assert results == [
{"id": document["id"], "title": document["title"], "path": document["path"]}
]
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
def test_api_documents_search_invalid_params(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/")
assert response.status_code == 400
assert response.json() == {"q": ["This field is required."]}
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
assert response.status_code == 400
assert response.json() == {"q": ["This field may not be blank."]}
response = client.get(
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
)
assert response.status_code == 400
assert response.json() == {"page": ["A valid integer is required."]}
@responses.activate
def test_api_documents_search_format(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
title="alpha",
users=(user_a, user_c),
link_traces=(user, user_b),
)
access = factories.UserDocumentAccessFactory(document=document, user=user)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[
{"_id": str(document.pk)},
],
status=200,
)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
},
),
(
{},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
"api_page_size": 21, # default page_size is 20
},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "score" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
docs_by_uuid = {str(doc.pk): doc for doc in docs}
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
# reorder randomly to simulate score ordering
random.shuffle(api_results)
# Find response
# pylint: disable-next=assignment-from-none
api_search = responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=api_results,
status=200,
)
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
start, end = expected["range"]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
# The find api results ordering by score is kept
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
# Check the query parameters.
assert api_search.call_count == 1
assert api_search.calls[0].response.status_code == 200
assert json_loads(api_search.calls[0].request.body) == {
"q": "alpha",
"visited": [],
"services": ["docs"],
"nb_results": 50,
"order_by": "updated_at",
"order_direction": "desc",
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination_endpoint_is_none(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "-updated_at" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
queryset = models.Document.objects.order_by("-updated_at")
start, end = expected["range"]
expected_results = [str(d.pk) for d in queryset[start:end]]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
assert [r["id"] for r in results] == expected_results
@@ -1,956 +0,0 @@
"""
Tests for search API endpoint in impress's core app when indexer is not
available and a path param is given.
"""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api.filters import remove_accents
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def disable_indexer(indexer_settings):
"""Disable search indexer for all tests in this file."""
indexer_settings.SEARCH_INDEXER_CLASS = None
def test_api_documents_search_descendants_list_anonymous_public_standalone():
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
document = factories.DocumentFactory(link_reach="public", title="doc parent")
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="doc child"
)
grand_child = factories.DocumentFactory(parent=child1, title="doc grand child")
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 4,
"next": None,
"previous": None,
"results": [
{
# the search should include the parent document itself
"abilities": document.get_abilities(AnonymousUser()),
"ancestors_link_role": None,
"ancestors_link_reach": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"numchild": 2,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": document.link_role
if (child1.link_reach == "public" and child1.link_role == "editor")
else document.link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
],
}
def test_api_documents_search_descendants_list_anonymous_public_parent():
"""
Anonymous users should be allowed to retrieve the descendants of a document who
has a public ancestor.
"""
grand_parent = factories.DocumentFactory(
link_reach="public", title="grand parent doc"
)
parent = factories.DocumentFactory(
parent=grand_parent,
link_reach=random.choice(["authenticated", "restricted"]),
title="parent doc",
)
document = factories.DocumentFactory(
link_reach=random.choice(["authenticated", "restricted"]),
parent=parent,
title="document",
)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child doc"
)
grand_child = factories.DocumentFactory(parent=child1, title="grand child doc")
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 4,
"next": None,
"previous": None,
"results": [
{
# the search should include the parent document itself
"abilities": document.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"numchild": 2,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
],
}
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
def test_api_documents_search_descendants_list_anonymous_restricted_or_authenticated(
reach,
):
"""
Anonymous users should not be able to retrieve descendants of a document that is not public.
"""
document = factories.DocumentFactory(title="parent", link_reach=reach)
child = factories.DocumentFactory(title="child", parent=document)
_grand_child = factories.DocumentFactory(title="grand child", parent=child)
response = APIClient().get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to search within this document."
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_search_descendants_list_authenticated_unrelated_public_or_authenticated(
reach,
):
"""
Authenticated users should be able to retrieve the descendants of a public/authenticated
document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, title="parent")
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, link_reach="restricted", title="child"
)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
],
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_search_descendants_list_authenticated_public_or_authenticated_parent(
reach,
):
"""
Authenticated users should be allowed to retrieve the descendants of a document who
has a public or authenticated ancestor.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach=reach, title="grand parent")
parent = factories.DocumentFactory(
parent=grand_parent, link_reach="restricted", title="parent"
)
document = factories.DocumentFactory(
link_reach="restricted", parent=parent, title="document"
)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, link_reach="restricted", title="child"
)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
],
}
def test_api_documents_search_descendants_list_authenticated_unrelated_restricted():
"""
Authenticated users should not be allowed to retrieve the descendants of 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", title="parent")
child1, _child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child"
)
_grand_child = factories.DocumentFactory(parent=child1, title="grand child")
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to search within this document."
}
def test_api_documents_search_descendants_list_authenticated_related_direct():
"""
Authenticated users should be allowed to retrieve the descendants of a document
to which they are directly related whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(title="parent")
access = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child"
)
factories.UserDocumentAccessFactory(document=child1)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
],
}
def test_api_documents_search_descendants_list_authenticated_related_parent():
"""
Authenticated users should be allowed to retrieve the descendants of a document if they
are related to one of its ancestors whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach="restricted", title="parent")
grand_parent_access = factories.UserDocumentAccessFactory(
document=grand_parent, user=user
)
parent = factories.DocumentFactory(
parent=grand_parent, link_reach="restricted", title="parent"
)
document = factories.DocumentFactory(
parent=parent, link_reach="restricted", title="document"
)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child"
)
factories.UserDocumentAccessFactory(document=child1)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role,
},
],
}
def test_api_documents_search_descendants_list_authenticated_related_child():
"""
Authenticated users should not be allowed to retrieve all the descendants of a document
as a result of being related to one of its children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
_grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1, user=user)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to search within this document."
}
def test_api_documents_search_descendants_list_authenticated_related_team_none(
mock_user_teams,
):
"""
Authenticated users should not be able to retrieve the descendants of a restricted document
related to teams in which the user is not.
"""
mock_user_teams.return_value = []
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", title="document")
factories.DocumentFactory.create_batch(2, parent=document, title="child")
factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to search within this document."
}
def test_api_documents_search_descendants_list_authenticated_related_team_members(
mock_user_teams,
):
"""
Authenticated users should be allowed to retrieve the descendants of a document to which they
are related via a team whatever the role.
"""
mock_user_teams.return_value = ["myteam"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", title="parent")
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child"
)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
# pylint: disable=R0801
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
],
}
@pytest.mark.parametrize(
"query,nb_results",
[
("", 7), # Empty string
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 2), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("velo", 1), # Accent-insensitive match (velo vs vélo)
("bêta", 1), # Accent-insensitive match (bêta vs beta)
],
)
def test_api_documents_search_descendants_search_on_title(query, nb_results):
"""Authenticated users should be able to search documents by their unaccented title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(users=[user])
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
]
for title in titles:
factories.DocumentFactory(title=title, parent=parent)
# Perform the search query
response = client.get(
"/api/v1.0/documents/search/", data={"q": query, "path": parent.path}
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert (
remove_accents(query).lower().strip()
in remove_accents(result["title"]).lower()
)
@@ -1,89 +0,0 @@
"""
Tests for Find search feature flags
"""
from unittest import mock
from django.http import HttpResponse
import pytest
import responses
from rest_framework.test import APIClient
from waffle.testutils import override_flag
from core.enums import FeatureFlag, SearchType
from core.services.search_indexers import get_document_indexer
pytestmark = pytest.mark.django_db
@responses.activate
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
@mock.patch("core.api.viewsets.DocumentViewSet._search_with_indexer")
@pytest.mark.parametrize(
"activated_flags,"
"expected_search_type,"
"expected_search_with_indexer_called,"
"expected_title_search_called",
[
([], SearchType.TITLE, False, True),
([FeatureFlag.FLAG_FIND_HYBRID_SEARCH], SearchType.HYBRID, True, False),
(
[
FeatureFlag.FLAG_FIND_HYBRID_SEARCH,
FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH,
],
SearchType.HYBRID,
True,
False,
),
([FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH], SearchType.FULL_TEXT, True, False),
],
)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def test_api_documents_search_success( # noqa : PLR0913
mock_search_with_indexer,
mock_title_search,
activated_flags,
expected_search_type,
expected_search_with_indexer_called,
expected_title_search_called,
):
"""
Test that the API endpoint for searching documents returns a successful response
with the expected search type according to the activated feature flags,
and that the appropriate search method is called.
"""
assert get_document_indexer() is not None
mock_search_with_indexer.return_value = HttpResponse()
mock_title_search.return_value = HttpResponse()
with override_flag(
FeatureFlag.FLAG_FIND_HYBRID_SEARCH,
active=FeatureFlag.FLAG_FIND_HYBRID_SEARCH in activated_flags,
):
with override_flag(
FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH,
active=FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH in activated_flags,
):
response = APIClient().get(
"/api/v1.0/documents/search/", data={"q": "alpha"}
)
assert response.status_code == 200
if expected_search_with_indexer_called:
mock_search_with_indexer.assert_called_once()
assert (
mock_search_with_indexer.call_args.kwargs["search_type"]
== expected_search_type
)
else:
assert not mock_search_with_indexer.called
if expected_title_search_called:
assert SearchType.TITLE == expected_search_type
mock_title_search.assert_called_once()
else:
assert not mock_title_search.called
@@ -73,8 +73,6 @@ def test_api_documents_trashbin_format():
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
@@ -101,7 +99,6 @@ def test_api_documents_trashbin_format():
"partial_update": False,
"restore": True,
"retrieve": True,
"search": False,
"tree": True,
"update": False,
"versions_destroy": False,
@@ -1,5 +1,7 @@
import pytest
from core import models
@pytest.mark.django_db
def test_update_blank_title_migration(migrator):
@@ -7,6 +7,8 @@ from django.core.files.storage import default_storage
import pycrdt
import pytest
from core import models
@pytest.mark.django_db
def test_populate_attachments_on_all_documents(migrator):
@@ -1,52 +0,0 @@
"""Module testing migration 0030 about adding is_first_connection to user model."""
from django.contrib.auth.hashers import make_password
import factory
import pytest
from core import models
@pytest.mark.django_db
def test_set_is_first_connection_false(migrator):
"""
Test that once the migration adding is_first_connection column to user model is applied
all existing user have the False value.
"""
old_state = migrator.apply_initial_migration(
("core", "0029_userreconciliationcsvimport_userreconciliation")
)
OldUser = old_state.apps.get_model("core", "User")
old_user1 = OldUser.objects.create(
email="email1@example.com", sub="user1", password=make_password("password")
)
old_user2 = OldUser.objects.create(
email="email2@example.com", sub="user2", password=make_password("password")
)
assert hasattr(old_user1, "is_first_connection") is False
assert hasattr(old_user2, "is_first_connection") is False
# # Apply the migration
new_state = migrator.apply_tested_migration(
("core", "0030_user_is_first_connection")
)
NewUser = new_state.apps.get_model("core", "User")
updated_user1 = NewUser.objects.get(id=old_user1.id)
assert updated_user1.is_first_connection is False
updated_user2 = NewUser.objects.get(id=old_user2.id)
assert updated_user2.is_first_connection is False
# create a new user after migration
new_user1 = NewUser.objects.create(
email="email3example.com", sub="user3", password=make_password("password")
)
assert new_user1.is_first_connection is True
@@ -1,193 +0,0 @@
"""Module testing migration 0031_clean_onboarding_accesses."""
from django.contrib.auth.hashers import make_password
import pytest
def create_user(OldUser, n):
"""Create a user with a unique sub and email based on the given index."""
return OldUser.objects.create(
email=f"user-{n}@example.com",
sub=f"user-{n}",
password=make_password("password"),
)
@pytest.mark.django_db
def test_clean_onboarding_accesses(migrator, settings):
"""Test migration 0031_clean_onboarding_accesses."""
old_state = migrator.apply_initial_migration(
("core", "0030_user_is_first_connection")
)
OldUser = old_state.apps.get_model("core", "User")
OldDocument = old_state.apps.get_model("core", "Document")
OldDocumentAccess = old_state.apps.get_model("core", "DocumentAccess")
# Create onboarding documents
onboarding_doc_1 = OldDocument.objects.create(
title="Onboarding Doc 1", depth=1, path="0000001", link_reach="public"
)
onboarding_doc_2 = OldDocument.objects.create(
title="Onboarding Doc 2", depth=1, path="0000002", link_reach="public"
)
onboarding_documents = [onboarding_doc_1, onboarding_doc_2]
settings.USER_ONBOARDING_DOCUMENTS = [str(doc.id) for doc in onboarding_documents]
# Create other documents
non_onboarding_doc_1 = OldDocument.objects.create(
title="Non-Onboarding Doc 1", depth=1, path="0000003", link_reach="public"
)
non_onboarding_doc_2 = OldDocument.objects.create(
title="Non-Onboarding Doc 2", depth=1, path="0000004", link_reach="public"
)
non_onboarding_doc_3 = OldDocument.objects.create(
title="Non-Onboarding Doc 3", depth=1, path="0000005", link_reach="public"
)
non_onboarding_documents = [
non_onboarding_doc_1,
non_onboarding_doc_2,
non_onboarding_doc_3,
]
all_documents = onboarding_documents + non_onboarding_documents
user_counter = 0
# For every document create privileged roles: owner and admin
for document in all_documents:
OldDocumentAccess.objects.create(
document=document,
user=create_user(OldUser, user_counter),
role="owner",
)
user_counter += 1
OldDocumentAccess.objects.create(
document=document,
user=create_user(OldUser, user_counter),
role="administrator",
)
user_counter += 1
# For every document, create non-privileged roles
for document in all_documents:
for role in ["reader", "editor", "commenter"]:
for _ in range(10):
OldDocumentAccess.objects.create(
document=document,
user=create_user(OldUser, user_counter),
role=role,
)
user_counter += 1
onboarding_ids = [doc.id for doc in onboarding_documents]
non_onboarding_ids = [doc.id for doc in non_onboarding_documents]
# All documents should have 32 accesses each, so 160 accesses total
assert OldDocumentAccess.objects.count() == 160
assert (
OldDocumentAccess.objects.filter(document_id__in=onboarding_ids)
.exclude(role__in=["administrator", "owner"])
.count()
== 60
)
assert (
OldDocumentAccess.objects.filter(
document_id__in=onboarding_ids, role__in=["administrator", "owner"]
).count()
== 4
)
assert (
OldDocumentAccess.objects.filter(document_id__in=non_onboarding_ids)
.exclude(role__in=["administrator", "owner"])
.count()
== 90
)
assert (
OldDocumentAccess.objects.filter(
document_id__in=non_onboarding_ids, role__in=["administrator", "owner"]
).count()
== 6
)
# Apply the migration
new_state = migrator.apply_tested_migration(
("core", "0031_clean_onboarding_accesses")
)
NewDocumentAccess = new_state.apps.get_model("core", "DocumentAccess")
# 60 accesses should have been removed (30 non-privileged for each onboarding doc)
assert NewDocumentAccess.objects.count() == 100
# Non-privileged roles should have been deleted on the onboarding documents
assert (
NewDocumentAccess.objects.filter(document_id__in=onboarding_ids)
.exclude(role__in=["administrator", "owner"])
.count()
== 0
)
# Privileged roles should have been kept
assert (
NewDocumentAccess.objects.filter(
document_id__in=onboarding_ids, role__in=["administrator", "owner"]
).count()
== 4
)
# On other documents, all accesses should remain
assert (
NewDocumentAccess.objects.filter(document_id__in=non_onboarding_ids)
.exclude(role__in=["administrator", "owner"])
.count()
== 90
)
# Privileged roles should have been kept
assert (
NewDocumentAccess.objects.filter(
document_id__in=non_onboarding_ids, role__in=["administrator", "owner"]
).count()
== 6
)
@pytest.mark.django_db
def test_clean_onboarding_accesses_no_setting(migrator, settings):
"""Test migration 0031 does not delete any access when USER_ONBOARDING_DOCUMENTS is empty."""
old_state = migrator.apply_initial_migration(
("core", "0030_user_is_first_connection")
)
OldUser = old_state.apps.get_model("core", "User")
OldDocument = old_state.apps.get_model("core", "Document")
OldDocumentAccess = old_state.apps.get_model("core", "DocumentAccess")
settings.USER_ONBOARDING_DOCUMENTS = []
doc_1 = OldDocument.objects.create(title="Doc 1", depth=1, path="0000001")
doc_2 = OldDocument.objects.create(title="Doc 2", depth=1, path="0000002")
user_counter = 0
for document in [doc_1, doc_2]:
for role in ["owner", "administrator", "reader", "editor", "commenter"]:
OldDocumentAccess.objects.create(
document=document,
user=create_user(OldUser, user_counter),
role=role,
)
user_counter += 1
assert OldDocumentAccess.objects.count() == 10
new_state = migrator.apply_tested_migration(
("core", "0031_clean_onboarding_accesses")
)
NewDocumentAccess = new_state.apps.get_model("core", "DocumentAccess")
# No accesses should have been deleted
assert NewDocumentAccess.objects.count() == 10
+5 -7
View File
@@ -21,9 +21,8 @@ pytestmark = pytest.mark.django_db
@override_settings(
AI_BOT={"name": "Test Bot", "color": "#000000"},
AI_FEATURE_ENABLED=False,
AI_FEATURE_BLOCKNOTE_ENABLED=False,
AI_FEATURE_LEGACY_ENABLED=False,
API_USERS_SEARCH_QUERY_MIN_LENGTH=6,
AI_MODEL="test-model",
AI_STREAM=False,
COLLABORATION_WS_URL="http://testcollab/",
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
CRISP_WEBSITE_ID="123",
@@ -49,9 +48,9 @@ def test_api_config(is_authenticated):
assert response.json() == {
"AI_BOT": {"name": "Test Bot", "color": "#000000"},
"AI_FEATURE_ENABLED": False,
"AI_FEATURE_BLOCKNOTE_ENABLED": False,
"AI_FEATURE_LEGACY_ENABLED": False,
"API_USERS_SEARCH_QUERY_MIN_LENGTH": 6,
"AI_MODEL": "test-model",
"AI_FEATURE_ENABLED": False,
"AI_STREAM": False,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
@@ -61,7 +60,6 @@ def test_api_config(is_authenticated):
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_JS_URL": "http://testjs/",
"FRONTEND_SILENT_LOGIN_ENABLED": False,
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
@@ -1,85 +0,0 @@
"""
Unit tests for the ReconciliationConfirmView API view.
"""
import uuid
from django.conf import settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_reconciliation_confirm_view_sets_active_checked():
"""GETting the active confirmation endpoint should set active_email_checked."""
user = factories.UserFactory(email="user.confirm1@example.com")
other = factories.UserFactory(email="user.confirm2@example.com")
rec = models.UserReconciliation.objects.create(
active_email=user.email,
inactive_email=other.email,
active_user=user,
inactive_user=other,
active_email_checked=False,
inactive_email_checked=False,
status="ready",
)
client = APIClient()
conf_id = rec.active_email_confirmation_id
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{conf_id}/"
resp = client.get(url)
assert resp.status_code == 200
assert resp.json() == {"detail": "Confirmation received"}
rec.refresh_from_db()
assert rec.active_email_checked is True
def test_reconciliation_confirm_view_sets_inactive_checked():
"""GETting the inactive confirmation endpoint should set inactive_email_checked."""
user = factories.UserFactory(email="user.confirm3@example.com")
other = factories.UserFactory(email="user.confirm4@example.com")
rec = models.UserReconciliation.objects.create(
active_email=user.email,
inactive_email=other.email,
active_user=user,
inactive_user=other,
active_email_checked=False,
inactive_email_checked=False,
status="ready",
)
client = APIClient()
conf_id = rec.inactive_email_confirmation_id
url = f"/api/{settings.API_VERSION}/user-reconciliations/inactive/{conf_id}/"
resp = client.get(url)
assert resp.status_code == 200
assert resp.json() == {"detail": "Confirmation received"}
rec.refresh_from_db()
assert rec.inactive_email_checked is True
def test_reconciliation_confirm_view_invalid_user_type_returns_400():
"""GETting with an invalid user_type should return 400."""
client = APIClient()
# Use a valid uuid format but invalid user_type
url = f"/api/{settings.API_VERSION}/user-reconciliations/other/{uuid.uuid4()}/"
resp = client.get(url)
assert resp.status_code == 400
assert resp.json() == {"detail": "Invalid user_type"}
def test_reconciliation_confirm_view_not_found_returns_404():
"""GETting with a non-existing confirmation_id should return 404."""
client = APIClient()
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{uuid.uuid4()}/"
resp = client.get(url)
assert resp.status_code == 404
assert resp.json() == {"detail": "Reconciliation entry not found"}
+23 -112
View File
@@ -2,8 +2,6 @@
Test users API endpoints in the impress core app.
"""
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
@@ -123,12 +121,12 @@ def test_api_users_list_query_full_name():
Authenticated users should be able to list users and filter by full name.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="contact@example.com", full_name="David Bowman")
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
response = client.get(
"/api/v1.0/users/?q=David",
@@ -168,13 +166,13 @@ def test_api_users_list_query_accented_full_name():
Authenticated users should be able to list users and filter by full name with accents.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
fred = factories.UserFactory(
email="contact@example.com", full_name="Frédérique Lefèvre"
email="contact@work.com", full_name="Frédérique Lefèvre"
)
response = client.get("/api/v1.0/users/?q=Frédérique")
@@ -203,82 +201,12 @@ def test_api_users_list_query_accented_full_name():
assert users == []
def test_api_users_list_sorted_by_closest_match():
"""
Authenticated users should be able to list users and the results should be
sorted by closest match to the query.
Sorting criteria are :
- Shared documents with the user (most recent first)
- Same full email domain (example.gouv.fr)
Addresses that match neither criteria should be excluded from the results.
Case in point: the logged-in user has recently shared documents
with pierre.dupont@beta.gouv.fr and less recently with pierre.durand@impots.gouv.fr.
Other users named Pierre also exist:
- pierre.thomas@example.com
- pierre.petit@anct.gouv.fr
- pierre.robert@culture.gouv.fr
The search results should be ordered as follows:
# Shared with first
- pierre.dupond@beta.gouv.fr # Most recent first
- pierre.durand@impots.gouv.fr
# Same full domain second
- pierre.petit@anct.gouv.fr
"""
user = factories.UserFactory(
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
)
client = APIClient()
client.force_login(user)
pierre_1 = factories.UserFactory(email="pierre.dupont@beta.gouv.fr")
pierre_2 = factories.UserFactory(email="pierre.durand@impots.gouv.fr")
_pierre_3 = factories.UserFactory(email="pierre.thomas@example.com")
pierre_4 = factories.UserFactory(email="pierre.petit@anct.gouv.fr")
_pierre_5 = factories.UserFactory(email="pierre.robert@culture.gouv.fr")
document_1 = factories.DocumentFactory(creator=user)
document_2 = factories.DocumentFactory(creator=user)
factories.UserDocumentAccessFactory(user=user, document=document_1)
factories.UserDocumentAccessFactory(user=user, document=document_2)
now = timezone.now()
last_week = now - timezone.timedelta(days=7)
last_month = now - timezone.timedelta(days=30)
# The factory cannot set the created_at directly, so we force it after creation
p1_d1 = factories.UserDocumentAccessFactory(user=pierre_1, document=document_1)
p1_d1.created_at = last_week
p1_d1.save()
p2_d2 = factories.UserDocumentAccessFactory(user=pierre_2, document=document_2)
p2_d2.created_at = last_month
p2_d2.save()
response = client.get("/api/v1.0/users/?q=Pierre")
assert response.status_code == 200
user_ids = [user["email"] for user in response.json()]
assert user_ids == [
str(pierre_1.email),
str(pierre_2.email),
str(pierre_4.email),
]
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
should be limited to API_USERS_LIST_LIMIT (by default 5).
should be limited to 10.
"""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -381,16 +309,28 @@ def test_api_users_list_query_email_exclude_doc_user():
def test_api_users_list_query_short_queries():
"""
If API_USERS_SEARCH_QUERY_MIN_LENGTH is not set, the default minimum length should be 3.
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com", full_name="John Doe")
factories.UserFactory(email="john.lennon@example.com", full_name="John Lennon")
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=joh")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 2)."]
}
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 4)."]
}
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert len(response.json()) == 2
@@ -416,7 +356,7 @@ def test_api_users_list_query_long_queries():
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -460,7 +400,6 @@ def test_api_users_retrieve_me_authenticated():
"full_name": user.full_name,
"language": user.language,
"short_name": user.short_name,
"is_first_connection": True,
}
@@ -490,37 +429,9 @@ def test_api_users_retrieve_me_authenticated_empty_name():
"full_name": "test_foo",
"language": user.language,
"short_name": "test_foo",
"is_first_connection": True,
}
def test_api_users_retrieve_me_onboarding():
"""
On first connection of a new user, the "is_first_connection" flag should be True.
The frontend can use this flag to trigger specific behavior for first time users,
e.g. showing an onboarding message, and update the flag to False after onboarding is done.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# First request: flag should be True
first_response = client.get("/api/v1.0/users/me/")
assert first_response.status_code == 200
assert first_response.json()["is_first_connection"] is True
update_response = client.post("/api/v1.0/users/onboarding-done/")
assert update_response.status_code == 200
# Second request: flag should be False
second_response = client.get("/api/v1.0/users/me/")
assert second_response.status_code == 200
assert second_response.json()["is_first_connection"] is False
def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()
@@ -156,8 +156,6 @@ def test_models_documents_get_abilities_forbidden(
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
@@ -189,7 +187,6 @@ def test_models_documents_get_abilities_forbidden(
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
"search": False,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
@@ -223,8 +220,6 @@ def test_models_documents_get_abilities_reader(
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
@@ -256,7 +251,6 @@ def test_models_documents_get_abilities_reader(
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
"search": True,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
@@ -294,7 +288,6 @@ def test_models_documents_get_abilities_commenter(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
@@ -328,7 +321,6 @@ def test_models_documents_get_abilities_commenter(
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
"search": True,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
@@ -364,8 +356,6 @@ def test_models_documents_get_abilities_editor(
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": is_authenticated,
"ai_transform": is_authenticated,
"ai_translate": is_authenticated,
"attachment_upload": True,
"can_edit": True,
"children_create": is_authenticated,
@@ -397,7 +387,6 @@ def test_models_documents_get_abilities_editor(
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
"search": True,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
@@ -422,8 +411,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"accesses_manage": True,
"accesses_view": True,
"ai_proxy": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
@@ -455,7 +442,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
"search": True,
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
@@ -465,7 +451,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
assert document.get_abilities(user) == {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
@@ -499,7 +484,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
"search": False,
}
@@ -514,8 +498,6 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"accesses_manage": True,
"accesses_view": True,
"ai_proxy": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
@@ -547,7 +529,6 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
"search": True,
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
@@ -572,8 +553,6 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"accesses_manage": False,
"accesses_view": True,
"ai_proxy": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
@@ -605,7 +584,6 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
"search": True,
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
@@ -637,8 +615,6 @@ def test_models_documents_get_abilities_reader_user(
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_proxy": access_from_link and ai_access_setting != "restricted",
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
@@ -671,7 +647,6 @@ def test_models_documents_get_abilities_reader_user(
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
"search": True,
}
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
@@ -704,7 +679,6 @@ def test_models_documents_get_abilities_commenter_user(
"accesses_view": True,
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_proxy": access_from_link and ai_access_setting != "restricted",
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
@@ -738,7 +712,6 @@ def test_models_documents_get_abilities_commenter_user(
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
"search": True,
}
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
@@ -768,8 +741,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"accesses_manage": False,
"accesses_view": True,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
@@ -801,7 +772,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
"search": True,
}
@@ -901,8 +871,6 @@ def test_models_document_get_abilities_ai_access_authenticated(is_authenticated,
abilities = document.get_abilities(user)
assert abilities["ai_proxy"] is True
assert abilities["ai_transform"] is True
assert abilities["ai_translate"] is True
@override_settings(AI_ALLOW_REACH_FROM="authenticated")
@@ -921,8 +889,6 @@ def test_models_document_get_abilities_ai_access_public(is_authenticated, reach)
abilities = document.get_abilities(user)
assert abilities["ai_proxy"] == is_authenticated
assert abilities["ai_transform"] == is_authenticated
assert abilities["ai_translate"] == is_authenticated
def test_models_documents_get_versions_slice_pagination(settings):
@@ -1045,10 +1011,7 @@ def test_models_documents__email_invitation__success():
f"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
f"on the following document: {document.title}" in email_content
)
assert (
f"docs/{document.id}/?utm_source=docssharelink&amp;utm_campaign={document.id}"
in email_content
)
assert f"docs/{document.id}/" in email_content
@pytest.mark.parametrize(
@@ -1078,18 +1041,10 @@ def test_models_documents__email_invitation__url_app_param(email_url_app):
# Determine expected domain
if email_url_app:
expected_url = (
f"https://test-example.com/docs/{document.id}/"
f"?utm_source=docssharelink&amp;utm_campaign={document.id}"
)
assert expected_url in email_content
assert f"https://test-example.com/docs/{document.id}/" in email_content
else:
# Default Site domain is example.com
expected_url = (
f"example.com/docs/{document.id}/"
f"?utm_source=docssharelink&amp;utm_campaign={document.id}"
)
assert expected_url in email_content
assert f"example.com/docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_empty_title():
@@ -1120,10 +1075,7 @@ def test_models_documents__email_invitation__success_empty_title():
"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
"on the following document: Untitled Document" in email_content
)
assert (
f"docs/{document.id}/?utm_source=docssharelink&amp;utm_campaign={document.id}"
in email_content
)
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_fr():
@@ -1158,10 +1110,7 @@ def test_models_documents__email_invitation__success_fr():
f"Test Sender2 (sender2@example.com) vous a invité avec le rôle &quot;propriétaire&quot; "
f"sur le document suivant : {document.title}" in email_content
)
assert (
f"docs/{document.id}/?utm_source=docssharelink&amp;utm_campaign={document.id}"
in email_content
)
assert f"docs/{document.id}/" in email_content
@mock.patch(
@@ -1,5 +1,5 @@
"""
Unit tests for FindDocumentIndexer
Unit tests for the Document model
"""
# pylint: disable=too-many-lines
@@ -12,8 +12,7 @@ from django.db import transaction
import pytest
from core import factories, models
from core.enums import SearchType
from core.services.search_indexers import FindDocumentIndexer
from core.services.search_indexers import SearchIndexer
pytestmark = pytest.mark.django_db
@@ -31,7 +30,7 @@ def reset_throttle():
reset_batch_indexer_throttle()
@mock.patch.object(FindDocumentIndexer, "push")
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer(mock_push):
@@ -42,7 +41,7 @@ def test_models_documents_post_save_indexer(mock_push):
accesses = {}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
assert len(data) == 1
@@ -65,14 +64,14 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
"""Test indexation task on doculment creation, no throttle"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
accesses = {}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
# 3 calls
assert len(data) == 3
@@ -92,7 +91,7 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
assert cache.get("file-batch-indexer-throttle") is None
@mock.patch.object(FindDocumentIndexer, "push")
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
"""Task should not start an indexation when disabled"""
@@ -107,13 +106,13 @@ def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_se
assert mock_push.assert_not_called
@mock.patch.object(FindDocumentIndexer, "push")
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_wrongly_configured(
mock_push, indexer_settings
):
"""Task should not start an indexation when disabled"""
indexer_settings.INDEXING_URL = None
indexer_settings.SEARCH_INDEXER_URL = None
user = factories.UserFactory()
@@ -124,7 +123,7 @@ def test_models_documents_post_save_indexer_wrongly_configured(
assert mock_push.assert_not_called
@mock.patch.object(FindDocumentIndexer, "push")
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_with_accesses(mock_push):
@@ -146,7 +145,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
assert len(data) == 1
assert sorted(data[0], key=itemgetter("id")) == sorted(
@@ -159,7 +158,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
)
@mock.patch.object(FindDocumentIndexer, "push")
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_deleted(mock_push):
@@ -208,7 +207,7 @@ def test_models_documents_post_save_indexer_deleted(mock_push):
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
assert len(data) == 2
@@ -245,14 +244,14 @@ def test_models_documents_indexer_hard_deleted():
factories.UserDocumentAccessFactory(document=doc, user=user)
# Call task on deleted document.
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with mock.patch.object(SearchIndexer, "push") as mock_push:
doc.delete()
# Hard delete document are not re-indexed.
assert mock_push.assert_not_called
@mock.patch.object(FindDocumentIndexer, "push")
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_restored(mock_push):
@@ -309,7 +308,7 @@ def test_models_documents_post_save_indexer_restored(mock_push):
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
# All docs are re-indexed
assert len(data) == 2
@@ -338,16 +337,16 @@ def test_models_documents_post_save_indexer_restored(mock_push):
@pytest.mark.usefixtures("indexer_settings")
def test_models_documents_post_save_indexer_throttle():
"""Test indexation task skipping on document update"""
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
user = factories.UserFactory()
with mock.patch.object(FindDocumentIndexer, "push"):
with mock.patch.object(SearchIndexer, "push"):
with transaction.atomic():
docs = factories.DocumentFactory.create_batch(5, users=(user,))
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with mock.patch.object(SearchIndexer, "push") as mock_push:
# Simulate 1 running task
cache.set("document-batch-indexer-throttle", 1)
@@ -360,7 +359,7 @@ def test_models_documents_post_save_indexer_throttle():
assert [call.args[0] for call in mock_push.call_args_list] == []
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with mock.patch.object(SearchIndexer, "push") as mock_push:
# No waiting task
cache.delete("document-batch-indexer-throttle")
@@ -390,7 +389,7 @@ def test_models_documents_access_post_save_indexer():
"""Test indexation task on DocumentAccess update"""
users = factories.UserFactory.create_batch(3)
with mock.patch.object(FindDocumentIndexer, "push"):
with mock.patch.object(SearchIndexer, "push"):
with transaction.atomic():
doc = factories.DocumentFactory(users=users)
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
@@ -399,7 +398,7 @@ def test_models_documents_access_post_save_indexer():
reset_batch_indexer_throttle()
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
for doc_access in doc_accesses:
doc_access.save()
@@ -427,7 +426,7 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
reset_batch_indexer_throttle()
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
for doc_access in doc_accesses:
doc_access.save()
@@ -440,77 +439,3 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
assert [len(d) for d in data] == [1] * 3
# the same document is indexed 3 times
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
@mock.patch.object(FindDocumentIndexer, "search_query")
@pytest.mark.usefixtures("indexer_settings")
def test_find_document_indexer_search(mock_search_query):
"""Test search function of FindDocumentIndexer returns formatted results"""
# Mock API response from Find
hits = [
{
"_id": "doc-123",
"_source": {
"title": "Test Document",
"content": "This is test content",
"updated_at": "2024-01-01T00:00:00Z",
"path": "/some/path/doc-123",
},
},
{
"_id": "doc-456",
"_source": {
"title.fr": "Document de test",
"content": "Contenu de test",
"updated_at": "2024-01-02T00:00:00Z",
},
},
]
mock_search_query.return_value = hits
q = "test"
token = "fake-token"
nb_results = 10
path = "/some/path/"
visited = ["doc-123"]
search_type = SearchType.HYBRID
results = FindDocumentIndexer().search(
q=q,
token=token,
nb_results=nb_results,
path=path,
visited=visited,
search_type=search_type,
)
mock_search_query.assert_called_once()
call_args = mock_search_query.call_args
assert call_args[1]["data"] == {
"q": q,
"visited": visited,
"services": ["docs"],
"nb_results": nb_results,
"order_by": "updated_at",
"order_direction": "desc",
"path": path,
"search_type": search_type,
}
assert len(results) == 2
assert results == [
{
"id": hits[0]["_id"],
"title": hits[0]["_source"]["title"],
"content": hits[0]["_source"]["content"],
"updated_at": hits[0]["_source"]["updated_at"],
"path": hits[0]["_source"]["path"],
},
{
"id": hits[1]["_id"],
"title": hits[1]["_source"]["title.fr"],
"title.fr": hits[1]["_source"]["title.fr"], # <- Find response artefact
"content": hits[1]["_source"]["content"],
"updated_at": hits[1]["_source"]["updated_at"],
},
]
@@ -79,7 +79,7 @@ def test_models_invitations_is_expired():
assert expired_invitation.is_expired is True
def test_models_invitations_new_user_convert_invitations_to_accesses():
def test_models_invitationd_new_userd_convert_invitations_to_accesses():
"""
Upon creating a new user, invitations linked to the email
should be converted to accesses and then deleted.
@@ -114,7 +114,7 @@ def test_models_invitations_new_user_convert_invitations_to_accesses():
).exists() # the other invitation remains
def test_models_invitations_new_user_filter_expired_invitations():
def test_models_invitationd_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 +145,7 @@ def test_models_invitations_new_user_filter_expired_invitations():
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 7), (20, 7)])
def test_models_invitations_new_userd_user_creation_constant_num_queries(
def test_models_invitationd_new_userd_user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
"""
@@ -1,669 +0,0 @@
"""
Unit tests for the UserReconciliationCsvImport model
"""
import uuid
from pathlib import Path
from django.core import mail
from django.core.files.base import ContentFile
import pytest
from core import factories, models
from core.admin import process_reconciliation
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
pytestmark = pytest.mark.django_db
@pytest.fixture(name="import_example_csv_basic")
def fixture_import_example_csv_basic():
"""
Import an example CSV file for user reconciliation
and return the created import object.
"""
# Create users referenced in the CSV
for i in range(40, 50):
factories.UserFactory(email=f"user.test{i}@example.com")
example_csv_path = Path(__file__).parent / "data/example_reconciliation_basic.csv"
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_basic.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
return csv_import
@pytest.fixture(name="import_example_csv_grist_form")
def fixture_import_example_csv_grist_form():
"""
Import an example CSV file for user reconciliation
and return the created import object.
"""
# Create users referenced in the CSV
for i in range(10, 40):
factories.UserFactory(email=f"user.test{i}@example.com")
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_grist_form.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_grist_form.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
return csv_import
def test_user_reconciliation_csv_import_entry_is_created(import_example_csv_basic):
"""Test that a UserReconciliationCsvImport entry is created correctly."""
assert import_example_csv_basic.status == "pending"
assert import_example_csv_basic.file.name.endswith(
"example_reconciliation_basic.csv"
)
def test_user_reconciliation_csv_import_entry_is_created_grist_form(
import_example_csv_grist_form,
):
"""Test that a UserReconciliationCsvImport entry is created correctly."""
assert import_example_csv_grist_form.status == "pending"
assert import_example_csv_grist_form.file.name.endswith(
"example_reconciliation_grist_form.csv"
)
def test_incorrect_csv_format_handling():
"""Test that an incorrectly formatted CSV file is handled gracefully."""
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_missing_column.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(
f.read(), name="example_reconciliation_missing_column.csv"
)
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert (
"CSV is missing mandatory columns: active_email, inactive_email, id"
in csv_import.logs
)
assert csv_import.status == "error"
def test_incorrect_email_format_handling():
"""Test that an incorrectly formatted CSV file is handled gracefully."""
example_csv_path = Path(__file__).parent / "data/example_reconciliation_error.csv"
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_error.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert "Invalid inactive email address on row 40" in csv_import.logs
assert csv_import.status == "done"
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["user.test40@example.com"]
email_content = " ".join(email.body.split())
assert "Reconciliation of your Docs accounts not completed" in email_content
def test_incorrect_csv_data_handling_grist_form():
"""Test that a CSV file with incorrect data is handled gracefully."""
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_grist_form_error.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(
f.read(), name="example_reconciliation_grist_form_error.csv"
)
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert (
"user.test20@example.com set as both active and inactive email"
in csv_import.logs
)
assert csv_import.status == "done"
def test_job_creates_reconciliation_entries(import_example_csv_basic):
"""Test that the CSV import job creates UserReconciliation entries."""
assert import_example_csv_basic.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_basic.id)
# Verify the job status changed
import_example_csv_basic.refresh_from_db()
assert import_example_csv_basic.status == "done"
assert "Import completed successfully." in import_example_csv_basic.logs
assert "6 rows processed." in import_example_csv_basic.logs
assert "5 reconciliation entries created." in import_example_csv_basic.logs
# Verify reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 5
def test_job_does_not_create_duplicated_reconciliation_entries(
import_example_csv_basic,
):
"""Test that the CSV import job doesn't create UserReconciliation entries
for source unique IDs that have already been processed."""
_already_created_entry = models.UserReconciliation.objects.create(
active_email="user.test40@example.com",
inactive_email="user.test41@example.com",
active_email_checked=0,
inactive_email_checked=0,
status="pending",
source_unique_id=1,
)
assert import_example_csv_basic.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_basic.id)
# Verify the job status changed
import_example_csv_basic.refresh_from_db()
assert import_example_csv_basic.status == "done"
assert "Import completed successfully." in import_example_csv_basic.logs
assert "6 rows processed." in import_example_csv_basic.logs
assert "4 reconciliation entries created." in import_example_csv_basic.logs
assert "1 rows were already processed." in import_example_csv_basic.logs
# Verify the correct number of reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 5
def test_job_creates_reconciliation_entries_grist_form(import_example_csv_grist_form):
"""Test that the CSV import job creates UserReconciliation entries."""
assert import_example_csv_grist_form.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_grist_form.id)
# Verify the job status changed
import_example_csv_grist_form.refresh_from_db()
assert "Import completed successfully" in import_example_csv_grist_form.logs
assert import_example_csv_grist_form.status == "done"
# Verify reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 9
def test_csv_import_reconciliation_data_is_correct(import_example_csv_basic):
"""Test that the data in created UserReconciliation entries matches the CSV."""
user_reconciliation_csv_import_job(import_example_csv_basic.id)
reconciliations = models.UserReconciliation.objects.order_by("created_at")
first_entry = reconciliations.first()
assert first_entry.active_email == "user.test40@example.com"
assert first_entry.inactive_email == "user.test41@example.com"
assert first_entry.active_email_checked is False
assert first_entry.inactive_email_checked is False
for rec in reconciliations:
assert rec.status == "ready"
@pytest.fixture(name="user_reconciliation_users_and_docs")
def fixture_user_reconciliation_users_and_docs():
"""Fixture to create two users with overlapping document accesses
for reconciliation tests."""
user_1 = factories.UserFactory(email="user.test1@example.com")
user_2 = factories.UserFactory(email="user.test2@example.com")
# Create 10 distinct document accesses for each user
userdocs_u1 = [
factories.UserDocumentAccessFactory(user=user_1, role="editor")
for _ in range(10)
]
userdocs_u2 = [
factories.UserDocumentAccessFactory(user=user_2, role="editor")
for _ in range(10)
]
# Make the first 3 documents of each list shared with the other user
# with a lower role
for ud in userdocs_u1[0:3]:
factories.UserDocumentAccessFactory(
user=user_2, document=ud.document, role="reader"
)
for ud in userdocs_u2[0:3]:
factories.UserDocumentAccessFactory(
user=user_1, document=ud.document, role="reader"
)
# Make the next 3 documents of each list shared with the other user
# with a higher role
for ud in userdocs_u1[3:6]:
factories.UserDocumentAccessFactory(
user=user_2, document=ud.document, role="owner"
)
for ud in userdocs_u2[3:6]:
factories.UserDocumentAccessFactory(
user=user_1, document=ud.document, role="owner"
)
return (user_1, user_2, userdocs_u1, userdocs_u2)
def test_user_reconciliation_is_created(user_reconciliation_users_and_docs):
"""Test that a UserReconciliation entry can be created and saved."""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=False,
inactive_email_checked=True,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
status="pending",
)
rec.save()
assert rec.status == "ready"
def test_user_reconciliation_verification_emails_are_sent(
user_reconciliation_users_and_docs,
):
"""Test that both UserReconciliation verification emails are sent."""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=False,
inactive_email_checked=False,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
status="pending",
)
rec.save()
# pylint: disable-next=no-member
assert len(mail.outbox) == 2
# pylint: disable-next=no-member
email_1 = mail.outbox[0]
assert email_1.to == [user_1.email]
email_1_content = " ".join(email_1.body.split())
assert (
"You have requested a reconciliation of your user accounts on Docs."
in email_1_content
)
active_email_confirmation_id = rec.active_email_confirmation_id
inactive_email_confirmation_id = rec.inactive_email_confirmation_id
assert (
f"user-reconciliations/active/{active_email_confirmation_id}/"
in email_1_content
)
# pylint: disable-next=no-member
email_2 = mail.outbox[1]
assert email_2.to == [user_2.email]
email_2_content = " ".join(email_2.body.split())
assert (
"You have requested a reconciliation of your user accounts on Docs."
in email_2_content
)
assert (
f"user-reconciliations/inactive/{inactive_email_confirmation_id}/"
in email_2_content
)
def test_user_reconciliation_only_starts_if_checks_are_made(
user_reconciliation_users_and_docs,
):
"""Test that the admin action does not process entries
unless both email checks are confirmed.
"""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
# Create a reconciliation entry where only one email has been checked
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=True,
inactive_email_checked=False,
status="pending",
)
rec.save()
# Capture counts before running admin action
accesses_before_active = models.DocumentAccess.objects.filter(user=user_1).count()
accesses_before_inactive = models.DocumentAccess.objects.filter(user=user_2).count()
users_active_before = (user_1.is_active, user_2.is_active)
# Call the admin action with the queryset containing our single rec
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
# Reload from DB and assert nothing was processed (checks prevent processing)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
assert rec.status == "ready"
assert (
models.DocumentAccess.objects.filter(user=user_1).count()
== accesses_before_active
)
assert (
models.DocumentAccess.objects.filter(user=user_2).count()
== accesses_before_inactive
)
assert (user_1.is_active, user_2.is_active) == users_active_before
def test_process_reconciliation_updates_accesses(
user_reconciliation_users_and_docs,
):
"""Test that accesses are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
u2doc1 = userdocs_u2[1].document
u2doc5 = userdocs_u2[5].document
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# After processing, inactive user should have no accesses
# and active user should have one access per union document
# with the highest role
assert rec.status == "done"
assert "Requested update for 10 DocumentAccess items" in rec.logs
assert "and deletion for 12 DocumentAccess items" in rec.logs
assert models.DocumentAccess.objects.filter(user=user_2).count() == 0
assert models.DocumentAccess.objects.filter(user=user_1).count() == 20
assert u1_2.role == "editor"
assert u1_5.role == "owner"
assert (
models.DocumentAccess.objects.filter(user=user_1, document=u2doc1).first().role
== "editor"
)
assert (
models.DocumentAccess.objects.filter(user=user_1, document=u2doc5).first().role
== "owner"
)
assert user_1.is_active is True
assert user_2.is_active is False
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == [user_1.email]
email_content = " ".join(email.body.split())
assert "Your accounts have been merged" in email_content
def test_process_reconciliation_updates_linktraces(
user_reconciliation_users_and_docs,
):
"""Test that linktraces are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
doc_both = u1_2.document
models.LinkTrace.objects.create(document=doc_both, user=user_1)
models.LinkTrace.objects.create(document=doc_both, user=user_2)
doc_inactive_only = userdocs_u2[4].document
models.LinkTrace.objects.create(
document=doc_inactive_only, user=user_2, is_masked=True
)
doc_active_only = userdocs_u1[4].document
models.LinkTrace.objects.create(document=doc_active_only, user=user_1)
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# Inactive user should have no linktraces
assert models.LinkTrace.objects.filter(user=user_2).count() == 0
# doc_both should have a single LinkTrace owned by the active user
assert (
models.LinkTrace.objects.filter(user=user_1, document=doc_both).exists() is True
)
assert models.LinkTrace.objects.filter(user=user_1, document=doc_both).count() == 1
assert (
models.LinkTrace.objects.filter(user=user_2, document=doc_both).exists()
is False
)
# doc_inactive_only should now be linked to active user and preserve is_masked
lt = models.LinkTrace.objects.filter(
user=user_1, document=doc_inactive_only
).first()
assert lt is not None
assert lt.is_masked is True
# doc_active_only should still belong to active user
assert models.LinkTrace.objects.filter(
user=user_1, document=doc_active_only
).exists()
def test_process_reconciliation_updates_threads_comments_reactions(
user_reconciliation_users_and_docs,
):
"""Test that threads, comments and reactions are transferred/deduplicated
on reconciliation."""
user_1, user_2, _userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
# Use a document from the inactive user's set
document = userdocs_u2[0].document
# Thread and comment created by inactive user -> should be moved to active
thread = factories.ThreadFactory(document=document, creator=user_2)
comment = factories.CommentFactory(thread=thread, user=user_2)
# Reaction where only inactive user reacted -> should be moved to active user
reaction_inactive_only = factories.ReactionFactory(comment=comment, users=[user_2])
# Reaction where both users reacted -> inactive user's participation should be removed
thread2 = factories.ThreadFactory(document=document, creator=user_1)
comment2 = factories.CommentFactory(thread=thread2, user=user_1)
reaction_both = factories.ReactionFactory(comment=comment2, users=[user_1, user_2])
# Reaction where only active user reacted -> unchanged
thread3 = factories.ThreadFactory(document=document, creator=user_1)
comment3 = factories.CommentFactory(thread=thread3, user=user_1)
reaction_active_only = factories.ReactionFactory(comment=comment3, users=[user_1])
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
# Refresh objects
thread.refresh_from_db()
comment.refresh_from_db()
reaction_inactive_only.refresh_from_db()
reaction_both.refresh_from_db()
reaction_active_only.refresh_from_db()
# Thread and comment creator should now be the active user
assert thread.creator == user_1
assert comment.user == user_1
# reaction_inactive_only: inactive user's participation should be removed and
# active user's participation added
reaction_inactive_only.refresh_from_db()
assert not reaction_inactive_only.users.filter(pk=user_2.pk).exists()
assert reaction_inactive_only.users.filter(pk=user_1.pk).exists()
# reaction_both: should end up with only active user's participation
assert reaction_both.users.filter(pk=user_2.pk).exists() is False
assert reaction_both.users.filter(pk=user_1.pk).exists() is True
# reaction_active_only should still have active user's participation
assert reaction_active_only.users.filter(pk=user_1.pk).exists()
def test_process_reconciliation_updates_favorites(
user_reconciliation_users_and_docs,
):
"""Test that favorites are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
doc_both = u1_2.document
models.DocumentFavorite.objects.create(document=doc_both, user=user_1)
models.DocumentFavorite.objects.create(document=doc_both, user=user_2)
doc_inactive_only = userdocs_u2[4].document
models.DocumentFavorite.objects.create(document=doc_inactive_only, user=user_2)
doc_active_only = userdocs_u1[4].document
models.DocumentFavorite.objects.create(document=doc_active_only, user=user_1)
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# Inactive user should have no document favorites
assert models.DocumentFavorite.objects.filter(user=user_2).count() == 0
# doc_both should have a single DocumentFavorite owned by the active user
assert (
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).exists()
is True
)
assert (
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).count()
== 1
)
assert (
models.DocumentFavorite.objects.filter(user=user_2, document=doc_both).exists()
is False
)
# doc_inactive_only should now be linked to active user
assert (
models.DocumentFavorite.objects.filter(
user=user_2, document=doc_inactive_only
).count()
== 0
)
assert models.DocumentFavorite.objects.filter(
user=user_1, document=doc_inactive_only
).exists()
# doc_active_only should still belong to active user
assert models.DocumentFavorite.objects.filter(
user=user_1, document=doc_active_only
).exists()
+21 -254
View File
@@ -2,12 +2,9 @@
Unit tests for the User model
"""
import uuid
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
from unittest import mock
from django.core.exceptions import ValidationError
from django.test.utils import override_settings
import pytest
@@ -29,6 +26,26 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id)
def test_models_users_send_mail_main_existing():
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
user.email_user("my subject", "my message")
mock_send.assert_called_once_with("my subject", "my message", None, [user.email])
def test_models_users_send_mail_main_missing():
"""The "email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
@pytest.mark.parametrize(
"sub,is_valid",
[
@@ -79,253 +96,3 @@ def test_modes_users_convert_valid_invitations():
id=invitation_other_document.id
).exists()
assert models.Invitation.objects.filter(id=other_email_invitation.id).exists()
@override_settings(USER_ONBOARDING_DOCUMENTS=[])
def test_models_users_handle_onboarding_documents_access_empty_setting():
"""
When USER_ONBOARDING_DOCUMENTS is empty, no accesses should be created.
"""
user = factories.UserFactory()
assert models.DocumentAccess.objects.filter(user=user).count() == 0
def test_models_users_handle_onboarding_document_link_trace_with_single_document():
"""
When USER_ONBOARDING_DOCUMENTS has a valid document ID,
a LinkTrace should be created for the new user.
The document should be pinned as a favorite for the user.
"""
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.PUBLIC)
with override_settings(USER_ONBOARDING_DOCUMENTS=[str(document.id)]):
user = factories.UserFactory()
assert models.LinkTrace.objects.filter(user=user, document=document).count() == 1
user_favorites = models.DocumentFavorite.objects.filter(user=user)
assert user_favorites.count() == 1
assert user_favorites.filter(document=document).exists()
def test_models_users_handle_onboarding_documents_access_with_multiple_documents():
"""
When USER_ONBOARDING_DOCUMENTS has multiple valid document IDs,
accesses should be created for all documents.
All accesses should have the READER role.
All documents should be pinned as favorites for the user.
"""
document1 = factories.DocumentFactory(
title="Document 1", link_reach=models.LinkReachChoices.PUBLIC
)
document2 = factories.DocumentFactory(
title="Document 2", link_reach=models.LinkReachChoices.AUTHENTICATED
)
document3 = factories.DocumentFactory(
title="Document 3", link_reach=models.LinkReachChoices.PUBLIC
)
with override_settings(
USER_ONBOARDING_DOCUMENTS=[
str(document1.id),
str(document2.id),
str(document3.id),
]
):
user = factories.UserFactory()
link_traces = models.LinkTrace.objects.filter(user=user)
assert link_traces.count() == 3
assert models.LinkTrace.objects.filter(user=user, document=document1).exists()
assert models.LinkTrace.objects.filter(user=user, document=document2).exists()
assert models.LinkTrace.objects.filter(user=user, document=document3).exists()
user_favorites = models.DocumentFavorite.objects.filter(user=user)
assert user_favorites.count() == 3
assert user_favorites.filter(document=document1).exists()
assert user_favorites.filter(document=document2).exists()
assert user_favorites.filter(document=document3).exists()
def test_models_users_handle_onboarding_documents_access_with_invalid_document_id():
"""
When USER_ONBOARDING_DOCUMENTS has an invalid document ID,
it should be skipped and logged, but not raise an exception.
"""
invalid_id = uuid.uuid4()
with override_settings(USER_ONBOARDING_DOCUMENTS=[str(invalid_id)]):
with patch("core.models.logger") as mock_logger:
user = factories.UserFactory()
mock_logger.warning.assert_called_once()
call_args = mock_logger.warning.call_args
assert "Onboarding document with id" in call_args[0][0]
assert models.LinkTrace.objects.filter(user=user).count() == 0
def test_models_users_handle_onboarding_documents_access_duplicate_prevention():
"""
If the same document is listed multiple times in USER_ONBOARDING_DOCUMENTS,
it should only create one access (or handle duplicates gracefully).
"""
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.PUBLIC)
with override_settings(
USER_ONBOARDING_DOCUMENTS=[str(document.id), str(document.id)]
):
user = factories.UserFactory()
link_traces = models.LinkTrace.objects.filter(user=user, document=document)
assert link_traces.count() == 1
def test_models_users_handle_onboarding_documents_on_restricted_document_is_not_allowed():
"""On-boarding document can be used when restricted"""
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
with override_settings(USER_ONBOARDING_DOCUMENTS=[str(document.id)]):
user = factories.UserFactory()
assert not models.LinkTrace.objects.filter(user=user, document=document).exists()
@override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=None)
def test_models_users_duplicate_onboarding_sandbox_document_no_setting():
"""
When USER_ONBOARDING_SANDBOX_DOCUMENT is not set, no sandbox document should be created.
"""
user = factories.UserFactory()
assert (
models.Document.objects.filter(creator=user, title__icontains="Sandbox").count()
== 0
)
initial_accesses = models.DocumentAccess.objects.filter(user=user).count()
assert initial_accesses == 0
def test_models_users_duplicate_onboarding_sandbox_document_creates_sandbox():
"""
When USER_ONBOARDING_SANDBOX_DOCUMENT is set with a valid template document,
a new sandbox document should be created for the user with OWNER access.
"""
template_document = factories.DocumentFactory(title="Getting started with Docs")
with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)):
user = factories.UserFactory()
sandbox_docs = models.Document.objects.filter(
creator=user, title="Getting started with Docs"
)
assert sandbox_docs.count() == 1
sandbox_doc = sandbox_docs.first()
assert sandbox_doc.creator == user
assert sandbox_doc.duplicated_from == template_document
access = models.DocumentAccess.objects.get(user=user, document=sandbox_doc)
assert access.role == models.RoleChoices.OWNER
def test_models_users_duplicate_onboarding_sandbox_document_with_invalid_template_id():
"""
When USER_ONBOARDING_SANDBOX_DOCUMENT has an invalid document ID,
it should be skipped and logged, but not raise an exception.
"""
invalid_id = uuid.uuid4()
with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(invalid_id)):
with patch("core.models.logger") as mock_logger:
user = factories.UserFactory()
mock_logger.warning.assert_called_once()
call_args = mock_logger.warning.call_args
assert "Onboarding sandbox document with id" in call_args[0][0]
sandbox_docs = models.Document.objects.filter(creator=user)
assert sandbox_docs.count() == 0
def test_models_users_duplicate_onboarding_sandbox_document_creates_unique_sandbox_per_user():
"""
Each new user should get their own independent sandbox document.
"""
template_document = factories.DocumentFactory(title="Getting started with Docs")
with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)):
user1 = factories.UserFactory()
user2 = factories.UserFactory()
sandbox_docs_user1 = models.Document.objects.filter(
creator=user1, title="Getting started with Docs"
)
sandbox_docs_user2 = models.Document.objects.filter(
creator=user2, title="Getting started with Docs"
)
assert sandbox_docs_user1.count() == 1
assert sandbox_docs_user2.count() == 1
assert sandbox_docs_user1.first().id != sandbox_docs_user2.first().id
def test_models_users_duplicate_onboarding_sandbox_document_integration_with_other_methods():
"""
Verify that sandbox creation works alongside other onboarding methods.
"""
template_document = factories.DocumentFactory(title="Getting started with Docs")
onboarding_doc = factories.DocumentFactory(
title="Onboarding Document", link_reach=models.LinkReachChoices.AUTHENTICATED
)
with override_settings(
USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id),
USER_ONBOARDING_DOCUMENTS=[str(onboarding_doc.id)],
):
user = factories.UserFactory()
sandbox_doc = models.Document.objects.filter(
creator=user, title="Getting started with Docs"
).first()
assert models.DocumentAccess.objects.filter(user=user).count() == 1
assert models.LinkTrace.objects.filter(user=user).count() == 1
assert models.DocumentAccess.objects.filter(
document=sandbox_doc, user=user, role=models.RoleChoices.OWNER
).exists()
assert models.LinkTrace.objects.filter(document=onboarding_doc, user=user).exists()
@pytest.mark.django_db(transaction=True)
def test_models_users_duplicate_onboarding_sandbox_race_condition():
"""
It should be possible to create several documents at the same time
without causing any race conditions or data integrity issues.
"""
def create_user():
return factories.UserFactory()
template_document = factories.DocumentFactory(title="Getting started with Docs")
with (
override_settings(
USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id),
),
ThreadPoolExecutor(max_workers=2) as executor,
):
future1 = executor.submit(create_user)
future2 = executor.submit(create_user)
user1 = future1.result()
user2 = future2.result()
assert isinstance(user1, models.User)
assert isinstance(user2, models.User)
@@ -1,23 +1,15 @@
"""
Test AI services in the impress core app.
Test ai API endpoints in the impress core app.
"""
# pylint: disable=protected-access
from collections.abc import AsyncIterator
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from openai import OpenAIError
from pydantic_ai.ui.vercel_ai.request_types import TextUIPart, UIMessage
from core.services.ai_services import (
BLOCKNOTE_TOOL_STRICT_PROMPT,
AIService,
convert_async_generator_to_sync,
)
from core.services.ai_services import AIService
pytestmark = pytest.mark.django_db
@@ -29,13 +21,6 @@ def ai_settings(settings):
settings.AI_BASE_URL = "http://example.com"
settings.AI_API_KEY = "test-key"
settings.AI_FEATURE_ENABLED = True
settings.AI_FEATURE_BLOCKNOTE_ENABLED = True
settings.AI_FEATURE_LEGACY_ENABLED = True
settings.LANGFUSE_PUBLIC_KEY = None
settings.AI_VERCEL_SDK_VERSION = 6
# -- AIService.__init__ --
@pytest.mark.parametrize(
@@ -57,534 +42,94 @@ def test_services_ai_setting_missing(setting_name, setting_value, settings):
AIService()
# -- AIService.transform --
@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_services_ai_client_error(mock_create):
def test_services_ai_proxy_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_services_ai_client_invalid_response(mock_create):
"""Fail when the client response is invalid"""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=None))]
)
with pytest.raises(
RuntimeError,
match="AI response does not contain an answer",
match="Failed to proxy AI request: Mocked client error",
):
AIService().transform("hello", "prompt")
AIService().proxy({"messages": [{"role": "user", "content": "hello"}]})
@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_services_ai_success(mock_create):
def test_services_ai_proxy_success(mock_create):
"""The AI request should work as expect when called with valid arguments."""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
mock_create.return_value = {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Salut"},
"finish_reason": "stop",
}
],
}
response = AIService().proxy({"messages": [{"role": "user", "content": "hello"}]})
expected_response = {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Salut"},
"finish_reason": "stop",
}
],
}
assert response == expected_response
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "hello"}], stream=False
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut"}
# -- AIService.translate --
@patch("openai.resources.chat.completions.Completions.create")
def test_services_ai_translate_success(mock_create):
"""Translate should call the AI API with the correct language prompt."""
def test_services_ai_proxy_with_stream(mock_create):
"""The AI request should work as expect when called with valid arguments."""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Bonjour"))]
mock_create.return_value = {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Salut"},
"finish_reason": "stop",
}
],
}
response = AIService().proxy(
{"messages": [{"role": "user", "content": "hello"}]}, stream=True
)
response = AIService().translate("<p>Hello</p>", "fr")
assert response == {"answer": "Bonjour"}
call_args = mock_create.call_args
system_content = call_args[1]["messages"][0]["content"]
assert "French" in system_content or "fr" in system_content
@patch("openai.resources.chat.completions.Completions.create")
def test_services_ai_translate_unknown_language(mock_create):
"""Translate with an unknown language code should use the code as-is."""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Translated"))]
expected_response = {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Salut"},
"finish_reason": "stop",
}
],
}
assert response == expected_response
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "hello"}], stream=True
)
response = AIService().translate("<p>Hello</p>", "xx-unknown")
assert response == {"answer": "Translated"}
call_args = mock_create.call_args
system_content = call_args[1]["messages"][0]["content"]
assert "xx-unknown" in system_content
# -- convert_async_generator_to_sync --
def test_convert_async_generator_to_sync_basic():
"""Should convert an async generator yielding items to a sync iterator."""
async def async_gen():
for item in ["hello", "world", "!"]:
yield item
result = list(convert_async_generator_to_sync(async_gen()))
assert result == ["hello", "world", "!"]
def test_convert_async_generator_to_sync_empty():
"""Should handle an empty async generator."""
async def async_gen():
return
yield
result = list(convert_async_generator_to_sync(async_gen()))
assert not result
def test_convert_async_generator_to_sync_exception():
"""Should propagate exceptions from the async generator."""
async def async_gen():
yield "first"
raise ValueError("async error")
sync_iter = convert_async_generator_to_sync(async_gen())
assert next(sync_iter) == "first"
with pytest.raises(ValueError, match="async error"):
next(sync_iter)
# -- AIService.inject_document_state_messages --
def test_inject_document_state_messages_no_metadata():
"""Messages without documentState metadata should pass through unchanged."""
messages = [
UIMessage(role="user", id="msg-1", parts=[TextUIPart(text="Hello")]),
]
result = AIService.inject_document_state_messages(messages)
assert len(result) == 1
assert result[0].id == "msg-1"
def test_inject_document_state_messages_with_selection():
"""A user message with documentState and selection should get an
assistant context message prepended."""
messages = [
UIMessage(
role="user",
id="msg-1",
parts=[TextUIPart(text="Fix this")],
metadata={
"documentState": {
"selection": {"start": 0, "end": 5},
"selectedBlocks": [{"type": "paragraph", "content": "Hello"}],
"blocks": [
{"type": "paragraph", "content": "Hello"},
{"type": "paragraph", "content": "World"},
],
}
},
),
]
result = AIService.inject_document_state_messages(messages)
assert len(result) == 2
# First message should be the injected assistant context
assert result[0].role == "assistant"
assert result[0].id == "assistant-document-state-msg-1"
assert len(result[0].parts) == 4
assert "selection" in result[0].parts[0].text.lower()
# Second message should be the original user message
assert result[1].id == "msg-1"
def test_inject_document_state_messages_without_selection():
"""A user message with documentState but no selection should describe
the full document context."""
messages = [
UIMessage(
role="user",
id="msg-1",
parts=[TextUIPart(text="Summarize")],
metadata={
"documentState": {
"selection": None,
"blocks": [
{"type": "paragraph", "content": "Hello"},
],
"isEmptyDocument": False,
}
},
),
]
result = AIService.inject_document_state_messages(messages)
assert len(result) == 2
assistant_msg = result[0]
assert assistant_msg.role == "assistant"
assert len(assistant_msg.parts) == 2
assert "no active selection" in assistant_msg.parts[0].text.lower()
assert "prefer updating" in assistant_msg.parts[0].text.lower()
def test_inject_document_state_messages_empty_document():
"""When the document is empty, the injected message should instruct
updating the empty block first."""
messages = [
UIMessage(
role="user",
id="msg-1",
parts=[TextUIPart(text="Write something")],
metadata={
"documentState": {
"selection": None,
"blocks": [{"type": "paragraph", "content": ""}],
"isEmptyDocument": True,
}
},
),
]
result = AIService.inject_document_state_messages(messages)
assert len(result) == 2
assistant_msg = result[0]
assert "update the empty block" in assistant_msg.parts[0].text.lower()
def test_inject_document_state_messages_mixed():
"""Only user messages with documentState get assistant context;
other messages pass through unchanged."""
messages = [
UIMessage(
role="assistant",
id="msg-0",
parts=[TextUIPart(text="Previous response")],
),
UIMessage(
role="user",
id="msg-1",
parts=[TextUIPart(text="Hello")],
),
UIMessage(
role="user",
id="msg-2",
parts=[TextUIPart(text="Fix this")],
metadata={
"documentState": {
"selection": {"start": 0, "end": 5},
"selectedBlocks": [{"type": "paragraph", "content": "Hello"}],
"blocks": [{"type": "paragraph", "content": "Hello"}],
}
},
),
]
result = AIService.inject_document_state_messages(messages)
# 3 original + 1 injected assistant message before msg-2
assert len(result) == 4
assert result[0].id == "msg-0"
assert result[1].id == "msg-1"
assert result[2].role == "assistant"
assert result[2].id == "assistant-document-state-msg-2"
assert result[3].id == "msg-2"
# -- AIService.tool_definitions_to_toolset --
def test_tool_definitions_to_toolset():
"""Should convert frontend tool definitions to an ExternalToolset."""
tool_definitions = {
"applyOperations": {
"description": "Apply operations to the document",
"inputSchema": {
"type": "object",
"properties": {
"operations": {"type": "array"},
},
},
"outputSchema": {"type": "object"},
},
"insertBlocks": {
"description": "Insert blocks",
"inputSchema": {"type": "object"},
},
}
toolset = AIService.tool_definitions_to_toolset(tool_definitions)
# The ExternalToolset wraps ToolDefinition objects
assert toolset is not None
# Access internal tool definitions
tool_defs = toolset.tool_defs
assert len(tool_defs) == 2
names = {td.name for td in tool_defs}
assert names == {"applyOperations", "insertBlocks"}
for td in tool_defs:
assert td.kind == "external"
if td.name == "applyOperations":
assert td.description == "Apply operations to the document"
assert td.metadata == {"output_schema": {"type": "object"}}
def test_tool_definitions_to_toolset_missing_fields():
"""Should handle tool definitions with missing optional fields."""
tool_definitions = {
"myTool": {},
}
toolset = AIService.tool_definitions_to_toolset(tool_definitions)
tool_defs = toolset.tool_defs
assert len(tool_defs) == 1
assert tool_defs[0].name == "myTool"
assert tool_defs[0].description == ""
assert tool_defs[0].parameters_json_schema == {}
assert tool_defs[0].metadata == {"output_schema": None}
# -- AIService.stream --
@patch.object(AIService, "_build_async_stream")
def test_services_ai_stream_sync_mode(mock_build, monkeypatch):
"""In sync mode, stream() should return a sync iterator."""
async def mock_async_gen():
yield "chunk1"
yield "chunk2"
mock_build.return_value = mock_async_gen()
monkeypatch.setenv("PYTHON_SERVER_MODE", "sync")
service = AIService()
request = MagicMock()
result = service.stream(request)
# Should be a regular (sync) iterator, not async
assert not isinstance(result, AsyncIterator)
assert list(result) == ["chunk1", "chunk2"]
mock_build.assert_called_once_with(request)
@patch.object(AIService, "_build_async_stream")
def test_services_ai_stream_async_mode(mock_build, monkeypatch):
"""In async mode, stream() should return the async iterator directly."""
async def mock_async_gen():
yield "chunk1"
yield "chunk2"
mock_async_iter = mock_async_gen()
mock_build.return_value = mock_async_iter
monkeypatch.setenv("PYTHON_SERVER_MODE", "async")
service = AIService()
request = MagicMock()
result = service.stream(request)
assert result is mock_async_iter
mock_build.assert_called_once_with(request)
@patch.object(AIService, "_build_async_stream")
def test_services_ai_stream_defaults_to_sync(mock_build, monkeypatch):
"""When PYTHON_SERVER_MODE is not set, stream() should default to sync."""
async def mock_async_gen():
yield "data"
mock_build.return_value = mock_async_gen()
monkeypatch.delenv("PYTHON_SERVER_MODE", raising=False)
service = AIService()
request = MagicMock()
result = service.stream(request)
# Default should be sync mode
assert not isinstance(result, AsyncIterator)
assert list(result) == ["data"]
# -- AIService._build_async_stream --
@patch("core.services.ai_services.VercelAIAdapter")
def test_services_ai_build_async_stream(mock_adapter_cls):
"""_build_async_stream should build the pydantic-ai streaming pipeline."""
async def mock_encode():
yield "event-data"
mock_run_input = MagicMock()
mock_run_input.model_extra = None
mock_run_input.messages = []
mock_adapter_cls.build_run_input.return_value = mock_run_input
mock_adapter_instance = MagicMock()
mock_adapter_instance.run_stream.return_value = MagicMock()
mock_adapter_instance.encode_stream.return_value = mock_encode()
mock_adapter_cls.return_value = mock_adapter_instance
service = AIService()
request = MagicMock()
request.META = {"HTTP_ACCEPT": "text/event-stream"}
request.raw_body = b'{"messages": []}'
result = service._build_async_stream(request)
assert isinstance(result, AsyncIterator)
mock_adapter_cls.build_run_input.assert_called_once_with(b'{"messages": []}')
mock_adapter_instance.run_stream.assert_called_once()
mock_adapter_instance.encode_stream.assert_called_once()
@patch("core.services.ai_services.VercelAIAdapter")
def test_services_ai_build_async_stream_with_tool_definitions(mock_adapter_cls):
"""_build_async_stream should build an ExternalToolset when
toolDefinitions are present in the request."""
async def mock_encode():
yield "event-data"
mock_run_input = MagicMock()
mock_run_input.model_extra = {
"toolDefinitions": {
"myTool": {
"description": "A tool",
"inputSchema": {"type": "object"},
}
}
}
mock_run_input.messages = []
mock_adapter_cls.build_run_input.return_value = mock_run_input
mock_adapter_instance = MagicMock()
mock_adapter_instance.run_stream.return_value = MagicMock()
mock_adapter_instance.encode_stream.return_value = mock_encode()
mock_adapter_cls.return_value = mock_adapter_instance
service = AIService()
request = MagicMock()
request.META = {}
request.raw_body = b"{}"
service._build_async_stream(request)
# run_stream should have been called with a toolset
call_kwargs = mock_adapter_instance.run_stream.call_args[1]
assert call_kwargs["toolsets"] is not None
assert len(call_kwargs["toolsets"]) == 1
@patch("core.services.ai_services.VercelAIAdapter")
def test_services_ai_build_async_stream_with_tool_definitions_required_system_prompt(
mock_adapter_cls,
):
"""The presence of the applyDocumentOperations tool must force the addition
of a system prompt"""
async def mock_encode():
yield "event-data"
mock_run_input = MagicMock()
mock_run_input.model_extra = {
"toolDefinitions": {
"applyDocumentOperations": {
"description": "A tool",
"inputSchema": {"type": "object"},
}
}
}
mock_run_input.messages = []
mock_adapter_cls.build_run_input.return_value = mock_run_input
mock_adapter_instance = MagicMock()
mock_adapter_instance.run_stream.return_value = MagicMock()
mock_adapter_instance.encode_stream.return_value = mock_encode()
mock_adapter_cls.return_value = mock_adapter_instance
service = AIService()
request = MagicMock()
request.META = {}
request.raw_body = b"{}"
service._build_async_stream(request)
# run_stream should have been called with a toolset
call_kwargs = mock_adapter_instance.run_stream.call_args[1]
assert call_kwargs["toolsets"] is not None
assert len(call_kwargs["toolsets"]) == 1
assert len(mock_run_input.messages) == 1
assert mock_run_input.messages[0].id == "system-force-tool-usage"
assert mock_run_input.messages[0].role == "system"
assert mock_run_input.messages[0].parts[0].text == BLOCKNOTE_TOOL_STRICT_PROMPT
@patch("core.services.ai_services.Agent")
@patch("core.services.ai_services.VercelAIAdapter")
def test_services_ai_build_async_stream_langfuse_enabled(
mock_adapter_cls, mock_agent_cls, settings
):
"""When LANGFUSE_PUBLIC_KEY is set, instrument should be enabled."""
settings.LANGFUSE_PUBLIC_KEY = "pk-test-123"
async def mock_encode():
yield "data"
mock_run_input = MagicMock()
mock_run_input.model_extra = None
mock_run_input.messages = []
mock_adapter_cls.build_run_input.return_value = mock_run_input
mock_adapter_instance = MagicMock()
mock_adapter_instance.run_stream.return_value = MagicMock()
mock_adapter_instance.encode_stream.return_value = mock_encode()
mock_adapter_cls.return_value = mock_adapter_instance
service = AIService()
request = MagicMock()
request.META = {}
request.raw_body = b"{}"
service._build_async_stream(request)
mock_agent_cls.instrument_all.assert_called_once()
# Agent should be created with instrument=True
mock_agent_cls.assert_called_once()
assert mock_agent_cls.call_args[1]["instrument"] is True
@@ -15,7 +15,7 @@ from requests import HTTPError
from core import factories, models, utils
from core.services.search_indexers import (
BaseDocumentIndexer,
FindDocumentIndexer,
SearchIndexer,
get_document_indexer,
get_visited_document_ids_of,
)
@@ -78,41 +78,41 @@ def test_services_search_indexer_is_configured(indexer_settings):
# Valid class
indexer_settings.SEARCH_INDEXER_CLASS = (
"core.services.search_indexers.FindDocumentIndexer"
"core.services.search_indexers.SearchIndexer"
)
get_document_indexer.cache_clear()
assert get_document_indexer() is not None
indexer_settings.INDEXING_URL = ""
indexer_settings.SEARCH_INDEXER_URL = ""
# Invalid url
get_document_indexer.cache_clear()
assert not get_document_indexer()
def test_services_indexing_url_is_none(indexer_settings):
def test_services_search_indexer_url_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if INDEXING_URL is None or empty.
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
"""
indexer_settings.INDEXING_URL = None
indexer_settings.SEARCH_INDEXER_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
SearchIndexer()
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_indexing_url_is_empty(indexer_settings):
def test_services_search_indexer_url_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if INDEXING_URL is empty string.
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
"""
indexer_settings.INDEXING_URL = ""
indexer_settings.SEARCH_INDEXER_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
SearchIndexer()
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_secret_is_none(indexer_settings):
@@ -122,7 +122,7 @@ def test_services_search_indexer_secret_is_none(indexer_settings):
indexer_settings.SEARCH_INDEXER_SECRET = None
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
SearchIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
@@ -136,35 +136,39 @@ def test_services_search_indexer_secret_is_empty(indexer_settings):
indexer_settings.SEARCH_INDEXER_SECRET = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
SearchIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
)
def test_services_search_url_is_none(indexer_settings):
def test_services_search_endpoint_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_URL is None.
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
"""
indexer_settings.SEARCH_URL = None
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
SearchIndexer()
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
def test_services_search_url_is_empty(indexer_settings):
def test_services_search_endpoint_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_URL is empty.
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
"""
indexer_settings.SEARCH_URL = ""
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
SearchIndexer()
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
@pytest.mark.usefixtures("indexer_settings")
@@ -188,7 +192,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
}
}
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
result = indexer.serialize_document(document, accesses)
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
@@ -217,7 +221,7 @@ def test_services_search_indexers_serialize_document_deleted():
parent.soft_delete()
document.refresh_from_db()
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
result = indexer.serialize_document(document, {})
assert result["is_active"] is False
@@ -228,7 +232,7 @@ def test_services_search_indexers_serialize_document_empty():
"""Empty documents returns empty content in the serialized json."""
document = factories.DocumentFactory(content="", title=None)
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
result = indexer.serialize_document(document, {})
assert result["content"] == ""
@@ -242,7 +246,7 @@ def test_services_search_indexers_index_errors(indexer_settings):
"""
factories.DocumentFactory()
indexer_settings.INDEXING_URL = "http://app-find/api/v1.0/documents/index/"
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
responses.add(
responses.POST,
@@ -252,10 +256,10 @@ def test_services_search_indexers_index_errors(indexer_settings):
)
with pytest.raises(HTTPError):
FindDocumentIndexer().index()
SearchIndexer().index()
@patch.object(FindDocumentIndexer, "push")
@patch.object(SearchIndexer, "push")
def test_services_search_indexers_batches_pass_only_batch_accesses(
mock_push, indexer_settings
):
@@ -272,7 +276,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub)
assert FindDocumentIndexer().index() == 5
assert SearchIndexer().index() == 5
# Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3
@@ -295,7 +299,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
assert seen_doc_ids == {str(d.id) for d in documents}
@patch.object(FindDocumentIndexer, "push")
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_batch_size_argument(mock_push):
"""
@@ -310,7 +314,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub)
assert FindDocumentIndexer().index(batch_size=2) == 5
assert SearchIndexer().index(batch_size=2) == 5
# Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3
@@ -333,7 +337,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
assert seen_doc_ids == {str(d.id) for d in documents}
@patch.object(FindDocumentIndexer, "push")
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ignore_empty_documents(mock_push):
"""
@@ -345,7 +349,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
empty_title = factories.DocumentFactory(title="")
empty_content = factories.DocumentFactory(content="")
assert FindDocumentIndexer().index() == 3
assert SearchIndexer().index() == 3
assert mock_push.call_count == 1
@@ -361,7 +365,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
}
@patch.object(FindDocumentIndexer, "push")
@patch.object(SearchIndexer, "push")
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
"""
Documents indexing batch can be empty if all the docs are empty.
@@ -373,14 +377,14 @@ def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings
# Only empty docs
factories.DocumentFactory.create_batch(5, content="", title="")
assert FindDocumentIndexer().index() == 1
assert SearchIndexer().index() == 1
assert mock_push.call_count == 1
results = [doc["id"] for doc in mock_push.call_args[0][0]]
assert results == [str(document.id)]
@patch.object(FindDocumentIndexer, "push")
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_link_reach(mock_push):
"""Document accesses and reach should take into account ancestors link reaches."""
@@ -391,7 +395,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
assert FindDocumentIndexer().index() == 4
assert SearchIndexer().index() == 4
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 4
@@ -401,7 +405,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
assert results[str(document.id)]["reach"] == "public"
@patch.object(FindDocumentIndexer, "push")
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_users(mock_push):
"""Document accesses and reach should include users from ancestors."""
@@ -411,7 +415,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
document = factories.DocumentFactory(parent=parent, users=[user_d])
assert FindDocumentIndexer().index() == 3
assert SearchIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
@@ -424,7 +428,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
}
@patch.object(FindDocumentIndexer, "push")
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_teams(mock_push):
"""Document accesses and reach should include teams from ancestors."""
@@ -432,7 +436,7 @@ def test_services_search_indexers_ancestors_teams(mock_push):
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
assert FindDocumentIndexer().index() == 3
assert SearchIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
@@ -447,9 +451,9 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
push() should call requests.post with the correct URL from settings
the timeout set to 10 seconds and the data as JSON.
"""
indexer_settings.INDEXING_URL = "http://example.com/index"
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
sample_data = [{"id": "123", "title": "Test"}]
mock_response = mock_post.return_value
@@ -460,7 +464,7 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.INDEXING_URL
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
assert kwargs.get("json") == sample_data
assert kwargs.get("timeout") == 10
@@ -494,7 +498,7 @@ def test_get_visited_document_ids_of():
factories.UserDocumentAccessFactory(user=user, document=doc2)
# The second document have an access for the user
assert get_visited_document_ids_of(queryset, user) == (str(doc1.pk),)
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
@pytest.mark.usefixtures("indexer_settings")
@@ -528,7 +532,7 @@ def test_get_visited_document_ids_of_deleted():
doc_deleted.soft_delete()
# Only the first document is not deleted
assert get_visited_document_ids_of(queryset, user) == (str(doc.pk),)
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
@responses.activate
@@ -538,7 +542,9 @@ def test_services_search_indexers_search_errors(indexer_settings):
"""
factories.DocumentFactory()
indexer_settings.SEARCH_URL = "http://app-find/api/v1.0/documents/search/"
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
"http://app-find/api/v1.0/documents/search/"
)
responses.add(
responses.POST,
@@ -548,17 +554,17 @@ def test_services_search_indexers_search_errors(indexer_settings):
)
with pytest.raises(HTTPError):
FindDocumentIndexer().search(q="alpha", token="mytoken")
SearchIndexer().search("alpha", token="mytoken")
@patch("requests.post")
def test_services_search_indexers_search(mock_post, indexer_settings):
"""
search() should call requests.post to SEARCH_URL with the
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
document ids from linktraces.
"""
user = factories.UserFactory()
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
@@ -572,11 +578,11 @@ def test_services_search_indexers_search(mock_post, indexer_settings):
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
indexer.search(q="alpha", visited=visited, token="mytoken")
indexer.search("alpha", visited=visited, token="mytoken")
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_URL
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
query_data = kwargs.get("json")
assert query_data["q"] == "alpha"
@@ -599,7 +605,7 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
user = factories.UserFactory()
indexer = FindDocumentIndexer()
indexer = SearchIndexer()
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
@@ -613,65 +619,17 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
indexer.search(q="alpha", visited=visited, token="mytoken")
indexer.search("alpha", visited=visited, token="mytoken")
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_URL
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
assert kwargs.get("json")["nb_results"] == 25
# The argument overrides the setting value
indexer.search(q="alpha", visited=visited, token="mytoken", nb_results=109)
indexer.search("alpha", visited=visited, token="mytoken", nb_results=109)
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_URL
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
assert kwargs.get("json")["nb_results"] == 109
def test_search_indexer_get_title_with_localized_field():
"""Test extracting title from localized title field."""
source = {"title.extension": "Bonjour", "id": 1, "content": "test"}
result = FindDocumentIndexer.get_title(source)
assert result == "Bonjour"
def test_search_indexer_get_title_with_multiple_localized_fields():
"""Test that first matching localized title is returned."""
source = {"title.extension": "Bonjour", "title.en": "Hello", "id": 1}
result = FindDocumentIndexer.get_title(source)
assert result in ["Bonjour", "Hello"]
def test_search_indexer_get_title_fallback_to_plain_title():
"""Test fallback to plain 'title' field when no localized field exists."""
source = {"title": "Hello World", "id": 1}
result = FindDocumentIndexer.get_title(source)
assert result == "Hello World"
def test_search_indexer_get_title_no_title_field():
"""Test that empty string is returned when no title field exists."""
source = {"id": 1, "content": "test"}
result = FindDocumentIndexer.get_title(source)
assert result == ""
def test_search_indexer_get_title_with_empty_localized_title():
"""Test that fallback works when localized title is empty."""
source = {"title.extension": "", "title": "Fallback Title", "id": 1}
result = FindDocumentIndexer.get_title(source)
assert result == "Fallback Title"
def test_search_indexer_get_title_with_multiple_extension():
"""Test extracting title from title field with multiple extensions."""
source = {"title.extension_1.extension_2": "Bonjour", "id": 1, "content": "test"}
result = FindDocumentIndexer.get_title(source)
assert result == "Bonjour"
-36
View File
@@ -28,39 +28,3 @@ def test_invalid_settings_oidc_email_configuration():
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
def test_settings_psycopg_pool_not_enabled():
"""
Test that not changing DB_PSYCOPG_POOL_ENABLED should not configure psycopg in the DATABASES
settings.
"""
class TestSettings(Base):
"""Fake test settings without enabling psycopg"""
TestSettings.post_setup()
assert TestSettings.DATABASES["default"].get("OPTIONS") == {}
def test_settings_psycopg_pool_enabled(monkeypatch):
"""
Test when DB_PSYCOPG_POOL_ENABLED is set to True, the psycopg pool options should be present
in the DATABASES OPTIONS.
"""
monkeypatch.setenv("DB_PSYCOPG_POOL_ENABLED", "True")
class TestSettings(Base):
"""Fake test settings without enabling psycopg"""
TestSettings.post_setup()
assert TestSettings.DATABASES["default"].get("OPTIONS") == {
"pool": {
"min_size": 4,
"max_size": None,
"timeout": 3,
}
}
+1 -141
View File
@@ -3,14 +3,9 @@
import base64
import uuid
from django.core.cache import cache
import pycrdt
import pytest
from core import factories, utils
pytestmark = pytest.mark.django_db
from core import utils
# This base64 string is an example of what is saved in the database.
# This base64 is generated from the blocknote editor, it contains
@@ -105,138 +100,3 @@ def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}
def test_utils_users_sharing_documents_with_cache_miss():
"""Test cache miss: should query database and cache result."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
user3 = factories.UserFactory()
doc1 = factories.DocumentFactory()
doc2 = factories.DocumentFactory()
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
factories.UserDocumentAccessFactory(user=user3, document=doc2)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
assert user2.id in result
cached_data = cache.get(cache_key)
assert cached_data == result
def test_utils_users_sharing_documents_with_cache_hit():
"""Test cache hit: should return cached data without querying database."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
test_cached_data = {user2.id: "2025-02-10"}
cache.set(cache_key, test_cached_data, 86400)
result = utils.users_sharing_documents_with(user1)
assert result == test_cached_data
def test_utils_users_sharing_documents_with_cache_invalidation_on_create():
"""Test that cache is invalidated when a DocumentAccess is created."""
# Create test data
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
# Pre-populate cache
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {}, 86400)
# Verify cache exists
assert cache.get(cache_key) is not None
# Create new DocumentAccess
factories.UserDocumentAccessFactory(user=user2, document=doc1)
# Cache should still exist (only created for user2 who was added)
# But if we create access for user1 being shared with, cache should be cleared
cache.set(cache_key, {"test": "data"}, 86400)
factories.UserDocumentAccessFactory(user=user1, document=doc1)
# Cache for user1 should be invalidated (cleared)
assert cache.get(cache_key) is None
def test_utils_users_sharing_documents_with_cache_invalidation_on_delete():
"""Test that cache is invalidated when a DocumentAccess is deleted."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
doc_access = factories.UserDocumentAccessFactory(user=user1, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {user2.id: "2025-02-10"}, 86400)
assert cache.get(cache_key) is not None
doc_access.delete()
assert cache.get(cache_key) is None
def test_utils_users_sharing_documents_with_empty_result():
"""Test when user is not sharing any documents."""
user1 = factories.UserFactory()
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
assert result == {}
cached_data = cache.get(cache_key)
assert cached_data == {}
def test_utils_get_value_by_pattern_matching_key():
"""Test extracting value from a dictionary with a matching key pattern."""
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
result = utils.get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
def test_utils_get_value_by_pattern_multiple_matches():
"""Test that all matching keys are returned."""
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
assert set(result) == {
"Bonjour",
"Hello",
}
def test_utils_get_value_by_pattern_multiple_extensions():
"""Test that all matching keys are returned."""
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
def test_utils_get_value_by_pattern_no_match():
"""Test that empty list is returned when no key matches the pattern."""
data = {"name": "Test", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
assert result == []
@@ -1,62 +0,0 @@
"""Tests for utils.users_sharing_documents_with function."""
from django.utils import timezone
import pytest
from core import factories, utils
pytestmark = pytest.mark.django_db
def test_utils_users_sharing_documents_with():
"""Test users_sharing_documents_with function."""
user = factories.UserFactory(
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
)
pierre_1 = factories.UserFactory(
email="pierre.dupont@beta.gouv.fr", full_name="Pierre Dupont"
)
pierre_2 = factories.UserFactory(
email="pierre.durand@impots.gouv.fr", full_name="Pierre Durand"
)
now = timezone.now()
yesterday = now - timezone.timedelta(days=1)
last_week = now - timezone.timedelta(days=7)
last_month = now - timezone.timedelta(days=30)
document_1 = factories.DocumentFactory(creator=user)
document_2 = factories.DocumentFactory(creator=user)
document_3 = factories.DocumentFactory(creator=user)
factories.UserDocumentAccessFactory(user=user, document=document_1)
factories.UserDocumentAccessFactory(user=user, document=document_2)
factories.UserDocumentAccessFactory(user=user, document=document_3)
# The factory cannot set the created_at directly, so we force it after creation
doc_1_pierre_1 = factories.UserDocumentAccessFactory(
user=pierre_1, document=document_1, created_at=last_week
)
doc_1_pierre_1.created_at = last_week
doc_1_pierre_1.save()
doc_2_pierre_2 = factories.UserDocumentAccessFactory(
user=pierre_2, document=document_2
)
doc_2_pierre_2.created_at = last_month
doc_2_pierre_2.save()
doc_3_pierre_2 = factories.UserDocumentAccessFactory(
user=pierre_2, document=document_3
)
doc_3_pierre_2.created_at = yesterday
doc_3_pierre_2.save()
shared_map = utils.users_sharing_documents_with(user)
assert shared_map == {
pierre_1.id: last_week,
pierre_2.id: yesterday,
}
-4
View File
@@ -59,10 +59,6 @@ urlpatterns = [
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
include(thread_related_router.urls),
),
path(
"user-reconciliations/<str:user_type>/<uuid:confirmation_id>/",
viewsets.ReconciliationConfirmView.as_view(),
),
]
),
),
+1 -73
View File
@@ -1,42 +1,13 @@
"""Utils for the core app."""
import base64
import logging
import re
import time
from collections import defaultdict
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
import pycrdt
from bs4 import BeautifulSoup
from core import enums, models
logger = logging.getLogger(__name__)
def get_value_by_pattern(data, pattern):
"""
Get all values from keys matching a regex pattern in a dictionary.
Args:
data (dict): Source dictionary to search
pattern (str): Regex pattern to match against keys
Returns:
list: List of values for all matching keys, empty list if no matches
Example:
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
["Bonjour"]
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
["Bonjour", "Hello"]
"""
regex = re.compile(pattern)
return [value for key, value in data.items() if regex.match(key)]
from core import enums
def get_ancestor_to_descendants_map(paths, steplen):
@@ -125,46 +96,3 @@ def extract_attachments(content):
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
def get_users_sharing_documents_with_cache_key(user):
"""Generate a unique cache key for each user."""
return f"users_sharing_documents_with_{user.id}"
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
start_time = time.time()
cache_key = get_users_sharing_documents_with_cache_key(user)
cached_result = cache.get(cache_key)
if cached_result is not None:
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
user.id,
elapsed,
)
return cached_result
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
result = {item["user"]: item["last_shared"] for item in shared_qs}
cache.set(cache_key, result, 86400) # Cache for 1 day
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
user.id,
elapsed,
)
return result
@@ -138,7 +138,6 @@ def create_demo(stdout):
password="!",
is_superuser=False,
is_active=True,
is_first_connection=False,
is_staff=False,
short_name=first_name,
full_name=f"{first_name:s} {random.choice(last_names):s}",
@@ -195,7 +194,6 @@ def create_demo(stdout):
password="!",
is_superuser=False,
is_active=True,
is_first_connection=False,
is_staff=False,
language=dev_user["language"] or random.choice(languages),
)
-18
View File
@@ -1,18 +0,0 @@
"""
ASGI config for impress project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/dev/howto/deployment/asgi/
"""
import os
from configurations.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
os.environ.setdefault("PYTHON_SERVER_MODE", "async")
application = get_asgi_application()
@@ -3,8 +3,8 @@
"default": {
"logo": {
"src": "/assets/icon-docs.svg",
"width": "54px",
"alt": "Docs Logo",
"style": { "width": "54px", "height": "auto" },
"withTitle": true
},
"externalLinks": [
@@ -125,42 +125,5 @@
}
}
}
},
"home": {
"with-proconnect": false,
"icon-banner": {
"src": "/assets/icon-docs.svg",
"style": {
"width": "64px",
"height": "auto"
},
"alt": ""
}
},
"header": {
"logo": {},
"icon": {
"src": "/assets/icon-docs.svg",
"style": {
"width": "32px",
"height": "auto"
},
"alt": "",
"withTitle": true
}
},
"favicon": {
"light": {
"href": "/assets/favicon-light.png",
"type": "image/png"
},
"dark": {
"href": "/assets/favicon-dark.png",
"type": "image/png"
}
},
"onboarding": {
"enabled": true,
"learn_more_url": ""
}
}
+10 -107
View File
@@ -99,7 +99,6 @@ class Base(Configuration):
"localhost", environ_name="DB_HOST", environ_prefix=None
),
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
# Psycopg pool can be configured in the post_setup method
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
@@ -113,8 +112,8 @@ class Base(Configuration):
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
)
INDEXING_URL = values.Value(
default=None, environ_name="INDEXING_URL", environ_prefix=None
SEARCH_INDEXER_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
)
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
@@ -122,8 +121,8 @@ class Base(Configuration):
SEARCH_INDEXER_SECRET = values.Value(
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
)
SEARCH_URL = values.Value(
default=None, environ_name="SEARCH_URL", environ_prefix=None
SEARCH_INDEXER_QUERY_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
)
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
@@ -170,11 +169,6 @@ class Base(Configuration):
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
AWS_S3_SIGNATURE_VERSION = values.Value(
"s3v4",
environ_name="AWS_S3_SIGNATURE_VERSION",
environ_prefix=None,
)
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
@@ -327,11 +321,9 @@ class Base(Configuration):
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"core.middleware.ForceSessionMiddleware",
"core.middleware.SaveRawBodyMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"csp.middleware.CSPMiddleware",
"waffle.middleware.WaffleMiddleware",
]
AUTHENTICATION_BACKENDS = [
@@ -353,7 +345,6 @@ class Base(Configuration):
"parler",
"treebeard",
"easy_thumbnails",
"waffle",
# Django
"django.contrib.admin",
"django.contrib.auth",
@@ -516,9 +507,7 @@ class Base(Configuration):
FRONTEND_JS_URL = values.Value(
None, environ_name="FRONTEND_JS_URL", environ_prefix=None
)
FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue(
default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None
)
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
@@ -560,16 +549,6 @@ class Base(Configuration):
SESSION_COOKIE_NAME = "docs_sessionid"
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = values.Value(
"lasuite.oidc_login.views.OIDCAuthenticationRequestView",
environ_name="OIDC_AUTHENTICATE_CLASS",
environ_prefix=None,
)
OIDC_CALLBACK_CLASS = values.Value(
"lasuite.oidc_login.views.OIDCAuthenticationCallbackView",
environ_name="OIDC_CALLBACK_CLASS",
environ_prefix=None,
)
OIDC_CREATE_USER = values.BooleanValue(
default=True,
environ_name="OIDC_CREATE_USER",
@@ -713,25 +692,12 @@ class Base(Configuration):
"hour": 100,
"day": 500,
}
# Master settings to enable AI features, if you set it to False,
# all AI features will be disabled even if the other settings are enabled.
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
)
# Far better UI but more flaky for the moment
# ⚠️ AGPL license, be sure to comply with the Blocknote license
# if you enable it (https://www.blocknotejs.org/)
AI_FEATURE_BLOCKNOTE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_BLOCKNOTE_ENABLED", environ_prefix=None
)
# UI with less features but more stable
# MIT friendly license, you can enable it without worrying about the license
AI_FEATURE_LEGACY_ENABLED = values.BooleanValue(
default=True, environ_name="AI_FEATURE_LEGACY_ENABLED", environ_prefix=None
)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
AI_VERCEL_SDK_VERSION = values.IntegerValue(
6, environ_name="AI_VERCEL_SDK_VERSION", environ_prefix=None
AI_STREAM = values.BooleanValue(
default=False, environ_name="AI_STREAM", environ_prefix=None
)
AI_USER_RATE_THROTTLE_RATES = {
"minute": 3,
@@ -764,7 +730,7 @@ class Base(Configuration):
# Imported file settings
CONVERSION_FILE_MAX_SIZE = values.IntegerValue(
20 * MB,
20 * MB, # 10MB
environ_name="CONVERSION_FILE_MAX_SIZE",
environ_prefix=None,
)
@@ -870,11 +836,6 @@ class Base(Configuration):
environ_name="API_USERS_LIST_LIMIT",
environ_prefix=None,
)
API_USERS_SEARCH_QUERY_MIN_LENGTH = values.PositiveIntegerValue(
default=3,
environ_name="API_USERS_SEARCH_QUERY_MIN_LENGTH",
environ_prefix=None,
)
# Content Security Policy
# See https://content-security-policy.com/ for more information.
@@ -908,16 +869,6 @@ class Base(Configuration):
),
}
# User accounts management
USER_RECONCILIATION_FORM_URL = values.Value(
None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None
)
USER_ONBOARDING_DOCUMENTS = values.ListValue(
[], environ_name="USER_ONBOARDING_DOCUMENTS", environ_prefix=None
)
USER_ONBOARDING_SANDBOX_DOCUMENT = values.Value(
None, environ_name="USER_ONBOARDING_SANDBOX_DOCUMENT", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
False,
@@ -1002,36 +953,6 @@ class Base(Configuration):
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
)
psycopg_pool_enabled = values.BooleanValue(
False, environ_name="DB_PSYCOPG_POOL_ENABLED", environ_prefix=""
)
if psycopg_pool_enabled:
cls.DATABASES["default"].update(
{
"OPTIONS": {
# https://www.psycopg.org/psycopg3/docs/api/pool.html#psycopg_pool.ConnectionPool
"pool": {
"min_size": values.IntegerValue(
4,
environ_name="DB_PSYCOPG_POOL_MIN_SIZE",
environ_prefix=None,
),
"max_size": values.IntegerValue(
None,
environ_name="DB_PSYCOPG_POOL_MAX_SIZE",
environ_prefix=None,
),
"timeout": values.IntegerValue(
3,
environ_name="DB_PSYCOPG_POOL_TIMEOUT",
environ_prefix=None,
),
}
},
}
)
class Build(Base):
"""Settings used when the application is built.
@@ -1161,7 +1082,6 @@ class Production(Base):
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SESSION_CACHE_ALIAS = "session"
# Privacy
SECURE_REFERRER_POLICY = "same-origin"
@@ -1169,12 +1089,11 @@ class Production(Base):
# Conversion API: Always verify SSL in production
CONVERSION_API_SECURE = True
# Cache
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
"redis://redis:6379/1",
environ_name="REDIS_URL",
environ_prefix=None,
),
@@ -1188,26 +1107,10 @@ class Production(Base):
},
"KEY_PREFIX": values.Value(
"docs",
environ_name="CACHES_DEFAULT_KEY_PREFIX",
environ_name="CACHES_KEY_PREFIX",
environ_prefix=None,
),
},
"session": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
30, # timeout in seconds
environ_name="CACHES_SESSION_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
-1
View File
@@ -13,6 +13,5 @@ from configurations.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
os.environ.setdefault("PYTHON_SERVER_MODE", "sync")
application = get_wsgi_application()
+122 -191
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-13 16:53\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -17,65 +17,69 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Titouroù personel"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Aotreoù"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Deiziadoù a-bouez"
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Gwezennadur"
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Titl"
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Me eo an aozer"
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr "Kuzhet"
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Sinedoù"
#: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Ur restr nevez a zo bet krouet ganeoc'h!"
#: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "C'hwi zo bet disklaeriet perc'henn ur restr nevez:"
#: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "Ar vaezienn-mañ a zo rekis."
#: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Korf"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Doare korf"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
@@ -231,305 +235,232 @@ msgstr "oberiant"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
#: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "implijer"
#: build/lib/core/models.py:210 core/models.py:210
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:710
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:413 core/models.py:413
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:712
msgid "Done"
msgstr ""
#: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:713
msgid "Error"
msgstr ""
#: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:779
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:711 core/models.py:711
msgid "Running"
msgstr ""
#: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titl"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "bomm"
#: build/lib/core/models.py:929 core/models.py:929
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Restr"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:942 core/models.py:1346
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open"
msgstr "Digeriñ"
#: build/lib/core/models.py:1382 core/models.py:1382
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:1386 core/models.py:1386
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
#: build/lib/core/models.py:1392 core/models.py:1392
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:1494 core/models.py:1494
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:1500 core/models.py:1500
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:1524 core/models.py:1524
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1530 core/models.py:1530
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1553 core/models.py:1553
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1559 core/models.py:1559
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1565 core/models.py:1565
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1571 core/models.py:1571
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1723 core/models.py:1723
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1729 core/models.py:1729
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "An implijer en deus goulennet tizhout ar restr-mañ."
#: build/lib/core/models.py:1786 core/models.py:1786
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!"
#: build/lib/core/models.py:1790 core/models.py:1790
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:"
#: build/lib/core/models.py:1796 core/models.py:1796
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} en defe c'hoant da dizhout ar restr: {title}"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1839 core/models.py:1839
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1842 core/models.py:1894
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1890 core/models.py:1890
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1939 core/models.py:1939
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1944 core/models.py:1944
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1954 core/models.py:1954
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1974 core/models.py:1974
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1994 core/models.py:1994
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo ar postel"
#: core/templates/mail/html/template.html:219
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Digeriñ"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, hoc'h ostilh nevez ret-holl evit aozañ, rannañ ha kenlabourat war ar restr e skipailh. "
#: core/templates/mail/html/template.html:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+122 -191
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-13 16:53\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -17,65 +17,69 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Wichtige Daten"
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Baumstruktur"
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Titel"
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
@@ -231,305 +235,232 @@ msgstr "aktiviert"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:210 core/models.py:210
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:710
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:413 core/models.py:413
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:712
msgid "Done"
msgstr ""
#: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:713
msgid "Error"
msgstr ""
#: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:779
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:711 core/models.py:711
msgid "Running"
msgstr ""
#: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "Auszug"
#: build/lib/core/models.py:929 core/models.py:929
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:942 core/models.py:1346
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open"
msgstr "Öffnen"
#: build/lib/core/models.py:1382 core/models.py:1382
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:1386 core/models.py:1386
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: build/lib/core/models.py:1392 core/models.py:1392
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:1494 core/models.py:1494
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:1500 core/models.py:1500
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1524 core/models.py:1524
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1530 core/models.py:1530
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1553 core/models.py:1553
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1559 core/models.py:1559
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1565 core/models.py:1565
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1571 core/models.py:1571
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1723 core/models.py:1723
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1729 core/models.py:1729
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1786 core/models.py:1786
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1790 core/models.py:1790
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1796 core/models.py:1796
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1839 core/models.py:1839
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1842 core/models.py:1894
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1890 core/models.py:1890
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1939 core/models.py:1939
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1944 core/models.py:1944
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1954 core/models.py:1954
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1974 core/models.py:1974
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1994 core/models.py:1994
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/template.html:219
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, Ihr neues unentbehrliches Werkzeug für die Organisation, den Austausch und die Zusammenarbeit in Ihren Dokumenten als Team. "
#: core/templates/mail/html/template.html:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+122 -191
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-13 16:53\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,65 +17,69 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr ""
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -231,305 +235,232 @@ msgstr ""
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:210 core/models.py:210
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:710
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:413 core/models.py:413
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:712
msgid "Done"
msgstr ""
#: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:713
msgid "Error"
msgstr ""
#: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:779
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:711 core/models.py:711
msgid "Running"
msgstr ""
#: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr ""
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:929 core/models.py:929
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:942 core/models.py:1346
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open"
msgstr ""
#: build/lib/core/models.py:1382 core/models.py:1382
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:1386 core/models.py:1386
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:1392 core/models.py:1392
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1494 core/models.py:1494
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1500 core/models.py:1500
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1524 core/models.py:1524
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1530 core/models.py:1530
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1553 core/models.py:1553
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1559 core/models.py:1559
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1565 core/models.py:1565
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1571 core/models.py:1571
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1723 core/models.py:1723
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1729 core/models.py:1729
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1786 core/models.py:1786
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1790 core/models.py:1790
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1796 core/models.py:1796
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1839 core/models.py:1839
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1842 core/models.py:1894
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1890 core/models.py:1890
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1939 core/models.py:1939
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1944 core/models.py:1944
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1954 core/models.py:1954
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1974 core/models.py:1974
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1994 core/models.py:1994
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:219
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/template.html:217
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+122 -191
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-13 16:53\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -17,65 +17,69 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Información Personal"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permisos"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Fechas importantes"
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Estructura en árbol"
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Título"
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Yo soy el creador"
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "¡Un nuevo documento se ha creado por ti!"
#: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "Se le ha concedido la propiedad de un nuevo documento :"
#: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Cuerpo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Tipo de Cuerpo"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
@@ -231,305 +235,232 @@ msgstr "activo"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas."
#: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "usuario"
#: build/lib/core/models.py:210 core/models.py:210
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:710
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:413 core/models.py:413
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:712
msgid "Done"
msgstr ""
#: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:713
msgid "Error"
msgstr ""
#: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:779
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:711 core/models.py:711
msgid "Running"
msgstr ""
#: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "título"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "resumen"
#: build/lib/core/models.py:929 core/models.py:929
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:942 core/models.py:1346
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open"
msgstr "Abrir"
#: build/lib/core/models.py:1382 core/models.py:1382
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:1386 core/models.py:1386
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
#: build/lib/core/models.py:1392 core/models.py:1392
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:1494 core/models.py:1494
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:1500 core/models.py:1500
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Ya existe una traza de enlace para este documento/usuario."
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:1524 core/models.py:1524
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1530 core/models.py:1530
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1553 core/models.py:1553
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1559 core/models.py:1559
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1565 core/models.py:1565
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1571 core/models.py:1571
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Debe establecerse un usuario o un equipo, no ambos."
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1723 core/models.py:1723
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1729 core/models.py:1729
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Este usuario ya ha solicitado acceso a este documento."
#: build/lib/core/models.py:1786 core/models.py:1786
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1790 core/models.py:1790
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1796 core/models.py:1796
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} está pidiendo acceso al documento: {title}"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1839 core/models.py:1839
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1842 core/models.py:1894
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1890 core/models.py:1890
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1939 core/models.py:1939
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1944 core/models.py:1944
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1954 core/models.py:1954
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1974 core/models.py:1974
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1994 core/models.py:1994
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo de correo electrónico"
#: core/templates/mail/html/template.html:219
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr "Docs, su nueva herramienta esencial para organizar, compartir y colaborar en sus documentos como equipo."
#: core/templates/mail/html/template.html:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+122 -198
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-13 16:53\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,65 +17,69 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr "Tâche d'importation créée et mise en file d'attente."
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr "Traiter les rapprochements de l'utilisateur sélectionné"
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Arborescence"
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Titre"
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Je suis l'auteur"
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr "Masqué"
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Favoris"
#: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "Ce champ est obligatoire."
#: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "La portée du lien '%(link_reach)s' n'est pas autorisée en fonction de la configuration du document parent."
#: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Corps"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Type de corps"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
@@ -231,312 +235,232 @@ msgstr "actif"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr "état de la première connexion"
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr "Si l'utilisateur a terminé le processus de première connexion."
#: build/lib/core/models.py:209 core/models.py:209
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:210 core/models.py:210
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address"
msgstr "Adresse email active"
#: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate"
msgstr "Adresse email à désactiver"
#: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file"
msgstr "Identifiant unique dans le fichier source"
#: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:710
msgid "Pending"
msgstr "En attente"
#: build/lib/core/models.py:413 core/models.py:413
msgid "Ready"
msgstr "Prêt"
#: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:712
msgid "Done"
msgstr "Terminé"
#: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:713
msgid "Error"
msgstr "Erreur"
#: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation"
msgstr "rapprochement de l'utilisateur"
#: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations"
msgstr "rapprochements de l'utilisateur"
#: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr "Vous avez demandé un rapprochement de vos comptes utilisateur sur Docs.\n"
" Pour confirmer que vous êtes bien à l'origine de cette demande\n"
" et que cet e-mail vous appartient :"
#: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Confirmez en cliquant sur le lien pour commencer le rapprochement"
#: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:779
msgid "Click here"
msgstr "Cliquez ici"
#: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm"
msgstr "Confirmer"
#: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr "Votre demande de rapprochement a été traitée.\n"
" De nouveaux documents sont probablement associés à votre compte :"
#: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged"
msgstr "Vos comptes ont été fusionnés"
#: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see"
msgstr "Cliquez ici pour voir"
#: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents"
msgstr "Voir mes documents"
#: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file"
msgstr "Fichier CSV"
#: build/lib/core/models.py:711 core/models.py:711
msgid "Running"
msgstr "En cours"
#: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import"
msgstr "importation CSV de rapprochement utilisateur"
#: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports"
msgstr "importations CSV de rapprochement utilisateur"
#: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr "Votre demande de rapprochement n'a pas abouti.\n"
" Le rapprochement a échoué pour les adresses e-mail suivantes :\n"
" {recipient_email}, {other_email}.\n"
" Veuillez vérifier qu'il n'y a pas de fautes de frappe.\n"
" Vous pouvez envoyer une nouvelle demande avec des adresses e-mail valides."
#: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed"
msgstr "Le rapprochement de vos comptes Docs n'est pas terminé"
#: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request"
msgstr "Faire une nouvelle demande"
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titre"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "extrait"
#: build/lib/core/models.py:929 core/models.py:929
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:942 core/models.py:1346
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open"
msgstr "Ouvrir"
#: build/lib/core/models.py:1382 core/models.py:1382
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:1386 core/models.py:1386
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
#: build/lib/core/models.py:1392 core/models.py:1392
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous : {title}"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:1494 core/models.py:1494
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:1500 core/models.py:1500
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:1524 core/models.py:1524
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1530 core/models.py:1530
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ce document est déjà un favori de cet utilisateur."
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1553 core/models.py:1553
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1559 core/models.py:1559
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1565 core/models.py:1565
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1571 core/models.py:1571
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1723 core/models.py:1723
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1729 core/models.py:1729
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
#: build/lib/core/models.py:1786 core/models.py:1786
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1790 core/models.py:1790
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} souhaiterait accéder au document suivant :"
#: build/lib/core/models.py:1796 core/models.py:1796
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} demande l'accès au document : {title}"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Conversation"
#: build/lib/core/models.py:1839 core/models.py:1839
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Conversations"
#: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1842 core/models.py:1894
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Anonyme"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Commentaire"
#: build/lib/core/models.py:1890 core/models.py:1890
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Commentaires"
#: build/lib/core/models.py:1939 core/models.py:1939
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Cet émoji a déjà été réagi à ce commentaire."
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Réaction"
#: build/lib/core/models.py:1944 core/models.py:1944
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Réactions"
#: build/lib/core/models.py:1954 core/models.py:1954
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Si ce modèle est public, utilisable par n'importe qui."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1974 core/models.py:1974
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1994 core/models.py:1994
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr "Docs IA"
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo de l'e-mail"
#: core/templates/mail/html/template.html:219
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/text/template.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/template.html:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+122 -191
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-13 16:53\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -17,65 +17,69 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Informazioni personali"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permessi"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Date importanti"
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Struttura ad albero"
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Titolo"
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Il creatore sono io"
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Preferiti"
#: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Un nuovo documento è stato creato a tuo nome!"
#: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "Sei ora proprietario di un nuovo documento:"
#: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Corpo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
@@ -231,305 +235,232 @@ msgstr "attivo"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account."
#: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "utente"
#: build/lib/core/models.py:210 core/models.py:210
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:710
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:413 core/models.py:413
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:712
msgid "Done"
msgstr ""
#: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:713
msgid "Error"
msgstr ""
#: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:779
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:711 core/models.py:711
msgid "Running"
msgstr ""
#: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titolo"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:929 core/models.py:929
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:942 core/models.py:1346
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open"
msgstr "Apri"
#: build/lib/core/models.py:1382 core/models.py:1382
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:1386 core/models.py:1386
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
#: build/lib/core/models.py:1392 core/models.py:1392
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha condiviso un documento con te: {title}"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1494 core/models.py:1494
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1500 core/models.py:1500
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:1524 core/models.py:1524
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1530 core/models.py:1530
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1553 core/models.py:1553
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1559 core/models.py:1559
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1565 core/models.py:1565
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1571 core/models.py:1571
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1723 core/models.py:1723
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1729 core/models.py:1729
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1786 core/models.py:1786
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1790 core/models.py:1790
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1796 core/models.py:1796
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1839 core/models.py:1839
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1842 core/models.py:1894
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1890 core/models.py:1890
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1939 core/models.py:1939
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1944 core/models.py:1944
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1954 core/models.py:1954
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1974 core/models.py:1974
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1994 core/models.py:1994
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/template.html:219
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Apri"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+122 -198
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-13 16:53\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -17,65 +17,69 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Machtigingen"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Belangrijke data"
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr "Import taak gemaakt en in de wachtrij geplaatst."
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr "Verwerk geselecteerde gebruikers samenvoeging"
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Boomstructuur"
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "Titel"
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Ik ben eigenaar"
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr "Gemaskeerd"
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Favoriet"
#: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document is namens u gemaakt!"
#: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
#: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "Dit veld is verplicht."
#: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Link bereik '%(link_reach)s' is niet toegestaan op basis van bovenliggende documentconfiguratie."
#: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Text"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Text type"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
@@ -231,312 +235,232 @@ msgstr "actief"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten."
#: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "gebruiker"
#: build/lib/core/models.py:210 core/models.py:210
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address"
msgstr "Actieve e-mail adres"
#: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate"
msgstr "E-mailadres om te deactiveren"
#: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file"
msgstr "Unieke ID in het bronbestand"
#: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:710
msgid "Pending"
msgstr "In behandeling"
#: build/lib/core/models.py:413 core/models.py:413
msgid "Ready"
msgstr "Klaar"
#: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:712
msgid "Done"
msgstr "Klaar"
#: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:713
msgid "Error"
msgstr "Fout"
#: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation"
msgstr "gebruiker samenvoegen"
#: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations"
msgstr "gebruikers samenvoegen"
#: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr "Je hebt gevraagd om een samenvoeging van je gebruikersaccounts op Docs.\n"
" Om te bevestigen dat u degene bent die het verzoek\n"
" heeft geïnitieerd en dat deze e-mail van u is:"
#: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Bevestig door te klikken op de link om de samenvoeging te starten"
#: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:779
msgid "Click here"
msgstr "Klik hier"
#: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm"
msgstr "Bevestig"
#: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr "Uw samenvoegingsverzoek is verwerkt.\n"
" Nieuwe documenten worden waarschijnlijk geassocieerd met uw account:"
#: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged"
msgstr "Je accounts zijn samengevoegd"
#: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see"
msgstr "Klik hier om te bekijken"
#: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents"
msgstr "Mijn documenten bekijken"
#: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file"
msgstr "CSV bestand"
#: build/lib/core/models.py:711 core/models.py:711
msgid "Running"
msgstr "Bezig"
#: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import"
msgstr "gebruiker samenvoeging CSV import"
#: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports"
msgstr "gebruiker reconciliation CSV imports"
#: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr "Uw verzoek tot verzoening is mislukt.\n"
" Verzoening mislukt voor de volgende e-mailadressen:\n"
" {recipient_email}, {other_email}.\n"
" Controleer op typefouten.\n"
" U kunt een ander verzoek indienen met de geldige e-mailadressen."
#: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed"
msgstr "Samenvoeging van je Docs accounts is niet voltooid"
#: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request"
msgstr "Maak een nieuw verzoek"
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titel"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "uittreksel"
#: build/lib/core/models.py:929 core/models.py:929
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:942 core/models.py:1346
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open"
msgstr "Open"
#: build/lib/core/models.py:1382 core/models.py:1382
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:1386 core/models.py:1386
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
#: build/lib/core/models.py:1392 core/models.py:1392
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} heeft een document met u gedeeld: {title}"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:1494 core/models.py:1494
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:1500 core/models.py:1500
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Een link bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:1524 core/models.py:1524
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1530 core/models.py:1530
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1553 core/models.py:1553
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1559 core/models.py:1559
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1565 core/models.py:1565
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1571 core/models.py:1571
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1723 core/models.py:1723
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1729 core/models.py:1729
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
#: build/lib/core/models.py:1786 core/models.py:1786
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1790 core/models.py:1790
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} verzoekt toegang tot het volgende document:"
#: build/lib/core/models.py:1796 core/models.py:1796
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} verzoekt toegang tot het document: {title}"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Kanaal"
#: build/lib/core/models.py:1839 core/models.py:1839
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Kanalen"
#: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1842 core/models.py:1894
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Anoniem"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Reactie"
#: build/lib/core/models.py:1890 core/models.py:1890
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Reacties"
#: build/lib/core/models.py:1939 core/models.py:1939
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Deze emoji is al op deze opmerking gereageerd."
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Reactie"
#: build/lib/core/models.py:1944 core/models.py:1944
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Reacties"
#: build/lib/core/models.py:1954 core/models.py:1954
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Sjabloon"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Sjabloon"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Sjabloon/gebruiker relatie"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Sjabloon/gebruiker relaties"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit sjabloon."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit sjabloon."
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "e-mailadres"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1974 core/models.py:1974
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1994 core/models.py:1994
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr "Docs AI"
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/template.html:219
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, jouw nieuwe essentiële tool voor het organiseren, delen en collaboreren van documenten als team. "
#: core/templates/mail/html/template.html:226
#: core/templates/mail/html/template.html:224
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

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