Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03ea6b29df | |||
| ea0a1aef10 | |||
| bb7d1353f6 | |||
| 1944f6177e | |||
| 6ce847d6e1 | |||
| e48080b27e | |||
| 73621c91e5 | |||
| ee2462310f | |||
| 2d6e34c555 | |||
| 3f638b22c4 | |||
| c9f42e7924 | |||
| a30384573e | |||
| 54dc72209c | |||
| 9cf30a0d5f | |||
| f24b047a7c | |||
| 3411df09ae | |||
| 2718321fbe | |||
| 217af2e2a8 | |||
| 53985f77f3 | |||
| a51ceeb409 | |||
| 1070b91d2f | |||
| 24ec1fa70e | |||
| 0ba6f02d1a | |||
| 8ce216f6e8 | |||
| 050b106a8f | |||
| 5011db9bd7 | |||
| e1e0e5ebd8 | |||
| 5c8fff01a5 | |||
| 1a022450c6 | |||
| 09438a8941 | |||
| 6f0dac4f48 | |||
| 9d6fe5da8f | |||
| 1ee313efb1 | |||
| 1ac6b42ae3 | |||
| ffae927c93 | |||
| 0d335105a1 | |||
| dc23883a9c | |||
| a8ce9eabf8 | |||
| 21217be587 | |||
| a8212753aa | |||
| c37dc8dd34 | |||
| e323af2cdb | |||
| 9f9f26974c | |||
| c80e7d05bb | |||
| 5d5ac0c1c8 | |||
| d0b756550b | |||
| 010ed4618a | |||
| c0994d7d1f | |||
| fa0c3847e4 | |||
| 49871c45b1 | |||
| 2cc0d71b89 | |||
| 33785440c6 | |||
| 75c7811755 | |||
| f4cb66d6b6 | |||
| 57dc56f83e | |||
| de1a0e4a73 | |||
| 17cb213ecd | |||
| 3ab0a47c3a | |||
| 685464f2d7 | |||
| 9af540de35 | |||
| 6c43ecc324 | |||
| 607bae0022 | |||
| 1d8b730715 | |||
| d02c6250c9 | |||
| b8c1504e7a | |||
| 18edcf8537 | |||
| 5d8741a70a | |||
| 48df68195a | |||
| 7cf42e6404 | |||
| 9903bd73e2 | |||
| 44b38347c4 | |||
| 709076067b | |||
| db014cfc6f | |||
| 52cd76eb93 | |||
| 505b144968 | |||
| 009de5299f | |||
| 0fddabb354 | |||
| cd25c3a63b | |||
| adb216fbdf | |||
| 235c1828e6 | |||
| 4588c71e8a | |||
| 6b7fc915dd | |||
| c3e83c6612 | |||
| 586089c8e4 | |||
| 1b5ce3ed10 | |||
| 989c70ed57 | |||
| c6ded3f267 | |||
| 781f0815a8 | |||
| 325c7d9786 | |||
| 1083aac920 | |||
| dcfb1115dd | |||
| f64800727a | |||
| 65b67a29b1 | |||
| b8bdcbf7ed | |||
| be995fd211 | |||
| dd5b6bd023 | |||
| 9345d8deab | |||
| f0cc29e779 | |||
| 767710231d | |||
| 3480604359 | |||
| 2e6c39262d | |||
| feb9f7d4a9 | |||
| b547657efd | |||
| 61dbda0bf6 | |||
| 548f32bf4e | |||
| dd02b9d940 | |||
| f81db395ef | |||
| 668d7cd404 | |||
| f199acf6c2 | |||
| 75f71368f4 | |||
| 21f5feab3e | |||
| 8ec89a8348 | |||
| 3b80ac7b4e | |||
| 68df717854 | |||
| 2f52dddc84 | |||
| b1231cea7c | |||
| f9f32db854 | |||
| 0d967aba48 | |||
| 5ec58cef99 | |||
| 1170bdbfc1 | |||
| e807237dbe | |||
| fa6f3e8b7c | |||
| b1a18b2477 | |||
| 7823303d03 | |||
| f84455728b | |||
| 5afc825109 | |||
| 55fe73d001 | |||
| 39b9c8b5a9 | |||
| b56ebf19af | |||
| 03d4b2afbe | |||
| 2556823a69 | |||
| f28da7c2c2 | |||
| dd2d2862be | |||
| c2387fcb02 | |||
| 80fdc72182 | |||
| 3636168a77 | |||
| 1034545b7c | |||
| 8901c6ee33 | |||
| f7d697d9bd | |||
| f9c9e444c9 | |||
| e1d2d9e5c8 | |||
| ab92fc43d6 | |||
| 3a3ed0453b | |||
| 43a1a76a2f | |||
| 62213812ee | |||
| 3d2b018927 | |||
| bb0502b49b | |||
| 9893558c74 |
@@ -0,0 +1,3 @@
|
||||
CVE-2026-26996
|
||||
CVE-2026-27903
|
||||
CVE-2026-27904
|
||||
@@ -5,149 +5,66 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- "main"
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'ci/trivy-fails'
|
||||
- "main"
|
||||
|
||||
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:
|
||||
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
|
||||
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
|
||||
|
||||
build-and-push-frontend:
|
||||
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
|
||||
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
|
||||
|
||||
build-and-push-y-provider:
|
||||
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
|
||||
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
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
- build-and-push-frontend
|
||||
- build-and-push-y-provider
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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@v4
|
||||
- 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
|
||||
@@ -0,0 +1,157 @@
|
||||
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@v4
|
||||
- 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@v4
|
||||
- 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@v4
|
||||
- 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
|
||||
@@ -19,6 +19,8 @@ jobs:
|
||||
test-front:
|
||||
needs: install-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -41,6 +43,8 @@ jobs:
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-dependencies
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -193,3 +197,38 @@ jobs:
|
||||
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
|
||||
omit-unchanged: true
|
||||
install-script: "yarn install --frozen-lockfile"
|
||||
|
||||
uikit-theme-checker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-dependencies
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Build theme
|
||||
run: cd src/frontend/apps/impress && yarn build-theme
|
||||
|
||||
- name: Ensure theme is up to date
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Error: build-theme produced git changes (tracked or untracked)."
|
||||
echo "--- git status --porcelain ---"
|
||||
git status --porcelain
|
||||
echo "--- git diff ---"
|
||||
git --no-pager diff
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -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 -- . ':(exclude)**/impress.yml' | grep "print("
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- src/backend ':(exclude)**/impress.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
sudo wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
|
||||
+158
-56
@@ -6,6 +6,106 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
- ✨(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
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
|
||||
- ✨(backend) add field for button label in email template #1817
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✅(e2e) fix e2e test for other browsers #1799
|
||||
- 🐛(export) fix export column NaN #1819
|
||||
- 🐛(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
|
||||
|
||||
### Security
|
||||
|
||||
- 🔒️(trivy) fix vulnerability about jaraco.context #1806
|
||||
|
||||
## [v4.4.0] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add documents/all endpoint with descendants #1553
|
||||
- ✅(export) add PDF regression tests #1762
|
||||
- 📝(docs) Add language configuration documentation #1757
|
||||
- 🔒(helm) Set default security context #1750
|
||||
- ✨(backend) use langfuse to monitor AI actions #1776
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) make html export accessible to screen reader users #1743
|
||||
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✅(backend) reduce flakiness on backend test #1769
|
||||
- 🐛(frontend) fix clickable main content regression #1773
|
||||
- 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error #1778
|
||||
- 💄(frontend) fix icon position in callout block #1779
|
||||
|
||||
### Security
|
||||
|
||||
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
|
||||
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
|
||||
|
||||
## [v4.3.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) redirecting system #1697
|
||||
@@ -20,11 +120,10 @@ and this project adheres to
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix tables deletion #1752
|
||||
- 🐛(frontend) fix tables deletion #1739
|
||||
- 🐛(frontend) fix children not display when first resize #1753
|
||||
|
||||
|
||||
## [4.2.0] - 2025-12-17
|
||||
## [v4.2.0] - 2025-12-17
|
||||
|
||||
### Added
|
||||
|
||||
@@ -48,8 +147,7 @@ and this project adheres to
|
||||
- 🐛(frontend) Select text + Go back one page crash the app #1733
|
||||
- 🐛(frontend) fix versioning conflict #1742
|
||||
|
||||
|
||||
## [4.1.0] - 2025-12-09
|
||||
## [v4.1.0] - 2025-12-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -68,7 +166,7 @@ and this project adheres to
|
||||
- 🐛(nginx) fix / location to handle new static pages #1682
|
||||
- 🐛(frontend) rerendering during resize window #1715
|
||||
|
||||
## [4.0.0] - 2025-12-01
|
||||
## [v4.0.0] - 2025-12-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -91,7 +189,7 @@ and this project adheres to
|
||||
- 🐛(frontend) preserve left panel width on window resize #1588
|
||||
- 🐛(frontend) prevent duplicate as first character in title #1595
|
||||
|
||||
## [3.10.0] - 2025-11-18
|
||||
## [v3.10.0] - 2025-11-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -125,7 +223,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove api managing templates
|
||||
|
||||
## [3.9.0] - 2025-11-10
|
||||
## [v3.9.0] - 2025-11-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -151,13 +249,13 @@ and this project adheres to
|
||||
- 🐛(frontend) button new doc UI fix #1557
|
||||
- 🐛(frontend) interlinking UI fix #1557
|
||||
|
||||
## [3.8.2] - 2025-10-17
|
||||
## [v3.8.2] - 2025-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(service-worker) fix sw registration and page reload logic #1500
|
||||
|
||||
## [3.8.1] - 2025-10-17
|
||||
## [v3.8.1] - 2025-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -171,7 +269,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove treebeard form for the document admin #1470
|
||||
|
||||
## [3.8.0] - 2025-10-14
|
||||
## [v3.8.0] - 2025-10-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -224,7 +322,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove custom DividerBlock ##1375
|
||||
|
||||
## [3.7.0] - 2025-09-12
|
||||
## [v3.7.0] - 2025-09-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -256,7 +354,7 @@ and this project adheres to
|
||||
|
||||
- 🐛(frontend) fix callout emoji list #1366
|
||||
|
||||
## [3.6.0] - 2025-09-04
|
||||
## [v3.6.0] - 2025-09-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -292,7 +390,7 @@ and this project adheres to
|
||||
- 🐛(frontend) fix display bug on homepage #1332
|
||||
- 🐛link role update #1287
|
||||
|
||||
## [3.5.0] - 2025-07-31
|
||||
## [v3.5.0] - 2025-07-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -320,7 +418,7 @@ and this project adheres to
|
||||
- 🐛(frontend) 401 redirection overridden #1214
|
||||
- 🐛(frontend) include root parent in search #1243
|
||||
|
||||
## [3.4.2] - 2025-07-18
|
||||
## [v3.4.2] - 2025-07-18
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -330,7 +428,7 @@ and this project adheres to
|
||||
|
||||
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
|
||||
|
||||
## [3.4.1] - 2025-07-15
|
||||
## [v3.4.1] - 2025-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -341,7 +439,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
|
||||
|
||||
## [3.4.0] - 2025-07-09
|
||||
## [v3.4.0] - 2025-07-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -385,7 +483,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove Beta from logo #1095
|
||||
|
||||
## [3.3.0] - 2025-05-06
|
||||
## [v3.3.0] - 2025-05-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -417,14 +515,14 @@ and this project adheres to
|
||||
|
||||
- 🔥(back) remove footer endpoint #948
|
||||
|
||||
## [3.2.1] - 2025-05-06
|
||||
## [v3.2.1] - 2025-05-06
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) fix list copy paste #943
|
||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||
|
||||
## [3.2.0] - 2025-05-05
|
||||
## [v3.2.0] - 2025-05-05
|
||||
|
||||
## Added
|
||||
|
||||
@@ -451,7 +549,7 @@ and this project adheres to
|
||||
- 🐛(backend) race condition create doc #633
|
||||
- 🐛(frontend) fix breaklines in custom blocks #908
|
||||
|
||||
## [3.1.0] - 2025-04-07
|
||||
## [v3.1.0] - 2025-04-07
|
||||
|
||||
## Added
|
||||
|
||||
@@ -469,7 +567,7 @@ and this project adheres to
|
||||
- 🐛(back) validate document content in serializer #822
|
||||
- 🐛(frontend) fix selection click past end of content #840
|
||||
|
||||
## [3.0.0] - 2025-03-28
|
||||
## [v3.0.0] - 2025-03-28
|
||||
|
||||
## Added
|
||||
|
||||
@@ -485,7 +583,7 @@ and this project adheres to
|
||||
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
||||
- 🔒️(back) restrict access to document accesses #801
|
||||
|
||||
## [2.6.0] - 2025-03-21
|
||||
## [v2.6.0] - 2025-03-21
|
||||
|
||||
## Added
|
||||
|
||||
@@ -503,7 +601,7 @@ and this project adheres to
|
||||
- 🔒️(back) throttle user list endpoint #636
|
||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||
|
||||
## [2.5.0] - 2025-03-18
|
||||
## [v2.5.0] - 2025-03-18
|
||||
|
||||
## Added
|
||||
|
||||
@@ -533,7 +631,7 @@ and this project adheres to
|
||||
- 🚨(helm) fix helmfile lint #736
|
||||
- 🚚(frontend) redirect to 401 page when 401 error #759
|
||||
|
||||
## [2.4.0] - 2025-03-06
|
||||
## [v2.4.0] - 2025-03-06
|
||||
|
||||
## Added
|
||||
|
||||
@@ -547,7 +645,7 @@ and this project adheres to
|
||||
|
||||
- 🐛(frontend) fix collaboration error #684
|
||||
|
||||
## [2.3.0] - 2025-03-03
|
||||
## [v2.3.0] - 2025-03-03
|
||||
|
||||
## Added
|
||||
|
||||
@@ -574,7 +672,7 @@ and this project adheres to
|
||||
- ♻️(frontend) improve table pdf rendering
|
||||
- 🐛(email) invitation emails in receivers language
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
## [v2.2.0] - 2025-02-10
|
||||
|
||||
## Added
|
||||
|
||||
@@ -593,7 +691,7 @@ and this project adheres to
|
||||
- 🐛(frontend) fix cursor breakline #609
|
||||
- 🐛(frontend) fix style pdf export #609
|
||||
|
||||
## [2.1.0] - 2025-01-29
|
||||
## [v2.1.0] - 2025-01-29
|
||||
|
||||
## Added
|
||||
|
||||
@@ -622,14 +720,14 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove "content" field from list serializer # 516
|
||||
|
||||
## [2.0.1] - 2025-01-17
|
||||
## [v2.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
|
||||
|
||||
## [2.0.0] - 2025-01-13
|
||||
## [v2.0.0] - 2025-01-13
|
||||
|
||||
## Added
|
||||
|
||||
@@ -660,7 +758,7 @@ and this project adheres to
|
||||
- 🐛(frontend) hide search and create doc button if not authenticated #555
|
||||
- 🐛(backend) race condition creation issue #556
|
||||
|
||||
## [1.10.0] - 2024-12-17
|
||||
## [v1.10.0] - 2024-12-17
|
||||
|
||||
## Added
|
||||
|
||||
@@ -681,7 +779,7 @@ and this project adheres to
|
||||
- 🐛(frontend) update doc editor height #481
|
||||
- 💄(frontend) add doc search #485
|
||||
|
||||
## [1.9.0] - 2024-12-11
|
||||
## [v1.9.0] - 2024-12-11
|
||||
|
||||
## Added
|
||||
|
||||
@@ -702,19 +800,19 @@ and this project adheres to
|
||||
- 🐛(frontend) Fix hidden menu on Firefox #468
|
||||
- 🐛(backend) fix sanitize problem IA #490
|
||||
|
||||
## [1.8.2] - 2024-11-28
|
||||
## [v1.8.2] - 2024-11-28
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(SW) change strategy html caching #460
|
||||
|
||||
## [1.8.1] - 2024-11-27
|
||||
## [v1.8.1] - 2024-11-27
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) link not clickable and flickering firefox #457
|
||||
|
||||
## [1.8.0] - 2024-11-25
|
||||
## [v1.8.0] - 2024-11-25
|
||||
|
||||
## Added
|
||||
|
||||
@@ -742,7 +840,7 @@ and this project adheres to
|
||||
- 🐛(frontend) users have view access when revoked #387
|
||||
- 🐛(frontend) fix placeholder editable when double clicks #454
|
||||
|
||||
## [1.7.0] - 2024-10-24
|
||||
## [v1.7.0] - 2024-10-24
|
||||
|
||||
## Added
|
||||
|
||||
@@ -769,7 +867,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(helm) remove infra related codes #366
|
||||
|
||||
## [1.6.0] - 2024-10-17
|
||||
## [v1.6.0] - 2024-10-17
|
||||
|
||||
## Added
|
||||
|
||||
@@ -791,13 +889,13 @@ and this project adheres to
|
||||
- 🐛(backend) fix nginx docker container #340
|
||||
- 🐛(frontend) fix copy paste firefox #353
|
||||
|
||||
## [1.5.1] - 2024-10-10
|
||||
## [v1.5.1] - 2024-10-10
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(db) fix users duplicate #316
|
||||
|
||||
## [1.5.0] - 2024-10-09
|
||||
## [v1.5.0] - 2024-10-09
|
||||
|
||||
## Added
|
||||
|
||||
@@ -825,7 +923,7 @@ and this project adheres to
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #297
|
||||
- 🐛(frontend) fix editor break line not working #302
|
||||
|
||||
## [1.4.0] - 2024-09-17
|
||||
## [v1.4.0] - 2024-09-17
|
||||
|
||||
## Added
|
||||
|
||||
@@ -845,7 +943,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
|
||||
|
||||
## [1.3.0] - 2024-09-05
|
||||
## [v1.3.0] - 2024-09-05
|
||||
|
||||
## Added
|
||||
|
||||
@@ -869,14 +967,14 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove saving modal #213
|
||||
|
||||
## [1.2.1] - 2024-08-23
|
||||
## [v1.2.1] - 2024-08-23
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️ Change ordering docs datagrid #195
|
||||
- 🔥(helm) use scaleway email #194
|
||||
|
||||
## [1.2.0] - 2024-08-22
|
||||
## [v1.2.0] - 2024-08-22
|
||||
|
||||
## Added
|
||||
|
||||
@@ -902,7 +1000,7 @@ and this project adheres to
|
||||
|
||||
- 🔥(helm) remove htaccess #181
|
||||
|
||||
## [1.1.0] - 2024-07-15
|
||||
## [v1.1.0] - 2024-07-15
|
||||
|
||||
## Added
|
||||
|
||||
@@ -917,7 +1015,7 @@ and this project adheres to
|
||||
- ♻️(frontend) create a doc from a modal #132
|
||||
- ♻️(frontend) manage members from the share modal #140
|
||||
|
||||
## [1.0.0] - 2024-07-02
|
||||
## [v1.0.0] - 2024-07-02
|
||||
|
||||
## Added
|
||||
|
||||
@@ -955,14 +1053,18 @@ and this project adheres to
|
||||
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
|
||||
- 🔥(frontend) Remove coming soon page (#121)
|
||||
|
||||
## [0.1.0] - 2024-05-24
|
||||
## [v0.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.2.0...main
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.6.0...main
|
||||
[v4.6.0]: https://github.com/suitenumerique/docs/releases/v4.6.0
|
||||
[v4.5.0]: https://github.com/suitenumerique/docs/releases/v4.5.0
|
||||
[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
|
||||
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
|
||||
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0
|
||||
@@ -997,12 +1099,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
|
||||
[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
|
||||
[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
|
||||
|
||||
+25
-12
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.3-alpine AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && apk upgrade --no-cache
|
||||
@@ -36,7 +36,7 @@ COPY ./src/mail /mail/app
|
||||
WORKDIR /mail/app
|
||||
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
yarn build
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
@@ -58,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
|
||||
@@ -81,7 +81,7 @@ RUN apk add --no-cache \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
RUN wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -98,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/
|
||||
@@ -109,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
|
||||
@@ -138,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"]
|
||||
@@ -151,7 +151,7 @@ RUN rm -rf /var/cache/apk/*
|
||||
|
||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||
|
||||
# Gunicorn
|
||||
# Gunicorn - not used by default but configuration file is provided
|
||||
RUN mkdir -p /usr/local/etc/gunicorn
|
||||
COPY docker/files/usr/local/etc/gunicorn/impress.py /usr/local/etc/gunicorn/impress.py
|
||||
|
||||
@@ -165,5 +165,18 @@ COPY --from=link-collector ${IMPRESS_STATIC_ROOT} ${IMPRESS_STATIC_ROOT}
|
||||
# Copy impress mails
|
||||
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
|
||||
|
||||
# The default command runs gunicorn WSGI server in impress's main module
|
||||
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/impress.py", "impress.wsgi:application"]
|
||||
# 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"]
|
||||
|
||||
@@ -213,6 +213,7 @@ logs: ## display app-dev logs (follow mode)
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## Start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d docspec
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d y-provider-development
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
|
||||
@@ -72,7 +72,7 @@ For some advanced features (ex: Export as PDF) Docs relies on XL packages from B
|
||||
|
||||
### Test it
|
||||
|
||||
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/)
|
||||
You can test Docs on your browser by visiting this [demo document](https://docs.la-suite.eu/docs/9137bbb5-3e8a-4ff7-8a36-fcc4e8bd57f4/)
|
||||
|
||||
### Run Docs locally
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ 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.
|
||||
|
||||
@@ -8,6 +8,7 @@ docker_build(
|
||||
dockerfile='../Dockerfile',
|
||||
only=['./src/backend', './src/mail', './docker'],
|
||||
target = 'backend-production',
|
||||
build_args={'DOCKER_USER': '1000:1000'},
|
||||
live_update=[
|
||||
sync('../src/backend', '/app'),
|
||||
run(
|
||||
@@ -23,6 +24,7 @@ docker_build(
|
||||
dockerfile='../src/frontend/servers/y-provider/Dockerfile',
|
||||
only=['./src/frontend/', './docker/', './.dockerignore'],
|
||||
target = 'y-provider',
|
||||
build_args={'DOCKER_USER': '1000:1000'},
|
||||
live_update=[
|
||||
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
|
||||
]
|
||||
@@ -34,6 +36,7 @@ docker_build(
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
target = 'impress',
|
||||
build_args={'DOCKER_USER': '1000:1000'},
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
|
||||
@@ -231,6 +231,11 @@ services:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
docspec:
|
||||
image: ghcr.io/docspecio/api:2.6.3
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -1,4 +1,6 @@
|
||||
# Runtime Theming 🎨
|
||||
# Customization Guide 🛠 ️
|
||||
|
||||
## Runtime Theming 🎨
|
||||
|
||||
### How to Use
|
||||
|
||||
@@ -32,7 +34,7 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
|
||||
|
||||
----
|
||||
|
||||
# Runtime JavaScript Injection 🚀
|
||||
## Runtime JavaScript Injection 🚀
|
||||
|
||||
### How to Use
|
||||
|
||||
@@ -87,7 +89,7 @@ Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom J
|
||||
|
||||
----
|
||||
|
||||
# **Your Docs icon** 📝
|
||||
## **Your Docs icon** 📝
|
||||
|
||||
You can add your own Docs icon in the header from the theme customization file.
|
||||
|
||||
@@ -105,7 +107,7 @@ This configuration is optional. If not set, the default icon will be used.
|
||||
|
||||
----
|
||||
|
||||
# **Footer Configuration** 📝
|
||||
## **Footer Configuration** 📝
|
||||
|
||||
The footer is configurable from the theme customization file.
|
||||
|
||||
@@ -128,7 +130,7 @@ Below is a visual example of a configured footer ⬇️:
|
||||
|
||||
----
|
||||
|
||||
# **Custom Translations** 📝
|
||||
## **Custom Translations** 📝
|
||||
|
||||
The translations can be partially overridden from the theme customization file.
|
||||
|
||||
@@ -140,4 +142,36 @@ THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||
|
||||
### Example of JSON
|
||||
|
||||
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
|
||||
----
|
||||
|
||||
## **Waffle Configuration** 🧇
|
||||
|
||||
The Waffle (La Gaufre) is a widget that displays a grid of services.
|
||||
|
||||

|
||||
|
||||
### Settings 🔧
|
||||
|
||||
```shellscript
|
||||
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The Waffle can be configured in the theme customization file with the `waffle` key.
|
||||
|
||||
### Available Properties
|
||||
|
||||
See: [LaGaufreV2Props](https://github.com/suitenumerique/ui-kit/blob/main/src/components/la-gaufre/LaGaufreV2.tsx#L49)
|
||||
|
||||
### Complete Example
|
||||
|
||||
From the theme customization file: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||
|
||||
### Behavior
|
||||
|
||||
- If `data.services` is provided, the Waffle will display those services statically
|
||||
- If no data is provided, services can be fetched dynamically from an API endpoint thanks to the `apiUrl` property
|
||||
|
||||
+32
-17
@@ -7,23 +7,29 @@ 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_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||
| CACHES_DEFAULT_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 |
|
||||
@@ -32,6 +38,8 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
|
||||
| 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"] |
|
||||
| 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 |
|
||||
@@ -54,17 +62,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_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 | |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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_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 |
|
||||
@@ -111,10 +124,12 @@ 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.
|
||||
@@ -125,31 +140,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
|
||||
```
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ backend:
|
||||
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||
DJANGO_EMAIL_LOGO_IMG: https://docs.127.0.0.1.nip.io/assets/logo-suite-numerique.png
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
DJANGO_EMAIL_URL_APP: https://docs.127.0.0.1.nip.io
|
||||
DJANGO_EMAIL_USE_SSL: False
|
||||
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
|
||||
LOGGING_LEVEL_LOGGERS_ROOT: INFO
|
||||
@@ -66,6 +67,7 @@ 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 }}"
|
||||
|
||||
@@ -127,6 +127,7 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_URL_APP=<url used in email templates to go to the app> # e.g. "https://docs.yourdomain.tld"
|
||||
```
|
||||
|
||||
### AI
|
||||
@@ -137,6 +138,8 @@ AI is disabled by default. To enable it, the following environment variables mus
|
||||
|
||||
```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
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# Language Configuration (2025-12)
|
||||
|
||||
This document explains how to configure and override the available languages in the Docs application.
|
||||
|
||||
## Default Languages
|
||||
|
||||
By default, the application supports the following languages (in priority order):
|
||||
|
||||
- English (en-us)
|
||||
- French (fr-fr)
|
||||
- German (de-de)
|
||||
- Dutch (nl-nl)
|
||||
- Spanish (es-es)
|
||||
|
||||
The default configuration is defined in `src/backend/impress/settings.py`:
|
||||
|
||||
```python
|
||||
LANGUAGES = values.SingleNestedTupleValue(
|
||||
(
|
||||
("en-us", "English"),
|
||||
("fr-fr", "Français"),
|
||||
("de-de", "Deutsch"),
|
||||
("nl-nl", "Nederlands"),
|
||||
("es-es", "Español"),
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## Overriding Languages
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
You can override the available languages by setting the `DJANGO_LANGUAGES` environment variable. This is the recommended approach for customizing language support without modifying the source code.
|
||||
|
||||
#### Format
|
||||
|
||||
The `DJANGO_LANGUAGES` variable expects a semicolon-separated list of language configurations, where each language is defined as `code,Display Name`:
|
||||
|
||||
```
|
||||
DJANGO_LANGUAGES=code1,Name1;code2,Name2;code3,Name3
|
||||
```
|
||||
|
||||
#### Example Configurations
|
||||
|
||||
**Example 1: English and French only**
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
|
||||
```
|
||||
|
||||
**Example 2: Add Italian and Chinese**
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文
|
||||
```
|
||||
|
||||
**Example 3: Custom subset of languages**
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=fr-fr,Français;de-de,Deutsch;es-es,Español
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
#### Development Environment
|
||||
|
||||
For local development, you can set the `DJANGO_LANGUAGES` variable in your environment configuration file:
|
||||
|
||||
**File:** `env.d/development/common.local`
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文;
|
||||
```
|
||||
|
||||
#### Production Environment
|
||||
|
||||
For production deployments, add the variable to your production environment configuration:
|
||||
|
||||
**File:** `env.d/production.dist/common`
|
||||
|
||||
```bash
|
||||
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
When using Docker Compose, you can set the environment variable in your `compose.yml` or `compose.override.yml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch
|
||||
```
|
||||
|
||||
## Important Considerations
|
||||
|
||||
### Language Codes
|
||||
|
||||
- Use standard language codes (ISO 639-1 with optional region codes)
|
||||
- Format: `language-region` (e.g., `en-us`, `fr-fr`, `de-de`)
|
||||
- Use lowercase for language codes and region identifiers
|
||||
|
||||
### Priority Order
|
||||
|
||||
Languages are listed in priority order. The first language in the list is used as the fallback language throughout the application when a specific translation is not available.
|
||||
|
||||
### Translation Availability
|
||||
|
||||
Before adding a new language, ensure that:
|
||||
|
||||
1. Translation files exist for that language in the `src/backend/locale/` directory
|
||||
2. The frontend application has corresponding translation files
|
||||
3. All required messages have been translated
|
||||
|
||||
#### Available Languages
|
||||
|
||||
The following languages have translation files available in `src/backend/locale/`:
|
||||
|
||||
- `br_FR` - Breton (France)
|
||||
- `cn_CN` - Chinese (China) - *Note: Use `zh-cn` in DJANGO_LANGUAGES*
|
||||
- `de_DE` - German (Germany) - Use `de-de`
|
||||
- `en_US` - English (United States) - Use `en-us`
|
||||
- `es_ES` - Spanish (Spain) - Use `es-es`
|
||||
- `fr_FR` - French (France) - Use `fr-fr`
|
||||
- `it_IT` - Italian (Italy) - Use `it-it`
|
||||
- `nl_NL` - Dutch (Netherlands) - Use `nl-nl`
|
||||
- `pt_PT` - Portuguese (Portugal) - Use `pt-pt`
|
||||
- `ru_RU` - Russian (Russia) - Use `ru-ru`
|
||||
- `sl_SI` - Slovenian (Slovenia) - Use `sl-si`
|
||||
- `sv_SE` - Swedish (Sweden) - Use `sv-se`
|
||||
- `tr_TR` - Turkish (Turkey) - Use `tr-tr`
|
||||
- `uk_UA` - Ukrainian (Ukraine) - Use `uk-ua`
|
||||
- `zh_CN` - Chinese (China) - Use `zh-cn`
|
||||
|
||||
**Note:** When configuring `DJANGO_LANGUAGES`, use lowercase with hyphens (e.g., `pt-pt`, `ru-ru`) rather than the directory name format.
|
||||
|
||||
### Translation Management
|
||||
|
||||
We use [Crowdin](https://crowdin.com/) to manage translations for the Docs application. Crowdin allows our community to contribute translations and helps maintain consistency across all supported languages.
|
||||
|
||||
**Want to add a new language or improve existing translations?**
|
||||
|
||||
If you would like us to support a new language or want to contribute to translations, please get in touch with the project maintainers. We can add new languages to our Crowdin project and coordinate translation efforts with the community.
|
||||
|
||||
### Cookie and Session
|
||||
|
||||
The application stores the user's language preference in a cookie named `docs_language`. The cookie path is set to `/` by default.
|
||||
|
||||
## Testing Language Configuration
|
||||
|
||||
After changing the language configuration:
|
||||
|
||||
1. Restart the application services
|
||||
2. Verify the language selector displays the correct languages
|
||||
3. Test switching between different languages
|
||||
4. Confirm that content is displayed in the selected language
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Languages not appearing
|
||||
|
||||
- Verify the environment variable is correctly formatted (semicolon-separated, comma between code and name)
|
||||
- Check that there are no trailing spaces in language codes or names
|
||||
- Ensure the application was restarted after changing the configuration
|
||||
|
||||
### Missing translations
|
||||
|
||||
If you add a new language but see untranslated text:
|
||||
|
||||
1. Check if translation files exist in `src/backend/locale/<language_code>/LC_MESSAGES/`
|
||||
2. Run Django's `makemessages` and `compilemessages` commands to generate/update translations
|
||||
3. Verify frontend translation files are available
|
||||
|
||||
## Related Configuration
|
||||
|
||||
- `LANGUAGE_CODE`: Default language code (default: `en-us`)
|
||||
- `LANGUAGE_COOKIE_NAME`: Cookie name for storing user language preference (default: `docs_language`)
|
||||
- `LANGUAGE_COOKIE_PATH`: Cookie path (default: `/`)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -20,6 +20,7 @@ DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
DJANGO_EMAIL_URL_APP="http://localhost:3000"
|
||||
|
||||
# Backend url
|
||||
IMPRESS_BASE_URL="http://localhost:8072"
|
||||
@@ -47,7 +48,7 @@ LOGIN_REDIRECT_URL=http://localhost:3000
|
||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
||||
LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
@@ -58,8 +59,13 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
# 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
|
||||
@@ -76,6 +82,8 @@ DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||
|
||||
DOCSPEC_API_URL=http://docspec:4000/conversion
|
||||
|
||||
# Theme customization
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
|
||||
# Throttle
|
||||
API_DOCUMENT_THROTTLE_RATE=1000/min
|
||||
API_CONFIG_THROTTLE_RATE=1000/min
|
||||
API_CONFIG_THROTTLE_RATE=1000/min
|
||||
|
||||
@@ -24,7 +24,8 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_URL_APP="https://${DOCS_HOST}"
|
||||
|
||||
# Media
|
||||
AWS_S3_ENDPOINT_URL=https://${S3_HOST}
|
||||
@@ -52,8 +53,13 @@ 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
|
||||
|
||||
+13
-1
@@ -25,18 +25,30 @@
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed django versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["django"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed celery versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["celery"],
|
||||
"allowedVersions": "<5.6.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"
|
||||
]
|
||||
}
|
||||
|
||||
+40
-16
@@ -1,20 +1,14 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib import admin, messages
|
||||
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 . import models
|
||||
|
||||
|
||||
class TemplateAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for template accesses."""
|
||||
|
||||
autocomplete_fields = ["user"]
|
||||
model = models.TemplateAccess
|
||||
extra = 0
|
||||
from core import models
|
||||
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
@@ -69,7 +63,6 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = (TemplateAccessInline,)
|
||||
list_display = (
|
||||
"id",
|
||||
"sub",
|
||||
@@ -104,15 +97,46 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
|
||||
|
||||
@admin.register(models.Template)
|
||||
class TemplateAdmin(admin.ModelAdmin):
|
||||
"""Template admin interface declaration."""
|
||||
@admin.register(models.UserReconciliationCsvImport)
|
||||
class UserReconciliationCsvImportAdmin(admin.ModelAdmin):
|
||||
"""Admin class for UserReconciliationCsvImport model."""
|
||||
|
||||
inlines = (TemplateAccessInline,)
|
||||
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 template accesses."""
|
||||
"""Inline admin class for document accesses."""
|
||||
|
||||
autocomplete_fields = ["user"]
|
||||
model = models.DocumentAccess
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import unicodedata
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import django_filters
|
||||
@@ -135,4 +136,6 @@ class UserSearchFilter(django_filters.FilterSet):
|
||||
Custom filter for searching users.
|
||||
"""
|
||||
|
||||
q = django_filters.CharFilter(min_length=5, max_length=254)
|
||||
q = django_filters.CharFilter(
|
||||
min_length=settings.API_USERS_SEARCH_QUERY_MIN_LENGTH, max_length=254
|
||||
)
|
||||
|
||||
@@ -98,10 +98,10 @@ class CanCreateInvitationPermission(permissions.BasePermission):
|
||||
|
||||
|
||||
class ResourceWithAccessPermission(permissions.BasePermission):
|
||||
"""A permission class for templates and invitations."""
|
||||
"""A permission class for invitations."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""check create permission for templates."""
|
||||
"""check create permission."""
|
||||
return request.user.is_authenticated or view.action != "create"
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import binascii
|
||||
import mimetypes
|
||||
from base64 import b64decode
|
||||
from os.path import splitext
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -15,10 +16,11 @@ import magic
|
||||
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,
|
||||
YdocConverter,
|
||||
Converter,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,30 +61,6 @@ class UserLightSerializer(UserSerializer):
|
||||
read_only_fields = ["full_name", "short_name"]
|
||||
|
||||
|
||||
class TemplateAccessSerializer(serializers.ModelSerializer):
|
||||
"""Serialize template accesses."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.TemplateAccess
|
||||
resource_field_name = "template"
|
||||
fields = ["id", "user", "team", "role", "abilities"]
|
||||
read_only_fields = ["id", "abilities"]
|
||||
|
||||
def get_abilities(self, instance) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return instance.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Make "user" field is readonly but only on update."""
|
||||
validated_data.pop("user", None)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
|
||||
@@ -188,6 +166,9 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
websocket = serializers.BooleanField(required=False, write_only=True)
|
||||
file = serializers.FileField(
|
||||
required=False, write_only=True, allow_null=True, max_length=255
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
@@ -204,6 +185,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"deleted_at",
|
||||
"depth",
|
||||
"excerpt",
|
||||
"file",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
@@ -243,8 +225,16 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
fields = super().get_fields()
|
||||
|
||||
request = self.context.get("request")
|
||||
if request and request.method == "POST":
|
||||
fields["id"].read_only = False
|
||||
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"]
|
||||
|
||||
return fields
|
||||
|
||||
@@ -273,6 +263,30 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
|
||||
return value
|
||||
|
||||
def validate_file(self, file):
|
||||
"""Add file size and type constraints as defined in settings."""
|
||||
if not file:
|
||||
return None
|
||||
|
||||
# Validate file size
|
||||
if file.size > settings.CONVERSION_FILE_MAX_SIZE:
|
||||
max_size = settings.CONVERSION_FILE_MAX_SIZE // (1024 * 1024)
|
||||
raise serializers.ValidationError(
|
||||
f"File size exceeds the maximum limit of {max_size:d} MB."
|
||||
)
|
||||
|
||||
_name, extension = splitext(file.name)
|
||||
|
||||
if extension.lower() not in settings.CONVERSION_FILE_EXTENSIONS_ALLOWED:
|
||||
raise serializers.ValidationError(
|
||||
(
|
||||
f"File extension {extension} is not allowed. Allowed extensions"
|
||||
f" are: {settings.CONVERSION_FILE_EXTENSIONS_ALLOWED}."
|
||||
)
|
||||
)
|
||||
|
||||
return file
|
||||
|
||||
def save(self, **kwargs):
|
||||
"""
|
||||
Process the content field to extract attachment keys and update the document's
|
||||
@@ -461,7 +475,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
language = user.language or language
|
||||
|
||||
try:
|
||||
document_content = YdocConverter().convert(validated_data["content"])
|
||||
document_content = Converter().convert(
|
||||
validated_data["content"], mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
except ConversionError as err:
|
||||
raise serializers.ValidationError(
|
||||
{"content": ["Could not convert content"]}
|
||||
@@ -583,10 +599,13 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||
class DocumentDuplicationSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for duplicating a document.
|
||||
Allows specifying whether to keep access permissions.
|
||||
Allows specifying whether to keep access permissions,
|
||||
and whether to duplicate descendant documents as well
|
||||
(deep copy) or not (shallow copy).
|
||||
"""
|
||||
|
||||
with_accesses = serializers.BooleanField(default=False)
|
||||
with_descendants = serializers.BooleanField(default=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
@@ -660,52 +679,6 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class TemplateSerializer(serializers.ModelSerializer):
|
||||
"""Serialize templates."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Template
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"accesses",
|
||||
"abilities",
|
||||
"css",
|
||||
"code",
|
||||
"is_public",
|
||||
]
|
||||
read_only_fields = ["id", "accesses", "abilities"]
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class DocumentGenerationSerializer(serializers.Serializer):
|
||||
"""Serializer to receive a request to generate a document on a template."""
|
||||
|
||||
body = serializers.CharField(label=_("Body"))
|
||||
body_type = serializers.ChoiceField(
|
||||
choices=["html", "markdown"],
|
||||
label=_("Body type"),
|
||||
required=False,
|
||||
default="html",
|
||||
)
|
||||
format = serializers.ChoiceField(
|
||||
choices=["pdf", "docx"],
|
||||
label=_("Format"),
|
||||
required=False,
|
||||
default="pdf",
|
||||
)
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
"""Serialize invitations."""
|
||||
|
||||
|
||||
+508
-121
@@ -3,8 +3,10 @@
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from urllib.parse import unquote, urlencode, urlparse
|
||||
@@ -35,29 +37,38 @@ 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
|
||||
from core.services import mime_types
|
||||
from core.services.ai_services import AIService
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
Converter,
|
||||
)
|
||||
from core.services.converter_services import (
|
||||
ServiceUnavailableError as YProviderServiceUnavailableError,
|
||||
)
|
||||
from core.services.converter_services import (
|
||||
ValidationError as YProviderValidationError,
|
||||
)
|
||||
from core.services.converter_services import (
|
||||
YdocConverter,
|
||||
)
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
from core.utils import (
|
||||
extract_attachments,
|
||||
filter_descendants,
|
||||
users_sharing_documents_with,
|
||||
)
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
|
||||
@@ -216,18 +227,80 @@ 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
|
||||
# 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)
|
||||
|
||||
return (
|
||||
user_email_domain = get_domain_from_email(current_user.email) or ""
|
||||
|
||||
candidates = list(
|
||||
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")[: settings.API_USERS_LIST_LIMIT]
|
||||
.order_by("-similarity")
|
||||
)
|
||||
|
||||
# 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"],
|
||||
@@ -245,6 +318,59 @@ class UserViewSet(
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -350,6 +476,9 @@ class DocumentViewSet(
|
||||
Returns: JSON response with the translated text.
|
||||
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
|
||||
|
||||
12. **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
|
||||
|
||||
Example:
|
||||
@@ -388,6 +517,7 @@ class DocumentViewSet(
|
||||
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
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
@@ -524,6 +654,28 @@ class DocumentViewSet(
|
||||
"IN SHARE ROW EXCLUSIVE MODE;"
|
||||
)
|
||||
|
||||
# Remove file from validated_data as it's not a model field
|
||||
# Process it if present
|
||||
uploaded_file = serializer.validated_data.pop("file", None)
|
||||
|
||||
# If a file is uploaded, convert it to Yjs format and set as content
|
||||
if uploaded_file:
|
||||
try:
|
||||
file_content = uploaded_file.read()
|
||||
|
||||
converter = Converter()
|
||||
converted_content = converter.convert(
|
||||
file_content,
|
||||
content_type=uploaded_file.content_type,
|
||||
accept=mime_types.YJS,
|
||||
)
|
||||
serializer.validated_data["content"] = converted_content
|
||||
serializer.validated_data["title"] = uploaded_file.name
|
||||
except ConversionError as err:
|
||||
raise drf.exceptions.ValidationError(
|
||||
{"file": ["Could not convert file content"]}
|
||||
) from err
|
||||
|
||||
obj = models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
**serializer.validated_data,
|
||||
@@ -625,12 +777,29 @@ class DocumentViewSet(
|
||||
"""Get list of favorite documents for the current user."""
|
||||
user = request.user
|
||||
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Among the results, we may have documents that are ancestors/descendants
|
||||
# of each other. In this case we want to keep only the highest ancestors.
|
||||
root_paths = utils.filter_root_paths(
|
||||
queryset.order_by("path").values_list("path", flat=True),
|
||||
skip_sorting=True,
|
||||
)
|
||||
|
||||
path_list = db.Q()
|
||||
for path in root_paths:
|
||||
path_list |= db.Q(path__startswith=path)
|
||||
|
||||
favorite_documents_ids = models.DocumentFavorite.objects.filter(
|
||||
user=user
|
||||
).values_list("document_id", flat=True)
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
queryset = self.queryset.filter(path_list)
|
||||
queryset = queryset.filter(id__in=favorite_documents_ids)
|
||||
queryset = queryset.annotate_user_roles(user)
|
||||
queryset = queryset.annotate(
|
||||
is_favorite=db.Value(True, output_field=db.BooleanField())
|
||||
)
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
@@ -858,6 +1027,60 @@ class DocumentViewSet(
|
||||
},
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
)
|
||||
def all(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns all documents (including descendants) that the user has access to.
|
||||
|
||||
Unlike the list endpoint which only returns top-level documents, this endpoint
|
||||
returns all documents including children, grandchildren, etc.
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
accessible_documents = self.get_queryset()
|
||||
accessible_paths = list(accessible_documents.values_list("path", flat=True))
|
||||
|
||||
if not accessible_paths:
|
||||
return self.get_response_for_queryset(self.queryset.none())
|
||||
|
||||
# Build query to include all descendants using path prefix matching
|
||||
descendants_clause = db.Q()
|
||||
for path in accessible_paths:
|
||||
descendants_clause |= db.Q(path__startswith=path)
|
||||
|
||||
queryset = self.queryset.filter(
|
||||
descendants_clause, ancestors_deleted_at__isnull=True
|
||||
)
|
||||
|
||||
# Apply existing filters
|
||||
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"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
queryset = queryset.annotate_user_roles(user)
|
||||
|
||||
# Annotate favorite status and filter if applicable as late as possible
|
||||
queryset = queryset.annotate_is_favorite(user)
|
||||
for field in ["is_favorite", "is_masked"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
# Apply ordering only now that everything is filtered and annotated
|
||||
queryset = filters.OrderingFilter().filter_queryset(
|
||||
self.request, queryset, self
|
||||
)
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
@@ -989,11 +1212,7 @@ class DocumentViewSet(
|
||||
@transaction.atomic
|
||||
def duplicate(self, request, *args, **kwargs):
|
||||
"""
|
||||
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.
|
||||
Duplicate a document, alongside its descendants if requested.
|
||||
"""
|
||||
# Get document while checking permissions
|
||||
document_to_duplicate = self.get_object()
|
||||
@@ -1002,8 +1221,43 @@ 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)
|
||||
user_role = document_to_duplicate.get_role(request.user)
|
||||
with_descendants = serializer.validated_data.get("with_descendants", False)
|
||||
|
||||
user_role = document_to_duplicate.get_role(user)
|
||||
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
|
||||
|
||||
base64_yjs_content = document_to_duplicate.content
|
||||
@@ -1022,11 +1276,41 @@ class DocumentViewSet(
|
||||
extracted_attachments & set(document_to_duplicate.attachments)
|
||||
)
|
||||
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
|
||||
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
|
||||
# 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(
|
||||
user_role
|
||||
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
|
||||
duplicated_document = models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
creator=user,
|
||||
title=title,
|
||||
content=base64_yjs_content,
|
||||
attachments=attachments,
|
||||
@@ -1035,55 +1319,63 @@ class DocumentViewSet(
|
||||
)
|
||||
models.DocumentAccess.objects.create(
|
||||
document=duplicated_document,
|
||||
user=self.request.user,
|
||||
user=user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
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,
|
||||
)
|
||||
|
||||
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(
|
||||
# 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_id=access.user_id,
|
||||
team=access.team,
|
||||
role=access.role,
|
||||
user=user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
for access in original_accesses
|
||||
]
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
return duplicated_document
|
||||
|
||||
def _search_simple(self, request, text):
|
||||
"""
|
||||
@@ -1544,6 +1836,45 @@ class DocumentViewSet(
|
||||
|
||||
return drf.response.Response(body, status=drf.status.HTTP_200_OK)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
name="Proxy AI requests to the AI provider",
|
||||
url_path="ai-proxy",
|
||||
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
|
||||
)
|
||||
def ai_proxy(self, request, *args, **kwargs):
|
||||
"""
|
||||
POST /api/v1.0/documents/<resource_id>/ai-proxy
|
||||
Proxy AI requests to the configured AI provider.
|
||||
This endpoint forwards requests to the AI provider and returns the complete response.
|
||||
"""
|
||||
# Check permissions first
|
||||
self.get_object()
|
||||
|
||||
if not settings.AI_FEATURE_ENABLED or not settings.AI_FEATURE_BLOCKNOTE_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,
|
||||
)
|
||||
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -1600,6 +1931,101 @@ class DocumentViewSet(
|
||||
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
|
||||
def _reject_invalid_ips(self, ips):
|
||||
"""
|
||||
Check if an IP address is safe from SSRF attacks.
|
||||
|
||||
Raises:
|
||||
drf.exceptions.ValidationError: If the IP is unsafe
|
||||
"""
|
||||
for ip in ips:
|
||||
# Block loopback addresses (check before private,
|
||||
# as 127.0.0.1 might be considered private)
|
||||
if ip.is_loopback:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to loopback addresses is not allowed"
|
||||
)
|
||||
|
||||
# Block link-local addresses (169.254.0.0/16) - check before private
|
||||
if ip.is_link_local:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to link-local addresses is not allowed"
|
||||
)
|
||||
|
||||
# Block private IP ranges
|
||||
if ip.is_private:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to private IP addresses is not allowed"
|
||||
)
|
||||
|
||||
# Block multicast addresses
|
||||
if ip.is_multicast:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to multicast addresses is not allowed"
|
||||
)
|
||||
|
||||
# Block reserved addresses (including 0.0.0.0)
|
||||
if ip.is_reserved:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"Access to reserved IP addresses is not allowed"
|
||||
)
|
||||
|
||||
def _validate_url_against_ssrf(self, url):
|
||||
"""
|
||||
Validate that a URL is safe from SSRF (Server-Side Request Forgery) attacks.
|
||||
|
||||
Blocks:
|
||||
- localhost and its variations
|
||||
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
- Link-local addresses (169.254.0.0/16)
|
||||
- Loopback addresses
|
||||
|
||||
Raises:
|
||||
drf.exceptions.ValidationError: If the URL is unsafe
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
raise drf.exceptions.ValidationError("Invalid hostname")
|
||||
|
||||
# Resolve hostname to IP address(es)
|
||||
# Check all resolved IPs to prevent DNS rebinding attacks
|
||||
try:
|
||||
# Try to parse as IP address first (if hostname is already an IP)
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
resolved_ips = [ip]
|
||||
except ValueError:
|
||||
# Resolve hostname to IP addresses (supports both IPv4 and IPv6)
|
||||
resolved_ips = []
|
||||
try:
|
||||
# Get all address info (IPv4 and IPv6)
|
||||
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC)
|
||||
for family, _, _, _, sockaddr in addr_info:
|
||||
if family == socket.AF_INET:
|
||||
# IPv4
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
resolved_ips.append(ip)
|
||||
elif family == socket.AF_INET6:
|
||||
# IPv6
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
resolved_ips.append(ip)
|
||||
except (socket.gaierror, OSError) as e:
|
||||
raise drf.exceptions.ValidationError(
|
||||
f"Failed to resolve hostname: {str(e)}"
|
||||
) from e
|
||||
|
||||
if not resolved_ips:
|
||||
raise drf.exceptions.ValidationError(
|
||||
"No IP addresses found for hostname"
|
||||
) from None
|
||||
except ValueError as e:
|
||||
raise drf.exceptions.ValidationError(f"Invalid IP address: {str(e)}") from e
|
||||
|
||||
# Check all resolved IPs to ensure none are private/internal
|
||||
self._reject_invalid_ips(resolved_ips)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
@@ -1633,6 +2059,16 @@ class DocumentViewSet(
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate URL against SSRF attacks
|
||||
try:
|
||||
self._validate_url_against_ssrf(url)
|
||||
except drf.exceptions.ValidationError as e:
|
||||
logger.error("Potential SSRF attack detected: %s", e)
|
||||
return drf.response.Response(
|
||||
{"detail": "Invalid URL used."},
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
@@ -1641,13 +2077,15 @@ class DocumentViewSet(
|
||||
"User-Agent": request.headers.get("User-Agent", ""),
|
||||
"Accept": request.headers.get("Accept", ""),
|
||||
},
|
||||
allow_redirects=False,
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if not content_type.startswith("image/"):
|
||||
return drf.response.Response(
|
||||
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
{"detail": "Invalid URL used."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
|
||||
@@ -1665,7 +2103,7 @@ class DocumentViewSet(
|
||||
except requests.RequestException as e:
|
||||
logger.exception(e)
|
||||
return drf.response.Response(
|
||||
{"error": f"Failed to fetch resource from {url}"},
|
||||
{"detail": "Invalid URL used."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -1700,14 +2138,14 @@ class DocumentViewSet(
|
||||
if base64_content is not None:
|
||||
# Convert using the y-provider service
|
||||
try:
|
||||
yprovider = YdocConverter()
|
||||
yprovider = Converter()
|
||||
result = yprovider.convert(
|
||||
base64.b64decode(base64_content),
|
||||
"application/vnd.yjs.doc",
|
||||
mime_types.YJS,
|
||||
{
|
||||
"markdown": "text/markdown",
|
||||
"html": "text/html",
|
||||
"json": "application/json",
|
||||
"markdown": mime_types.MARKDOWN,
|
||||
"html": mime_types.HTML,
|
||||
"json": mime_types.JSON,
|
||||
}[content_format],
|
||||
)
|
||||
content = result
|
||||
@@ -1928,64 +2366,6 @@ class DocumentAccessViewSet(
|
||||
)
|
||||
|
||||
|
||||
class TemplateViewSet(
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Template ViewSet"""
|
||||
|
||||
filter_backends = [drf.filters.OrderingFilter]
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticatedOrSafe,
|
||||
permissions.ResourceWithAccessPermission,
|
||||
]
|
||||
throttle_scope = "template"
|
||||
ordering = ["-created_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
serializer_class = serializers.TemplateSerializer
|
||||
queryset = models.Template.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
"""Custom queryset to get user related templates."""
|
||||
queryset = super().get_queryset()
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
user_roles_query = (
|
||||
models.TemplateAccess.objects.filter(
|
||||
db.Q(user=user) | db.Q(team__in=user.teams),
|
||||
template_id=db.OuterRef("pk"),
|
||||
)
|
||||
.values("template")
|
||||
.annotate(roles_array=ArrayAgg("role"))
|
||||
.values("roles_array")
|
||||
)
|
||||
return queryset.annotate(user_roles=db.Subquery(user_roles_query)).distinct()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Restrict templates returned by the list endpoint"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
queryset = queryset.filter(
|
||||
db.Q(accesses__user=user)
|
||||
| db.Q(accesses__team__in=user.teams)
|
||||
| db.Q(is_public=True)
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
|
||||
|
||||
class InvitationViewset(
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
@@ -2190,14 +2570,21 @@ class ConfigView(drf.views.APIView):
|
||||
Return a dictionary of public settings.
|
||||
"""
|
||||
array_settings = [
|
||||
"AI_BOT",
|
||||
"AI_FEATURE_ENABLED",
|
||||
"AI_FEATURE_BLOCKNOTE_ENABLED",
|
||||
"AI_FEATURE_LEGACY_ENABLED",
|
||||
"API_USERS_SEARCH_QUERY_MIN_LENGTH",
|
||||
"COLLABORATION_WS_URL",
|
||||
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
|
||||
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
|
||||
"CONVERSION_FILE_MAX_SIZE",
|
||||
"CRISP_WEBSITE_ID",
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_CSS_URL",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
"FRONTEND_JS_URL",
|
||||
"FRONTEND_SILENT_LOGIN_ENABLED",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"POSTHOG_KEY",
|
||||
|
||||
@@ -53,15 +53,6 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
if create and (extracted is True):
|
||||
UserDocumentAccessFactory(user=self, role="owner")
|
||||
|
||||
@factory.post_generation
|
||||
def with_owned_template(self, create, extracted, **kwargs):
|
||||
"""
|
||||
Create a template for which the user is owner to check
|
||||
that there is no interference
|
||||
"""
|
||||
if create and (extracted is True):
|
||||
UserTemplateAccessFactory(user=self, role="owner")
|
||||
|
||||
|
||||
class ParentNodeFactory(factory.declarations.ParameteredAttribute):
|
||||
"""Custom factory attribute for setting the parent node."""
|
||||
@@ -202,50 +193,6 @@ class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class TemplateFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create templates"""
|
||||
|
||||
class Meta:
|
||||
model = models.Template
|
||||
django_get_or_create = ("title",)
|
||||
skip_postgeneration_save = True
|
||||
|
||||
title = factory.Sequence(lambda n: f"template{n}")
|
||||
is_public = factory.Faker("boolean")
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to template from a given list of users with or without roles."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
if isinstance(item, models.User):
|
||||
UserTemplateAccessFactory(template=self, user=item)
|
||||
else:
|
||||
UserTemplateAccessFactory(template=self, user=item[0], role=item[1])
|
||||
|
||||
|
||||
class UserTemplateAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake template user accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.TemplateAccess
|
||||
|
||||
template = factory.SubFactory(TemplateFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class TeamTemplateAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake template team accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.TemplateAccess
|
||||
|
||||
template = factory.SubFactory(TemplateFactory)
|
||||
team = factory.Sequence(lambda n: f"team{n}")
|
||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||
|
||||
|
||||
class InvitationFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create invitations for a user"""
|
||||
|
||||
|
||||
@@ -19,3 +19,21 @@ 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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-09 14:18
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0027_auto_20251120_0956"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="templateaccess",
|
||||
name="template",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="templateaccess",
|
||||
name="user",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="Template",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="TemplateAccess",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
# 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
+517
-172
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Declare and configure the models for the impress core application
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import hashlib
|
||||
@@ -14,7 +15,6 @@ 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
|
||||
@@ -32,14 +32,14 @@ from rest_framework.exceptions import ValidationError
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
|
||||
|
||||
from .choices import (
|
||||
from core.choices import (
|
||||
PRIVILEGED_ROLES,
|
||||
LinkReachChoices,
|
||||
LinkRoleChoices,
|
||||
RoleChoices,
|
||||
get_equivalent_link_definition,
|
||||
)
|
||||
from .validators import sub_validator
|
||||
from core.validators import sub_validator
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -118,11 +118,11 @@ class UserManager(auth_models.UserManager):
|
||||
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return self.get(email=email)
|
||||
return self.get(email__iexact=email)
|
||||
except self.model.DoesNotExist:
|
||||
pass
|
||||
elif (
|
||||
self.filter(email=email).exists()
|
||||
self.filter(email__iexact=email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise DuplicateEmailError(
|
||||
@@ -209,14 +209,77 @@ 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 to which s.he was invited.
|
||||
If it's a new user, give its user access to the documents they were invited to.
|
||||
"""
|
||||
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,
|
||||
give access 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_accesses = []
|
||||
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
|
||||
|
||||
onboarding_accesses.append(
|
||||
DocumentAccess(
|
||||
user=self, document=document, role=RoleChoices.READER
|
||||
)
|
||||
)
|
||||
favorite_documents.append(
|
||||
DocumentFavorite(user=self, document_id=document_id)
|
||||
)
|
||||
|
||||
DocumentAccess.objects.bulk_create(onboarding_accesses)
|
||||
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:
|
||||
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.
|
||||
@@ -250,11 +313,37 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
valid_invitations.delete()
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
raise ValueError("User has no email address.")
|
||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
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)
|
||||
|
||||
@cached_property
|
||||
def teams(self):
|
||||
@@ -265,6 +354,417 @@ 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."""
|
||||
|
||||
@@ -782,6 +1282,7 @@ class Document(MP_Node, BaseModel):
|
||||
return {
|
||||
"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,
|
||||
@@ -816,15 +1317,16 @@ class Document(MP_Node, BaseModel):
|
||||
def send_email(self, subject, emails, context=None, language=None):
|
||||
"""Generate and send email from a template."""
|
||||
context = context or {}
|
||||
domain = Site.objects.get_current().domain
|
||||
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
|
||||
language = language or get_language()
|
||||
context.update(
|
||||
{
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"document": self,
|
||||
"domain": domain,
|
||||
"link": f"{domain}/docs/{self.id}/",
|
||||
"document_title": self.title or str(_("Untitled Document")),
|
||||
"link": f"{domain}/docs/{self.id}/?utm_source=docssharelink&utm_campaign={self.id}",
|
||||
"link_label": self.title or str(_("Untitled Document")),
|
||||
"button_label": _("Open"),
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
}
|
||||
)
|
||||
@@ -1428,163 +1930,6 @@ class Reaction(BaseModel):
|
||||
return f"Reaction {self.emoji} on comment {self.comment.id}"
|
||||
|
||||
|
||||
class Template(BaseModel):
|
||||
"""HTML and CSS code used for formatting the print around the MarkDown body."""
|
||||
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
description = models.TextField(_("description"), blank=True)
|
||||
code = models.TextField(_("code"), blank=True)
|
||||
css = models.TextField(_("css"), blank=True)
|
||||
is_public = models.BooleanField(
|
||||
_("public"),
|
||||
default=False,
|
||||
help_text=_("Whether this template is public for anyone to use."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_template"
|
||||
ordering = ("title",)
|
||||
verbose_name = _("Template")
|
||||
verbose_name_plural = _("Templates")
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_role(self, user):
|
||||
"""Return the roles a user has on a resource as an iterable."""
|
||||
if not user.is_authenticated:
|
||||
return None
|
||||
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
try:
|
||||
roles = self.accesses.filter(
|
||||
models.Q(user=user) | models.Q(team__in=user.teams),
|
||||
).values_list("role", flat=True)
|
||||
except (models.ObjectDoesNotExist, IndexError):
|
||||
roles = []
|
||||
|
||||
return RoleChoices.max(*roles)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the template.
|
||||
"""
|
||||
role = self.get_role(user)
|
||||
is_owner_or_admin = role in PRIVILEGED_ROLES
|
||||
can_get = self.is_public or bool(role)
|
||||
can_update = is_owner_or_admin or role == RoleChoices.EDITOR
|
||||
|
||||
return {
|
||||
"destroy": role == RoleChoices.OWNER,
|
||||
"generate_document": can_get,
|
||||
"accesses_manage": is_owner_or_admin,
|
||||
"update": can_update,
|
||||
"partial_update": can_update,
|
||||
"retrieve": can_get,
|
||||
}
|
||||
|
||||
|
||||
class TemplateAccess(BaseAccess):
|
||||
"""Relation model to give access to a template for a user or a team with a role."""
|
||||
|
||||
template = models.ForeignKey(
|
||||
Template,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="accesses",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_template_access"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Template/user relation")
|
||||
verbose_name_plural = _("Template/user relations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "template"],
|
||||
condition=models.Q(user__isnull=False), # Exclude null users
|
||||
name="unique_template_user",
|
||||
violation_error_message=_("This user is already in this template."),
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "template"],
|
||||
condition=models.Q(team__gt=""), # Exclude empty string teams
|
||||
name="unique_template_team",
|
||||
violation_error_message=_("This team is already in this template."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=False, team="")
|
||||
| models.Q(user__isnull=True, team__gt=""),
|
||||
name="check_template_access_either_user_or_team",
|
||||
violation_error_message=_("Either user or team must be set, not both."),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
|
||||
|
||||
def get_role(self, user):
|
||||
"""
|
||||
Get the role a user has on a resource.
|
||||
"""
|
||||
if not user.is_authenticated:
|
||||
return None
|
||||
|
||||
try:
|
||||
roles = self.user_roles or []
|
||||
except AttributeError:
|
||||
teams = user.teams
|
||||
try:
|
||||
roles = self.template.accesses.filter(
|
||||
models.Q(user=user) | models.Q(team__in=teams),
|
||||
).values_list("role", flat=True)
|
||||
except (Template.DoesNotExist, IndexError):
|
||||
roles = []
|
||||
|
||||
return RoleChoices.max(*roles)
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the template access.
|
||||
"""
|
||||
role = self.get_role(user)
|
||||
is_owner_or_admin = role in PRIVILEGED_ROLES
|
||||
|
||||
if self.role == RoleChoices.OWNER:
|
||||
can_delete = (role == RoleChoices.OWNER) and self.template.accesses.filter(
|
||||
role=RoleChoices.OWNER
|
||||
).count() > 1
|
||||
set_role_to = (
|
||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||
if can_delete
|
||||
else []
|
||||
)
|
||||
else:
|
||||
can_delete = is_owner_or_admin
|
||||
set_role_to = []
|
||||
if role == RoleChoices.OWNER:
|
||||
set_role_to.append(RoleChoices.OWNER)
|
||||
if is_owner_or_admin:
|
||||
set_role_to.extend(
|
||||
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
|
||||
)
|
||||
|
||||
# Remove the current role as we don't want to propose it as an option
|
||||
try:
|
||||
set_role_to.remove(self.role)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": bool(set_role_to),
|
||||
"partial_update": bool(set_role_to),
|
||||
"retrieve": bool(role),
|
||||
"set_role_to": set_role_to,
|
||||
}
|
||||
|
||||
|
||||
class Invitation(BaseModel):
|
||||
"""User invitation to a document."""
|
||||
|
||||
@@ -1624,7 +1969,7 @@ class Invitation(BaseModel):
|
||||
|
||||
# Check if an identity already exists for the provided email
|
||||
if (
|
||||
User.objects.filter(email=self.email).exists()
|
||||
User.objects.filter(email__iexact=self.email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise ValidationError(
|
||||
|
||||
@@ -1,12 +1,69 @@
|
||||
"""AI services."""
|
||||
|
||||
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 django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from openai import OpenAI
|
||||
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.
|
||||
|
||||
You MUST respond ONLY by calling applyDocumentOperations.
|
||||
The tool input MUST be valid JSON:
|
||||
{ "operations": [ ... ] }
|
||||
|
||||
Each operation MUST include "type" and it MUST be one of:
|
||||
- "update" (requires: id, block)
|
||||
- "add" (requires: referenceId, position, blocks)
|
||||
- "delete" (requires: id)
|
||||
|
||||
VALID SHAPES (FOLLOW EXACTLY):
|
||||
|
||||
Update:
|
||||
{ "type":"update", "id":"<id$>", "block":"<p>...</p>" }
|
||||
IMPORTANT: "block" MUST be a STRING containing a SINGLE valid HTML element.
|
||||
|
||||
Add:
|
||||
{ "type":"add", "referenceId":"<id$>", "position":"before|after", "blocks":["<p>...</p>"] }
|
||||
IMPORTANT: "blocks" MUST be an ARRAY OF STRINGS.
|
||||
Each item MUST be a STRING containing a SINGLE valid HTML element.
|
||||
|
||||
Delete:
|
||||
{ "type":"delete", "id":"<id$>" }
|
||||
|
||||
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. "
|
||||
@@ -52,6 +109,40 @@ AI_TRANSLATE = (
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class AIService:
|
||||
"""Service class for AI-related operations."""
|
||||
|
||||
@@ -92,3 +183,198 @@ class AIService:
|
||||
language_display = enums.ALL_LANGUAGES.get(language, language)
|
||||
system_content = AI_TRANSLATE.format(language=language_display)
|
||||
return self.call_ai_api(system_content, text)
|
||||
|
||||
@staticmethod
|
||||
def inject_document_state_messages(
|
||||
messages: list[UIMessage],
|
||||
) -> list[UIMessage]:
|
||||
"""Inject document state context before user messages.
|
||||
|
||||
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")
|
||||
|
||||
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)),
|
||||
]
|
||||
|
||||
result.append(
|
||||
UIMessage(
|
||||
role="assistant",
|
||||
id=f"assistant-document-state-{message.id}",
|
||||
parts=parts,
|
||||
)
|
||||
)
|
||||
|
||||
result.append(message)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def tool_definitions_to_toolset(
|
||||
tool_definitions: Dict[str, Any],
|
||||
) -> ExternalToolset:
|
||||
"""Convert serialized tool definitions to a pydantic-ai ExternalToolset.
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
if instrument_enabled:
|
||||
langfuse = get_client()
|
||||
langfuse.auth_check()
|
||||
Agent.instrument_all()
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
"""Y-Provider API services."""
|
||||
|
||||
import logging
|
||||
import typing
|
||||
from base64 import b64encode
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core.services import mime_types
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
"""Base exception for conversion-related errors."""
|
||||
@@ -19,8 +25,81 @@ class ServiceUnavailableError(ConversionError):
|
||||
"""Raised when the conversion service is unavailable."""
|
||||
|
||||
|
||||
class ConverterProtocol(typing.Protocol):
|
||||
"""Protocol for converter classes."""
|
||||
|
||||
def convert(self, data, content_type, accept):
|
||||
"""Convert content from one format to another."""
|
||||
|
||||
|
||||
class Converter:
|
||||
"""Orchestrates conversion between different formats using specialized converters."""
|
||||
|
||||
docspec: ConverterProtocol
|
||||
ydoc: ConverterProtocol
|
||||
|
||||
def __init__(self):
|
||||
self.docspec = DocSpecConverter()
|
||||
self.ydoc = YdocConverter()
|
||||
|
||||
def convert(self, data, content_type, accept):
|
||||
"""Convert input into other formats using external microservices."""
|
||||
|
||||
if content_type == mime_types.DOCX and accept == mime_types.YJS:
|
||||
blocknote_data = self.docspec.convert(
|
||||
data, mime_types.DOCX, mime_types.BLOCKNOTE
|
||||
)
|
||||
return self.ydoc.convert(
|
||||
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
|
||||
)
|
||||
|
||||
return self.ydoc.convert(data, content_type, accept)
|
||||
|
||||
|
||||
class DocSpecConverter:
|
||||
"""Service class for DocSpec conversion-related operations."""
|
||||
|
||||
def _request(self, url, data, content_type):
|
||||
"""Make a request to the DocSpec API."""
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Accept": mime_types.BLOCKNOTE},
|
||||
files={"file": ("document.docx", data, content_type)},
|
||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||
verify=settings.CONVERSION_API_SECURE,
|
||||
)
|
||||
if not response.ok:
|
||||
logger.error(
|
||||
"DocSpec API error: url=%s, status=%d, response=%s",
|
||||
url,
|
||||
response.status_code,
|
||||
response.text[:200] if response.text else "empty",
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def convert(self, data, content_type, accept):
|
||||
"""Convert a Document to BlockNote."""
|
||||
if not data:
|
||||
raise ValidationError("Input data cannot be empty")
|
||||
|
||||
if content_type != mime_types.DOCX or accept != mime_types.BLOCKNOTE:
|
||||
raise ValidationError(
|
||||
f"Conversion from {content_type} to {accept} is not supported."
|
||||
)
|
||||
|
||||
try:
|
||||
return self._request(settings.DOCSPEC_API_URL, data, content_type).content
|
||||
except requests.RequestException as err:
|
||||
logger.exception("DocSpec service error: url=%s", settings.DOCSPEC_API_URL)
|
||||
raise ServiceUnavailableError(
|
||||
"Failed to connect to DocSpec conversion service",
|
||||
) from err
|
||||
|
||||
|
||||
class YdocConverter:
|
||||
"""Service class for conversion-related operations."""
|
||||
"""Service class for YDoc conversion-related operations."""
|
||||
|
||||
@property
|
||||
def auth_header(self):
|
||||
@@ -41,32 +120,34 @@ class YdocConverter:
|
||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||
verify=settings.CONVERSION_API_SECURE,
|
||||
)
|
||||
if not response.ok:
|
||||
logger.error(
|
||||
"Y-Provider API error: url=%s, status=%d, response=%s",
|
||||
url,
|
||||
response.status_code,
|
||||
response.text[:200] if response.text else "empty",
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def convert(
|
||||
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
|
||||
):
|
||||
def convert(self, data, content_type=mime_types.MARKDOWN, accept=mime_types.YJS):
|
||||
"""Convert a Markdown text into our internal format using an external microservice."""
|
||||
|
||||
if not text:
|
||||
raise ValidationError("Input text cannot be empty")
|
||||
if not data:
|
||||
raise ValidationError("Input data cannot be empty")
|
||||
|
||||
url = f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/"
|
||||
try:
|
||||
response = self._request(
|
||||
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
||||
text,
|
||||
content_type,
|
||||
accept,
|
||||
)
|
||||
if accept == "application/vnd.yjs.doc":
|
||||
response = self._request(url, data, content_type, accept)
|
||||
if accept == mime_types.YJS:
|
||||
return b64encode(response.content).decode("utf-8")
|
||||
if accept in {"text/markdown", "text/html"}:
|
||||
if accept in {mime_types.MARKDOWN, "text/html"}:
|
||||
return response.text
|
||||
if accept == "application/json":
|
||||
if accept == mime_types.JSON:
|
||||
return response.json()
|
||||
raise ValidationError("Unsupported format")
|
||||
except requests.RequestException as err:
|
||||
logger.exception("Y-Provider service error: url=%s", url)
|
||||
raise ServiceUnavailableError(
|
||||
"Failed to connect to conversion service",
|
||||
f"Failed to connect to YDoc conversion service {content_type}, {accept}",
|
||||
) from err
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""MIME type constants for document conversion."""
|
||||
|
||||
BLOCKNOTE = "application/vnd.blocknote+json"
|
||||
YJS = "application/vnd.yjs.doc"
|
||||
MARKDOWN = "text/markdown"
|
||||
JSON = "application/json"
|
||||
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
HTML = "text/html"
|
||||
@@ -4,12 +4,14 @@ 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 . import models
|
||||
from .tasks.search import trigger_batch_document_indexer
|
||||
from core import models
|
||||
from core.tasks.search import trigger_batch_document_indexer
|
||||
from core.utils import get_users_sharing_documents_with_cache_key
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.Document)
|
||||
@@ -26,8 +28,24 @@ 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)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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()
|
||||
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Generate Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Generate Document</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Generate PDF</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Custom template tags for the core application of People."""
|
||||
"""Custom template tags for the core application of Docs."""
|
||||
|
||||
import base64
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import re
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
@@ -69,6 +68,30 @@ 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.
|
||||
@@ -158,6 +181,39 @@ 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
|
||||
):
|
||||
@@ -323,85 +379,6 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_json_response():
|
||||
"""Test get_userinfo method with a JSON response."""
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
json={
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"email": "john.doe@example.com",
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "John"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "john.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
|
||||
"""Test get_userinfo method with a token response."""
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
||||
return {
|
||||
"first_name": "Jane",
|
||||
"last_name": "Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
result = oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
assert result["first_name"] == "Jane"
|
||||
assert result["last_name"] == "Doe"
|
||||
assert result["email"] == "jane.doe@example.com"
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||
@responses.activate
|
||||
def test_authentication_get_userinfo_invalid_response(settings):
|
||||
"""
|
||||
Test get_userinfo method with an invalid JWT response that
|
||||
causes verify_token to raise an error.
|
||||
"""
|
||||
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||
responses.add(
|
||||
responses.GET,
|
||||
re.compile(r".*/userinfo"),
|
||||
body="fake.jwt.token",
|
||||
status=200,
|
||||
content_type="application/jwt",
|
||||
)
|
||||
|
||||
oidc_backend = OIDCAuthenticationBackend()
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info response was not valid JWT",
|
||||
):
|
||||
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||
|
||||
|
||||
def test_authentication_getter_existing_disabled_user_via_sub(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
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
|
||||
|
@@ -0,0 +1,2 @@
|
||||
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
|
||||
"user.test40@example.com",,0,0,pending,40
|
||||
|
@@ -0,0 +1,5 @@
|
||||
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
|
||||
|
@@ -0,0 +1,2 @@
|
||||
merge_accept,active_email,inactive_email,status,id
|
||||
true,user.test20@example.com,user.test20@example.com,pending,20
|
||||
|
@@ -0,0 +1,6 @@
|
||||
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
|
||||
|
@@ -596,6 +596,38 @@ 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.
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Test AI proxy API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest.mock import 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(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI settings."""
|
||||
settings.AI_MODEL = "llama"
|
||||
settings.AI_BASE_URL = "http://localhost-ai:12345/"
|
||||
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(
|
||||
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_proxy_anonymous_forbidden(reach, role):
|
||||
"""
|
||||
Anonymous users should not be able to request AI proxy 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-proxy/"
|
||||
response = APIClient().post(
|
||||
url,
|
||||
{
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM="public")
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
def test_api_documents_ai_proxy_anonymous_success(mock_stream):
|
||||
"""
|
||||
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"])
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
|
||||
response = APIClient().post(
|
||||
url,
|
||||
b"{}",
|
||||
content_type="application/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"
|
||||
|
||||
content = b"".join(response.streaming_content).decode()
|
||||
assert "chunk1" in content
|
||||
assert "chunk2" in content
|
||||
mock_stream.assert_called_once()
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
|
||||
def test_api_documents_ai_proxy_anonymous_limited_by_setting():
|
||||
"""
|
||||
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")
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
|
||||
response = APIClient().post(
|
||||
url,
|
||||
b"{}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("public", "reader"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_ai_proxy_authenticated_forbidden(reach, role):
|
||||
"""
|
||||
Users who are not related to a document can't request AI proxy 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-proxy/"
|
||||
response = client.post(
|
||||
url,
|
||||
b"{}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
[
|
||||
("authenticated", "editor"),
|
||||
("public", "editor"),
|
||||
],
|
||||
)
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
def test_api_documents_ai_proxy_authenticated_success(mock_stream, reach, role):
|
||||
"""
|
||||
Authenticated users should be able to request AI proxy to a document
|
||||
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_stream.return_value = iter(["data: response\n"])
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
|
||||
response = client.post(
|
||||
url,
|
||||
b"{}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "text/event-stream"
|
||||
mock_stream.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_ai_proxy_reader(via, mock_user_teams):
|
||||
"""Users with reader access should not be able to request AI proxy."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
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-proxy/"
|
||||
response = client.post(
|
||||
url,
|
||||
b"{}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@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):
|
||||
"""Users with sufficient permissions should be able to request AI proxy."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
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_stream.return_value = iter(["data: success\n"])
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
|
||||
response = client.post(
|
||||
url,
|
||||
b"{}",
|
||||
content_type="application/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"
|
||||
|
||||
content = b"".join(response.streaming_content).decode()
|
||||
assert "success" in content
|
||||
mock_stream.assert_called_once()
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
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"{}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == ["AI feature is not enabled."]
|
||||
|
||||
|
||||
@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):
|
||||
"""
|
||||
Throttling per document should be triggered on the AI proxy 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"])
|
||||
|
||||
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",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
user = factories.UserFactory()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
url,
|
||||
b"{}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
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})
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
def test_api_documents_ai_proxy_throttling_user(mock_stream):
|
||||
"""
|
||||
Throttling per user should be triggered on the AI proxy endpoint.
|
||||
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
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",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.json() == {
|
||||
"detail": "Request was throttled. Expected available in 60 seconds."
|
||||
}
|
||||
|
||||
|
||||
@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."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
mock_stream.return_value = iter(["data: part1\n", "data: part2\n", "data: part3\n"])
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
|
||||
response = client.post(
|
||||
url,
|
||||
b"{}",
|
||||
content_type="application/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
|
||||
|
||||
|
||||
def test_api_documents_ai_proxy_invalid_payload():
|
||||
"""AI Proxy should return a 400 if the payload is invalid."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/ai-proxy/",
|
||||
b'{"foo": "bar", "trigger": "submit-message"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid submitted payload"}
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: all
|
||||
|
||||
The 'all' endpoint returns ALL documents (including descendants) that the user has access to.
|
||||
This is different from the 'list' endpoint which only returns top-level documents.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_documents_all_anonymous(reach, role):
|
||||
"""
|
||||
Anonymous users should not be able to list any documents via the all endpoint
|
||||
whatever the link reach and link role.
|
||||
"""
|
||||
parent = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
factories.DocumentFactory(parent=parent, link_reach=reach, link_role=role)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_with_children():
|
||||
"""
|
||||
Authenticated users should see all documents including children,
|
||||
even though children don't have DocumentAccess records.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create a document tree: parent -> child -> grandchild
|
||||
parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent, user=user, role="owner")
|
||||
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
# Verify setup
|
||||
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
|
||||
assert models.DocumentAccess.objects.filter(document=child).count() == 0
|
||||
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# All three documents should be returned (parent + child + grandchild)
|
||||
assert len(results) == 3
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
|
||||
depths = {result["depth"] for result in results}
|
||||
assert depths == {1, 2, 3}
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_multiple_trees():
|
||||
"""
|
||||
Users should see all accessible documents from multiple document trees.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Tree 1: User has access
|
||||
tree1_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=tree1_parent, user=user)
|
||||
tree1_child = factories.DocumentFactory(parent=tree1_parent)
|
||||
|
||||
# Tree 2: User has access
|
||||
tree2_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=tree2_parent, user=user)
|
||||
tree2_child1 = factories.DocumentFactory(parent=tree2_parent)
|
||||
tree2_child2 = factories.DocumentFactory(parent=tree2_parent)
|
||||
|
||||
# Tree 3: User does NOT have access
|
||||
tree3_parent = factories.DocumentFactory()
|
||||
factories.DocumentFactory(parent=tree3_parent)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should return 5 documents (tree1: 2, tree2: 3, tree3: 0)
|
||||
assert len(results) == 5
|
||||
results_ids = {result["id"] for result in results}
|
||||
expected_ids = {
|
||||
str(tree1_parent.id),
|
||||
str(tree1_child.id),
|
||||
str(tree2_parent.id),
|
||||
str(tree2_child1.id),
|
||||
str(tree2_child2.id),
|
||||
}
|
||||
assert results_ids == expected_ids
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_explicit_access_to_parent_and_child():
|
||||
"""
|
||||
When a user has explicit DocumentAccess to both parent AND child,
|
||||
both should appear in the 'all' endpoint results (unlike 'list' which deduplicates).
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Parent with explicit access
|
||||
parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent, user=user)
|
||||
|
||||
# Child also has explicit access (e.g., shared separately)
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
factories.UserDocumentAccessFactory(document=child, user=user)
|
||||
|
||||
# Grandchild has no explicit access
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
# Verify setup
|
||||
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
|
||||
assert models.DocumentAccess.objects.filter(document=child).count() == 1
|
||||
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# All three should appear
|
||||
assert len(results) == 3
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
|
||||
# Each document should appear exactly once (no duplicates)
|
||||
results_ids_list = [result["id"] for result in results]
|
||||
assert len(results_ids_list) == len(set(results_ids_list)) # No duplicates
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_via_team(mock_user_teams):
|
||||
"""
|
||||
Users should see all documents (including descendants) for documents accessed via teams.
|
||||
"""
|
||||
mock_user_teams.return_value = ["team1", "team2"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document tree via team1
|
||||
parent1 = factories.DocumentFactory()
|
||||
factories.TeamDocumentAccessFactory(document=parent1, team="team1")
|
||||
child1 = factories.DocumentFactory(parent=parent1)
|
||||
|
||||
# Document tree via team2
|
||||
parent2 = factories.DocumentFactory()
|
||||
factories.TeamDocumentAccessFactory(document=parent2, team="team2")
|
||||
child2 = factories.DocumentFactory(parent=parent2)
|
||||
|
||||
# Document tree via unknown team
|
||||
parent3 = factories.DocumentFactory()
|
||||
factories.TeamDocumentAccessFactory(document=parent3, team="team3")
|
||||
factories.DocumentFactory(parent=parent3)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should return 4 documents (team1: 2, team2: 2, team3: 0)
|
||||
assert len(results) == 4
|
||||
results_ids = {result["id"] for result in results}
|
||||
expected_ids = {
|
||||
str(parent1.id),
|
||||
str(child1.id),
|
||||
str(parent2.id),
|
||||
str(child2.id),
|
||||
}
|
||||
assert results_ids == expected_ids
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_soft_deleted():
|
||||
"""
|
||||
Soft-deleted documents and their descendants should not be included.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Active tree
|
||||
active_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=active_parent, user=user)
|
||||
active_child = factories.DocumentFactory(parent=active_parent)
|
||||
|
||||
# Soft-deleted tree
|
||||
deleted_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
|
||||
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
|
||||
deleted_parent.soft_delete()
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should only return active documents
|
||||
assert len(results) == 2
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(active_parent.id), str(active_child.id)}
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_permanently_deleted():
|
||||
"""
|
||||
Permanently deleted documents should not be included.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Active tree
|
||||
active_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=active_parent, user=user)
|
||||
active_child = factories.DocumentFactory(parent=active_parent)
|
||||
|
||||
# Permanently deleted tree (deleted > 30 days ago)
|
||||
deleted_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
|
||||
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
|
||||
|
||||
fourty_days_ago = timezone.now() - timedelta(days=40)
|
||||
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
|
||||
deleted_parent.soft_delete()
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should only return active documents
|
||||
assert len(results) == 2
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(active_parent.id), str(active_child.id)}
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_link_reach_restricted():
|
||||
"""
|
||||
Documents with link_reach=restricted accessed via LinkTrace should not appear
|
||||
in the all endpoint results.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document with direct access (should appear)
|
||||
parent_with_access = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent_with_access, user=user)
|
||||
child_with_access = factories.DocumentFactory(parent=parent_with_access)
|
||||
|
||||
# Document with only LinkTrace and restricted reach (should NOT appear)
|
||||
parent_restricted = factories.DocumentFactory(
|
||||
link_reach="restricted", link_traces=[user]
|
||||
)
|
||||
factories.DocumentFactory(parent=parent_restricted)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Only documents with direct access should appear
|
||||
assert len(results) == 2
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent_with_access.id), str(child_with_access.id)}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_all_authenticated_link_reach_public_or_authenticated(reach):
|
||||
"""
|
||||
Documents with link_reach=public or authenticated accessed via LinkTrace
|
||||
should appear with all their descendants.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document accessed via LinkTrace with non-restricted reach
|
||||
parent = factories.DocumentFactory(link_reach=reach, link_traces=[user])
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# All descendants should be included
|
||||
assert len(results) == 3
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
|
||||
|
||||
def test_api_documents_all_format():
|
||||
"""Validate the format of documents as returned by the all endpoint."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
|
||||
# Check pagination structure
|
||||
assert content == {
|
||||
"count": 2,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
|
||||
# Verify parent document format
|
||||
parent_result = [r for r in results if r["id"] == str(document.id)][0]
|
||||
assert parent_result == {
|
||||
"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),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 1,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
# Verify child document format
|
||||
child_result = [r for r in results if r["id"] == str(child.id)][0]
|
||||
assert child_result["depth"] == 2
|
||||
assert child_result["user_role"] == access.role # Inherited from parent
|
||||
assert child_result["nb_accesses_direct"] == 0 # No direct access on child
|
||||
|
||||
|
||||
def test_api_documents_all_distinct():
|
||||
"""
|
||||
A document should only appear once even if the user has multiple access paths to it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document with multiple accesses for the same user
|
||||
document = factories.DocumentFactory(users=[user, other_user])
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should return 2 documents (parent + child), each appearing once
|
||||
assert len(results) == 2
|
||||
results_ids = [result["id"] for result in results]
|
||||
assert results_ids.count(str(document.id)) == 1
|
||||
assert results_ids.count(str(child.id)) == 1
|
||||
|
||||
|
||||
def test_api_documents_all_comparison_with_list():
|
||||
"""
|
||||
The 'all' endpoint should return more documents than 'list' when there are children.
|
||||
'list' returns only top-level documents, 'all' returns all descendants.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create a document tree
|
||||
parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent, user=user)
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
# Call list endpoint
|
||||
list_response = client.get("/api/v1.0/documents/")
|
||||
list_results = list_response.json()["results"]
|
||||
|
||||
# Call all endpoint
|
||||
all_response = client.get("/api/v1.0/documents/all/")
|
||||
all_results = all_response.json()["results"]
|
||||
|
||||
# list should return only parent
|
||||
assert len(list_results) == 1
|
||||
assert list_results[0]["id"] == str(parent.id)
|
||||
|
||||
# all should return parent + child + grandchild
|
||||
assert len(all_results) == 3
|
||||
all_ids = {result["id"] for result in all_results}
|
||||
assert all_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Test on the CORS proxy API for documents."""
|
||||
|
||||
import socket
|
||||
import unittest.mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests.exceptions import RequestException
|
||||
@@ -10,11 +13,17 @@ from core import factories
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_valid_url():
|
||||
def test_api_docs_cors_proxy_valid_url(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with a valid URL."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
@@ -56,11 +65,17 @@ def test_api_docs_cors_proxy_without_url_query_string():
|
||||
assert response.json() == {"detail": "Missing 'url' query parameter"}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_anonymous_document_not_public():
|
||||
def test_api_docs_cors_proxy_anonymous_document_not_public(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with an anonymous user and a non-public document."""
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
@@ -73,14 +88,22 @@ def test_api_docs_cors_proxy_anonymous_document_not_public():
|
||||
}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
|
||||
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(
|
||||
mock_getaddrinfo,
|
||||
):
|
||||
"""
|
||||
Test the CORS proxy API for documents with an authenticated user accessing a protected
|
||||
document.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -115,14 +138,22 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
|
||||
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(
|
||||
mock_getaddrinfo,
|
||||
):
|
||||
"""
|
||||
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
|
||||
document.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -138,18 +169,72 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
|
||||
}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_unsupported_media_type():
|
||||
def test_api_docs_cors_proxy_unsupported_media_type(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with an unsupported media type."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 415
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid URL used."}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_redirect(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with a redirect."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(
|
||||
url_to_fetch,
|
||||
body=b"",
|
||||
status=302,
|
||||
headers={"Location": "https://external-url.com/other/assets/index.html"},
|
||||
)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid URL used."}
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_url_not_returning_200(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with a URL that does not return 200."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(url_to_fetch, body=b"", status=404)
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid URL used."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -173,11 +258,17 @@ def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
|
||||
assert response.json() == ["Enter a valid URL."]
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_request_failed():
|
||||
def test_api_docs_cors_proxy_request_failed(mock_getaddrinfo):
|
||||
"""Test the CORS proxy API for documents with a request failed."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(url_to_fetch, body=RequestException("Connection refused"))
|
||||
@@ -185,6 +276,164 @@ def test_api_docs_cors_proxy_request_failed():
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
|
||||
}
|
||||
assert response.json() == {"detail": "Invalid URL used."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url_to_fetch",
|
||||
[
|
||||
"http://localhost/image.png",
|
||||
"https://localhost/image.png",
|
||||
"http://127.0.0.1/image.png",
|
||||
"https://127.0.0.1/image.png",
|
||||
"http://0.0.0.0/image.png",
|
||||
"https://0.0.0.0/image.png",
|
||||
"http://[::1]/image.png",
|
||||
"https://[::1]/image.png",
|
||||
"http://[0:0:0:0:0:0:0:1]/image.png",
|
||||
"https://[0:0:0:0:0:0:0:1]/image.png",
|
||||
],
|
||||
)
|
||||
def test_api_docs_cors_proxy_blocks_localhost(url_to_fetch):
|
||||
"""Test that the CORS proxy API blocks localhost variations."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url_to_fetch",
|
||||
[
|
||||
"http://10.0.0.1/image.png",
|
||||
"https://10.0.0.1/image.png",
|
||||
"http://172.16.0.1/image.png",
|
||||
"https://172.16.0.1/image.png",
|
||||
"http://192.168.1.1/image.png",
|
||||
"https://192.168.1.1/image.png",
|
||||
"http://10.255.255.255/image.png",
|
||||
"https://10.255.255.255/image.png",
|
||||
"http://172.31.255.255/image.png",
|
||||
"https://172.31.255.255/image.png",
|
||||
"http://192.168.255.255/image.png",
|
||||
"https://192.168.255.255/image.png",
|
||||
],
|
||||
)
|
||||
def test_api_docs_cors_proxy_blocks_private_ips(url_to_fetch):
|
||||
"""Test that the CORS proxy API blocks private IP addresses."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url_to_fetch",
|
||||
[
|
||||
"http://169.254.1.1/image.png",
|
||||
"https://169.254.1.1/image.png",
|
||||
"http://169.254.255.255/image.png",
|
||||
"https://169.254.255.255/image.png",
|
||||
],
|
||||
)
|
||||
def test_api_docs_cors_proxy_blocks_link_local(url_to_fetch):
|
||||
"""Test that the CORS proxy API blocks link-local addresses."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_private_ip(mock_getaddrinfo):
|
||||
"""Test that the CORS proxy API blocks DNS rebinding attacks to private IPs."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a private IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://malicious-domain.com/image.png"
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
mock_getaddrinfo.assert_called_once()
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_localhost(mock_getaddrinfo):
|
||||
"""Test that the CORS proxy API blocks DNS rebinding attacks to localhost."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return localhost
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://malicious-domain.com/image.png"
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
mock_getaddrinfo.assert_called_once()
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
def test_api_docs_cors_proxy_handles_dns_resolution_failure(mock_getaddrinfo):
|
||||
"""Test that the CORS proxy API handles DNS resolution failures gracefully."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to fail
|
||||
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://nonexistent-domain-12345.com/image.png"
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
mock_getaddrinfo.assert_called_once()
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
def test_api_docs_cors_proxy_blocks_multiple_resolved_ips_if_any_private(
|
||||
mock_getaddrinfo,
|
||||
):
|
||||
"""Test that the CORS proxy API blocks if any resolved IP is private."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return both public and private IPs
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0)),
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://example.com/image.png"
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid URL used."
|
||||
mock_getaddrinfo.assert_called_once()
|
||||
|
||||
@@ -16,6 +16,7 @@ from rest_framework.test import APIClient
|
||||
from core import factories
|
||||
from core.api.serializers import ServerCreateDocumentSerializer
|
||||
from core.models import Document, Invitation, User
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import ConversionError, YdocConverter
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -191,7 +192,9 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -236,7 +239,9 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -297,7 +302,9 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -393,7 +400,9 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -474,7 +483,9 @@ def test_api_documents_create_for_owner_with_default_language(
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
assert mock_send.call_args[0][3] == "de-de"
|
||||
|
||||
|
||||
@@ -501,7 +512,9 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
@@ -537,7 +550,9 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
@@ -571,7 +586,9 @@ def test_api_documents_create_for_owner_with_converter_exception(
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with(
|
||||
"Document content", mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"content": ["Could not convert content"]}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: create with file upload
|
||||
"""
|
||||
|
||||
from base64 import b64decode, binascii
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.models import Document
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
ServiceUnavailableError,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_create_with_file_anonymous():
|
||||
"""Anonymous users should not be allowed to create documents with file upload."""
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "test_document.docx"
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_docx_file_success(mock_convert):
|
||||
"""
|
||||
Authenticated users should be able to create documents by uploading a DOCX file.
|
||||
The file should be converted to YJS format and the title should be set from filename.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion
|
||||
converted_yjs = "base64encodedyjscontent"
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "My Important Document.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "My Important Document.docx"
|
||||
assert document.content == converted_yjs
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
# Verify the converter was called correctly
|
||||
mock_convert.assert_called_once_with(
|
||||
file_content,
|
||||
content_type=mime_types.DOCX,
|
||||
accept=mime_types.YJS,
|
||||
)
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_markdown_file_success(mock_convert):
|
||||
"""
|
||||
Authenticated users should be able to create documents by uploading a Markdown file.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion
|
||||
converted_yjs = "base64encodedyjscontent"
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a fake Markdown file
|
||||
file_content = b"# Test Document\n\nThis is a test."
|
||||
file = BytesIO(file_content)
|
||||
file.name = "readme.md"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "readme.md"
|
||||
assert document.content == converted_yjs
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
# Verify the converter was called correctly
|
||||
mock_convert.assert_called_once_with(
|
||||
file_content,
|
||||
content_type=mime_types.MARKDOWN,
|
||||
accept=mime_types.YJS,
|
||||
)
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_and_explicit_title(mock_convert):
|
||||
"""
|
||||
When both file and title are provided, the filename should override the title.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion
|
||||
converted_yjs = "base64encodedyjscontent"
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "Uploaded Document.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
"title": "This should be overridden",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
# The filename should take precedence
|
||||
assert document.title == "Uploaded Document.docx"
|
||||
|
||||
|
||||
def test_api_documents_create_with_empty_file():
|
||||
"""
|
||||
Creating a document with an empty file should fail with a validation error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create an empty file
|
||||
file = BytesIO(b"")
|
||||
file.name = "empty.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"file": ["The submitted file is empty."]}
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_conversion_error(mock_convert):
|
||||
"""
|
||||
When conversion fails, the API should return a 400 error with appropriate message.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion to raise an error
|
||||
mock_convert.side_effect = ConversionError("Failed to convert document")
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake invalid docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "corrupted.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"file": ["Could not convert file content"]}
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_service_unavailable(mock_convert):
|
||||
"""
|
||||
When the conversion service is unavailable, appropriate error should be returned.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion to raise ServiceUnavailableError
|
||||
mock_convert.side_effect = ServiceUnavailableError(
|
||||
"Failed to connect to conversion service"
|
||||
)
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "document.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"file": ["Could not convert file content"]}
|
||||
assert not Document.objects.exists()
|
||||
|
||||
|
||||
def test_api_documents_create_without_file_still_works():
|
||||
"""
|
||||
Creating a document without a file should still work as before (backward compatibility).
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"title": "Regular document without file",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "Regular document without file"
|
||||
assert document.content is None
|
||||
assert document.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_null_value(mock_convert):
|
||||
"""
|
||||
Passing file=null should be treated as no file upload.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"title": "Document with null file",
|
||||
"file": None,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "Document with null file"
|
||||
# Converter should not have been called
|
||||
mock_convert.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_preserves_content_format(mock_convert):
|
||||
"""
|
||||
Verify that the converted content is stored correctly in the document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion with realistic base64-encoded YJS data
|
||||
converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA="
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a fake DOCX file
|
||||
file_content = b"fake docx with complex formatting"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "complex_document.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
|
||||
# Verify the content is stored as returned by the converter
|
||||
assert document.content == converted_yjs
|
||||
|
||||
# Verify it's valid base64 (can be decoded)
|
||||
try:
|
||||
b64decode(converted_yjs)
|
||||
except binascii.Error:
|
||||
pytest.fail("Content should be valid base64-encoded data")
|
||||
|
||||
|
||||
@patch("core.services.converter_services.Converter.convert")
|
||||
def test_api_documents_create_with_file_unicode_filename(mock_convert):
|
||||
"""
|
||||
Test that Unicode characters in filenames are handled correctly.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Mock the conversion
|
||||
converted_yjs = "base64encodedyjscontent"
|
||||
mock_convert.return_value = converted_yjs
|
||||
|
||||
# Create a file with Unicode characters in the name
|
||||
file_content = b"fake docx content"
|
||||
file = BytesIO(file_content)
|
||||
file.name = "文档-télécharger-документ.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
document = Document.objects.get()
|
||||
assert document.title == "文档-télécharger-документ.docx"
|
||||
|
||||
|
||||
def test_api_documents_create_with_file_max_size_exceeded(settings):
|
||||
"""
|
||||
The uploaded file should not exceed the maximum size in settings.
|
||||
"""
|
||||
settings.CONVERSION_FILE_MAX_SIZE = 1 # 1 byte for test
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = BytesIO(b"a" * (10))
|
||||
file.name = "test.docx"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
assert response.json() == {"file": ["File size exceeds the maximum limit of 0 MB."]}
|
||||
|
||||
|
||||
def test_api_documents_create_with_file_extension_not_allowed(settings):
|
||||
"""
|
||||
The uploaded file should not have an allowed extension.
|
||||
"""
|
||||
settings.CONVERSION_FILE_EXTENSIONS_ALLOWED = [".docx"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = BytesIO(b"fake docx content")
|
||||
file.name = "test.md"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/documents/",
|
||||
{
|
||||
"file": file,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"file": [
|
||||
"File extension .md is not allowed. Allowed extensions are: ['.docx']."
|
||||
]
|
||||
}
|
||||
@@ -318,3 +318,424 @@ 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"
|
||||
|
||||
@@ -83,3 +83,34 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_favorite_list_with_favorite_children():
|
||||
"""Authenticated users should receive their favorite documents, including children."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
root = factories.DocumentFactory(creator=user, users=[user])
|
||||
children = factories.DocumentFactory.create_batch(
|
||||
2, parent=root, favorited_by=[user]
|
||||
)
|
||||
|
||||
access = factories.UserDocumentAccessFactory(
|
||||
user=user, role=models.RoleChoices.READER, document__favorited_by=[user]
|
||||
)
|
||||
|
||||
other_root = factories.DocumentFactory(creator=user, users=[user])
|
||||
factories.DocumentFactory.create_batch(2, parent=other_root)
|
||||
|
||||
response = client.get("/api/v1.0/documents/favorite_list/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 3
|
||||
|
||||
content = response.json()["results"]
|
||||
|
||||
assert content[0]["id"] == str(children[0].id)
|
||||
assert content[1]["id"] == str(children[1].id)
|
||||
assert content[2]["id"] == str(access.document.id)
|
||||
|
||||
@@ -29,6 +29,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_proxy": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": document.link_role == "editor",
|
||||
@@ -107,6 +108,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_proxy": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": grand_parent.link_role == "editor",
|
||||
@@ -215,6 +217,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"abilities": {
|
||||
"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",
|
||||
@@ -300,6 +303,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"abilities": {
|
||||
"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",
|
||||
@@ -498,6 +502,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"abilities": {
|
||||
"accesses_manage": access.role in ["administrator", "owner"],
|
||||
"accesses_view": True,
|
||||
"ai_proxy": access.role not in ["reader", "commenter"],
|
||||
"ai_transform": access.role not in ["reader", "commenter"],
|
||||
"ai_translate": access.role not in ["reader", "commenter"],
|
||||
"attachment_upload": access.role not in ["reader", "commenter"],
|
||||
@@ -1057,3 +1062,48 @@ 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."]
|
||||
|
||||
@@ -72,6 +72,7 @@ def test_api_documents_trashbin_format():
|
||||
"abilities": {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_proxy": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: create
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.models import Template
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create templates."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/templates/",
|
||||
{
|
||||
"title": "my template",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert not Template.objects.exists()
|
||||
|
||||
|
||||
def test_api_templates_create_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to create templates and should automatically be declared
|
||||
as the owner of the newly created template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/templates/",
|
||||
{
|
||||
"title": "my template",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert not Template.objects.exists()
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: delete
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a template."""
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.Template.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_templates_delete_not_implemented():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template to which they are not
|
||||
related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
is_public = random.choice([True, False])
|
||||
template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")])
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert models.Template.objects.count() == 1
|
||||
@@ -1,237 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_list_anonymous():
|
||||
"""Anonymous users should only be able to list public templates."""
|
||||
factories.TemplateFactory.create_batch(2, is_public=False)
|
||||
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
|
||||
expected_ids = {str(template.id) for template in public_templates}
|
||||
|
||||
response = APIClient().get("/api/v1.0/templates/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_templates_list_authenticated_direct():
|
||||
"""
|
||||
Authenticated users should be able to list templates they are a direct
|
||||
owner/administrator/member of or that are public.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
related_templates = [
|
||||
access.template
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
|
||||
]
|
||||
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
|
||||
factories.TemplateFactory.create_batch(2, is_public=False)
|
||||
|
||||
expected_ids = {
|
||||
str(template.id) for template in related_templates + public_templates
|
||||
}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 7
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
def test_api_templates_list_authenticated_via_team(mock_user_teams):
|
||||
"""
|
||||
Authenticated users should be able to list templates they are a
|
||||
owner/administrator/member of via a team or that are public.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mock_user_teams.return_value = ["team1", "team2", "unknown"]
|
||||
|
||||
templates_team1 = [
|
||||
access.template
|
||||
for access in factories.TeamTemplateAccessFactory.create_batch(2, team="team1")
|
||||
]
|
||||
templates_team2 = [
|
||||
access.template
|
||||
for access in factories.TeamTemplateAccessFactory.create_batch(3, team="team2")
|
||||
]
|
||||
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
|
||||
factories.TemplateFactory.create_batch(2, is_public=False)
|
||||
|
||||
expected_ids = {
|
||||
str(template.id)
|
||||
for template in templates_team1 + templates_team2 + public_templates
|
||||
}
|
||||
|
||||
response = client.get("/api/v1.0/templates/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 7
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_templates_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template_ids = [
|
||||
str(access.template_id)
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/templates/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
template_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/templates/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
template_ids.remove(content["results"][0]["id"])
|
||||
assert template_ids == []
|
||||
|
||||
|
||||
def test_api_templates_list_authenticated_distinct():
|
||||
"""A template with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
template = factories.TemplateFactory(users=[user, other_user], is_public=True)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(template.id)
|
||||
|
||||
|
||||
def test_api_templates_list_order_default():
|
||||
"""The templates list should be sorted by 'created_at' in descending order by default."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template_ids = [
|
||||
str(access.template.id)
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
response_template_ids = [template["id"] for template in response_data["results"]]
|
||||
|
||||
template_ids.reverse()
|
||||
assert response_template_ids == template_ids, (
|
||||
"created_at values are not sorted from newest to oldest"
|
||||
)
|
||||
|
||||
|
||||
def test_api_templates_list_order_param():
|
||||
"""
|
||||
The templates list is sorted by 'created_at' in ascending order when setting
|
||||
the "ordering" query parameter.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
templates_ids = [
|
||||
str(access.template.id)
|
||||
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
|
||||
]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/templates/?ordering=created_at",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
response_template_ids = [template["id"] for template in response_data["results"]]
|
||||
|
||||
assert response_template_ids == templates_ids, (
|
||||
"created_at values are not sorted from oldest to newest"
|
||||
)
|
||||
|
||||
|
||||
def test_api_template_throttling(settings):
|
||||
"""Test api template throttling."""
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = "2/minute"
|
||||
client = APIClient()
|
||||
for _i in range(2):
|
||||
response = client.get("/api/v1.0/templates/")
|
||||
assert response.status_code == 200
|
||||
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||
response = client.get("/api/v1.0/templates/")
|
||||
assert response.status_code == 429
|
||||
mock_capture_message.assert_called_once_with(
|
||||
"Rate limit exceeded for scope template", "warning"
|
||||
)
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = current_rate
|
||||
@@ -1,522 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: retrieve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_retrieve_anonymous_public():
|
||||
"""Anonymous users should be allowed to retrieve public templates."""
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"title": template.title,
|
||||
"is_public": True,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_anonymous_not_public():
|
||||
"""Anonymous users should not be able to retrieve a template that is not public."""
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_unrelated_public():
|
||||
"""
|
||||
Authenticated users should be able to retrieve a public template to which they are
|
||||
not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"generate_document": True,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
},
|
||||
"accesses": [],
|
||||
"title": template.title,
|
||||
"is_public": True,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_unrelated_not_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve a template that is not public and
|
||||
to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory()
|
||||
access1 = factories.UserTemplateAccessFactory(template=template, user=user)
|
||||
access2 = factories.UserTemplateAccessFactory(template=template)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access1.id),
|
||||
"user": str(user.id),
|
||||
"team": "",
|
||||
"role": access1.role,
|
||||
"abilities": access1.get_abilities(user),
|
||||
},
|
||||
{
|
||||
"id": str(access2.id),
|
||||
"user": str(access2.user.id),
|
||||
"team": "",
|
||||
"role": access2.role,
|
||||
"abilities": access2.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["user"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": template.is_public,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve a template related to teams in
|
||||
which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
factories.TeamTemplateAccessFactory(template=template, team="owners", role="owner")
|
||||
factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["readers"],
|
||||
["unknown", "readers"],
|
||||
["editors"],
|
||||
["unknown", "editors"],
|
||||
],
|
||||
)
|
||||
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
access_reader = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
expected_abilities = {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
}
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": access_reader.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": access_editor.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": access_administrator.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": access_owner.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": expected_abilities,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": False,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["administrators"],
|
||||
["members", "administrators"],
|
||||
["unknown", "administrators"],
|
||||
],
|
||||
)
|
||||
def test_api_templates_retrieve_authenticated_related_team_administrators(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
access_reader = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"set_role_to": [],
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": False,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams",
|
||||
[
|
||||
["owners"],
|
||||
["owners", "administrators"],
|
||||
["members", "administrators", "owners"],
|
||||
["unknown", "owners"],
|
||||
],
|
||||
)
|
||||
def test_api_templates_retrieve_authenticated_related_team_owners(
|
||||
teams, mock_user_teams
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a template to which they
|
||||
are related via a team whatever the role and see all its accesses.
|
||||
"""
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
|
||||
access_reader = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="readers", role="reader"
|
||||
)
|
||||
access_editor = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="editors", role="editor"
|
||||
)
|
||||
access_administrator = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="administrators", role="administrator"
|
||||
)
|
||||
access_owner = factories.TeamTemplateAccessFactory(
|
||||
template=template, team="owners", role="owner"
|
||||
)
|
||||
other_access = factories.TeamTemplateAccessFactory(template=template)
|
||||
factories.TeamTemplateAccessFactory()
|
||||
|
||||
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(access_reader.id),
|
||||
"user": None,
|
||||
"team": "readers",
|
||||
"role": "reader",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "editor"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_editor.id),
|
||||
"user": None,
|
||||
"team": "editors",
|
||||
"role": "editor",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "administrator", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_administrator.id),
|
||||
"user": None,
|
||||
"team": "administrators",
|
||||
"role": "administrator",
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"set_role_to": ["owner", "editor", "reader"],
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(access_owner.id),
|
||||
"user": None,
|
||||
"team": "owners",
|
||||
"role": "owner",
|
||||
"abilities": {
|
||||
# editable only if there is another owner role than the user's team...
|
||||
"destroy": other_access.role == "owner",
|
||||
"retrieve": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"]
|
||||
if other_access.role == "owner"
|
||||
else [],
|
||||
"update": other_access.role == "owner",
|
||||
"partial_update": other_access.role == "owner",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": str(other_access.id),
|
||||
"user": None,
|
||||
"team": other_access.team,
|
||||
"role": other_access.role,
|
||||
"abilities": other_access.get_abilities(user),
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert response.json() == {
|
||||
"id": str(template.id),
|
||||
"title": template.title,
|
||||
"abilities": template.get_abilities(user),
|
||||
"is_public": False,
|
||||
"code": template.code,
|
||||
"css": template.css,
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
Tests for Templates API endpoint in impress's core app: update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_update_anonymous():
|
||||
"""Anonymous users should not be allowed to update a template."""
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
new_template_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_templates_update_not_implemented():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a template.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(users=[(user, "owner")])
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
).data
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
@@ -19,7 +19,11 @@ 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,
|
||||
COLLABORATION_WS_URL="http://testcollab/",
|
||||
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
|
||||
CRISP_WEBSITE_ID="123",
|
||||
@@ -43,14 +47,21 @@ def test_api_config(is_authenticated):
|
||||
response = client.get("/api/v1.0/config/")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
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,
|
||||
"COLLABORATION_WS_URL": "http://testcollab/",
|
||||
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
|
||||
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
|
||||
"CONVERSION_FILE_MAX_SIZE": 20971520,
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"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"],
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
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"}
|
||||
@@ -2,6 +2,8 @@
|
||||
Test users API endpoints in the impress core app.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -121,12 +123,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()
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
|
||||
dave = factories.UserFactory(email="contact@example.com", full_name="David Bowman")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=David",
|
||||
@@ -166,13 +168,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()
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
fred = factories.UserFactory(
|
||||
email="contact@work.com", full_name="Frédérique Lefèvre"
|
||||
email="contact@example.com", full_name="Frédérique Lefèvre"
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Frédérique")
|
||||
@@ -201,12 +203,82 @@ 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 10.
|
||||
should be limited to API_USERS_LIST_LIMIT (by default 5).
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -309,28 +381,16 @@ def test_api_users_list_query_email_exclude_doc_user():
|
||||
|
||||
def test_api_users_list_query_short_queries():
|
||||
"""
|
||||
Queries shorter than 5 characters should return an empty result set.
|
||||
If API_USERS_SEARCH_QUERY_MIN_LENGTH is not set, the default minimum length should be 3.
|
||||
"""
|
||||
user = factories.UserFactory(email="paul@example.com")
|
||||
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory(email="john.doe@example.com")
|
||||
factories.UserFactory(email="john.lennon@example.com")
|
||||
factories.UserFactory(email="john.doe@example.com", full_name="John Doe")
|
||||
factories.UserFactory(email="john.lennon@example.com", full_name="John Lennon")
|
||||
|
||||
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.")
|
||||
response = client.get("/api/v1.0/users/?q=joh")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
|
||||
@@ -356,7 +416,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()
|
||||
user = factories.UserFactory(email="user@example.com")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ def test_models_documents_get_abilities_forbidden(
|
||||
expected_abilities = {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_proxy": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
@@ -220,6 +221,7 @@ def test_models_documents_get_abilities_reader(
|
||||
expected_abilities = {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_proxy": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
@@ -290,6 +292,7 @@ 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,
|
||||
@@ -357,6 +360,7 @@ def test_models_documents_get_abilities_editor(
|
||||
expected_abilities = {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": False,
|
||||
"ai_proxy": is_authenticated,
|
||||
"ai_transform": is_authenticated,
|
||||
"ai_translate": is_authenticated,
|
||||
"attachment_upload": True,
|
||||
@@ -413,6 +417,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
expected_abilities = {
|
||||
"accesses_manage": True,
|
||||
"accesses_view": True,
|
||||
"ai_proxy": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
@@ -455,6 +460,7 @@ 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,
|
||||
@@ -501,6 +507,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
expected_abilities = {
|
||||
"accesses_manage": True,
|
||||
"accesses_view": True,
|
||||
"ai_proxy": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
@@ -557,6 +564,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
expected_abilities = {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": True,
|
||||
"ai_proxy": True,
|
||||
"ai_transform": True,
|
||||
"ai_translate": True,
|
||||
"attachment_upload": True,
|
||||
@@ -620,6 +628,7 @@ def test_models_documents_get_abilities_reader_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,
|
||||
@@ -686,6 +695,7 @@ 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,
|
||||
@@ -747,6 +757,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
assert abilities == {
|
||||
"accesses_manage": False,
|
||||
"accesses_view": True,
|
||||
"ai_proxy": False,
|
||||
"ai_transform": False,
|
||||
"ai_translate": False,
|
||||
"attachment_upload": False,
|
||||
@@ -878,6 +889,7 @@ def test_models_document_get_abilities_ai_access_authenticated(is_authenticated,
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
|
||||
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities["ai_proxy"] is True
|
||||
assert abilities["ai_transform"] is True
|
||||
assert abilities["ai_translate"] is True
|
||||
|
||||
@@ -897,6 +909,7 @@ def test_models_document_get_abilities_ai_access_public(is_authenticated, reach)
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
|
||||
|
||||
abilities = document.get_abilities(user)
|
||||
assert abilities["ai_proxy"] == is_authenticated
|
||||
assert abilities["ai_transform"] == is_authenticated
|
||||
assert abilities["ai_translate"] == is_authenticated
|
||||
|
||||
@@ -1021,7 +1034,51 @@ def test_models_documents__email_invitation__success():
|
||||
f"Test Sender (sender@example.com) invited you with the role "editor" "
|
||||
f"on the following document: {document.title}" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
assert (
|
||||
f"docs/{document.id}/?utm_source=docssharelink&utm_campaign={document.id}"
|
||||
in email_content
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email_url_app",
|
||||
[
|
||||
"https://test-example.com", # Test with EMAIL_URL_APP set
|
||||
None, # Test fallback to Site domain
|
||||
],
|
||||
)
|
||||
def test_models_documents__email_invitation__url_app_param(email_url_app):
|
||||
"""
|
||||
Test that email invitation uses EMAIL_URL_APP when set, or falls back to Site domain.
|
||||
"""
|
||||
with override_settings(EMAIL_URL_APP=email_url_app):
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
sender = factories.UserFactory(
|
||||
full_name="Test Sender", email="sender@example.com"
|
||||
)
|
||||
document.send_invitation_email(
|
||||
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
# Determine expected domain
|
||||
if email_url_app:
|
||||
expected_url = (
|
||||
f"https://test-example.com/docs/{document.id}/"
|
||||
f"?utm_source=docssharelink&utm_campaign={document.id}"
|
||||
)
|
||||
assert expected_url in email_content
|
||||
else:
|
||||
# Default Site domain is example.com
|
||||
expected_url = (
|
||||
f"example.com/docs/{document.id}/"
|
||||
f"?utm_source=docssharelink&utm_campaign={document.id}"
|
||||
)
|
||||
assert expected_url in email_content
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success_empty_title():
|
||||
@@ -1052,7 +1109,10 @@ def test_models_documents__email_invitation__success_empty_title():
|
||||
"Test Sender (sender@example.com) invited you with the role "editor" "
|
||||
"on the following document: Untitled Document" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
assert (
|
||||
f"docs/{document.id}/?utm_source=docssharelink&utm_campaign={document.id}"
|
||||
in email_content
|
||||
)
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success_fr():
|
||||
@@ -1087,7 +1147,10 @@ def test_models_documents__email_invitation__success_fr():
|
||||
f"Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" "
|
||||
f"sur le document suivant : {document.title}" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
assert (
|
||||
f"docs/{document.id}/?utm_source=docssharelink&utm_campaign={document.id}"
|
||||
in email_content
|
||||
)
|
||||
|
||||
|
||||
@mock.patch(
|
||||
@@ -1393,7 +1456,7 @@ def test_models_documents_restore_complex(django_assert_num_queries):
|
||||
assert child2.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
# Restore the item
|
||||
with django_assert_num_queries(13):
|
||||
with django_assert_num_queries(14):
|
||||
document.restore()
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
"""
|
||||
Unit tests for the TemplateAccess model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_template_accesses_str():
|
||||
"""
|
||||
The str representation should include user email, template title and role.
|
||||
"""
|
||||
user = factories.UserFactory(email="david.bowman@example.com")
|
||||
access = factories.UserTemplateAccessFactory(
|
||||
role="reader",
|
||||
user=user,
|
||||
template__title="admins",
|
||||
)
|
||||
assert str(access) == "david.bowman@example.com is reader in template admins"
|
||||
|
||||
|
||||
def test_models_template_accesses_unique_user():
|
||||
"""Template accesses should be unique for a given couple of user and template."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="This user is already in this template.",
|
||||
):
|
||||
factories.UserTemplateAccessFactory(user=access.user, template=access.template)
|
||||
|
||||
|
||||
def test_models_template_accesses_several_empty_teams():
|
||||
"""A template can have several template accesses with an empty team."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
factories.UserTemplateAccessFactory(template=access.template)
|
||||
|
||||
|
||||
def test_models_template_accesses_unique_team():
|
||||
"""Template accesses should be unique for a given couple of team and template."""
|
||||
access = factories.TeamTemplateAccessFactory()
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="This team is already in this template.",
|
||||
):
|
||||
factories.TeamTemplateAccessFactory(team=access.team, template=access.template)
|
||||
|
||||
|
||||
def test_models_template_accesses_several_null_users():
|
||||
"""A template can have several template accesses with a null user."""
|
||||
access = factories.TeamTemplateAccessFactory()
|
||||
factories.TeamTemplateAccessFactory(template=access.template)
|
||||
|
||||
|
||||
def test_models_template_accesses_user_and_team_set():
|
||||
"""User and team can't both be set on a template access."""
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Either user or team must be set, not both.",
|
||||
):
|
||||
factories.UserTemplateAccessFactory(team="my-team")
|
||||
|
||||
|
||||
def test_models_template_accesses_user_and_team_empty():
|
||||
"""User and team can't both be empty on a template access."""
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Either user or team must be set, not both.",
|
||||
):
|
||||
factories.UserTemplateAccessFactory(user=None)
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_anonymous():
|
||||
"""Check abilities returned for an anonymous user."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
abilities = access.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_authenticated():
|
||||
"""Check abilities returned for an authenticated user."""
|
||||
access = factories.UserTemplateAccessFactory()
|
||||
user = factories.UserFactory()
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
# - for owner
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_self_allowed():
|
||||
"""
|
||||
Check abilities of self access for the owner of a template when
|
||||
there is more than one owner left.
|
||||
"""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template, role="owner")
|
||||
abilities = access.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_self_last():
|
||||
"""
|
||||
Check abilities of self access for the owner of a template when there is only one owner left.
|
||||
"""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
abilities = access.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_owner():
|
||||
"""Check abilities of owner access for the owner of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "editor", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_administrator():
|
||||
"""Check abilities of administrator access for the owner of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["owner", "editor", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_editor():
|
||||
"""Check abilities of editor access for the owner of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="editor")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["owner", "administrator", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_owner_of_reader():
|
||||
"""Check abilities of reader access for the owner of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["owner", "administrator", "editor"],
|
||||
}
|
||||
|
||||
|
||||
# - for administrator
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_administrator_of_owner():
|
||||
"""Check abilities of owner access for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_administrator_of_administrator():
|
||||
"""Check abilities of administrator access for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["editor", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_administrator_of_editor():
|
||||
"""Check abilities of editor access for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="editor")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "reader"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_administrator_of_reader():
|
||||
"""Check abilities of reader access for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "editor"],
|
||||
}
|
||||
|
||||
|
||||
# - For editor
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_editor_of_owner():
|
||||
"""Check abilities of owner access for the editor of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="editor"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_editor_of_administrator():
|
||||
"""Check abilities of administrator access for the editor of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="editor"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_editor_of_editor_user(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Check abilities of editor access for the editor of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="editor")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="editor"
|
||||
).user
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
# - For reader
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_reader_of_owner():
|
||||
"""Check abilities of owner access for the reader of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="owner")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="reader"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_reader_of_administrator():
|
||||
"""Check abilities of administrator access for the reader of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="reader"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_for_reader_of_reader_user(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Check abilities of reader access for the reader of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
factories.UserTemplateAccessFactory(template=access.template) # another one
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="reader"
|
||||
).user
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_template_access_get_abilities_preset_role(django_assert_num_queries):
|
||||
"""No query is done if the role is preset, e.g., with a query annotation."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
user = factories.UserTemplateAccessFactory(
|
||||
template=access.template, role="reader"
|
||||
).user
|
||||
access.user_roles = ["reader"]
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
abilities = access.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
"""
|
||||
Unit tests for the Template model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_templates_str():
|
||||
"""The str representation should be the title of the template."""
|
||||
template = factories.TemplateFactory(title="admins")
|
||||
assert str(template) == "admins"
|
||||
|
||||
|
||||
def test_models_templates_id_unique():
|
||||
"""The "id" field should be unique."""
|
||||
template = factories.TemplateFactory()
|
||||
with pytest.raises(ValidationError, match="Template with this Id already exists."):
|
||||
factories.TemplateFactory(id=template.id)
|
||||
|
||||
|
||||
def test_models_templates_title_null():
|
||||
"""The "title" field should not be null."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be null."):
|
||||
models.Template.objects.create(title=None)
|
||||
|
||||
|
||||
def test_models_templates_title_empty():
|
||||
"""The "title" field should not be empty."""
|
||||
with pytest.raises(ValidationError, match="This field cannot be blank."):
|
||||
models.Template.objects.create(title="")
|
||||
|
||||
|
||||
def test_models_templates_title_max_length():
|
||||
"""The "title" field should be 100 characters maximum."""
|
||||
factories.TemplateFactory(title="a" * 255)
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match=r"Ensure this value has at most 255 characters \(it has 256\)\.",
|
||||
):
|
||||
factories.TemplateFactory(title="a" * 256)
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_anonymous_public():
|
||||
"""Check abilities returned for an anonymous user if the template is public."""
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
abilities = template.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_anonymous_not_public():
|
||||
"""Check abilities returned for an anonymous user if the template is private."""
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
abilities = template.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_authenticated_public():
|
||||
"""Check abilities returned for an authenticated user if the user is public."""
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
abilities = template.get_abilities(factories.UserFactory())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_authenticated_not_public():
|
||||
"""Check abilities returned for an authenticated user if the template is private."""
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
abilities = template.get_abilities(factories.UserFactory())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_owner():
|
||||
"""Check abilities returned for the owner of a template."""
|
||||
user = factories.UserFactory()
|
||||
access = factories.UserTemplateAccessFactory(role="owner", user=user)
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"accesses_manage": True,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_administrator():
|
||||
"""Check abilities returned for the administrator of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="administrator")
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"accesses_manage": True,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
|
||||
"""Check abilities returned for the editor of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="editor")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"accesses_manage": False,
|
||||
"partial_update": True,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
|
||||
"""Check abilities returned for the reader of a template."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
|
||||
"""No query is done if the role is preset e.g. with query annotation."""
|
||||
access = factories.UserTemplateAccessFactory(role="reader")
|
||||
access.template.user_roles = ["reader"]
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
abilities = access.template.get_abilities(access.user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"accesses_manage": False,
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
"""
|
||||
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()
|
||||
@@ -2,9 +2,11 @@
|
||||
Unit tests for the User model
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -26,26 +28,6 @@ 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",
|
||||
[
|
||||
@@ -96,3 +78,217 @@ 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_documents_access_with_single_document():
|
||||
"""
|
||||
When USER_ONBOARDING_DOCUMENTS has a valid document ID,
|
||||
an access should be created for the new user with the READER role.
|
||||
|
||||
The document should be pinned as a favorite for the user.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
with override_settings(USER_ONBOARDING_DOCUMENTS=[str(document.id)]):
|
||||
user = factories.UserFactory()
|
||||
|
||||
assert (
|
||||
models.DocumentAccess.objects.filter(user=user, document=document).count() == 1
|
||||
)
|
||||
|
||||
access = models.DocumentAccess.objects.get(user=user, document=document)
|
||||
assert access.role == models.RoleChoices.READER
|
||||
|
||||
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")
|
||||
document2 = factories.DocumentFactory(title="Document 2")
|
||||
document3 = factories.DocumentFactory(title="Document 3")
|
||||
|
||||
with override_settings(
|
||||
USER_ONBOARDING_DOCUMENTS=[
|
||||
str(document1.id),
|
||||
str(document2.id),
|
||||
str(document3.id),
|
||||
]
|
||||
):
|
||||
user = factories.UserFactory()
|
||||
|
||||
user_accesses = models.DocumentAccess.objects.filter(user=user)
|
||||
assert user_accesses.count() == 3
|
||||
|
||||
assert models.DocumentAccess.objects.filter(user=user, document=document1).exists()
|
||||
assert models.DocumentAccess.objects.filter(user=user, document=document2).exists()
|
||||
assert models.DocumentAccess.objects.filter(user=user, document=document3).exists()
|
||||
|
||||
for access in user_accesses:
|
||||
assert access.role == models.RoleChoices.READER
|
||||
|
||||
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.DocumentAccess.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()
|
||||
|
||||
with override_settings(
|
||||
USER_ONBOARDING_DOCUMENTS=[str(document.id), str(document.id)]
|
||||
):
|
||||
user = factories.UserFactory()
|
||||
|
||||
user_accesses = models.DocumentAccess.objects.filter(user=user, document=document)
|
||||
|
||||
assert user_accesses.count() >= 1
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
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()
|
||||
|
||||
user_accesses = models.DocumentAccess.objects.filter(user=user)
|
||||
assert user_accesses.count() == 2
|
||||
|
||||
sandbox_access = user_accesses.get(document=sandbox_doc)
|
||||
onboarding_access = user_accesses.get(document=onboarding_doc)
|
||||
|
||||
assert sandbox_access.role == models.RoleChoices.OWNER
|
||||
assert onboarding_access.role == models.RoleChoices.READER
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""
|
||||
Test ai API endpoints in the impress core app.
|
||||
Test AI services in the impress core app.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
@@ -9,12 +11,33 @@ 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 AIService
|
||||
from core.services.ai_services import (
|
||||
BLOCKNOTE_TOOL_STRICT_PROMPT,
|
||||
AIService,
|
||||
convert_async_generator_to_sync,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI settings."""
|
||||
settings.AI_MODEL = "llama"
|
||||
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(
|
||||
"setting_name, setting_value",
|
||||
[
|
||||
@@ -23,22 +46,25 @@ pytestmark = pytest.mark.django_db
|
||||
("AI_MODEL", None),
|
||||
],
|
||||
)
|
||||
def test_api_ai_setting_missing(setting_name, setting_value):
|
||||
def test_services_ai_setting_missing(setting_name, setting_value, settings):
|
||||
"""Setting should be set"""
|
||||
setattr(settings, setting_name, setting_value)
|
||||
|
||||
with override_settings(**{setting_name: setting_value}):
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured,
|
||||
match="AI configuration not set",
|
||||
):
|
||||
AIService()
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured,
|
||||
match="AI configuration not set",
|
||||
):
|
||||
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_api_ai__client_error(mock_create):
|
||||
def test_services_ai_client_error(mock_create):
|
||||
"""Fail when the client raises an error"""
|
||||
|
||||
mock_create.side_effect = OpenAIError("Mocked client error")
|
||||
@@ -54,7 +80,7 @@ def test_api_ai__client_error(mock_create):
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__client_invalid_response(mock_create):
|
||||
def test_services_ai_client_invalid_response(mock_create):
|
||||
"""Fail when the client response is invalid"""
|
||||
|
||||
mock_create.return_value = MagicMock(
|
||||
@@ -72,7 +98,7 @@ def test_api_ai__client_invalid_response(mock_create):
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__success(mock_create):
|
||||
def test_services_ai_success(mock_create):
|
||||
"""The AI request should work as expect when called with valid arguments."""
|
||||
|
||||
mock_create.return_value = MagicMock(
|
||||
@@ -82,3 +108,483 @@ def test_api_ai__success(mock_create):
|
||||
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."""
|
||||
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content="Bonjour"))]
|
||||
)
|
||||
|
||||
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"))]
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Test Converter orchestration services."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import Converter
|
||||
|
||||
|
||||
@patch("core.services.converter_services.DocSpecConverter")
|
||||
@patch("core.services.converter_services.YdocConverter")
|
||||
def test_converter_docx_to_yjs_orchestration(mock_ydoc_class, mock_docspec_class):
|
||||
"""Test that DOCX to YJS conversion uses both DocSpec and Ydoc converters."""
|
||||
# Setup mocks
|
||||
mock_docspec = MagicMock()
|
||||
mock_ydoc = MagicMock()
|
||||
mock_docspec_class.return_value = mock_docspec
|
||||
mock_ydoc_class.return_value = mock_ydoc
|
||||
|
||||
# Mock the conversion chain: DOCX -> BlockNote -> YJS
|
||||
blocknote_data = b'[{"type": "paragraph", "content": "test"}]'
|
||||
yjs_data = "base64encodedyjs"
|
||||
|
||||
mock_docspec.convert.return_value = blocknote_data
|
||||
mock_ydoc.convert.return_value = yjs_data
|
||||
|
||||
# Execute conversion
|
||||
converter = Converter()
|
||||
docx_data = b"fake docx data"
|
||||
result = converter.convert(docx_data, mime_types.DOCX, mime_types.YJS)
|
||||
|
||||
# Verify the orchestration
|
||||
mock_docspec.convert.assert_called_once_with(
|
||||
docx_data, mime_types.DOCX, mime_types.BLOCKNOTE
|
||||
)
|
||||
mock_ydoc.convert.assert_called_once_with(
|
||||
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
|
||||
)
|
||||
assert result == yjs_data
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter")
|
||||
def test_converter_markdown_to_yjs_delegation(mock_ydoc_class):
|
||||
"""Test that Markdown to YJS conversion is delegated to YdocConverter."""
|
||||
mock_ydoc = MagicMock()
|
||||
mock_ydoc_class.return_value = mock_ydoc
|
||||
|
||||
yjs_data = "base64encodedyjs"
|
||||
mock_ydoc.convert.return_value = yjs_data
|
||||
|
||||
converter = Converter()
|
||||
markdown_data = "# Test Document"
|
||||
result = converter.convert(markdown_data, mime_types.MARKDOWN, mime_types.YJS)
|
||||
|
||||
mock_ydoc.convert.assert_called_once_with(
|
||||
markdown_data, mime_types.MARKDOWN, mime_types.YJS
|
||||
)
|
||||
assert result == yjs_data
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter")
|
||||
def test_converter_yjs_to_html_delegation(mock_ydoc_class):
|
||||
"""Test that YJS to HTML conversion is delegated to YdocConverter."""
|
||||
mock_ydoc = MagicMock()
|
||||
mock_ydoc_class.return_value = mock_ydoc
|
||||
|
||||
html_data = "<p>Test Document</p>"
|
||||
mock_ydoc.convert.return_value = html_data
|
||||
|
||||
converter = Converter()
|
||||
yjs_data = b"yjs binary data"
|
||||
result = converter.convert(yjs_data, mime_types.YJS, mime_types.HTML)
|
||||
|
||||
mock_ydoc.convert.assert_called_once_with(yjs_data, mime_types.YJS, mime_types.HTML)
|
||||
assert result == html_data
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter")
|
||||
def test_converter_blocknote_to_yjs_delegation(mock_ydoc_class):
|
||||
"""Test that BlockNote to YJS conversion is delegated to YdocConverter."""
|
||||
mock_ydoc = MagicMock()
|
||||
mock_ydoc_class.return_value = mock_ydoc
|
||||
|
||||
yjs_data = "base64encodedyjs"
|
||||
mock_ydoc.convert.return_value = yjs_data
|
||||
|
||||
converter = Converter()
|
||||
blocknote_data = b'[{"type": "paragraph"}]'
|
||||
result = converter.convert(blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS)
|
||||
|
||||
mock_ydoc.convert.assert_called_once_with(
|
||||
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
|
||||
)
|
||||
assert result == yjs_data
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import (
|
||||
ServiceUnavailableError,
|
||||
ValidationError,
|
||||
@@ -21,9 +22,9 @@ def test_auth_header(settings):
|
||||
|
||||
|
||||
def test_convert_empty_text():
|
||||
"""Should raise ValidationError when text is empty."""
|
||||
"""Should raise ValidationError when data is empty."""
|
||||
converter = YdocConverter()
|
||||
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
||||
with pytest.raises(ValidationError, match="Input data cannot be empty"):
|
||||
converter.convert("")
|
||||
|
||||
|
||||
@@ -36,7 +37,7 @@ def test_convert_service_unavailable(mock_post):
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to conversion service",
|
||||
match="Failed to connect to YDoc conversion service",
|
||||
):
|
||||
converter.convert("test text")
|
||||
|
||||
@@ -52,7 +53,7 @@ def test_convert_http_error(mock_post):
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to conversion service",
|
||||
match="Failed to connect to YDoc conversion service",
|
||||
):
|
||||
converter.convert("test text")
|
||||
|
||||
@@ -83,8 +84,8 @@ def test_convert_full_integration(mock_post, settings):
|
||||
data="test markdown",
|
||||
headers={
|
||||
"Authorization": "Bearer test-key",
|
||||
"Content-Type": "text/markdown",
|
||||
"Accept": "application/vnd.yjs.doc",
|
||||
"Content-Type": mime_types.MARKDOWN,
|
||||
"Accept": mime_types.YJS,
|
||||
},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
@@ -108,9 +109,7 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = converter.convert(
|
||||
b"test_content", "application/vnd.yjs.doc", "text/markdown"
|
||||
)
|
||||
result = converter.convert(b"test_content", mime_types.YJS, mime_types.MARKDOWN)
|
||||
|
||||
assert result == expected_response
|
||||
mock_post.assert_called_once_with(
|
||||
@@ -118,8 +117,8 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
|
||||
data=b"test_content",
|
||||
headers={
|
||||
"Authorization": "Bearer test-key",
|
||||
"Content-Type": "application/vnd.yjs.doc",
|
||||
"Accept": "text/markdown",
|
||||
"Content-Type": mime_types.YJS,
|
||||
"Accept": mime_types.MARKDOWN,
|
||||
},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
@@ -135,7 +134,7 @@ def test_convert_timeout(mock_post):
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to conversion service",
|
||||
match="Failed to connect to YDoc conversion service",
|
||||
):
|
||||
converter.convert("test text")
|
||||
|
||||
@@ -144,5 +143,5 @@ def test_convert_none_input():
|
||||
"""Should raise ValidationError when input is None."""
|
||||
converter = YdocConverter()
|
||||
|
||||
with pytest.raises(ValidationError, match="Input text cannot be empty"):
|
||||
with pytest.raises(ValidationError, match="Input data cannot be empty"):
|
||||
converter.convert(None)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Test DocSpec converter services."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from core.services import mime_types
|
||||
from core.services.converter_services import (
|
||||
DocSpecConverter,
|
||||
ServiceUnavailableError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
def test_docspec_convert_empty_data():
|
||||
"""Should raise ValidationError when data is empty."""
|
||||
converter = DocSpecConverter()
|
||||
with pytest.raises(ValidationError, match="Input data cannot be empty"):
|
||||
converter.convert("", mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
def test_docspec_convert_none_input():
|
||||
"""Should raise ValidationError when input is None."""
|
||||
converter = DocSpecConverter()
|
||||
with pytest.raises(ValidationError, match="Input data cannot be empty"):
|
||||
converter.convert(None, mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
def test_docspec_convert_unsupported_content_type():
|
||||
"""Should raise ValidationError when content type is not DOCX."""
|
||||
converter = DocSpecConverter()
|
||||
with pytest.raises(
|
||||
ValidationError, match="Conversion from text/plain to .* is not supported"
|
||||
):
|
||||
converter.convert(b"test data", "text/plain", mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
def test_docspec_convert_unsupported_accept():
|
||||
"""Should raise ValidationError when accept type is not BLOCKNOTE."""
|
||||
converter = DocSpecConverter()
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match=f"Conversion from {mime_types.DOCX} to {mime_types.YJS} is not supported",
|
||||
):
|
||||
converter.convert(b"test data", mime_types.DOCX, mime_types.YJS)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_docspec_convert_service_unavailable(mock_post):
|
||||
"""Should raise ServiceUnavailableError when service is unavailable."""
|
||||
converter = DocSpecConverter()
|
||||
mock_post.side_effect = requests.RequestException("Connection error")
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to DocSpec conversion service",
|
||||
):
|
||||
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_docspec_convert_http_error(mock_post):
|
||||
"""Should raise ServiceUnavailableError when HTTP error occurs."""
|
||||
converter = DocSpecConverter()
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error")
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to DocSpec conversion service",
|
||||
):
|
||||
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_docspec_convert_timeout(mock_post):
|
||||
"""Should raise ServiceUnavailableError when request times out."""
|
||||
converter = DocSpecConverter()
|
||||
mock_post.side_effect = requests.Timeout("Request timed out")
|
||||
|
||||
with pytest.raises(
|
||||
ServiceUnavailableError,
|
||||
match="Failed to connect to DocSpec conversion service",
|
||||
):
|
||||
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_docspec_convert_success(mock_post, settings):
|
||||
"""Test successful DOCX to BlockNote conversion."""
|
||||
settings.DOCSPEC_API_URL = "http://docspec.test/convert"
|
||||
settings.CONVERSION_API_TIMEOUT = 5
|
||||
settings.CONVERSION_API_SECURE = False
|
||||
|
||||
converter = DocSpecConverter()
|
||||
|
||||
expected_content = b'[{"type": "paragraph", "content": "test"}]'
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = expected_content
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
docx_data = b"fake docx binary data"
|
||||
result = converter.convert(docx_data, mime_types.DOCX, mime_types.BLOCKNOTE)
|
||||
|
||||
assert result == expected_content
|
||||
|
||||
# Verify the request was made correctly
|
||||
mock_post.assert_called_once_with(
|
||||
"http://docspec.test/convert",
|
||||
headers={"Accept": mime_types.BLOCKNOTE},
|
||||
files={"file": ("document.docx", docx_data, mime_types.DOCX)},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
@@ -3,9 +3,14 @@
|
||||
import base64
|
||||
import uuid
|
||||
|
||||
import pycrdt
|
||||
from django.core.cache import cache
|
||||
|
||||
from core import utils
|
||||
import pycrdt
|
||||
import pytest
|
||||
|
||||
from core import factories, utils
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# This base64 string is an example of what is saved in the database.
|
||||
# This base64 is generated from the blocknote editor, it contains
|
||||
@@ -100,3 +105,103 @@ 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 == {}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -10,7 +10,6 @@ from core.api import viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
router.register("templates", viewsets.TemplateViewSet, basename="templates")
|
||||
router.register("documents", viewsets.DocumentViewSet, basename="documents")
|
||||
router.register("users", viewsets.UserViewSet, basename="users")
|
||||
|
||||
@@ -60,6 +59,10 @@ 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,13 +1,21 @@
|
||||
"""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
|
||||
from core import enums, models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
@@ -96,3 +104,46 @@ 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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<img width="200" src="http://localhost:3000/assets/logo-gouv.png" />
|
||||
<br/>
|
||||
@@ -216,29 +216,6 @@ def create_demo(stdout):
|
||||
|
||||
queue.flush()
|
||||
|
||||
with Timeit(stdout, "Creating Template"):
|
||||
with open(
|
||||
file="demo/data/template/code.txt", mode="r", encoding="utf-8"
|
||||
) as text_file:
|
||||
code_data = text_file.read()
|
||||
|
||||
with open(
|
||||
file="demo/data/template/css.txt", mode="r", encoding="utf-8"
|
||||
) as text_file:
|
||||
css_data = text_file.read()
|
||||
|
||||
queue.push(
|
||||
models.Template(
|
||||
id="baca9e2a-59fb-42ef-b5c6-6f6b05637111",
|
||||
title="Demo Template",
|
||||
description="This is the demo template",
|
||||
code=code_data,
|
||||
css=css_data,
|
||||
is_public=True,
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""A management command to create a demo database."""
|
||||
|
||||
@@ -25,7 +25,6 @@ def test_commands_create_demo():
|
||||
"""The create_demo management command should create objects as expected."""
|
||||
call_command("create_demo")
|
||||
|
||||
assert models.Template.objects.count() == 1
|
||||
assert models.User.objects.count() >= 10
|
||||
assert models.Document.objects.count() >= 10
|
||||
assert models.DocumentAccess.objects.count() > 10
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
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,5 +125,38 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
-22
@@ -29,6 +29,10 @@ from sentry_sdk.integrations.logging import ignore_logger
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data"))
|
||||
|
||||
KB = 1024
|
||||
MB = KB * KB
|
||||
GB = MB * KB
|
||||
|
||||
|
||||
def get_release():
|
||||
"""
|
||||
@@ -165,10 +169,15 @@ 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(
|
||||
10 * (2**20), # 10MB
|
||||
10 * MB, # 10MB
|
||||
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -317,6 +326,7 @@ 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",
|
||||
@@ -406,16 +416,6 @@ class Base(Configuration):
|
||||
environ_name="API_DOCUMENT_ACCESS_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"template": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="API_TEMPLATE_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"template_access": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="API_TEMPLATE_ACCESS_THROTTLE_RATE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"invitation": values.Value(
|
||||
default="60/minute",
|
||||
environ_name="API_INVITATION_THROTTLE_RATE",
|
||||
@@ -453,7 +453,7 @@ class Base(Configuration):
|
||||
"REDOC_DIST": "SIDECAR",
|
||||
}
|
||||
|
||||
TRASHBIN_CUTOFF_DAYS = values.Value(
|
||||
TRASHBIN_CUTOFF_DAYS = values.IntegerValue(
|
||||
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -465,6 +465,7 @@ class Base(Configuration):
|
||||
EMAIL_HOST_PASSWORD = SecretFileValue(None)
|
||||
EMAIL_LOGO_IMG = values.Value(None)
|
||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||
EMAIL_URL_APP = values.Value(None)
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
@@ -512,7 +513,9 @@ 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",
|
||||
@@ -554,6 +557,16 @@ 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",
|
||||
@@ -675,30 +688,64 @@ class Base(Configuration):
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
|
||||
# AI service
|
||||
AI_FEATURE_ENABLED = values.BooleanValue(
|
||||
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
|
||||
)
|
||||
AI_API_KEY = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None)
|
||||
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
|
||||
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
|
||||
# AI settings
|
||||
AI_ALLOW_REACH_FROM = values.Value(
|
||||
choices=("public", "authenticated", "restricted"),
|
||||
default="authenticated",
|
||||
environ_name="AI_ALLOW_REACH_FROM",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AI_API_KEY = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None)
|
||||
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
|
||||
AI_BOT = values.DictValue(
|
||||
default={
|
||||
"name": _("Docs AI"),
|
||||
"color": "#8bc6ff",
|
||||
},
|
||||
environ_name="AI_BOT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AI_DOCUMENT_RATE_THROTTLE_RATES = {
|
||||
"minute": 5,
|
||||
"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_USER_RATE_THROTTLE_RATES = {
|
||||
"minute": 3,
|
||||
"hour": 50,
|
||||
"day": 200,
|
||||
}
|
||||
|
||||
LANGFUSE_SECRET_KEY = SecretFileValue(
|
||||
None, environ_name="LANGFUSE_SECRET_KEY", environ_prefix=None
|
||||
)
|
||||
LANGFUSE_PUBLIC_KEY = values.Value(
|
||||
None, environ_name="LANGFUSE_PUBLIC_KEY", environ_prefix=None
|
||||
)
|
||||
LANGFUSE_BASE_URL = values.Value(
|
||||
None, environ_name="LANGFUSE_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Y provider microservice
|
||||
Y_PROVIDER_API_KEY = SecretFileValue(
|
||||
environ_name="Y_PROVIDER_API_KEY",
|
||||
@@ -709,6 +756,22 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# DocSpec API microservice
|
||||
DOCSPEC_API_URL = values.Value(environ_name="DOCSPEC_API_URL", environ_prefix=None)
|
||||
|
||||
# Imported file settings
|
||||
CONVERSION_FILE_MAX_SIZE = values.IntegerValue(
|
||||
20 * MB,
|
||||
environ_name="CONVERSION_FILE_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
CONVERSION_FILE_EXTENSIONS_ALLOWED = values.ListValue(
|
||||
default=[".docx", ".md"],
|
||||
environ_name="CONVERSION_FILE_EXTENSIONS_ALLOWED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Conversion endpoint
|
||||
CONVERSION_API_ENDPOINT = values.Value(
|
||||
default="convert",
|
||||
@@ -804,6 +867,11 @@ 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.
|
||||
@@ -837,6 +905,16 @@ 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,
|
||||
@@ -1050,15 +1128,20 @@ 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"
|
||||
|
||||
# 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/1",
|
||||
"redis://redis:6379/0",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
@@ -1072,10 +1155,26 @@ class Production(Base):
|
||||
},
|
||||
"KEY_PREFIX": values.Value(
|
||||
"docs",
|
||||
environ_name="CACHES_KEY_PREFIX",
|
||||
environ_name="CACHES_DEFAULT_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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,5 +13,6 @@ 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()
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Breton\n"
|
||||
"Language: br_FR\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Titouroù personel"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Aotreoù"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Deiziadoù a-bouez"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Gwezennadur"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Titl"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Me eo an aozer"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr "Kuzhet"
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Sinedoù"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
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:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
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:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr "Ar vaezienn-mañ a zo rekis."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: 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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "eilenn {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,289 @@ msgstr "implijer"
|
||||
msgid "users"
|
||||
msgstr "implijerien"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "titl"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr "bomm"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "Restr"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "Restroù"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "Restr hep titl"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Digeriñ"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, 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:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, 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:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Roud liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Roudoù liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
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:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "Restr muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "Restroù muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
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:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr "Liamm restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr "Liammoù restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "An implijer-mañ a zo dija er restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
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:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr "Goulenn tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Goulennoù tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
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:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, 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:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, 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:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, 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:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "deskrivadur"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "kod"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "publik"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
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:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Patrom"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Patromoù"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Liamm patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Liammoù patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "An implijer-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "postel"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Pedadenn d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Pedadennoù d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Digeriñ"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Daten"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Baumstruktur"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Favorit"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sie sind Besitzer eines neuen Dokuments:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: 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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "Kopie von {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,289 @@ msgstr "Benutzer"
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr "Auszug"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, 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:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, 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:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
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:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
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:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
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:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Dieses Team ist bereits in diesem Template."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: 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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,289 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Información Personal"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Permisos"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Fechas importantes"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Estructura en árbol"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Título"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Yo soy el creador"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Favorito"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "¡Un nuevo documento se ha creado por ti!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
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:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: 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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia de {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,289 @@ msgstr "usuario"
|
||||
msgid "users"
|
||||
msgstr "usuarios"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "título"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr "resumen"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "Documento"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "Documentos"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento sin título"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "¡{name} ha compartido un documento contigo!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, 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:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha compartido un documento contigo: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Traza del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Trazas del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
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:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento favorito"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "Documentos favoritos"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
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:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relación documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relaciones documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Este usuario ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Este equipo ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
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:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr "Solicitud de acceso"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Solicitud de accesos"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
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:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "¡{name} desea acceder a un documento!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} desea acceso al siguiente documento:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, 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:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "descripción"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "código"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "público"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
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:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Plantilla"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Plantillas"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relación plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relaciones plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Este usuario ya forma parte de la plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Este equipo ya se encuentra en esta plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "dirección de correo electrónico"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitación al documento"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitaciones a documentos"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Infos Personnelles"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Arborescence"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Je suis l'auteur"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr "Masqué"
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Favoris"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nouveau document a été créé pour vous !"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
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:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr "Ce champ est obligatoire."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, 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/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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copie de {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Noyau d'application Impress"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,296 @@ msgstr "utilisateur"
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr "Adresse email active"
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr "Adresse email à désactiver"
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr "Identifiant unique dans le fichier source"
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr "Prêt"
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr "Terminé"
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr "Erreur"
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr "rapprochement de l'utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr "rapprochements de l'utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
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:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr "Cliquez ici"
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmer"
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
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:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr "Vos comptes ont été fusionnés"
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr "Cliquez ici pour voir"
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr "Voir mes documents"
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr "Fichier CSV"
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr "En cours"
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr "importation CSV de rapprochement utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr "importations CSV de rapprochement utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr "Le rapprochement de vos comptes Docs n'est pas terminé"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr "Faire une nouvelle demande"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "titre"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr "extrait"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "Document"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "Documents"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "Document sans titre"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, 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:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, 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:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Trace du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Traces du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
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:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favori"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "Documents favoris"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
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:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relation document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relations document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Cet utilisateur est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Cette équipe est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
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:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
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:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} souhaiterait accéder au document suivant !"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, 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:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, 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:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr "Conversation"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr "Conversations"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr "Anonyme"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr "Commentaire"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr "Commentaires"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
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:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr "Réaction"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr "Réactions"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
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:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Modèle"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Modèles"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relation modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relations modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Cet utilisateur est déjà dans ce modèle."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Cette équipe est déjà modèle."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "adresse e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitation à un document"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitations à un document"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Informazioni personali"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Permessi"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Date importanti"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Struttura ad albero"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Titolo"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Il creatore sono io"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Preferiti"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nuovo documento è stato creato a tuo nome!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sei ora proprietario di un nuovo documento:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: 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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia di {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,289 @@ msgstr "utente"
|
||||
msgid "users"
|
||||
msgstr "utenti"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "titolo"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "Documento"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "Documenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento senza titolo"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Apri"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ha condiviso un documento con te!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, 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:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, 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:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento preferito"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "Documenti preferiti"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Questo utente è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Questo team è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "descrizione"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "pubblico"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Indica se questo modello è pubblico per chiunque."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Modello"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Modelli"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Questo utente è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Questo team è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "indirizzo e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Invito al documento"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Inviti al documento"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Apri"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Machtigingen"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke data"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Boomstructuur"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Ik ben eigenaar"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr "Gemaskeerd"
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriet"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Een nieuw document is namens u gemaakt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
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:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr "Dit veld is verplicht."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, 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/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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "kopie van {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Docs kern applicatie"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,296 @@ msgstr "gebruiker"
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr "Actieve e-mail adres"
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr "E-mailadres om te deactiveren"
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr "Unieke ID in het bronbestand"
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr "In behandeling"
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr "Klaar"
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr "Klaar"
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr "Fout"
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr "gebruiker samenvoegen"
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr "gebruikers samenvoegen"
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
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:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr "Klik hier"
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr "Bevestig"
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
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:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr "Je accounts zijn samengevoegd"
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr "Klik hier om te bekijken"
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr "Mijn documenten bekijken"
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr "CSV bestand"
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr "Bezig"
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr "gebruiker samenvoeging CSV import"
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr "gebruiker reconciliation CSV imports"
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr "Samenvoeging van je Docs accounts is niet voltooid"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr "Maak een nieuw verzoek"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "titel"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr "uittreksel"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "Document"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "Documenten"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "Naamloos Document"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} heeft een document met u gedeeld!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, 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:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, 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:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
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:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favoriet"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "Document favorieten"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
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:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr "Document/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr "Document/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "De gebruiker bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dit team bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
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:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr "Document verzoekt om toegang"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Document verzoekt om toegangen"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
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:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} verzoekt toegang tot een document!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, 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:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, 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:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr "Kanaal"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr "Kanalen"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr "Anoniem"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr "Reactie"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr "Reacties"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Deze emoji is al op deze opmerking gereageerd."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr "Reactie"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr "Reacties"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "omschrijving"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "publiek"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
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:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Sjabloon"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Sjabloon"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Sjabloon/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Sjabloon/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "De gebruiker bestaat al in dit sjabloon."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Het team bestaat al in dit sjabloon."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "e-mailadres"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Document uitnodiging"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Document uitnodigingen"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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 ""
|
||||
|
||||
#: 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:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Language: pt_PT\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Informações Pessoais"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Permissões"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Datas importantes"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Estrutura de árvore"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Título"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Eu sou o criador"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Favorito"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Um novo documento foi criado em seu nome!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "A propriedade de um novo documento foi concedida a você:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Tipo de corpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "cópia de {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,289 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Личная информация"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Разрешения"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Важные даты"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Древовидная структура"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Заголовок"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Создатель - я"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr "Скрытый"
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Избранное"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Новый документ был создан от вашего имени!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Вы назначены владельцем для нового документа:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr "Это поле обязательное."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа."
|
||||
|
||||
#: 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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копия {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Ядро приложения Impress"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,296 @@ msgstr "пользователь"
|
||||
msgid "users"
|
||||
msgstr "пользователи"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr "Активный адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr "Адрес электронной почты для деактивации"
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr "Уникальный идентификатор в исходном файле"
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr "В обработке"
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr "Готово"
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr "Выполнено"
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr "Ошибка"
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr "сверка данных пользователя"
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr "сверки данных пользователя"
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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 "Вы запросили сверку учётных записей пользователя в Docs.\n"
|
||||
" Чтобы подтвердить факт того, что вы являетесь инициатором запроса\n"
|
||||
" и что этот адрес принадлежит вам:"
|
||||
|
||||
#: build/lib/core/models.py:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr "Чтобы начать сверку, подтвердите это, нажав на ссылку"
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr "Нажмите здесь"
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr "Подтверждение"
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr "Ваш запрос на сверку был обработан.\n"
|
||||
" Новые документы, вероятно, связаны с вашей учётной записью:"
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr "Ваши учётные записи были объединены"
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr "Нажмите здесь, чтобы просмотреть"
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr "Просмотреть мои документы"
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr "CSV-файл"
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr "Выполнение"
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr "импорт из CSV сверки пользователей"
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr "импорты из CSV сверки пользователями"
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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 "Ваш запрос на сверку не удался.\n"
|
||||
" Сверка не удалась для следующих email адресов:\n"
|
||||
" {recipient_email}, {other_email}.\n"
|
||||
" Пожалуйста, проверьте, нет ли в них опечаток.\n"
|
||||
" Вы можете отправить ещё один запрос с действительными адресами электронной почты."
|
||||
|
||||
#: build/lib/core/models.py:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr "Сверка ваших учётных записей Docs не завершена"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr "Создать новый запрос"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr "отрывок"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "Документ"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "Документы"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "Безымянный документ"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} делится с вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} делится с вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трассировка связи документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трассировка связей документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "Избранный документ"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "Избранные документы"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Этот документ уже помечен как избранный для этого пользователя."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr "Отношение документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr "Отношения документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Этот пользователь уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Эта команда уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr "Документ запрашивает доступ"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Документ запрашивает доступы"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Этот пользователь уже запросил доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хочет получить доступ к документу!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} хочет получить доступ к следующему документу:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запрашивает доступ к документу: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr "Обсуждение"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr "Обсуждения"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr "Аноним"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr "Комментарий"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr "Комментарии"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Этот эмодзи уже использован в этом комментарии."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr "Реакция"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr "Реакции"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "описание"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "доступно всем"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Этот шаблон доступен всем пользователям."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Шаблоны"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Отношение шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Отношения шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Этот пользователь уже указан в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Эта команда уже указана в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Приглашение для документа"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Приглашения для документов"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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 "Docs ИИ"
|
||||
|
||||
#: core/templates/mail/html/template.html:153
|
||||
#: core/templates/mail/text/template.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Логотип email"
|
||||
|
||||
#: 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/html/template.html:219
|
||||
#: 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, ваш новый инструмент для организации и совместного использования документов в вашей команде. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Osebni podatki"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Dovoljenja"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Pomembni datumi"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Drevesna struktura"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Naslov"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Ustvaril sem jaz"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Priljubljena"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Nov dokument je bil ustvarjen v vašem imenu!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Dodeljeno vam je bilo lastništvo nad novim dokumentom:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Telo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Vrsta telesa"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Oblika"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,289 @@ msgstr "uporabnik"
|
||||
msgid "users"
|
||||
msgstr "uporabniki"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "naslov"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr "odlomek"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "Dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "Dokument brez naslova"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Odpri"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} je delil dokument z vami!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} je delil dokument z vami: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/sled povezave uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Sledi povezav dokumenta/uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "Priljubljeni dokument"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "Priljubljeni dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr "Odnos dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr "Odnosi dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Ta uporabnik je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ta ekipa je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "opis"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "koda"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "javno"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ali je ta predloga javna za uporabo."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Predloga"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Predloge"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Odnos predloga/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Odnosi med predlogo in uporabnikom"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Ta uporabnik je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ta ekipa je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "elektronski naslov"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Vabilo na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Vabila na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
|
||||
|
||||
#: 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 "E-pošta z logotipom"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Odpri"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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 " Dokumenti, vaše novo bistveno orodje za organiziranje, skupno rabo in skupinsko sodelovanje pri dokumentih. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Language: sv_SE\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Personuppgifter"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Behörigheter"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Viktiga datum"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Skaparen är jag"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriter"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ett nytt dokument skapades åt dig!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Du har beviljats äganderätt till ett nytt dokument:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,289 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Öppna"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "e-postadress"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Bjud in dokument"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Inbjudningar dokument"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
|
||||
|
||||
#: 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 "Logotyp e-post"
|
||||
|
||||
#: core/templates/mail/html/template.html:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Öppna"
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: 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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,289 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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:200
|
||||
#: core/templates/mail/text/template.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/template.html:217
|
||||
#: core/templates/mail/html/template.html:219
|
||||
#: 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:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
@@ -17,73 +17,73 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "Особисті дані"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "Дозволи"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "Важливі дати"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: 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
|
||||
msgid "Tree structure"
|
||||
msgstr "Ієрархічна структура"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "Заголовок"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "Творець — я"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr "Приховано"
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "Обране"
|
||||
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Новий документ був створений від вашого імені!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Ви тепер є власником нового документа:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr "Це поле є обов’язковим."
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа."
|
||||
|
||||
#: 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:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копія {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Ядро додатку Impress"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,224 +239,296 @@ msgstr "користувач"
|
||||
msgid "users"
|
||||
msgstr "користувачі"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr "Активна електронна адреса"
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr "Електронна адреса, що буде деактивована"
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr "Унікальний ідентифікатор у вихідному файлі"
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr "В очікуванні"
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr "Готово"
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr "Виконано"
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr "Помилка"
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr "узгодження користувачів"
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr "узгодження користувачів"
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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 "Ви запросили узгодження своїх облікових записів користувачів у Docs.\n"
|
||||
" Щоб підтвердити, що саме ви ініціювали запит\n"
|
||||
" і що ця електронна адреса належить вам:"
|
||||
|
||||
#: build/lib/core/models.py:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr "Підтвердіть, натиснувши на посилання, щоб почати узгодження"
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr "Натисніть тут"
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr "Підтвердження"
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr "Ваш запит на узгодження оброблено.\n"
|
||||
" Нові документи, ймовірно, пов'язані з вашим обліковим записом:"
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr "Ваші облікові записи були об'єднані"
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr "Натисніть тут, щоб переглянути"
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr "Переглянути мої документи"
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr "CSV-файл"
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr "Виконується"
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr "імпорт CSV для узгодження користувачів"
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr "імпорт CSV для узгодження користувачів"
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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 "Ваш запит на узгодження не був виконаний.\n"
|
||||
" Узгодження не вдалося для наступних адрес електронної пошти:\n"
|
||||
" {recipient_email}, {other_email}.\n"
|
||||
" Перевірте, чи немає помилок.\n"
|
||||
" Ви можете надіслати інший запит із дійсними адресами електронної пошти."
|
||||
|
||||
#: build/lib/core/models.py:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr "Узгодження ваших облікових записів не завершено"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr "Зробити новий запит"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr "уривок"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "Документ"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "Документи"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "Документ без назви"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "Відкрити"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ділиться з вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ділиться з вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трасування посилання Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трасування посилань Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "Обраний документ"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "Обрані документи"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Цей документ вже вказаний як обраний для одного користувача."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Цей користувач вже має доступ до цього документу."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ця команда вже має доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Вкажіть користувача або команду, а не обох."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr "Запит доступу до документа"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Запит доступу для документа"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Цей користувач вже попросив доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хоче отримати доступ до документа!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} бажає отримати доступ до наступного документа:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запитує доступ до документа: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr "Обговорення"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr "Обговорення"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr "Анонім"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr "Коментар"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr "Коментарі"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Цим емодзі вже відреагували на цей коментар."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr "Реакція"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr "Реакції"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "опис"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "публічне"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Шаблони"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Цей користувач вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ця команда вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "електронна адреса"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "Запрошення до редагування документа"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "Запрошення до редагування документів"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
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 "Docs ШІ"
|
||||
|
||||
#: core/templates/mail/html/template.html:153
|
||||
#: core/templates/mail/text/template.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Логотип пошти"
|
||||
|
||||
#: 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/html/template.html:219
|
||||
#: 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, ваш новий важливий інструмент для організації, обміну та командної співпраці над вашими документами. "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
|
||||
"PO-Revision-Date: 2026-02-27 08:24\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Language: zh_CN\n"
|
||||
@@ -17,123 +17,123 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
msgid "Personal info"
|
||||
msgstr "个人信息"
|
||||
msgstr "個人資訊"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
msgid "Permissions"
|
||||
msgstr "权限"
|
||||
msgstr "權限"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
msgid "Important dates"
|
||||
msgstr "重要日期"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
msgid "Tree structure"
|
||||
msgstr "树状结构"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
msgid "Title"
|
||||
msgstr "标题"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
msgid "Creator is me"
|
||||
msgstr "创建者是我"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
msgid "Masked"
|
||||
msgstr "已屏蔽"
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
msgid "Favorite"
|
||||
msgstr "收藏"
|
||||
|
||||
#: 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:501 core/api/serializers.py:501
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "您已被授予新文档的所有权:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr "必填字段。"
|
||||
|
||||
#: 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."
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "正文"
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "正文类型"
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
msgid "Tree structure"
|
||||
msgstr "樹狀結構"
|
||||
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "格式"
|
||||
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
|
||||
msgid "Title"
|
||||
msgstr "標題"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
|
||||
msgid "Creator is me"
|
||||
msgstr "建立者是我"
|
||||
|
||||
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
|
||||
msgid "Masked"
|
||||
msgstr "已隱藏"
|
||||
|
||||
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
|
||||
msgid "Favorite"
|
||||
msgstr "我的最愛"
|
||||
|
||||
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "已代表您建立新文件!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "您已獲得新文件的所有權:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
|
||||
msgid "This field is required."
|
||||
msgstr "此欄位為必填。"
|
||||
|
||||
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "根據父文件設定,不允許連結範圍「%(link_reach)s」。"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "{title} 的副本"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Impress 核心應用程式"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "阅读者"
|
||||
msgstr "檢視者"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
|
||||
#: core/choices.py:44
|
||||
msgid "Commenter"
|
||||
msgstr ""
|
||||
msgstr "評論者"
|
||||
|
||||
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
|
||||
#: core/choices.py:45
|
||||
msgid "Editor"
|
||||
msgstr "编辑者"
|
||||
msgstr "編輯者"
|
||||
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "超级管理员"
|
||||
msgstr "管理員"
|
||||
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "所有者"
|
||||
msgstr "擁有者"
|
||||
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "受限的"
|
||||
msgstr "受限"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "已验证"
|
||||
msgstr "已驗證"
|
||||
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "公开"
|
||||
msgstr "公開"
|
||||
|
||||
#: build/lib/core/enums.py:36 core/enums.py:36
|
||||
msgid "First child"
|
||||
msgstr "第一个子项"
|
||||
msgstr "第一個子項目"
|
||||
|
||||
#: build/lib/core/enums.py:37 core/enums.py:37
|
||||
msgid "Last child"
|
||||
msgstr "最后一个子项"
|
||||
msgstr "最後一個子項目"
|
||||
|
||||
#: build/lib/core/enums.py:38 core/enums.py:38
|
||||
msgid "First sibling"
|
||||
msgstr "第一个同级项"
|
||||
msgstr "第一個同級項目"
|
||||
|
||||
#: build/lib/core/enums.py:39 core/enums.py:39
|
||||
msgid "Last sibling"
|
||||
msgstr "最后一个同级项"
|
||||
msgstr "最後一個同級項目"
|
||||
|
||||
#: build/lib/core/enums.py:40 core/enums.py:40
|
||||
msgid "Left"
|
||||
@@ -145,31 +145,31 @@ msgstr "右"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "记录的主密钥为 UUID"
|
||||
msgstr "記錄的主鍵(UUID)"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
msgid "created on"
|
||||
msgstr "创建时间"
|
||||
msgstr "建立於"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "记录的创建日期和时间"
|
||||
msgstr "記錄建立的日期與時間"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
msgid "updated on"
|
||||
msgstr "更新时间"
|
||||
msgstr "更新於"
|
||||
|
||||
#: build/lib/core/models.py:94 core/models.py:94
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "记录的最后更新时间"
|
||||
msgstr "記錄最後更新的日期與時間"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "未找到具有该 sub 的用户,但该邮箱已关联到一个注册用户。"
|
||||
msgstr "我們找不到具有此 sub 的使用者,但此電子郵件地址已與已註冊使用者關聯。"
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
msgid "sub"
|
||||
@@ -177,7 +177,7 @@ msgstr "sub"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr "必填项。限255个字符以内。仅支持ASCII字符。"
|
||||
msgstr "必填。255 個字元(含)以下。僅限 ASCII 字元。"
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
msgid "full name"
|
||||
@@ -185,280 +185,345 @@ msgstr "全名"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
msgid "short name"
|
||||
msgstr "简称"
|
||||
msgstr "簡稱"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
msgid "identity email address"
|
||||
msgstr "身份电子邮件地址"
|
||||
msgstr "身份驗證電子郵件地址"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
msgid "admin email address"
|
||||
msgstr "管理员电子邮件地址"
|
||||
msgstr "管理員電子郵件地址"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
msgid "language"
|
||||
msgstr "语言"
|
||||
msgstr "語言"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "用户希望看到的界面语言。"
|
||||
msgstr "使用者希望介面顯示的語言。"
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "用户查看时间希望的时区。"
|
||||
msgstr "使用者希望時間顯示的時區。"
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "device"
|
||||
msgstr "设备"
|
||||
msgstr "裝置"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "用户是设备还是真实用户。"
|
||||
msgstr "使用者是裝置還是真實使用者。"
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "staff status"
|
||||
msgstr "员工状态"
|
||||
msgstr "工作人員狀態"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "用户是否可以登录该管理员站点。"
|
||||
msgstr "使用者是否可以登入此管理後台。"
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
msgid "active"
|
||||
msgstr "激活"
|
||||
msgstr "啟用"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
|
||||
msgstr "此使用者是否應被視為處於啟用狀態。請取消勾選此項而非刪除帳號。"
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
msgid "user"
|
||||
msgstr "用户"
|
||||
msgstr "使用者"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "users"
|
||||
msgstr "个用户"
|
||||
msgstr "使用者"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "Active email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 core/models.py:361
|
||||
msgid "Email address to deactivate"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:388 core/models.py:388
|
||||
msgid "Unique ID in the source file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
|
||||
#: core/models.py:692
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:395 core/models.py:395
|
||||
msgid "Ready"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
|
||||
#: core/models.py:694
|
||||
msgid "Done"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
|
||||
#: core/models.py:695
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "user reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
msgid "user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:644 core/models.py:644
|
||||
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:650 core/models.py:650
|
||||
msgid "Confirm by clicking the link to start the reconciliation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
|
||||
#: core/models.py:761
|
||||
msgid "Click here"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:656 core/models.py:656
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:667 core/models.py:667
|
||||
msgid "Your reconciliation request has been processed.\n"
|
||||
" New documents are likely associated with your account:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:672 core/models.py:672
|
||||
msgid "Your accounts have been merged"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:677 core/models.py:677
|
||||
msgid "Click here to see"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:678 core/models.py:678
|
||||
msgid "See my documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:688 core/models.py:688
|
||||
msgid "CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:703 core/models.py:703
|
||||
msgid "user reconciliation CSV import"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:704 core/models.py:704
|
||||
msgid "user reconciliation CSV imports"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:748 core/models.py:748
|
||||
#, 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:756 core/models.py:756
|
||||
msgid "Reconciliation of your Docs accounts not completed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "Make a new request"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
msgid "title"
|
||||
msgstr "标题"
|
||||
msgstr "標題"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
msgid "excerpt"
|
||||
msgstr "摘要"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Document"
|
||||
msgstr "文档"
|
||||
msgstr "文件"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Documents"
|
||||
msgstr "个文档"
|
||||
msgstr "文件"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
|
||||
#: core/models.py:924 core/models.py:1328
|
||||
msgid "Untitled Document"
|
||||
msgstr "未命名文档"
|
||||
msgstr "未命名文件"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:1329 core/models.py:1329
|
||||
msgid "Open"
|
||||
msgstr "開啟"
|
||||
|
||||
#: build/lib/core/models.py:1364 core/models.py:1364
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} 与您共享了一个文档!"
|
||||
msgstr "{name} 與您分享了一份文件!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:1368 core/models.py:1368
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
|
||||
msgstr "{name} 邀請您以「{role}」角色參與以下文件:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:1374 core/models.py:1374
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} 与您共享了一个文档:{title}"
|
||||
msgstr "{name} 與您分享了一份文件:{title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:1475 core/models.py:1475
|
||||
msgid "Document/user link trace"
|
||||
msgstr "文档/用户链接跟踪"
|
||||
msgstr "文件/使用者連結追蹤"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:1476 core/models.py:1476
|
||||
msgid "Document/user link traces"
|
||||
msgstr "个文档/用户链接跟踪"
|
||||
msgstr "文件/使用者連結追蹤"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:1482 core/models.py:1482
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "此文档/用户的链接跟踪已存在。"
|
||||
msgstr "此文件/使用者已存在連結追蹤。"
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1505 core/models.py:1505
|
||||
msgid "Document favorite"
|
||||
msgstr "文档收藏"
|
||||
msgstr "文件收藏"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1506 core/models.py:1506
|
||||
msgid "Document favorites"
|
||||
msgstr "文档收藏夹"
|
||||
msgstr "文件收藏"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1512 core/models.py:1512
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "该文档已被同一用户的收藏关系实例关联。"
|
||||
msgstr "此使用者已將此文件加入收藏。"
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1534 core/models.py:1534
|
||||
msgid "Document/user relation"
|
||||
msgstr "文档/用户关系"
|
||||
msgstr "文件/使用者關聯"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1535 core/models.py:1535
|
||||
msgid "Document/user relations"
|
||||
msgstr "文档/用户关系集"
|
||||
msgstr "文件/使用者關聯"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1541 core/models.py:1541
|
||||
msgid "This user is already in this document."
|
||||
msgstr "该用户已在此文档中。"
|
||||
msgstr "此使用者已在此文件中。"
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1547 core/models.py:1547
|
||||
msgid "This team is already in this document."
|
||||
msgstr "该团队已在此文档中。"
|
||||
msgstr "此團隊已在此文件中。"
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "必须设置用户或团队之一,不能同时设置两者。"
|
||||
msgstr "必須設定使用者或團隊其中之一,不能同時設定兩者。"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1704 core/models.py:1704
|
||||
msgid "Document ask for access"
|
||||
msgstr "文档需要访问权限"
|
||||
msgstr "要求文件存取權"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1705 core/models.py:1705
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "文档需要访问权限"
|
||||
msgstr "要求文件存取權"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1711 core/models.py:1711
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "用户已申请该文档的访问权限。"
|
||||
msgstr "此使用者已要求過存取此文件的權限。"
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1768 core/models.py:1768
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} 申请访问文档!"
|
||||
msgstr "{name} 想要存取文件!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1772 core/models.py:1772
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} 申请访问以下文档:"
|
||||
msgstr "{name} 想要存取以下文件:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1778 core/models.py:1778
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name}申请文档:{title}的访问权限"
|
||||
msgstr "{name} 正要求存取文件:{title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1820 core/models.py:1820
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
msgstr "對話串"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1821 core/models.py:1821
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
msgstr "對話串"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
|
||||
#: core/models.py:1824 core/models.py:1876
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
msgstr "匿名"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1871 core/models.py:1871
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
msgstr "評論"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1872 core/models.py:1872
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
msgstr "評論"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1921 core/models.py:1921
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
msgstr "此評論已標記過此表情符號。"
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1925 core/models.py:1925
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
msgstr "回應"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1926 core/models.py:1926
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
msgstr "回應"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
msgid "description"
|
||||
msgstr "说明"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "代码"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "公开"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "该模板是否公开供任何人使用。"
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "模板/用户关系"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "模板/用户关系集"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "该用户已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "该团队已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1936 core/models.py:1936
|
||||
msgid "email address"
|
||||
msgstr "电子邮件地址"
|
||||
msgstr "電子郵件地址"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "Document invitation"
|
||||
msgstr "文档邀请"
|
||||
msgstr "文件邀請"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1956 core/models.py:1956
|
||||
msgid "Document invitations"
|
||||
msgstr "文档邀请"
|
||||
msgstr "文件邀請"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1976 core/models.py:1976
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "此电子邮件已经与现有注册用户关联。"
|
||||
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 "徽标邮件"
|
||||
msgstr "電子郵件標誌"
|
||||
|
||||
#: 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/html/template.html:219
|
||||
#: 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——您的全新必备工具,帮助团队组织、共享和协作处理文档。 "
|
||||
msgstr " Docs,您團隊組織、分享及協作文件的全新必備工具。 "
|
||||
|
||||
#: core/templates/mail/html/template.html:224
|
||||
#: core/templates/mail/html/template.html:226
|
||||
#: core/templates/mail/text/template.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr " 由 %(brandname)s 倾力打造。 "
|
||||
msgstr " 由 %(brandname)s 提供 "
|
||||
|
||||
|
||||
+21
-17
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "4.2.0"
|
||||
version = "4.6.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -19,19 +19,19 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
description = "An application to print markdown to pdf from a set of managed templates."
|
||||
description = "Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing."
|
||||
keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"beautifulsoup4==4.14.2",
|
||||
"boto3==1.40.74",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"boto3==1.42.17",
|
||||
"Brotli==1.2.0",
|
||||
"celery[redis]==5.5.3",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.1.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-csp==4.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.22",
|
||||
@@ -39,8 +39,8 @@ dependencies = [
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.9",
|
||||
"django-treebeard==4.7.1",
|
||||
"django<6.0.0",
|
||||
"django-treebeard==4.8.0",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2024.4.2",
|
||||
@@ -48,18 +48,22 @@ dependencies = [
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.25.1",
|
||||
"langfuse==3.11.2",
|
||||
"lxml==6.0.2",
|
||||
"markdown==3.10",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"openai==2.8.0",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"pycrdt==0.12.43",
|
||||
"openai==2.21.0",
|
||||
"psycopg[binary]==3.3.2",
|
||||
"pycrdt==0.12.44",
|
||||
"pydantic==2.12.5",
|
||||
"pydantic-ai-slim[openai,logfire,web]==1.58.0",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk==2.44.0",
|
||||
"sentry-sdk==2.48.0",
|
||||
"uvicorn==0.40.0",
|
||||
"whitenoise==6.11.0",
|
||||
]
|
||||
|
||||
@@ -73,20 +77,20 @@ dependencies = [
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"django-test-migrations==1.5.0",
|
||||
"drf-spectacular-sidecar==2025.10.1",
|
||||
"drf-spectacular-sidecar==2025.12.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.7.0",
|
||||
"pyfakefs==5.10.2",
|
||||
"ipython==9.8.0",
|
||||
"pyfakefs==6.0.0",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==9.0.1",
|
||||
"pytest==9.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.8",
|
||||
"ruff==0.14.5",
|
||||
"ruff==0.14.10",
|
||||
"types-requests==2.32.4.20250913",
|
||||
]
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user