Compare commits

..

41 Commits

Author SHA1 Message Date
Anthony LC
f1b41cc4c4 POC override 2025-11-21 14:28:31 +01:00
Jacques ROUSSEL
175d80db16 ♻️(helmfile) rename helmfile
Rename helmfile to helmfile.yaml.gotmpl to be compatible with helmfile
0.162 and 1.1.9
2025-11-20 15:13:55 +01:00
Manuel Raynaud
f8b8390758 ♻️(backend) UserSerializer fallback strategy from UserLightSerializer
In the UserLightSerializer we were fallbacking on a strategy to never
have a full_name or short_name empty. We use the part of the email
befire the @. We are doing the same thing now in the main
UserSerializer.
2025-11-20 14:41:48 +01:00
Anthony LC
a1463e0a10 🐛(frontend) fix button markdown not visible
On smaller screens, the markdown button in the
toolbar was not every time visible.
We fix this issue.
2025-11-20 14:41:48 +01:00
Anthony LC
0b555eed9f ♻️(frontend) replace default comment toolbar button
Replace the default comment toolbar button with
a custom one to follow the design system.
2025-11-20 14:41:47 +01:00
Anthony LC
1bf810d596 ♻️(frontend) add user avatar to thread comments
We extracted the UserAvatar component from the
doc-share feature and integrated it into
the users feature. It will be used in the
thread comments feature as well.
2025-11-20 14:41:47 +01:00
Anthony LC
48e1370ba3 (frontend) add comments feature
Implemented the comments feature for the document
editor.
We are now able to add, view, and manage comments
within the document editor interface.
2025-11-20 14:41:47 +01:00
Anthony LC
b13571c6df (backend) implement thread and reactions API
In order to use comment we also have to implement a thread and reactions
API. A thread has multiple comments and comments can have multiple
reactions.
2025-11-20 14:41:47 +01:00
Manuel Raynaud
a2a63cd13e (backend) add comment viewset
This commit add the CRUD part to manage comment lifeycle. Permissions
are relying on the Document and Comment abilities. Comment viewset
depends on the Document route and is added to the
document_related_router. Dedicated serializer and permission are
created.
2025-11-20 14:20:06 +01:00
Manuel Raynaud
3ebb62d786 (backend) add Comment model
In order to store the comments on a document, we created a new model
Comment. User is nullable because anonymous users can comment a Document
is this one is public with a link_role commentator.
2025-11-20 14:20:06 +01:00
Manuel Raynaud
0caee61d86 (backend) add commentator role
To allow a user to comment a document we added a new role: commentator.
Commentator is higher than reader but lower than editor.
2025-11-20 14:20:06 +01:00
Cyril
10a319881d (frontend) preserve left panel width on window resize
prevents automatic resizing to keep user-defined width stable

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-20 10:07:17 +01:00
Cyril
26620f3471 (frontend) improve share modal button accessibility
Added aria-labels to remove and invite buttons

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-20 08:28:18 +01:00
Anthony LC
0d0e17c8d5 🔖(minor) release 3.10.0
Added:
- (frontend) enable ODT export for documents
- (frontend) improve mobile UX by showing subdocs count

Changed:
- ♻️(frontend) preserve @ character when esc is pressed
  after typing it
- ♻️(frontend) make summary button fixed to remain visible
  during scroll
- ♻️(frontend) pdf embed use full width

Fixed:
- (frontend) improve accessibility:
  - (frontend) improve ARIA in doc grid and editor
    for a11y
  - (frontend) improve accessibility and styling
    of summary table
  - (frontend) add focus trap and enter key support
    to remove doc modal
- 🐛(docx) fix image overflow by limiting width to
  600px during export
- 🐛(frontend) fix fallback translations with Trans
- 🐛(pdf) fix table cell alignment issue in exported
  documents
- 🐛(pdf) preserve image aspect ratio in PDF export
2025-11-19 14:48:08 +01:00
Anthony LC
257de6d068 📌(frontend) pin Blocknote to 0.42.3
We were pinned to a specific package registry
coming from a pull request, the new version is now
published to npm, so we can use the version from there.
2025-11-19 14:48:08 +01:00
AntoLC
5a4c02a978 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-19 12:26:04 +01:00
Manuel Raynaud
0090ccc981 🔥(backend) remove api managing templates
A complete API was able to manage templates lifecycle, from the creation
to the deletion and managing accesses on them. This API is not used by
the frontend application, is not finished. A connected user can interact
with this API and lead to unwanted behavior in the interface. Refering
ot issue #1222 templates can maybe totaly remove in the future. While
it's here and used, we only keep list and retrive endpoints. The
template management can still be done in the admin interface.
2025-11-19 11:23:31 +00:00
Anthony LC
d403878f8c 🐛(frontend) fix alignment of side menu
Recent refactoring moved the side menu position.
This commit fixes its alignment.
2025-11-19 10:39:35 +01:00
Anthony LC
191b046641 📌(frontend) pin Blocknote to fix mounting issue
We have a mounting issue with the Blocknote
version 0.42.1. To not wait for the next Blocknote
release, we pin the Blocknote packages
to a specific pull request version.
2025-11-19 10:05:25 +01:00
Anthony LC
aeac49d760 🔥(frontend) remove fix about color in export
The last version of Blocknote fixed the issue
with colors in exports.
We can remove our workaround now.
2025-11-18 16:19:56 +01:00
Anthony LC
b5dcbbb057 ⬆️(frontend) upgrade Blocknote to 0.42.1
We upgraded Blocknote to version 0.42.1 to benefit
from the latest features and bug fixes.
There is some compatibility issues with
tiptap extensions, so we had to pin tiptap
to "3.10.2" to ensure everything works smoothly.
There is as well some issues with prosemirror,
we pinned the problematic packages.
2025-11-18 16:19:56 +01:00
Cyril
2e64298ff4 (pdf) preserve image aspect ratio in PDF export
images were distorted in PDF exports; height is now computed to fix that

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-18 15:36:42 +01:00
Manuel Raynaud
8dad9ea6c4 🔧(helm) create a symlink between helmfile config
We want to have both helmfile.yaml and helmfile.gotmpl present.
helmfile.gotmpl is for now a symlink of helmfile.yaml
2025-11-18 11:10:03 +00:00
Anthony LC
3ae8046ffc 🐛(frontend) fix fallback translations with Trans
The fallback of the Trans component didn't work
anymore after upgrade to 16.2.3 of react-i18next.
Upgrading to 16.3.3 fixed the issue.
We added a test to cover this case.
2025-11-17 15:23:48 +01:00
Anthony LC
a4e3168682 🐛(export) fix export unsupported colors
Some colors bind to a text style are not supported.
It comes often from a paste style, we don't
display them if they are not supported by the
editor.
2025-11-17 10:33:17 +01:00
renovate[bot]
c8955133a4 ⬆️(dependencies) update python dependencies 2025-11-17 08:29:46 +00:00
Anthony LC
b069310bf0 🛂(frontend) disabled role not allowed to be assigned
We disable roles that the current user is not allowed
to assign when sharing a document. This prevents
users from selecting roles they cannot actually
assign, improving the user experience and reducing
confusion.
2025-11-17 08:48:16 +01:00
Manuel Raynaud
1292c33a58 ♻️(backend) rely on set_role_to from DocumentAskForAccess abilities
Like in other abilities, we compute a set_role_to property on the
abilities. This set_role_to contains all the roles lower or equal than
the current user role. We rely on this propoerty to validate the accept
endpoint and it will be used by the front allpication to built the role
select list.
2025-11-17 08:48:16 +01:00
Manuel Raynaud
bf68a5ae40 🔒️(backend) remove owner as valid role for ask_for_access serializer
When a ask_for_access creation is made, we explicitly remove the owner
role to prevent role escalation.
2025-11-17 08:47:15 +01:00
Manuel Raynaud
8799b4aa2f 🔒️(backend) role in ask_for_access must be lower than user role
We check that the role set in a ask_for_access is not higher than the
user's role accepting the request. We prevent case where ad min will
grant a user owner in order to take control of the document. Only owner
can accept an owner role.
2025-11-17 08:47:14 +01:00
Cyril
d96abb1ccf (frontend) make summary button fixed to remain visible during scroll
ensures persistent access to table of contents by fixing button position

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-14 13:42:56 +01:00
Cyril
dc12a99d4a (pdf) fix table cell alignment issue in exported documents
ensures correct horizontal alignment of text based on cell props

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-14 13:10:03 +01:00
Cyril
82a0c1a770 (frontend) add unit test for mobile rendering in docheaderinfo
ensures numchild count is displayed correctly on mobile interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 13:41:35 +01:00
Cyril
a758254b60 (frontend) improve mobile UX by showing subdocs count
helps users notice root documents have children in mobile view

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 13:41:25 +01:00
Cyril
6314cb3a18 (frontend) add focus trap and enter key support to remove doc modal
improves a11y by enabling keyboard-triggered modal with proper focus trap

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 12:59:11 +01:00
Cyril
3e410e3519 (frontend) enable ODT export for documents
provides ODT export with support for callout, upload, interlinking and tests

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) add image and interlinking support for odt export

Added image mapping with SVG conversion and clickable document links.

Signed-off-by: Cyril <c.gromoff@gmail.com>

(e2e) add e2e tests for odt export and interlinking features

covers odt document export and cross-section interlinking use cases

Signed-off-by: Cyril <c.gromoff@gmail.com>

(odt) add generic helper and style callout block for odt export

create odtRegisterParagraphStyleForBlock and apply background/padding styles

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 11:14:09 +01:00
Cyril
aba7959344 (frontend) fix pdf embed to use full width
Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 11:28:10 +01:00
Cyril
3d45c7c215 (frontend) improve accessibility and styling of summary table
adds semantic structure, aria attributes, and token-based focus styling

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 10:02:05 +01:00
Cyril
cdb26b480a (frontend) preserve @ character when esc is pressed after typing it
improves user experience by keeping @ symbol after cancelling mention trigger

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 09:24:14 +01:00
Cyril
23a0f2761f (docx) fix image overflow by limiting width to 600px during export
ensures all images keep proportions and stay within page bounds in docx export

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 08:40:05 +01:00
Cyril
0d596e338c (frontend) move editor button out of grid and fix roles/aria-label
improves accessibility and layout consistency of editor interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-10 18:13:33 +01:00
120 changed files with 7093 additions and 2533 deletions

View File

@@ -59,6 +59,14 @@ jobs:
-
name: Checkout repository
uses: actions/checkout@v4
- name: Checkout custom code repository
uses: actions/checkout@v4
with:
repository: 'AntoLC/docs-customized'
ref: 'main'
path: docs-custom
-
name: Docker meta
id: meta
@@ -76,7 +84,7 @@ jobs:
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production --build-arg CUSTOM_CODE=docs-custom'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
@@ -87,6 +95,7 @@ jobs:
target: frontend-production
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
CUSTOM_CODE=docs-custom
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}

View File

@@ -21,10 +21,10 @@ jobs:
shell: bash
run: |
set -e
HELMFILE=src/helm/helmfile.yaml
HELMFILE=src/helm/helmfile.yaml.gotmpl
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1
helmfile -e $env lint -f $HELMFILE || exit 1
echo -e "\n"
done
done

View File

@@ -6,6 +6,47 @@ and this project adheres to
## [Unreleased]
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
## [3.10.0] - 2025-11-18
### Added
- ✨(export) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
### Changed
- ♻️(frontend) preserve @ character when esc is pressed after typing it #1512
- ♻️(frontend) make summary button fixed to remain visible during scroll #1581
- ♻️(frontend) pdf embed use full width #1526
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
- ♿(frontend) improve accessibility and styling of summary table #1528
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
- 🐛(frontend) fix alignment of side menu #1597
- 🐛(frontend) fix fallback translations with Trans #1620
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
- 🐛(export) fix table cell alignment issue in exported documents #1582
- 🐛(export) preserve image aspect ratio in PDF export #1622
- 🐛(export) Export fails when paste with style #1552
### Security
- mitigate role escalation in the ask_for_access viewset #1580
- 🐛(frontend) preserve left panel width on window resize #1588
### Removed
- 🔥(backend) remove api managing templates
## [3.9.0] - 2025-11-10
### Added
@@ -13,6 +54,7 @@ and this project adheres to
- ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
- ✨ Add comments feature to the editor #1330
### Changed
@@ -141,6 +183,7 @@ and this project adheres to
### Added
- ✨(backend) Comments on text editor #1309
- 👷(CI) add bundle size check job #1268
- ✨(frontend) use title first emoji as doc icon in tree #1289
@@ -843,7 +886,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.9.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1

View File

@@ -171,3 +171,19 @@ class ResourceAccessPermission(IsAuthenticated):
action = view.action
return abilities.get(action, False)
class CommentPermission(permissions.BasePermission):
"""Permission class for comments."""
def has_permission(self, request, view):
"""Check permission for a given object."""
if view.action in ["create", "list"]:
document_abilities = view.get_document_or_404().get_abilities(request.user)
return document_abilities["comment"]
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)

View File

@@ -1,4 +1,5 @@
"""Client serializers for the impress core app."""
# pylint: disable=too-many-lines
import binascii
import mimetypes
@@ -24,22 +25,13 @@ from core.services.converter_services import (
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
full_name = serializers.SerializerMethodField(read_only=True)
short_name = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
def get_full_name(self, instance):
"""Return the full name of the user."""
@@ -58,6 +50,15 @@ class UserLightSerializer(UserSerializer):
return instance.short_name
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
class Meta:
model = models.User
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
@@ -786,7 +787,9 @@ class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access."""
role = serializers.ChoiceField(
choices=models.RoleChoices.choices,
choices=[
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
],
required=False,
default=models.RoleChoices.READER,
)
@@ -810,11 +813,11 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer):
]
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
def get_abilities(self, invitation) -> dict:
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return invitation.get_abilities(request.user)
return instance.get_abilities(request.user)
return {}
@@ -889,3 +892,124 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)
class ReactionSerializer(serializers.ModelSerializer):
"""Serialize reactions."""
users = UserLightSerializer(many=True, read_only=True)
class Meta:
model = models.Reaction
fields = [
"id",
"emoji",
"created_at",
"users",
]
read_only_fields = ["id", "created_at", "users"]
class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments (nested under a thread) with reactions and abilities."""
user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField()
reactions = ReactionSerializer(many=True, read_only=True)
class Meta:
model = models.Comment
fields = [
"id",
"user",
"body",
"created_at",
"updated_at",
"reactions",
"abilities",
]
read_only_fields = [
"id",
"user",
"created_at",
"updated_at",
"reactions",
"abilities",
]
def validate(self, attrs):
"""Validate comment data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["thread_id"] = self.context["thread_id"]
attrs["user_id"] = user.id if user else None
return attrs
def get_abilities(self, obj):
"""Return comment's abilities."""
request = self.context.get("request")
if request:
return obj.get_abilities(request.user)
return {}
class ThreadSerializer(serializers.ModelSerializer):
"""Serialize threads in a backward compatible shape for current frontend.
We expose a flatten representation where ``content`` maps to the first
comment's body. Creating a thread requires a ``content`` field which is
stored as the first comment.
"""
creator = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
body = serializers.JSONField(write_only=True, required=True)
comments = serializers.SerializerMethodField(read_only=True)
comments = CommentSerializer(many=True, read_only=True)
class Meta:
model = models.Thread
fields = [
"id",
"body",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
def validate(self, attrs):
"""Validate thread data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["document_id"] = self.context["resource_id"]
attrs["creator_id"] = user.id if user else None
return attrs
def get_abilities(self, thread):
"""Return thread's abilities."""
request = self.context.get("request")
if request:
return thread.get_abilities(request.user)
return {}

View File

@@ -21,6 +21,7 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -1831,10 +1832,7 @@ class DocumentAccessViewSet(
class TemplateViewSet(
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Template ViewSet"""
@@ -1890,100 +1888,6 @@ class TemplateViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
models.TemplateAccess.objects.create(
template=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
GET /api/v1.0/templates/<template_id>/accesses/:<template_access_id>
Return list of all template accesses related to the logged-in user or one
template access if an id is provided.
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
- user: str
- role: str [administrator|editor|reader]
Return newly created template access
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return updated template access
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated template access
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
Delete targeted template access
"""
lookup_field = "pk"
permission_classes = [permissions.ResourceAccessPermission]
throttle_scope = "template_access"
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
@cached_property
def template(self):
"""Get related template from resource ID in url."""
try:
return models.Template.objects.get(pk=self.kwargs["resource_id"])
except models.Template.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
user = self.request.user
teams = user.teams
queryset = self.filter_queryset(self.get_queryset())
# Limit to resource access instances related to a resource THAT also has
# a resource access instance for the logged-in user (we don't want to list
# only the resource access instances pointing to the logged-in user)
queryset = queryset.filter(
db.Q(template__accesses__user=user)
| db.Q(template__accesses__team__in=teams),
).distinct()
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""
Actually create the new template access:
- Ensures the `template_id` is explicitly set from the URL.
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.template.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a template can assign other users as owners."
)
serializer.save(template_id=self.kwargs["resource_id"])
class InvitationViewset(
drf.mixins.CreateModelMixin,
@@ -2162,7 +2066,18 @@ class DocumentAskForAccessViewSet(
serializer = serializers.RoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
document_ask_for_access.accept(role=serializer.validated_data.get("role"))
target_role = serializer.validated_data.get(
"role", document_ask_for_access.role
)
abilities = document_ask_for_access.get_abilities(request.user)
if target_role not in abilities["set_role_to"]:
return drf.response.Response(
{"detail": "You cannot accept a role higher than your own."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
document_ask_for_access.accept(role=target_role)
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
@@ -2236,3 +2151,132 @@ class ConfigView(drf.views.APIView):
)
return theme_customization
class CommentViewSetMixin:
"""Comment ViewSet Mixin."""
_document = None
def get_document_or_404(self):
"""Get the document related to the viewset or raise a 404 error."""
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"],
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
return self._document
class ThreadViewSet(
ResourceAccessViewsetMixin,
CommentViewSetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Thread API: list/create threads and nested comment operations."""
permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.ThreadSerializer
queryset = models.Thread.objects.select_related("creator", "document").filter(
resolved=False
)
resource_field_name = "document"
def perform_create(self, serializer):
"""Create the first comment of the thread."""
body = serializer.validated_data["body"]
del serializer.validated_data["body"]
thread = serializer.save()
models.Comment.objects.create(
thread=thread,
user=self.request.user if self.request.user.is_authenticated else None,
body=body,
)
@drf.decorators.action(detail=True, methods=["post"], url_path="resolve")
def resolve(self, request, *args, **kwargs):
"""Resolve a thread."""
thread = self.get_object()
if not thread.resolved:
thread.resolved = True
thread.resolved_at = timezone.now()
thread.resolved_by = request.user
thread.save(update_fields=["resolved", "resolved_at", "resolved_by"])
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
class CommentViewSet(
CommentViewSetMixin,
viewsets.ModelViewSet,
):
"""Comment API: list/create comments and nested reaction operations."""
permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.CommentSerializer
queryset = models.Comment.objects.select_related("user").all()
def get_queryset(self):
"""Override to filter on related resource."""
return (
super()
.get_queryset()
.filter(
thread=self.kwargs["thread_id"],
thread__document=self.kwargs["resource_id"],
)
)
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["document_id"] = self.kwargs["resource_id"]
context["thread_id"] = self.kwargs["thread_id"]
return context
@drf.decorators.action(
detail=True,
methods=["post", "delete"],
)
def reactions(self, request, *args, **kwargs):
"""POST: add reaction; DELETE: remove reaction.
Emoji is expected in request.data['emoji'] for both operations.
"""
comment = self.get_object()
serializer = serializers.ReactionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
if request.method == "POST":
reaction, created = models.Reaction.objects.get_or_create(
comment=comment,
emoji=serializer.validated_data["emoji"],
)
if not created and reaction.users.filter(id=request.user.id).exists():
return drf.response.Response(
{"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST
)
reaction.users.add(request.user)
return drf.response.Response(status=status.HTTP_201_CREATED)
# DELETE
try:
reaction = models.Reaction.objects.get(
comment=comment,
emoji=serializer.validated_data["emoji"],
users__in=[request.user],
)
except models.Reaction.DoesNotExist as e:
raise drf.exceptions.NotFound("Reaction not found.") from e
reaction.users.remove(request.user)
if not reaction.users.exists():
reaction.delete()
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -33,6 +33,7 @@ class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
@@ -40,6 +41,7 @@ class RoleChoices(PriorityTextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")

View File

@@ -256,3 +256,49 @@ class InvitationFactory(factory.django.DjangoModelFactory):
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
class ThreadFactory(factory.django.DjangoModelFactory):
"""A factory to create threads for a document"""
class Meta:
model = models.Thread
document = factory.SubFactory(DocumentFactory)
creator = factory.SubFactory(UserFactory)
class CommentFactory(factory.django.DjangoModelFactory):
"""A factory to create comments for a thread"""
class Meta:
model = models.Comment
thread = factory.SubFactory(ThreadFactory)
user = factory.SubFactory(UserFactory)
body = factory.Faker("text")
class ReactionFactory(factory.django.DjangoModelFactory):
"""A factory to create reactions for a comment"""
class Meta:
model = models.Reaction
comment = factory.SubFactory(CommentFactory)
emoji = "test"
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to reaction from a given list of users or create one if not provided."""
if not create:
return
if not extracted:
# the factory is being created, but no users were provided
user = UserFactory()
self.users.add(user)
return
# Add the iterable of groups using bulk addition
self.users.add(*extracted)

View File

@@ -0,0 +1,275 @@
# Generated by Django 5.2.6 on 2025-09-16 08:59
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0025_alter_user_short_name"),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaskforaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="invitation",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="templateaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.CreateModel(
name="Thread",
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",
),
),
("resolved", models.BooleanField(default=False)),
("resolved_at", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"creator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="threads",
to=settings.AUTH_USER_MODEL,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="threads",
to="core.document",
),
),
(
"resolved_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="resolved_threads",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Thread",
"verbose_name_plural": "Threads",
"db_table": "impress_thread",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="Comment",
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",
),
),
("body", models.JSONField()),
("metadata", models.JSONField(blank=True, default=dict)),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thread_comment",
to=settings.AUTH_USER_MODEL,
),
),
(
"thread",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="core.thread",
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "impress_comment",
"ordering": ("created_at",),
},
),
migrations.CreateModel(
name="Reaction",
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",
),
),
("emoji", models.CharField(max_length=32)),
(
"comment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reactions",
to="core.comment",
),
),
(
"users",
models.ManyToManyField(
related_name="reactions", to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Reaction",
"verbose_name_plural": "Reactions",
"db_table": "impress_comment_reaction",
"constraints": [
models.UniqueConstraint(
fields=("comment", "emoji"),
name="unique_comment_emoji",
violation_error_message="This emoji has already been reacted to this comment.",
)
],
},
),
]

View File

@@ -756,6 +756,7 @@ class Document(MP_Node, BaseModel):
can_update = (
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
can_comment = (can_update or role == RoleChoices.COMMENTER) and not is_deleted
can_create_children = can_update and user.is_authenticated
can_destroy = (
is_owner
@@ -786,6 +787,7 @@ class Document(MP_Node, BaseModel):
"children_list": can_get,
"children_create": can_create_children,
"collaboration_auth": can_get,
"comment": can_comment,
"content": can_get,
"cors_proxy": can_get,
"descendants": can_get,
@@ -1146,7 +1148,12 @@ class DocumentAccess(BaseAccess):
set_role_to = []
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
[
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
)
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
@@ -1205,23 +1212,14 @@ class DocumentAskForAccess(BaseModel):
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
roles = []
user_role = self.document.get_role(user)
is_admin_or_owner = user_role in PRIVILEGED_ROLES
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.document.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
set_role_to = [
role
for role in RoleChoices.values
if RoleChoices.get_priority(role) <= RoleChoices.get_priority(user_role)
]
return {
"destroy": is_admin_or_owner,
@@ -1229,6 +1227,7 @@ class DocumentAskForAccess(BaseModel):
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
"accept": is_admin_or_owner,
"set_role_to": set_role_to,
}
def accept(self, role=None):
@@ -1278,6 +1277,153 @@ class DocumentAskForAccess(BaseModel):
self.document.send_email(subject, [email], context, language)
class Thread(BaseModel):
"""Discussion thread attached to a document.
A thread groups one or many comments. For backward compatibility with the
existing frontend (useComments hook) we still expose a flattened serializer
that returns a "content" field representing the first comment's body.
"""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="threads",
)
creator = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="threads",
null=True,
blank=True,
)
resolved = models.BooleanField(default=False)
resolved_at = models.DateTimeField(null=True, blank=True)
resolved_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="resolved_threads",
null=True,
blank=True,
)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
db_table = "impress_thread"
ordering = ("-created_at",)
verbose_name = _("Thread")
verbose_name_plural = _("Threads")
def __str__(self):
author = self.creator or _("Anonymous")
return f"Thread by {author!s} on {self.document!s}"
def get_abilities(self, user):
"""Compute and return abilities for a given user (mirrors comment logic)."""
role = self.document.get_role(user)
doc_abilities = self.document.get_abilities(user)
read_access = doc_abilities.get("comment", False)
write_access = self.creator == user or role in [
RoleChoices.OWNER,
RoleChoices.ADMIN,
]
return {
"destroy": write_access,
"update": write_access,
"partial_update": write_access,
"resolve": write_access,
"retrieve": read_access,
}
@property
def first_comment(self):
"""Return the first createdcomment of the thread."""
return self.comments.order_by("created_at").first()
class Comment(BaseModel):
"""A comment belonging to a thread."""
thread = models.ForeignKey(
Thread,
on_delete=models.CASCADE,
related_name="comments",
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="thread_comment",
null=True,
blank=True,
)
body = models.JSONField()
metadata = models.JSONField(default=dict, blank=True)
class Meta:
db_table = "impress_comment"
ordering = ("created_at",)
verbose_name = _("Comment")
verbose_name_plural = _("Comments")
def __str__(self):
"""Return the string representation of the comment."""
author = self.user or _("Anonymous")
return f"Comment by {author!s} on thread {self.thread_id}"
def get_abilities(self, user):
"""Return the abilities of the comment."""
role = self.thread.document.get_role(user)
doc_abilities = self.thread.document.get_abilities(user)
read_access = doc_abilities.get("comment", False)
can_react = read_access and user.is_authenticated
write_access = self.user == user or role in [
RoleChoices.OWNER,
RoleChoices.ADMIN,
]
return {
"destroy": write_access,
"update": write_access,
"partial_update": write_access,
"reactions": can_react,
"retrieve": read_access,
}
class Reaction(BaseModel):
"""Aggregated reactions for a given emoji on a comment.
We store one row per (comment, emoji) and maintain the list of user IDs who
reacted with that emoji. This matches the frontend interface where a
reaction exposes: emoji, createdAt (first reaction date) and userIds.
"""
comment = models.ForeignKey(
Comment,
on_delete=models.CASCADE,
related_name="reactions",
)
emoji = models.CharField(max_length=32)
users = models.ManyToManyField(User, related_name="reactions")
class Meta:
db_table = "impress_comment_reaction"
constraints = [
models.UniqueConstraint(
fields=["comment", "emoji"],
name="unique_comment_emoji",
violation_error_message=_(
"This emoji has already been reacted to this comment."
),
),
]
verbose_name = _("Reaction")
verbose_name_plural = _("Reactions")
def __str__(self):
"""Return the string representation of the reaction."""
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."""

View File

@@ -293,6 +293,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
}
assert result_dict[str(document_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
@@ -301,7 +302,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
# Add an access for the other user on the parent
parent_access_other_user = factories.UserDocumentAccessFactory(
document=parent, user=other_user, role="editor"
document=parent, user=other_user, role="commenter"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
@@ -314,6 +315,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"commenter",
"editor",
"administrator",
"owner",
@@ -321,6 +323,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
@@ -333,28 +336,28 @@ def test_api_document_accesses_retrieve_set_role_to_child():
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
],
@@ -415,44 +418,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "administrator", "reader", "editor"],
[
["reader", "editor", "administrator"],
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
],
@@ -460,7 +463,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["editor", "editor", "administrator", "editor"],
[
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
["editor", "administrator"],
[],

View File

@@ -115,7 +115,10 @@ def test_api_documents_ask_for_access_create_authenticated_non_root_document():
assert response.status_code == 404
def test_api_documents_ask_for_access_create_authenticated_specific_role():
@pytest.mark.parametrize(
"role", [role for role in RoleChoices if role != RoleChoices.OWNER]
)
def test_api_documents_ask_for_access_create_authenticated_specific_role(role):
"""
Authenticated users should be able to create a document ask for access with a specific role.
"""
@@ -127,17 +130,35 @@ def test_api_documents_ask_for_access_create_authenticated_specific_role():
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
data={"role": role},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.EDITOR,
role=role,
).exists()
def test_api_documents_ask_for_access_create_authenticated_owner_role():
"""
Authenticated users should not be able to create a document ask for access with the owner role.
"""
document = DocumentFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 400
assert response.json() == {"role": ['"owner" is not a valid choice.']}
def test_api_documents_ask_for_access_create_authenticated_already_has_access():
"""Authenticated users with existing access can ask for access with a different role."""
user = UserFactory()
@@ -266,6 +287,7 @@ def test_api_documents_ask_for_access_list_authenticated_own_request():
"update": False,
"partial_update": False,
"retrieve": False,
"set_role_to": [],
},
}
],
@@ -335,6 +357,16 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == {
"count": 3,
"next": None,
@@ -354,6 +386,7 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
for document_ask_for_access in document_ask_for_accesses
@@ -446,6 +479,14 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == {
"id": str(document_ask_for_access.id),
"document": str(document.id),
@@ -460,6 +501,7 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
@@ -749,6 +791,53 @@ def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update
assert document_access.role == RoleChoices.ADMIN
def test_api_documents_ask_for_access_accept_admin_cannot_accept_owner_role():
"""
Admin users should not be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.ADMIN)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 400
assert response.json() == {
"detail": "You cannot accept a role higher than your own."
}
def test_api_documents_ask_for_access_accept_owner_can_accept_owner_role():
"""
Owner users should be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.OWNER)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role):
"""

View File

@@ -0,0 +1,878 @@
"""Test API for comments on documents."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# List comments
def test_list_comments_anonymous_user_public_document():
"""Anonymous users should be allowed to list comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment1, comment2 = factories.CommentFactory.create_batch(2, thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(AnonymousUser()),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(AnonymousUser()),
"reactions": [],
},
],
}
@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"])
def test_list_comments_anonymous_user_non_public_document(link_reach):
"""Anonymous users should not be allowed to list comments on a non-public document."""
document = factories.DocumentFactory(
link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 401
def test_list_comments_authenticated_user_accessible_document():
"""Authenticated users should be allowed to list comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment1 = factories.CommentFactory(thread=thread)
comment2 = factories.CommentFactory(thread=thread, user=user)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(user),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(user),
"reactions": [],
},
],
}
def test_list_comments_authenticated_user_non_accessible_document():
"""Authenticated users should not be allowed to list comments on a non-accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
def test_list_comments_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to list comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
# Create comment
def test_create_comment_anonymous_user_public_document():
"""
Anonymous users should be allowed to create comments on a public document
with commenter link_role.
"""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": None,
"abilities": {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to create comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 401
def test_create_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to create comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": {
"full_name": user.full_name,
"short_name": user.short_name,
},
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to create comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 403
# Retrieve comment
def test_retrieve_comment_anonymous_user_public_document():
"""Anonymous users should be allowed to retrieve comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
assert response.json() == {
"id": str(comment.id),
"body": comment.body,
"created_at": comment.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment.user.full_name,
"short_name": comment.user.short_name,
},
"reactions": [],
"abilities": comment.get_abilities(AnonymousUser()),
}
def test_retrieve_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to retrieve comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_retrieve_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to retrieve comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
def test_retrieve_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to retrieve comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Update comment
def test_update_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to update comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to update comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_authenticated_user_accessible_document():
"""Authenticated users should not be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_user_own_comment():
"""Authenticated users should be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
def test_update_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_no_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
# Delete comment
def test_delete_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to delete comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to delete comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_authenticated_user_accessible_document_own_comment():
"""Authenticated users should be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_accessible_document_not_own_comment():
"""Authenticated users should not be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be able to delete comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Create reaction
@pytest.mark.parametrize("link_role", models.LinkRoleChoices.values)
def test_create_reaction_anonymous_user_public_document(link_role):
"""No matter the link_role, an anonymous user can not react to a comment."""
document = factories.DocumentFactory(link_reach="public", link_role=link_role)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 401
def test_create_reaction_authenticated_user_public_document():
"""
Authenticated users should not be able to reaction to a comment on a public document with
link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_accessible_public_document():
"""
Authenticated users should be able to react to a comment on a public document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_connected_document_link_role_reader():
"""
Authenticated users should not be able to react to a comment on a connected document
with link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"link_role",
[
role
for role in models.LinkRoleChoices.values
if role != models.LinkRoleChoices.READER
],
)
def test_create_reaction_authenticated_user_connected_document(link_role):
"""
Authenticated users should be able to react to a comment on a connected document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=link_role
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_restricted_accessible_document():
"""
Authenticated users should not be able to react to a comment on a restricted accessible document
they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_restricted_accessible_document_role_reader():
"""
Authenticated users should not be able to react to a comment on a restricted accessible
document with role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"role",
[role for role in models.RoleChoices.values if role != models.RoleChoices.READER],
)
def test_create_reaction_authenticated_user_restricted_accessible_document_role_commenter(
role,
):
"""
Authenticated users should be able to react to a comment on a restricted accessible document
with role commenter.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 400
assert response.json() == {"user_already_reacted": True}
# Delete reaction
def test_delete_reaction_not_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
def test_delete_reaction_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
reaction.refresh_from_db()
assert reaction.users.exists()

View File

@@ -36,6 +36,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"cors_proxy": True,
"content": True,
"descendants": True,
@@ -46,8 +47,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": False,
@@ -113,6 +114,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -220,6 +222,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -229,8 +232,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -304,6 +307,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -494,13 +498,14 @@ def test_api_documents_retrieve_authenticated_related_parent():
"abilities": {
"accesses_manage": access.role in ["administrator", "owner"],
"accesses_view": True,
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
"can_edit": access.role != "reader",
"children_create": access.role != "reader",
"ai_transform": access.role not in ["reader", "commenter"],
"ai_translate": access.role not in ["reader", "commenter"],
"attachment_upload": access.role not in ["reader", "commenter"],
"can_edit": access.role not in ["reader", "commenter"],
"children_create": access.role not in ["reader", "commenter"],
"children_list": True,
"collaboration_auth": True,
"comment": access.role != "reader",
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -516,11 +521,11 @@ def test_api_documents_retrieve_authenticated_related_parent():
"media_auth": True,
"media_check": True,
"move": access.role in ["administrator", "owner"],
"partial_update": access.role != "reader",
"partial_update": access.role not in ["reader", "commenter"],
"restore": access.role == "owner",
"retrieve": True,
"tree": True,
"update": access.role != "reader",
"update": access.role not in ["reader", "commenter"],
"versions_destroy": access.role in ["administrator", "owner"],
"versions_list": True,
"versions_retrieve": True,

File diff suppressed because it is too large Load Diff

View File

@@ -81,6 +81,7 @@ def test_api_documents_trashbin_format():
"collaboration_auth": False,
"descendants": False,
"cors_proxy": False,
"comment": False,
"content": False,
"destroy": False,
"duplicate": False,
@@ -88,8 +89,8 @@ def test_api_documents_trashbin_format():
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": False,

View File

@@ -1,799 +0,0 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_list_anonymous():
"""Anonymous users should not be allowed to list template accesses."""
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(2, template=template)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(3, template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == []
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list template accesses for a template
to which they are directly related, whatever their role in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
user=user,
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
role=random.choice(models.RoleChoices.values),
)
access1 = factories.TeamTemplateAccessFactory(template=template)
access2 = factories.UserTemplateAccessFactory(template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
content = response.json()
assert len(content) == 3
assert sorted(content, key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id) if via == "user" else None,
"team": "lasuite" if via == "team" else "",
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": None,
"team": access1.team,
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_template_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a template access.
"""
access = factories.UserTemplateAccessFactory()
response = APIClient().get(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another template should be excluded even if the user is related to it
for access in [
factories.UserTemplateAccessFactory(),
factories.UserTemplateAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_template_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a template access."""
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
A user who is a direct administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
"""
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
else:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.UserTemplateAccessFactory(template=template, role="owner")
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_template_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template access."""
access = factories.UserTemplateAccessFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
response = client.delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template, role=random.choice(["reader", "editor", "administrator"])
)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners(via, mock_user_teams):
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
assert models.TemplateAccess.objects.count() == 2
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
def test_api_template_accesses_throttling(settings):
"""Test api template accesses throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = "2/minute"
template = factories.TemplateFactory()
user = factories.UserFactory()
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template_access", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = current_rate

View File

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

View File

@@ -42,7 +42,5 @@ def test_api_templates_create_authenticated():
format="json",
)
assert response.status_code == 201
template = Template.objects.get()
assert template.title == "my template"
assert template.accesses.filter(role="owner", user=user).exists()
assert response.status_code == 405
assert not Template.objects.exists()

View File

@@ -8,7 +8,6 @@ import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -25,7 +24,7 @@ def test_api_templates_delete_anonymous():
assert models.Template.objects.count() == 1
def test_api_templates_delete_authenticated_unrelated():
def test_api_templates_delete_not_implemented():
"""
Authenticated users should not be allowed to delete a template to which they are not
related.
@@ -36,72 +35,11 @@ def test_api_templates_delete_authenticated_unrelated():
client.force_login(user)
is_public = random.choice([True, False])
template = factories.TemplateFactory(is_public=is_public)
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 == 403 if is_public else 404
assert response.status_code == 405
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_member_or_administrator(
via, role, mock_user_teams
):
"""
Authenticated users should not be allowed to delete a template for which they are
only a member or administrator.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
"""
Authenticated users should be able to delete a template they own.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 204
assert models.Template.objects.exists() is False

View File

@@ -2,14 +2,11 @@
Tests for Templates API endpoint in impress's core app: update
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -17,7 +14,6 @@ pytestmark = pytest.mark.django_db
def test_api_templates_update_anonymous():
"""Anonymous users should not be allowed to update a template."""
template = factories.TemplateFactory()
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
@@ -28,145 +24,18 @@ def test_api_templates_update_anonymous():
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
def test_api_templates_update_authenticated_unrelated():
def test_api_templates_update_not_implemented():
"""
Authenticated users should not be allowed to update a template to which they are not related.
Authenticated users should not be allowed to update a template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
old_template_values = serializers.TemplateSerializer(instance=template).data
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 == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
"""
Users who are readers of a template should not be allowed to update it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="reader"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
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 == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
via, role, mock_user_teams
):
"""Administrator or owner of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
old_template_values = serializers.TemplateSerializer(instance=template).data
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 == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
template = factories.TemplateFactory(users=[(user, "owner")])
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
@@ -176,55 +45,10 @@ def test_api_templates_update_authenticated_owners(via, mock_user_teams):
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
assert response.status_code == 405
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a template should not grant authorization to update
another template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
is_public = random.choice([True, False])
template = factories.TemplateFactory(title="Old title", is_public=is_public)
old_template_values = serializers.TemplateSerializer(instance=template).data
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",
response = client.patch(
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 403 if is_public else 404
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
assert response.status_code == 405

View File

@@ -278,6 +278,35 @@ def test_api_users_retrieve_me_authenticated():
}
def test_api_users_retrieve_me_authenticated_empty_name():
"""
Authenticated users should be able to retrieve their own user via the "/users/me" path.
when no name is provided, the full name and short name should be the email without the domain.
"""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name=None,
)
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(user.id),
"email": "test_foo@test.com",
"full_name": "test_foo",
"language": user.language,
"short_name": "test_foo",
}
def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()

View File

@@ -0,0 +1,283 @@
"""Test the comment model."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from core import factories
from core.models import LinkReachChoices, LinkRoleChoices, RoleChoices
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"role,can_comment",
[
(LinkRoleChoices.READER, False),
(LinkRoleChoices.COMMENTER, True),
(LinkRoleChoices.EDITOR, True),
],
)
def test_comment_get_abilities_anonymous_user_public_document(role, can_comment):
"""Anonymous users cannot comment on a document."""
document = factories.DocumentFactory(
link_role=role, link_reach=LinkReachChoices.PUBLIC
)
comment = factories.CommentFactory(thread__document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_reach", [LinkReachChoices.RESTRICTED, LinkReachChoices.AUTHENTICATED]
)
def test_comment_get_abilities_anonymous_user_restricted_document(link_reach):
"""Anonymous users cannot comment on a restricted document."""
document = factories.DocumentFactory(link_reach=link_reach)
comment = factories.CommentFactory(thread__document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": False,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment):
"""Readers cannot comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader_own_comment(
link_role, link_reach, can_comment
):
"""User with reader role on a document has all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(
thread__document=document, user=user if can_comment else None
)
assert comment.get_abilities(user) == {
"destroy": can_comment,
"update": can_comment,
"partial_update": can_comment,
"reactions": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commenter(link_role, link_reach):
"""Commenters can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTER)],
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commenter_own_comment(link_role, link_reach):
"""Commenters have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTER)],
)
comment = factories.CommentFactory(thread__document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor(link_role, link_reach):
"""Editors can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach):
"""Editors have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(thread__document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
def test_comment_get_abilities_user_admin():
"""Admins have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)])
comment = factories.CommentFactory(
thread__document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
def test_comment_get_abilities_user_owner():
"""Owners have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)])
comment = factories.CommentFactory(
thread__document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}

View File

@@ -123,7 +123,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -166,7 +166,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -183,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -200,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -217,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -234,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -271,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commenter", "editor", "administrator"],
}
@@ -288,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commenter", "editor", "administrator"],
}
@@ -305,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commenter", "editor", "administrator"],
}

View File

@@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth):
[
(True, "restricted", "reader"),
(True, "restricted", "editor"),
(True, "restricted", "commenter"),
(False, "restricted", "reader"),
(False, "restricted", "editor"),
(False, "restricted", "commenter"),
(False, "authenticated", "reader"),
(False, "authenticated", "editor"),
(False, "authenticated", "commenter"),
],
)
def test_models_documents_get_abilities_forbidden(
@@ -165,6 +168,7 @@ def test_models_documents_get_abilities_forbidden(
"destroy": False,
"duplicate": False,
"favorite": False,
"comment": False,
"invite_owner": False,
"mask": False,
"media_auth": False,
@@ -172,8 +176,8 @@ def test_models_documents_get_abilities_forbidden(
"move": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"partial_update": False,
@@ -223,6 +227,7 @@ def test_models_documents_get_abilities_reader(
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -232,8 +237,78 @@ def test_models_documents_get_abilities_reader(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": is_authenticated,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": False,
"restore": False,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definition"]
)
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_commenter(
is_authenticated, reach, django_assert_num_queries
):
"""
Check abilities returned for a document giving commenter role to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="commenter")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"content": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": is_authenticated,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -289,6 +364,7 @@ def test_models_documents_get_abilities_editor(
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -298,8 +374,8 @@ def test_models_documents_get_abilities_editor(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -344,6 +420,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -353,8 +430,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -385,6 +462,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": False,
"children_list": False,
"collaboration_auth": False,
"comment": False,
"descendants": False,
"cors_proxy": False,
"content": False,
@@ -394,8 +472,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": False,
@@ -430,6 +508,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -439,8 +518,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"invite_owner": False,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -485,6 +564,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -494,8 +574,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -547,6 +627,8 @@ def test_models_documents_get_abilities_reader_user(
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_reach != "restricted"
and document.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -556,8 +638,73 @@ def test_models_documents_get_abilities_reader_user(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": access_from_link,
"restore": False,
"retrieve": True,
"tree": True,
"update": access_from_link,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definition"]
)
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
def test_models_documents_get_abilities_commenter_user(
ai_access_setting, django_assert_num_queries
):
"""Check abilities returned for the commenter of a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "commenter")])
access_from_link = (
document.link_reach != "restricted" and document.link_role == "editor"
)
expected_abilities = {
"accesses_manage": False,
"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_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"content": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -607,6 +754,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -616,8 +764,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -1320,7 +1468,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
"reader",
{
"public": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"public",
"commenter",
{
"public": ["commenter", "editor"],
},
),
("public", "editor", {"public": ["editor"]}),
@@ -1328,8 +1483,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"authenticated",
"reader",
{
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"authenticated",
"commenter",
{
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
},
),
(
@@ -1342,8 +1505,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"reader",
{
"restricted": None,
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"restricted",
"commenter",
{
"restricted": None,
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
},
),
(
@@ -1360,15 +1532,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
None,
{
"public": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
None,
"reader",
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"restricted": None,
},
),
@@ -1376,8 +1548,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
None,
None,
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"restricted": None,
},
),

View File

@@ -26,20 +26,22 @@ document_related_router.register(
viewsets.InvitationViewset,
basename="invitations",
)
document_related_router.register(
"threads",
viewsets.ThreadViewSet,
basename="threads",
)
document_related_router.register(
"ask-for-access",
viewsets.DocumentAskForAccessViewSet,
basename="ask_for_access",
)
# - Routes nested under a template
template_related_router = DefaultRouter()
template_related_router.register(
"accesses",
viewsets.TemplateAccessViewSet,
basename="template_accesses",
thread_related_router = DefaultRouter()
thread_related_router.register(
"comments",
viewsets.CommentViewSet,
basename="comments",
)
@@ -55,8 +57,8 @@ urlpatterns = [
include(document_related_router.urls),
),
re_path(
r"^templates/(?P<resource_id>[0-9a-z-]*)/",
include(template_related_router.urls),
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
include(thread_related_router.urls),
),
]
),

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -234,8 +234,8 @@ msgstr "implijer"
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "titl"
@@ -311,8 +311,8 @@ msgstr "An implijer-mañ a zo dija er restr-mañ."
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
@@ -328,78 +328,78 @@ msgstr "Goulennoù tizhout ar restr"
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:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, 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:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, 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:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, 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:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
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:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -234,8 +234,8 @@ msgstr "Benutzer"
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "Titel"
@@ -311,8 +311,8 @@ msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
@@ -328,78 +328,78 @@ msgstr ""
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -234,8 +234,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr ""
@@ -311,8 +311,8 @@ msgstr ""
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
@@ -328,78 +328,78 @@ msgstr ""
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr ""
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr ""
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -234,8 +234,8 @@ msgstr "usuario"
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "título"
@@ -311,8 +311,8 @@ msgstr "Este usuario ya forma parte del documento."
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "Debe establecerse un usuario o un equipo, no ambos."
@@ -328,78 +328,78 @@ msgstr "Solicitud de accesos"
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:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, 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:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
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:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -234,8 +234,8 @@ msgstr "utilisateur"
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "titre"
@@ -311,8 +311,8 @@ msgstr "Cet utilisateur est déjà dans ce document."
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
@@ -328,78 +328,78 @@ msgstr "Demande d'accès au document"
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:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, 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:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, 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:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
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:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -234,8 +234,8 @@ msgstr "utente"
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "titolo"
@@ -311,8 +311,8 @@ msgstr "Questo utente è già presente in questo documento."
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
@@ -328,78 +328,78 @@ msgstr ""
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -234,8 +234,8 @@ msgstr "gebruiker"
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "titel"
@@ -311,8 +311,8 @@ msgstr "De gebruiker bestaat al in dit document."
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
@@ -328,78 +328,78 @@ msgstr "Document verzoekt om toegangen"
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:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, 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:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, 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:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
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:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Sjabloon"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Sjabloon"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Sjabloon/gebruiker relatie"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Sjabloon/gebruiker relaties"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit sjabloon."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit sjabloon."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "e-mailadres"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -234,8 +234,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr ""
@@ -311,8 +311,8 @@ msgstr ""
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
@@ -328,78 +328,78 @@ msgstr ""
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr ""
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr ""
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -234,8 +234,8 @@ msgstr "пользователь"
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "заголовок"
@@ -311,8 +311,8 @@ msgstr "Этот пользователь уже имеет доступ к эт
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
@@ -328,78 +328,78 @@ msgstr "Документ запрашивает доступы"
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Этот шаблон доступен всем пользователям."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -234,8 +234,8 @@ msgstr "uporabnik"
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "naslov"
@@ -311,8 +311,8 @@ msgstr "Ta uporabnik je že v tem dokumentu."
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
@@ -328,78 +328,78 @@ msgstr ""
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -234,8 +234,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr ""
@@ -311,8 +311,8 @@ msgstr ""
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
@@ -328,78 +328,78 @@ msgstr ""
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr ""
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr ""
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -234,8 +234,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr ""
@@ -311,8 +311,8 @@ msgstr ""
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
@@ -328,78 +328,78 @@ msgstr ""
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr ""
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr ""
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -234,8 +234,8 @@ msgstr "користувач"
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "заголовок"
@@ -311,8 +311,8 @@ msgstr "Цей користувач вже має доступ до цього
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
@@ -328,78 +328,78 @@ msgstr "Запит доступу для документа"
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -234,8 +234,8 @@ msgstr "用户"
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "标题"
@@ -311,8 +311,8 @@ msgstr "该用户已在此文档中。"
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
@@ -328,78 +328,78 @@ msgstr "文档需要访问权限"
msgid "This user has already asked for access to this document."
msgstr "用户已申请该文档的访问权限。"
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 申请访问文档!"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 申请访问以下文档:"
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name}申请文档:{title}的访问权限"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.9.0"
version = "3.10.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -26,15 +26,15 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.14.2",
"boto3==1.40.59",
"boto3==1.40.74",
"Brotli==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==7.6.1",
"django-countries==8.1.0",
"django-csp==4.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.16",
"django-lasuite[all]==0.0.18",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
@@ -42,24 +42,24 @@ dependencies = [
"django==5.2.8",
"django-treebeard==4.7.1",
"djangorestframework==3.16.1",
"drf_spectacular==0.28.0",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"lxml==6.0.2",
"markdown==3.9",
"markdown==3.10",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.6.0",
"openai==2.6.1",
"openai==2.8.0",
"psycopg[binary]==3.2.12",
"pycrdt==0.12.42",
"pycrdt==0.12.43",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.42.1",
"sentry-sdk==2.44.0",
"whitenoise==6.11.0",
]
@@ -76,17 +76,17 @@ dev = [
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.6.0",
"pyfakefs==5.10.0",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==8.4.2",
"pytest==9.0.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.2",
"ruff==0.14.5",
"types-requests==2.32.4.20250913",
]

View File

@@ -47,6 +47,15 @@ ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
ARG PUBLISH_AS_MIT
ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
ARG CUSTOM_CODE
COPY ./${CUSTOM_CODE} /tmp/custom_code
RUN if [ -n "$CUSTOM_CODE" ] && [ -d "/tmp/custom_code" ] && [ "$(ls -A /tmp/custom_code)" ]; then \
echo "Custom code provided. Replacing files from $CUSTOM_CODE..."; \
cp -Rv /tmp/custom_code/${CUSTOM_CODE}/* .; \
else \
echo "No custom code provided. Skipping replacement..."; \
fi
RUN yarn build
# ---- Front-end image ----

View File

@@ -0,0 +1,295 @@
import { expect, test } from '@playwright/test';
import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common';
import { writeInEditor } from './utils-editor';
import {
addNewMember,
connectOtherUserToDoc,
updateRoleUser,
updateShareLink,
} from './utils-share';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Comments', () => {
test('it checks comments with 2 users in real time', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1);
// We share the doc with another user
const otherBrowserName = getOtherBrowserName(browserName);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Administrator', otherBrowserName);
await expect(
page
.getByRole('listbox', { name: 'Suggestions' })
.getByText(new RegExp(otherBrowserName)),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
// We add a comment with the first user
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
await editor.getByText('Hello').click();
await thread.getByText('This is a comment').first().hover();
// We add a reaction with the first user
await thread.locator('[data-test="addreaction"]').first().click();
await page.getByRole('button', { name: '👍' }).click();
await expect(
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1');
const urlCommentDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
otherBrowserName,
docUrl: urlCommentDoc,
docTitle,
});
const otherEditor = otherPage.locator('.ProseMirror');
await otherEditor.getByText('Hello').click();
const otherThread = otherPage.locator('.bn-thread');
await otherThread.getByText('This is a comment').first().hover();
await otherThread.locator('[data-test="addreaction"]').first().click();
await otherPage.getByRole('button', { name: '👍' }).click();
// We check that the comment made by the first user is visible for the second user
await expect(
otherThread.getByText('This is a comment').first(),
).toBeVisible();
await expect(
otherThread.getByText(`E2E ${browserName}`).first(),
).toBeVisible();
await expect(otherThread.locator('.bn-comment-reaction')).toHaveText('👍2');
// We add a comment with the second user
await otherThread
.getByRole('paragraph')
.last()
.fill('This is a comment from the other user');
await otherThread.locator('[data-test="save"]').click();
// We check that the second user can see the comment he just made
await expect(
otherThread.getByRole('img', { name: `E2E ${otherBrowserName}` }).first(),
).toBeVisible();
await expect(
otherThread.getByText('This is a comment from the other user').first(),
).toBeVisible();
await expect(
otherThread.getByText(`E2E ${otherBrowserName}`).first(),
).toBeVisible();
// We check that the first user can see the comment made by the second user in real time
await expect(
thread.getByText('This is a comment from the other user').first(),
).toBeVisible();
await expect(
thread.getByText(`E2E ${otherBrowserName}`).first(),
).toBeVisible();
await cleanup();
});
test('it checks the comments interactions', async ({ page, browserName }) => {
await createDoc(page, 'comment-interaction', browserName, 1);
// Checks add react reaction
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('Hello World');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
// Check background color changed
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await editor.getByText('Hello').click();
await thread.getByText('This is a comment').first().hover();
// We add a reaction with the first user
await thread.locator('[data-test="addreaction"]').first().click();
await page.getByRole('button', { name: '👍' }).click();
await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1');
// Edit Comment
await thread.getByText('This is a comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
const commentEditor = thread.getByText('This is a comment').first();
await commentEditor.fill('This is an edited comment');
const saveBtn = thread.getByRole('button', { name: 'Save' });
await saveBtn.click();
await expect(saveBtn).toBeHidden();
await expect(
thread.getByText('This is an edited comment').first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
// Add second comment
await thread.getByRole('paragraph').last().fill('This is a second comment');
await thread.getByRole('button', { name: 'Save' }).click();
await expect(
thread.getByText('This is an edited comment').first(),
).toBeVisible();
await expect(
thread.getByText('This is a second comment').first(),
).toBeVisible();
// Delete second comment
await thread.getByText('This is a second comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await expect(
thread.getByText('This is a second comment').first(),
).toBeHidden();
// Resolve thread
await thread.getByText('This is an edited comment').first().hover();
await thread.locator('[data-test="resolve"]').click();
await expect(thread).toBeHidden();
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
});
test('it checks the comments abilities', async ({ page, browserName }) => {
test.slow();
const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1);
// We share the doc with another user
const otherBrowserName = getOtherBrowserName(browserName);
// Add a new member with editor role
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor', otherBrowserName);
await expect(
page
.getByRole('listbox', { name: 'Suggestions' })
.getByText(new RegExp(otherBrowserName)),
).toBeVisible();
const urlCommentDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
otherBrowserName,
docUrl: urlCommentDoc,
docTitle,
});
const otherEditor = await writeInEditor({
page: otherPage,
text: 'Hello, I can edit the document',
});
await expect(
otherEditor.getByText('Hello, I can edit the document'),
).toBeVisible();
await otherEditor.getByText('Hello').selectText();
await otherPage.getByRole('button', { name: 'Comment' }).click();
const otherThread = otherPage.locator('.bn-thread');
await otherThread
.getByRole('paragraph')
.first()
.fill('I can add a comment');
await otherThread.locator('[data-test="save"]').click();
await expect(
otherThread.getByText('I can add a comment').first(),
).toBeHidden();
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
// We change the role of the second user to reader
await updateRoleUser(page, 'Reader', `user.test@${otherBrowserName}.test`);
// With the reader role, the second user cannot see comments
await otherPage.reload();
await verifyDocName(otherPage, docTitle);
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await otherEditor.getByText('Hello').click();
await expect(otherThread).toBeHidden();
await otherEditor.getByText('Hello').selectText();
await expect(
otherPage.getByRole('button', { name: 'Comment' }),
).toBeHidden();
await otherPage.reload();
// Change the link role of the doc to set it in commenting mode
await updateShareLink(page, 'Public', 'Editing');
// Anonymous user can see and add comments
await otherPage.getByRole('button', { name: 'Logout' }).click();
await otherPage.goto(urlCommentDoc);
await verifyDocName(otherPage, docTitle);
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await otherEditor.getByText('Hello').click();
await expect(
otherThread.getByText('I can add a comment').first(),
).toBeVisible();
await otherThread
.locator('.ProseMirror.bn-editor[contenteditable="true"]')
.getByRole('paragraph')
.first()
.fill('Comment by anonymous user');
await otherThread.locator('[data-test="save"]').click();
await expect(
otherThread.getByText('Comment by anonymous user').first(),
).toBeVisible();
await expect(
otherThread.getByRole('img', { name: `Anonymous` }).first(),
).toBeVisible();
await otherThread.getByText('Comment by anonymous user').first().hover();
await expect(otherThread.locator('[data-test="moreactions"]')).toBeHidden();
await cleanup();
});
});

View File

@@ -806,6 +806,12 @@ test.describe('Doc Editor', () => {
});
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
await page.keyboard.press('@');
await page.keyboard.press('Escape');
await expect(editor.getByText('@')).toBeVisible();
});
test('it checks multiple big doc scroll to the top', async ({

View File

@@ -31,7 +31,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText('Download your document in a .docx or .pdf format.'),
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -142,6 +142,51 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
test('it exports the doc to odt', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are being displayed correctly
@@ -442,4 +487,68 @@ test.describe('Doc Export', () => {
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain(randomDoc);
});
test('it exports the doc with interlinking to odt', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking-odt',
browserName,
1,
);
await verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child-odt',
);
await verifyDocName(page, docChild);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await input.fill('export-interlink');
await expect(searchContainer).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
// We are in docChild, we want to create a link to randomDoc (parent)
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
name: randomDoc,
});
await expect(interlink).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
});
});

View File

@@ -84,7 +84,7 @@ test.describe('Document create member', () => {
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: /^Invite / }).click();
// Check invitation added
await expect(
@@ -135,7 +135,7 @@ test.describe('Document create member', () => {
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: /^Invite / }).click();
// Check invitation sent
@@ -154,7 +154,7 @@ test.describe('Document create member', () => {
response.url().includes('/invitations/') && response.status() === 400,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: /^Invite / }).click();
await expect(
page.getByText(`"${email}" is already invited to the document.`),
).toBeVisible();
@@ -191,7 +191,7 @@ test.describe('Document create member', () => {
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: /^Invite / }).click();
// Check invitation sent
const responseCreateInvitation = await responsePromiseCreateInvitation;

View File

@@ -160,6 +160,9 @@ test.describe('Document list members', () => {
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soloOwner).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
@@ -186,9 +189,17 @@ test.describe('Document list members', () => {
await list.click();
await expect(currentUserRole).toBeVisible();
await newUserRoles.click();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
});
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await list.click();
await list.click({
force: true, // Force click to close the dropdown
});
await expect(currentUserRole).toBeHidden();
});

View File

@@ -27,6 +27,33 @@ test.describe('Home page', () => {
// Check the titles
const h2 = page.locator('h2');
await expect(h2.getByText('Govs ❤️ Open Source.')).toBeVisible();
await expect(page.getByText('Docs is built on top of')).toBeVisible();
await expect(
page.getByRole('link', {
name: 'Django Rest Framework',
}),
).toHaveAttribute('href', 'https://www.django-rest-framework.org/');
await expect(page.getByText('You can easily self-host Docs')).toBeVisible();
await expect(
page
.getByRole('link', {
name: 'licence',
})
.first(),
).toHaveAttribute(
'href',
'https://github.com/suitenumerique/docs/blob/main/LICENSE',
);
await expect(
page.getByText('Docs is the result of a joint effort lead by the French'),
).toBeVisible();
await expect(
page
.getByRole('link', {
name: 'Zendis',
})
.first(),
).toHaveAttribute('href', 'https://zendis.de/');
await expect(
h2.getByText('Collaborative writing, Simplified.'),
).toBeVisible();

View File

@@ -70,6 +70,14 @@ export const keyCloakSignIn = async (
await page.click('button[type="submit"]', { force: true });
};
export const getOtherBrowserName = (browserName: BrowserName) => {
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
if (!otherBrowserName) {
throw new Error('No alternative browser found');
}
return otherBrowserName;
};
export const randomName = (name: string, browserName: string, length: number) =>
Array.from({ length }, (_el, index) => {
return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`;
@@ -125,7 +133,9 @@ export const verifyDocName = async (page: Page, docName: string) => {
try {
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName);
).toContainText(docName, {
timeout: 1000,
});
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
}

View File

@@ -1,20 +1,20 @@
import { Page, chromium, expect } from '@playwright/test';
import {
BROWSERS,
BrowserName,
getOtherBrowserName,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
export type Role = 'Administrator' | 'Owner' | 'Editor' | 'Reader';
export type LinkReach = 'Private' | 'Connected' | 'Public';
export type LinkRole = 'Reading' | 'Editing';
export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
role: Role,
fillText = 'user.test',
) => {
const responsePromiseSearchUser = page.waitForResponse(
@@ -40,7 +40,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: /^Invite / }).click();
return users[index].email;
};
@@ -88,21 +88,30 @@ export const updateRoleUser = async (
* @param docTitle The title of the document (optional).
* @returns An object containing the other browser, context, and page.
*/
type ConnectOtherUserToDocParams = {
docUrl: string;
docTitle?: string;
withoutSignIn?: boolean;
} & (
| {
otherBrowserName: BrowserName;
browserName?: never;
}
| {
browserName: BrowserName;
otherBrowserName?: never;
}
);
export const connectOtherUserToDoc = async ({
browserName,
docUrl,
docTitle,
otherBrowserName: _otherBrowserName,
withoutSignIn,
}: {
browserName: BrowserName;
docUrl: string;
docTitle?: string;
withoutSignIn?: boolean;
}) => {
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
if (!otherBrowserName) {
throw new Error('No alternative browser found');
}
}: ConnectOtherUserToDocParams) => {
const otherBrowserName =
_otherBrowserName || getOtherBrowserName(browserName);
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.9.0",
"version": "3.10.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",

View File

@@ -98,8 +98,8 @@ const dsfrTheme = {
},
font: {
families: {
base: 'Marianne',
accent: 'Marianne',
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
},

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.9.0",
"version": "3.10.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -19,13 +19,14 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.41.1",
"@blocknote/core": "0.41.1",
"@blocknote/mantine": "0.41.1",
"@blocknote/react": "0.41.1",
"@blocknote/xl-docx-exporter": "0.41.1",
"@blocknote/xl-multi-column": "0.41.1",
"@blocknote/xl-pdf-exporter": "0.41.1",
"@blocknote/code-block": "0.42.3",
"@blocknote/core": "0.42.3",
"@blocknote/mantine": "0.42.3",
"@blocknote/react": "0.42.3",
"@blocknote/xl-docx-exporter": "0.42.3",
"@blocknote/xl-multi-column": "0.42.3",
"@blocknote/xl-odt-exporter": "0.42.3",
"@blocknote/xl-pdf-exporter": "0.42.3",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
@@ -42,7 +43,7 @@
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.22.0",
"@tanstack/react-query": "5.90.6",
"@tiptap/extensions": "3.10.1",
"@tiptap/extensions": "*",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -61,7 +62,7 @@
"react": "*",
"react-aria-components": "1.13.0",
"react-dom": "*",
"react-i18next": "16.2.3",
"react-i18next": "16.3.3",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",

View File

@@ -12,6 +12,7 @@ import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useKeyboardAction } from '@/hooks';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
@@ -57,6 +58,7 @@ export const DropdownMenu = ({
testId,
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const keyboardAction = useKeyboardAction();
const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null);
@@ -93,6 +95,14 @@ export const DropdownMenu = ({
}
}, [isOpen, options]);
const triggerOption = useCallback(
(option: DropdownMenuOption) => {
onOpenChange?.(false);
void option.callback?.();
},
[onOpenChange],
);
if (disabled) {
return children;
}
@@ -170,9 +180,9 @@ export const DropdownMenu = ({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
triggerOption(option);
}}
onKeyDown={keyboardAction(() => triggerOption(option))}
key={option.label}
$align="center"
$justify="space-between"

View File

@@ -556,8 +556,10 @@
--c--theme--logo--widthHeader: 110px;
--c--theme--logo--widthFooter: 220px;
--c--theme--logo--alt: gouvernement logo;
--c--theme--font--families--base: marianne;
--c--theme--font--families--accent: marianne;
--c--theme--font--families--base:
marianne, inter, roboto flex variable, sans-serif;
--c--theme--font--families--accent:
marianne, inter, roboto flex variable, sans-serif;
--c--components--la-gaufre: true;
--c--components--home-proconnect: true;
--c--components--favicon--ico: /assets/favicon-dsfr.ico;

View File

@@ -436,7 +436,12 @@ export const tokens = {
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
font: { families: { base: 'Marianne', accent: 'Marianne' } },
font: {
families: {
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
},
},
components: {
'la-gaufre': true,

View File

@@ -13,3 +13,5 @@ export interface User {
short_name: string;
language?: string;
}
export type UserLight = Pick<User, 'full_name' | 'short_name'>;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box, BoxType } from '@/components';
type AvatarSvgProps = {
initials: string;
background: string;
fontFamily?: string;
} & BoxType;
export const AvatarSvg: React.FC<AvatarSvgProps> = ({
initials,
background,
fontFamily,
...props
}) => (
<Box
as="svg"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
{...props}
>
<rect
x="0.5"
y="0.5"
width="23"
height="23"
rx="11.5"
ry="11.5"
fill={background}
stroke="rgba(255,255,255,0.5)"
strokeWidth="1"
/>
<text
x="50%"
y="50%"
dy="0.35em"
textAnchor="middle"
fontSize="10"
fontWeight="600"
fill="rgba(255,255,255,0.9)"
fontFamily={fontFamily || 'Arial'}
>
{initials}
</text>
</Box>
);

View File

@@ -0,0 +1,70 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { tokens } from '@/cunningham';
import { AvatarSvg } from './AvatarSvg';
const colors = tokens.themes.default.theme.colors;
const avatarsColors = [
colors['blue-500'],
colors['brown-500'],
colors['cyan-500'],
colors['gold-500'],
colors['green-500'],
colors['olive-500'],
colors['orange-500'],
colors['pink-500'],
colors['purple-500'],
colors['yellow-500'],
];
const getColorFromName = (name: string) => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return avatarsColors[Math.abs(hash) % avatarsColors.length];
};
const getInitialFromName = (name: string) => {
const splitName = name?.split(' ');
return (splitName[0]?.charAt(0) || '?') + (splitName?.[1]?.charAt(0) || '');
};
type UserAvatarProps = {
fullName?: string;
background?: string;
};
export const UserAvatar = ({ fullName, background }: UserAvatarProps) => {
const name = fullName?.trim() || '?';
return (
<AvatarSvg
className="--docs--user-avatar"
initials={getInitialFromName(name).toUpperCase()}
background={background || getColorFromName(name)}
/>
);
};
export const avatarUrlFromName = (
fullName?: string,
fontFamily?: string,
): string => {
const name = fullName?.trim() || '?';
const initials = getInitialFromName(name).toUpperCase();
const background = getColorFromName(name);
const svgMarkup = renderToStaticMarkup(
<AvatarSvg
className="--docs--user-avatar"
initials={initials}
background={background}
fontFamily={fontFamily}
/>,
);
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgMarkup)}`;
};

View File

@@ -1,2 +1,3 @@
export * from './Auth';
export * from './ButtonLogin';
export * from './UserAvatar';

View File

@@ -12,13 +12,16 @@ import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, useProviderStore } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
import {
useHeadings,
@@ -34,6 +37,7 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { cssComments, useComments } from './comments/';
import {
AccessibleImageBlock,
CalloutBlock,
@@ -79,8 +83,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { user } = useAuth();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const { themeTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const refEditorContainer = useRef<HTMLDivElement>(null);
const canSeeComment = doc.abilities.comment && isDesktop;
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
@@ -88,16 +95,25 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const collabName = user?.full_name || user?.email || t('Anonymous');
const collabName = user?.full_name || user?.email;
const cursorName = collabName || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const threadStore = useComments(doc.id, canSeeComment, user);
const currentUserAvatarUrl = useMemo(() => {
if (canSeeComment) {
return avatarUrlFromName(collabName, themeTokens?.font?.families?.base);
}
}, [canSeeComment, collabName, themeTokens?.font?.families?.base]);
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
provider: provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: collabName,
name: cursorName,
color: randomColor(),
},
/**
@@ -138,11 +154,28 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
},
showCursorLabels: showCursorLabels as 'always' | 'activity',
},
comments: { threadStore },
dictionary: {
...locales[lang as keyof typeof locales],
multi_column:
multiColumnLocales?.[lang as keyof typeof multiColumnLocales],
},
resolveUsers: async (userIds) => {
return Promise.resolve(
userIds.map((encodedURIUserId) => {
const fullName = decodeURIComponent(encodedURIUserId);
return {
id: encodedURIUserId,
username: fullName || t('Anonymous'),
avatarUrl: avatarUrlFromName(
fullName,
themeTokens?.font?.families?.base,
),
};
}),
);
},
tables: {
splitCells: true,
cellBackgroundColor: true,
@@ -152,7 +185,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
uploadFile,
schema: blockNoteSchema,
},
[collabName, lang, provider, uploadFile],
[cursorName, lang, provider, uploadFile, threadStore],
);
useHeadings(editor);
@@ -170,7 +203,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box ref={refEditorContainer} $css={cssEditor}>
<Box
ref={refEditorContainer}
$css={css`
${cssEditor};
${cssComments(canSeeComment, currentUserAvatarUrl)}
`}
>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
@@ -180,12 +219,14 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
/>
</Box>
)}
<BlockNoteView
className="--docs--main-editor"
editor={editor}
formattingToolbar={false}
slashMenu={false}
theme="light"
comments={canSeeComment}
aria-label={t('Document editor')}
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
@@ -195,11 +236,18 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
};
interface BlockNoteReaderProps {
docId: Doc['id'];
initialContent: Y.XmlFragment;
}
export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
export const BlockNoteReader = ({
docId,
initialContent,
}: BlockNoteReaderProps) => {
const { user } = useAuth();
const { setEditor } = useEditorStore();
const threadStore = useComments(docId, false, user);
const { t } = useTranslation();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -211,6 +259,10 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
provider: undefined,
},
schema: blockNoteSchema,
comments: { threadStore },
resolveUsers: async () => {
return Promise.resolve([]);
},
},
[initialContent],
);
@@ -226,13 +278,21 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
useHeadings(editor);
return (
<Box $css={cssEditor}>
<Box
$css={css`
${cssEditor};
${cssComments(false)}
`}
>
<BlockNoteView
className="--docs--main-editor"
editor={editor}
editable={false}
theme="light"
aria-label={t('Document version viewer')}
formattingToolbar={false}
slashMenu={false}
comments={false}
/>
</Box>
);

View File

@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core/config/api';
import { CommentToolbarButton } from '../comments/CommentToolbarButton';
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
import { AIGroupButton } from './AIButton';
@@ -25,10 +26,12 @@ export const BlockNoteToolbar = () => {
const { data: conf } = useConfig();
const toolbarItems = useMemo(() => {
const toolbarItems = getFormattingToolbarItems([
let toolbarItems = getFormattingToolbarItems([
...blockTypeSelectItems(dict),
getCalloutFormattingToolbarItems(t),
]);
// Find the index of the file download button
const fileDownloadButtonIndex = toolbarItems.findIndex(
(item) =>
typeof item === 'object' &&
@@ -36,6 +39,8 @@ export const BlockNoteToolbar = () => {
'key' in item &&
(item as { key: string }).key === 'fileDownloadButton',
);
// Replace the default file download button with our custom FileDownloadButton
if (fileDownloadButtonIndex !== -1) {
toolbarItems.splice(
fileDownloadButtonIndex,
@@ -50,12 +55,22 @@ export const BlockNoteToolbar = () => {
);
}
// Remove default Comment button
toolbarItems = toolbarItems.filter((item) => {
if (typeof item === 'object' && item !== null && 'key' in item) {
return item.key !== 'addCommentButton';
}
return true;
});
return toolbarItems;
}, [dict, t]);
const formattingToolbar = useCallback(() => {
return (
<FormattingToolbar>
<CommentToolbarButton />
{toolbarItems}
{/* Extra button to do some AI powered actions */}

View File

@@ -6,6 +6,9 @@ import {
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Text } from '@/components';
type Block = {
type: string;
@@ -83,8 +86,18 @@ export function MarkdownButton() {
mainTooltip={t('Convert Markdown')}
onClick={handleConvertMarkdown}
className="--docs--editor-markdown-button"
>
M
</Components.FormattingToolbar.Button>
label="M"
icon={
<Text
aria-hidden={true}
$css={css`
font-family: var(--c--theme--font--families--base);
`}
$weight="bold"
>
M
</Text>
}
/>
);
}

View File

@@ -99,6 +99,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
<>
{isDesktop && (
<Box
$height="100vh"
$position="absolute"
$css={css`
top: 72px;
@@ -116,6 +117,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
initialContent={provider.document.getXmlFragment(
'document-store',
)}
docId={doc.id}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />

View File

@@ -0,0 +1,69 @@
import { useBlockNoteEditor, useComponentsContext } from '@blocknote/react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDocStore } from '@/features/docs/doc-management';
import {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '../../types';
export const CommentToolbarButton = () => {
const Components = useComponentsContext();
const { currentDoc } = useDocStore();
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const editor = useBlockNoteEditor<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>();
if (!editor.isEditable || !Components || !currentDoc?.abilities.comment) {
return null;
}
return (
<Box $direction="row" className="--docs--comment-toolbar-button">
<Components.Generic.Toolbar.Button
className="bn-button"
onClick={() => {
editor.comments?.startPendingComment();
}}
aria-haspopup="dialog"
>
<Box
$direction="row"
$align="center"
$gap={spacingsTokens['xs']}
$padding={{ right: '2xs' }}
>
<Icon
iconName="comment"
className="--docs--icon-bg"
$theme="greyscale"
$variation="600"
$padding="0.15rem"
$size="16px"
$color={colorsTokens['greyscale-600']}
/>
{t('Comment')}
</Box>
</Components.Generic.Toolbar.Button>
<Box
$background={colorsTokens['greyscale-100']}
$width="1px"
$height="70%"
$margin={{ left: '2px' }}
$css={css`
align-self: center;
`}
/>
</Box>
);
};

View File

@@ -0,0 +1,569 @@
import { CommentBody, ThreadStore } from '@blocknote/core/comments';
import type { Awareness } from 'y-protocols/awareness';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs/doc-management';
import { useEditorStore } from '../../stores';
import { DocsThreadStoreAuth } from './DocsThreadStoreAuth';
import {
ClientCommentData,
ClientThreadData,
ServerComment,
ServerReaction,
ServerThread,
} from './types';
type ServerThreadListResponse = APIList<ServerThread>;
export class DocsThreadStore extends ThreadStore {
protected static COMMENTS_PING = 'commentsPing';
protected threads: Map<string, ClientThreadData> = new Map();
private subscribers = new Set<
(threads: Map<string, ClientThreadData>) => void
>();
private awareness?: Awareness;
private lastPingAt = 0;
private pingTimer?: ReturnType<typeof setTimeout>;
constructor(
protected docId: Doc['id'],
awareness: Awareness | undefined,
protected docAuth: DocsThreadStoreAuth,
) {
super(docAuth);
if (docAuth.canSee) {
this.awareness = awareness;
this.awareness?.on('update', this.onAwarenessUpdate);
void this.refreshThreads();
}
}
public destroy() {
this.awareness?.off('update', this.onAwarenessUpdate);
if (this.pingTimer) {
clearTimeout(this.pingTimer);
}
}
private onAwarenessUpdate = async ({
added,
updated,
}: {
added: number[];
updated: number[];
}) => {
if (!this.awareness) {
return;
}
const states = this.awareness.getStates();
const listClientIds = [...added, ...updated];
for (const clientId of listClientIds) {
// Skip our own client ID
if (clientId === this.awareness.clientID) {
continue;
}
const state = states.get(clientId) as
| {
[DocsThreadStore.COMMENTS_PING]?: {
at: number;
docId: string;
isResolving: boolean;
threadId: string;
};
}
| undefined;
const ping = state?.commentsPing;
// Skip if no ping information is available
if (!ping) {
continue;
}
// Skip if the document ID doesn't match
if (ping.docId !== this.docId) {
continue;
}
// Skip if the ping timestamp is past
if (ping.at <= this.lastPingAt) {
continue;
}
this.lastPingAt = ping.at;
// If we know the threadId, schedule a targeted refresh. Otherwise, fall back to full refresh.
if (ping.threadId) {
await this.refreshThread(ping.threadId);
} else {
await this.refreshThreads();
}
}
};
/**
* To ping the other clients for updates on a specific thread
* @param threadId
*/
private ping(threadId?: string) {
this.awareness?.setLocalStateField(DocsThreadStore.COMMENTS_PING, {
at: Date.now(),
docId: this.docId,
threadId,
});
}
/**
* Notifies all subscribers about the current thread state
*/
private notifySubscribers() {
// Always emit a new Map reference to help consumers detect changes
const threads = new Map(this.threads);
this.subscribers.forEach((cb) => {
try {
cb(threads);
} catch (e) {
console.warn('DocsThreadStore subscriber threw', e);
}
});
}
private upsertClientThreadData(thread: ClientThreadData) {
const next = new Map(this.threads);
next.set(thread.id, thread);
this.threads = next;
}
private removeThread(threadId: string) {
const next = new Map(this.threads);
next.delete(threadId);
this.threads = next;
}
/**
* To subscribe to thread updates
* @param cb
* @returns
*/
public subscribe(cb: (threads: Map<string, ClientThreadData>) => void) {
if (!this.docAuth.canSee) {
return () => {};
}
this.subscribers.add(cb);
// Emit initial state asynchronously to avoid running during editor init
setTimeout(() => {
if (this.subscribers.has(cb)) {
cb(this.getThreads());
}
}, 0);
return () => {
this.subscribers.delete(cb);
};
}
public addThreadToDocument = (options: {
threadId: string;
selection: {
prosemirror: {
head: number;
anchor: number;
};
yjs: {
head: unknown;
anchor: unknown;
};
};
}) => {
const { threadId } = options;
const { editor } = useEditorStore.getState();
// Should not happen
if (!editor) {
console.warn('Editor to add thread not ready');
return Promise.resolve();
}
editor._tiptapEditor
.chain()
.focus?.()
.setMark?.('comment', { orphan: false, threadId })
.run?.();
return Promise.resolve();
};
public createThread = async (options: {
initialComment: {
body: CommentBody;
metadata?: unknown;
};
metadata?: unknown;
}) => {
const response = await fetchAPI(`documents/${this.docId}/threads/`, {
method: 'POST',
body: JSON.stringify({
body: options.initialComment.body,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to create thread in document',
await errorCauses(response),
);
}
const thread = (await response.json()) as ServerThread;
const threadData: ClientThreadData = serverThreadToClientThread(thread);
this.upsertClientThreadData(threadData);
this.notifySubscribers();
this.ping(threadData.id);
return threadData;
};
public getThread(threadId: string) {
const thread = this.threads.get(threadId);
if (!thread) {
throw new Error('Thread not found');
}
return thread;
}
public getThreads(): Map<string, ClientThreadData> {
if (!this.docAuth.canSee) {
return new Map();
}
return this.threads;
}
public async refreshThread(threadId: string) {
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/`,
{ method: 'GET' },
);
// If not OK and 404, the thread might have been deleted but the
// thread modal is still open, so we close it to avoid side effects
if (response.status === 404) {
// use escape key event to close the thread modal
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
bubbles: true,
cancelable: true,
}),
);
await this.refreshThreads();
return;
}
if (!response.ok) {
throw new APIError(
`Failed to fetch thread ${threadId}`,
await errorCauses(response),
);
}
const serverThread = (await response.json()) as ServerThread;
const clientThread = serverThreadToClientThread(serverThread);
this.upsertClientThreadData(clientThread);
this.notifySubscribers();
}
public async refreshThreads(): Promise<void> {
const response = await fetchAPI(`documents/${this.docId}/threads/`, {
method: 'GET',
});
if (!response.ok) {
throw new APIError(
'Failed to get threads in document',
await errorCauses(response),
);
}
const threads = (await response.json()) as ServerThreadListResponse;
const next = new Map<string, ClientThreadData>();
threads.results.forEach((thread) => {
const threadData: ClientThreadData = serverThreadToClientThread(thread);
next.set(thread.id, threadData);
});
this.threads = next;
this.notifySubscribers();
}
public addComment = async (options: {
comment: {
body: CommentBody;
metadata?: unknown;
};
threadId: string;
}) => {
const { threadId } = options;
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/comments/`,
{
method: 'POST',
body: JSON.stringify({
body: options.comment.body,
}),
},
);
if (!response.ok) {
throw new APIError('Failed to add comment ', await errorCauses(response));
}
const comment = (await response.json()) as ServerComment;
// Optimistically update local thread with new comment
const existing = this.threads.get(threadId);
if (existing) {
const updated: ClientThreadData = {
...existing,
updatedAt: new Date(comment.updated_at || comment.created_at),
comments: [...existing.comments, serverCommentToClientComment(comment)],
};
this.upsertClientThreadData(updated);
this.notifySubscribers();
} else {
// Fallback to fetching the thread if we don't have it locally
await this.refreshThread(threadId);
}
this.ping(threadId);
return serverCommentToClientComment(comment);
};
public updateComment = async (options: {
comment: {
body: CommentBody;
metadata?: unknown;
};
threadId: string;
commentId: string;
}) => {
const { threadId, commentId, comment } = options;
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/comments/${commentId}/`,
{
method: 'PUT',
body: JSON.stringify({
body: comment.body,
}),
},
);
if (!response.ok) {
throw new APIError(
'Failed to add thread to document',
await errorCauses(response),
);
}
await this.refreshThread(threadId);
this.ping(threadId);
return;
};
public deleteComment = async (options: {
threadId: string;
commentId: string;
softDelete?: boolean;
}) => {
const { threadId, commentId } = options;
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/comments/${commentId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete comment',
await errorCauses(response),
);
}
// Optimistically remove the comment locally if we have the thread
const existing = this.threads.get(threadId);
if (existing) {
const updated: ClientThreadData = {
...existing,
updatedAt: new Date(),
comments: existing.comments.filter((c) => c.id !== commentId),
};
this.upsertClientThreadData(updated);
this.notifySubscribers();
} else {
// Fallback to fetching the thread
await this.refreshThread(threadId);
}
this.ping(threadId);
};
/**
* UI not implemented
* @param _options
*/
public deleteThread = async (_options: { threadId: string }) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${_options.threadId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete thread',
await errorCauses(response),
);
}
// Remove locally and notify; no need to refetch everything
this.removeThread(_options.threadId);
this.notifySubscribers();
this.ping(_options.threadId);
};
public resolveThread = async (_options: { threadId: string }) => {
const { threadId } = _options;
const response = await fetchAPI(
`documents/${this.docId}/threads/${threadId}/resolve/`,
{ method: 'POST' },
);
if (!response.ok) {
throw new APIError(
'Failed to resolve thread',
await errorCauses(response),
);
}
await this.refreshThreads();
this.ping(threadId);
};
/**
* Todo: Not implemented backend side
* @returns
* @throws
*/
public unresolveThread = async (_options: { threadId: string }) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${_options.threadId}/unresolve/`,
{ method: 'POST' },
);
if (!response.ok) {
throw new APIError(
'Failed to unresolve thread',
await errorCauses(response),
);
}
await this.refreshThread(_options.threadId);
this.ping(_options.threadId);
};
public addReaction = async (options: {
threadId: string;
commentId: string;
emoji: string;
}) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${options.threadId}/comments/${options.commentId}/reactions/`,
{
method: 'POST',
body: JSON.stringify({ emoji: options.emoji }),
},
);
if (!response.ok) {
throw new APIError(
'Failed to add reaction to comment',
await errorCauses(response),
);
}
await this.refreshThread(options.threadId);
this.notifySubscribers();
this.ping(options.threadId);
};
public deleteReaction = async (options: {
threadId: string;
commentId: string;
emoji: string;
}) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${options.threadId}/comments/${options.commentId}/reactions/`,
{ method: 'DELETE', body: JSON.stringify({ emoji: options.emoji }) },
);
if (!response.ok) {
throw new APIError(
'Failed to delete reaction from comment',
await errorCauses(response),
);
}
await this.refreshThread(options.threadId);
this.notifySubscribers();
this.ping(options.threadId);
};
}
const serverReactionToReactionData = (r: ServerReaction) => {
return {
emoji: r.emoji,
createdAt: new Date(r.created_at),
userIds: r.users?.map((user) =>
encodeURIComponent(user.full_name || ''),
) || [''],
};
};
const serverCommentToClientComment = (c: ServerComment): ClientCommentData => ({
type: 'comment',
id: c.id,
userId: encodeURIComponent(c.user?.full_name || ''),
body: c.body,
createdAt: new Date(c.created_at),
updatedAt: new Date(c.updated_at),
reactions: (c.reactions ?? []).map(serverReactionToReactionData),
metadata: { abilities: c.abilities },
});
const serverThreadToClientThread = (t: ServerThread): ClientThreadData => ({
type: 'thread',
id: t.id,
createdAt: new Date(t.created_at),
updatedAt: new Date(t.updated_at),
comments: (t.comments ?? []).map(serverCommentToClientComment),
resolved: t.resolved,
resolvedUpdatedAt: t.resolved_updated_at
? new Date(t.resolved_updated_at)
: undefined,
resolvedBy: t.resolved_by || undefined,
metadata: { abilities: t.abilities, metadata: t.metadata },
});

View File

@@ -0,0 +1,94 @@
import { ThreadStoreAuth } from '@blocknote/core/comments';
import { ClientCommentData, ClientThreadData } from './types';
export class DocsThreadStoreAuth extends ThreadStoreAuth {
constructor(
private readonly userId: string,
public canSee: boolean,
) {
super();
}
canCreateThread(): boolean {
return true;
}
canAddComment(_thread: ClientThreadData): boolean {
return true;
}
canUpdateComment(comment: ClientCommentData): boolean {
if (
comment.metadata.abilities.partial_update &&
comment.userId === this.userId
) {
return true;
}
return false;
}
canDeleteComment(comment: ClientCommentData): boolean {
if (comment.metadata.abilities.destroy) {
return true;
}
return false;
}
canDeleteThread(thread: ClientThreadData): boolean {
if (thread.metadata.abilities.destroy) {
return true;
}
return false;
}
canResolveThread(thread: ClientThreadData): boolean {
if (thread.metadata.abilities.resolve) {
return true;
}
return false;
}
/**
* Not implemented backend side
* @param _thread
* @returns
*/
canUnresolveThread(_thread: ClientThreadData): boolean {
return false;
}
canAddReaction(comment: ClientCommentData, emoji?: string): boolean {
if (!comment.metadata.abilities.reactions) {
return false;
}
if (!emoji) {
return true;
}
return !comment.reactions.some(
(reaction) =>
reaction.emoji === emoji && reaction.userIds.includes(this.userId),
);
}
canDeleteReaction(comment: ClientCommentData, emoji?: string): boolean {
if (!comment.metadata.abilities.reactions) {
return false;
}
if (!emoji) {
return true;
}
return comment.reactions.some(
(reaction) =>
reaction.emoji === emoji && reaction.userIds.includes(this.userId),
);
}
}

View File

@@ -0,0 +1,3 @@
export * from './CommentToolbarButton';
export * from './styles';
export * from './useComments';

View File

@@ -0,0 +1,214 @@
import { css } from 'styled-components';
export const cssComments = (
canSeeComment: boolean,
currentUserAvatarUrl?: string,
) => css`
& .--docs--main-editor,
& .--docs--main-editor .ProseMirror {
// Comments marks in the editor
.bn-editor {
.bn-thread-mark:not([data-orphan='true']),
.bn-thread-mark-selected:not([data-orphan='true']) {
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
color: var(--c--theme--colors--greyscale-700);
}
}
em-emoji-picker {
box-shadow: 0px 6px 18px 0px #00001229;
min-height: 420px;
}
// Thread modal
.bn-thread {
width: 400px;
padding: 8px;
box-shadow: 0px 6px 18px 0px #00001229;
margin-left: 20px;
gap: 0;
overflow: auto;
max-height: 500px;
.bn-default-styles {
font-family: var(--c--theme--font--families--base);
}
.bn-block {
font-size: 14px;
}
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before {
font-style: normal;
font-size: 14px;
}
// Remove tooltip
*[role='tooltip'] {
display: none;
}
.bn-thread-comment {
padding: 8px;
& .bn-editor {
padding-left: 32px;
.bn-inline-content {
color: var(--c--theme--colors--greyscale-700);
}
}
// Emoji
& .bn-badge-group {
padding-left: 32px;
.bn-badge label {
padding: 0 4px;
background: none;
border: 1px solid var(--c--theme--colors--greyscale-300);
border-radius: 4px;
height: 24px;
}
}
// Top bar (Name / Date / Actions) when actions displayed
&:has(.bn-comment-actions) {
& > .mantine-Group-root {
max-width: 70%;
right: 0.3rem !important;
top: 0.3rem !important;
}
.bn-menu-dropdown {
box-shadow: 0px 0px 6px 0px #0000911a;
}
}
// Top bar (Name / Date / Actions)
& > .mantine-Group-root {
flex-wrap: nowrap;
max-width: 100%;
gap: 0.5rem;
// Date
span.mantine-focus-auto {
display: inline-block;
}
.bn-comment-actions {
background: transparent;
border: none;
.mantine-Button-root {
background-color: transparent;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
}
}
button[role='menuitem'] svg {
color: var(--c--theme--colors--greyscale-600);
}
}
& svg {
color: var(--c--theme--colors--info-600);
}
}
// Actions button edit comment
.bn-container + .bn-comment-actions-wrapper {
.bn-comment-actions {
flex-direction: row-reverse;
background: none;
border: none;
gap: 0.4rem !important;
& > button {
height: 24px;
padding-inline: 4px;
&[data-test='save'] {
border: 1px solid var(--c--theme--colors--info-600);
background: var(--c--theme--colors--info-600);
color: white;
}
&[data-test='cancel'] {
background: white;
border: 1px solid var(--c--theme--colors--greyscale-300);
color: var(--c--theme--colors--info-600);
}
}
}
}
}
// Input to add a new comment
.bn-thread-composer,
&:has(> .bn-comment-editor + .bn-comment-actions-wrapper) {
padding: 0.5rem 8px;
flex-direction: row;
gap: 10px;
.bn-container.bn-comment-editor {
min-width: 0;
}
&::before {
content: '';
width: 26px;
height: 26px;
flex: 0 0 26px;
background-image: ${currentUserAvatarUrl
? `url("${currentUserAvatarUrl}")`
: 'none'};
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
}
// Actions button send comment
.bn-thread-composer .bn-comment-actions-wrapper,
&:not(.selected) .bn-comment-actions-wrapper {
flex-basis: fit-content;
.bn-action-toolbar.bn-comment-actions {
border: none;
button {
font-size: 0;
background: var(--c--theme--colors--info-600);
width: 24px;
height: 24px;
padding: 0;
&:disabled {
background: var(--c--theme--colors--greyscale-300);
}
& .mantine-Button-label::before {
content: '🡡';
font-size: 13px;
color: var(--c--theme--colors--greyscale-100);
}
}
}
}
// Input first comment
&:not(.selected) {
gap: 0.5rem;
.bn-container.bn-comment-editor {
min-width: 0;
.ProseMirror.bn-editor {
cursor: text;
}
}
}
}
}
`;

View File

@@ -0,0 +1,55 @@
import { CommentData, ThreadData } from '@blocknote/core/comments';
import { UserLight } from '@/features/auth';
export interface CommentAbilities {
destroy: boolean;
update: boolean;
partial_update: boolean;
retrieve: boolean;
reactions: boolean;
}
export interface ThreadAbilities {
destroy: boolean;
update: boolean;
partial_update: boolean;
retrieve: boolean;
resolve: boolean;
}
export interface ServerReaction {
emoji: string;
created_at: string;
users: UserLight[] | null;
}
export interface ServerComment {
id: string;
user: UserLight | null;
body: unknown;
created_at: string;
updated_at: string;
reactions: ServerReaction[];
abilities: CommentAbilities;
}
export interface ServerThread {
id: string;
created_at: string;
updated_at: string;
user: UserLight | null;
resolved: boolean;
resolved_updated_at: string | null;
resolved_by: string | null;
metadata: unknown;
comments: ServerComment[];
abilities: ThreadAbilities;
}
export type ClientCommentData = Omit<CommentData, 'metadata'> & {
metadata: { abilities: CommentAbilities };
};
export type ClientThreadData = Omit<ThreadData, 'metadata'> & {
metadata: { abilities: ThreadAbilities; metadata: unknown };
};

View File

@@ -0,0 +1,33 @@
import { useEffect, useMemo } from 'react';
import { User } from '@/features/auth';
import { Doc, useProviderStore } from '@/features/docs/doc-management';
import { DocsThreadStore } from './DocsThreadStore';
import { DocsThreadStoreAuth } from './DocsThreadStoreAuth';
export function useComments(
docId: Doc['id'],
canComment: boolean,
user: User | null | undefined,
) {
const { provider } = useProviderStore();
const threadStore = useMemo(() => {
return new DocsThreadStore(
docId,
provider?.awareness ?? undefined,
new DocsThreadStoreAuth(
encodeURIComponent(user?.full_name || ''),
canComment,
),
);
}, [docId, canComment, provider?.awareness, user?.full_name]);
useEffect(() => {
return () => {
threadStore?.destroy();
};
}, [threadStore]);
return threadStore;
}

View File

@@ -173,5 +173,4 @@ export const getCalloutFormattingToolbarItems = (
name: t('Callout'),
type: 'callout',
icon: () => <Icon iconName="lightbulb" $size="16px" />,
isSelected: (block) => block.type === 'callout',
});

View File

@@ -22,8 +22,8 @@ import { Box, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
const PDFBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="pdf"] {
width: fit-content;
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper[style*="fit-content"] {
width: 100% !important;
}
`;

View File

@@ -3,6 +3,7 @@ import {
StyleSchema,
} from '@blocknote/core';
import { useBlockNoteEditor } from '@blocknote/react';
import type { KeyboardEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -99,6 +100,55 @@ export const SearchPage = ({
}, 100);
}, [inputRef]);
const closeSearch = (insertContent: string) => {
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
disabled: true,
trigger,
},
});
contentRef(null);
editor.focus();
editor.insertInlineContent([insertContent]);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
// Keep the trigger character ('@' or '/') in the editor when closing with Escape
closeSearch(trigger);
} else if (e.key === 'Backspace' && search.length === 0) {
e.preventDefault();
closeSearch('');
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
// Allow arrow keys to be handled by the command menu for navigation
const commandList = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector('[cmdk-list]');
// Create a synthetic keyboard event for the command menu
const syntheticEvent = new KeyboardEvent('keydown', {
key: e.key,
bubbles: true,
cancelable: true,
});
commandList?.dispatchEvent(syntheticEvent);
e.preventDefault();
} else if (e.key === 'Enter') {
// Handle Enter key to select the currently highlighted item
const selectedItem = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector(
'[cmdk-item][data-selected="true"]',
) as HTMLElement;
selectedItem?.click();
e.preventDefault();
}
};
return (
<Box as="span" $position="relative">
<Box
@@ -124,50 +174,7 @@ export const SearchPage = ({
const value = (e.target as HTMLInputElement).value;
setSearch(value);
}}
onKeyDown={(e) => {
if (
(e.key === 'Backspace' && search.length === 0) ||
e.key === 'Escape'
) {
e.preventDefault();
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
disabled: true,
trigger,
},
});
contentRef(null);
editor.focus();
editor.insertInlineContent(['']);
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
// Allow arrow keys to be handled by the command menu for navigation
const commandList = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector('[cmdk-list]');
// Create a synthetic keyboard event for the command menu
const syntheticEvent = new KeyboardEvent('keydown', {
key: e.key,
bubbles: true,
cancelable: true,
});
commandList?.dispatchEvent(syntheticEvent);
e.preventDefault();
} else if (e.key === 'Enter') {
// Handle Enter key to select the currently highlighted item
const selectedItem = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector(
'[cmdk-item][data-selected="true"]',
) as HTMLElement;
selectedItem?.click();
e.preventDefault();
}
}}
onKeyDown={handleKeyDown}
/>
</Box>
<Box
@@ -224,6 +231,8 @@ export const SearchPage = ({
},
});
contentRef(null);
editor.insertInlineContent([
{
type: 'interlinkingLinkInline',

View File

@@ -7,153 +7,155 @@ export const cssEditor = css`
height: 100%;
}
& .ProseMirror {
/**
* WCAG Accessibility contrast fixes for BlockNote editor
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
color: #767676 !important;
font-weight: 400;
}
/**
* Ensure long placeholder text is truncated with ellipsis
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: inherit;
height: inherit;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
position: relative;
}
/**
* Ensure images with unsafe URLs are not interactive
*/
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
}
/**
* Collaboration cursor styles
*/
.collaboration-cursor-custom__base {
position: relative;
}
.collaboration-cursor-custom__caret {
position: absolute;
height: 100%;
width: 2px;
bottom: 4%;
left: -1px;
}
.collaboration-cursor-custom__label {
color: #0d0d0d;
font-size: 12px;
font-weight: 600;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
position: absolute;
top: -17px;
left: 0px;
padding: 0px 6px;
border-radius: 0px;
white-space: nowrap;
transition: clip-path 0.3s ease-in-out;
border-radius: 4px 4px 4px 0;
box-shadow: inset -2px 2px 6px #ffffff00;
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
}
.collaboration-cursor-custom__base[data-active]
.collaboration-cursor-custom__label {
pointer-events: none;
box-shadow: inset -2px 2px 6px #ffffff88;
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
}
/**
* Side menu
*/
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 50px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
.bn-side-menu[data-block-type='divider'] {
height: 38px;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: #767676 !important;
}
/**
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block > .bn-block-content[data-background-color] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
text-decoration: none;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-600);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
}
& .bn-editor {
color: var(--c--theme--colors--greyscale-700);
}
/**
* Quotes
*/
blockquote {
border-left: 4px solid var(--c--theme--colors--greyscale-300);
font-style: italic;
}
/**
* WCAG Accessibility contrast fixes for BlockNote editor
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
color: #767676 !important;
font-weight: 400;
}
/**
* Divider
*/
[data-content-type='divider'] hr {
background: #d3d2cf;
margin: 1rem 0;
width: 100%;
border: 1px solid #d3d2cf;
}
/**
* Ensure long placeholder text is truncated with ellipsis
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: inherit;
height: inherit;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
position: relative;
}
/**
* Ensure images with unsafe URLs are not interactive
*/
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
}
/**
* Collaboration cursor styles
*/
.collaboration-cursor-custom__base {
position: relative;
}
.collaboration-cursor-custom__caret {
position: absolute;
height: 100%;
width: 2px;
bottom: 4%;
left: -1px;
}
.collaboration-cursor-custom__label {
color: #0d0d0d;
font-size: 12px;
font-weight: 600;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
position: absolute;
top: -17px;
left: 0px;
padding: 0px 6px;
border-radius: 0px;
white-space: nowrap;
transition: clip-path 0.3s ease-in-out;
border-radius: 4px 4px 4px 0;
box-shadow: inset -2px 2px 6px #ffffff00;
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
}
.collaboration-cursor-custom__base[data-active]
.collaboration-cursor-custom__label {
pointer-events: none;
box-shadow: inset -2px 2px 6px #ffffff88;
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
}
/**
* Side menu
*/
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 54px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
.bn-side-menu[data-block-type='divider'] {
height: 38px;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: #767676 !important;
}
/**
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block > .bn-block-content[data-background-color] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
text-decoration: none;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-600);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
.bn-toolbar {
max-width: 95vw;
}
/**
* Quotes
*/
blockquote {
border-left: 4px solid var(--c--theme--colors--greyscale-300);
font-style: italic;
}
/**
* Divider
*/
[data-content-type='divider'] hr {
background: #d3d2cf;
margin: 1rem 0;
width: 100%;
border: 1px solid #d3d2cf;
}
& .bn-block-outer:not(:first-child) {

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { DocsExporterODT } from '../types';
import { odtRegisterParagraphStyleForBlock } from '../utils';
export const blockMappingCalloutODT: DocsExporterODT['mappings']['blockMapping']['callout'] =
(block, exporter) => {
// Map callout to paragraph with emoji prefix
const emoji = block.props.emoji || '💡';
// Transform inline content (text, bold, links, etc.)
const inlineContent = exporter.transformInlineContent(block.content);
// Resolve background and alignment → create a dedicated paragraph style
const styleName = odtRegisterParagraphStyleForBlock(
exporter,
{
backgroundColor: block.props.backgroundColor,
textAlignment: block.props.textAlignment,
},
{ paddingCm: 0.42 },
);
return React.createElement(
'text:p',
{
'text:style-name': styleName,
},
`${emoji} `,
...inlineContent,
);
};

View File

@@ -31,15 +31,10 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, previewWidth);
const img = new Image();
img.src = pngConverted;
await new Promise((resolve) => {
img.onload = () => {
dimensions = { width: img.width, height: img.height };
resolve(null);
};
});
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };
} else {
dimensions = await getImageDimensions(blob);
}
@@ -50,9 +45,9 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
const { width, height } = dimensions;
if (previewWidth && previewWidth > MAX_WIDTH) {
previewWidth = MAX_WIDTH;
}
// Ensure the final width never exceeds MAX_WIDTH to prevent images
// from overflowing the page width in the exported document
const finalWidth = Math.min(previewWidth || width, MAX_WIDTH);
return [
new Paragraph({
@@ -71,8 +66,8 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
}
: undefined,
transformation: {
width: previewWidth || width,
height: ((previewWidth || width) / width) * height,
width: finalWidth,
height: (finalWidth / width) * height,
},
}),
],

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { DocsExporterODT } from '../types';
import { convertSvgToPng, odtRegisterParagraphStyleForBlock } from '../utils';
const MAX_WIDTH = 600;
export const blockMappingImageODT: DocsExporterODT['mappings']['blockMapping']['image'] =
async (block, exporter) => {
try {
const blob = await exporter.resolveFile(block.props.url);
if (!blob || !blob.type) {
console.warn(`Failed to resolve image: ${block.props.url}`);
return null;
}
let pngConverted: string | undefined;
let dimensions: { width: number; height: number } | undefined;
let previewWidth = block.props.previewWidth || undefined;
if (!blob.type.includes('image')) {
console.warn(`Not an image type: ${blob.type}`);
return null;
}
if (blob.type.includes('svg')) {
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };
} else {
dimensions = await getImageDimensions(blob);
}
if (!dimensions) {
return null;
}
const { width, height } = dimensions;
if (previewWidth && previewWidth > MAX_WIDTH) {
previewWidth = MAX_WIDTH;
}
// Convert image to base64 for ODT embedding
const arrayBuffer = pngConverted
? await (await fetch(pngConverted)).arrayBuffer()
: await blob.arrayBuffer();
const base64 = btoa(
Array.from(new Uint8Array(arrayBuffer))
.map((byte) => String.fromCharCode(byte))
.join(''),
);
const finalWidth = previewWidth || width;
const finalHeight = ((previewWidth || width) / width) * height;
const baseParagraphProps = {
backgroundColor: block.props.backgroundColor,
textAlignment: block.props.textAlignment,
};
const paragraphStyleName = odtRegisterParagraphStyleForBlock(
exporter,
baseParagraphProps,
{ paddingCm: 0 },
);
// Convert pixels to cm (ODT uses cm for dimensions)
const widthCm = finalWidth / 37.795275591;
const heightCm = finalHeight / 37.795275591;
// Create ODT image structure using React.createElement
const frame = React.createElement(
'text:p',
{
'text:style-name': paragraphStyleName,
},
React.createElement(
'draw:frame',
{
'draw:name': `Image${Date.now()}`,
'text:anchor-type': 'as-char',
'svg:width': `${widthCm}cm`,
'svg:height': `${heightCm}cm`,
},
React.createElement(
'draw:image',
{
xlinkType: 'simple',
xlinkShow: 'embed',
xlinkActuate: 'onLoad',
},
React.createElement('office:binary-data', {}, base64),
),
),
);
// Add caption if present
if (block.props.caption) {
const captionStyleName = odtRegisterParagraphStyleForBlock(
exporter,
baseParagraphProps,
{ paddingCm: 0, parentStyleName: 'Caption' },
);
return [
frame,
React.createElement(
'text:p',
{ 'text:style-name': captionStyleName },
block.props.caption,
),
];
}
return frame;
} catch (error) {
console.error(`Error processing image for ODT export:`, error);
return null;
}
};
async function getImageDimensions(blob: Blob) {
if (typeof window !== 'undefined') {
const bmp = await createImageBitmap(blob);
const { width, height } = bmp;
bmp.close();
return { width, height };
}
}

View File

@@ -6,12 +6,14 @@ import { convertSvgToPng } from '../utils';
const PIXELS_PER_POINT = 0.75;
const FONT_SIZE = 16;
const MAX_WIDTH = 600;
export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['image'] =
async (block, exporter) => {
const blob = await exporter.resolveFile(block.props.url);
let pngConverted: string | undefined;
let width = block.props.previewWidth || undefined;
let dimensions: { width: number; height: number } | undefined;
let previewWidth = block.props.previewWidth || undefined;
if (!blob.type.includes('image')) {
return <View wrap={false} />;
@@ -20,16 +22,33 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
if (blob.type.includes('svg')) {
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
width = width || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, width);
previewWidth = previewWidth || FALLBACK_SIZE;
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };
} else {
dimensions = await getImageDimensions(blob);
}
if (!dimensions) {
return <View wrap={false} />;
}
const { width, height } = dimensions;
// Ensure the final width never exceeds MAX_WIDTH to prevent images
// from overflowing the page width in the exported document
const finalWidth = Math.min(previewWidth || width, MAX_WIDTH);
const finalHeight = (finalWidth / width) * height;
return (
<View wrap={false}>
<Image
src={pngConverted || blob}
style={{
width: width ? width * PIXELS_PER_POINT : undefined,
width: finalWidth * PIXELS_PER_POINT,
height: finalHeight * PIXELS_PER_POINT,
maxWidth: '100%',
}}
/>
@@ -38,6 +57,21 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
);
};
async function getImageDimensions(blob: Blob) {
if (typeof window !== 'undefined') {
const url = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = url;
return new Promise<{ width: number; height: number }>((resolve) => {
img.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
});
}
}
function caption(
props: Partial<DefaultProps & { caption: string; previewWidth: number }>,
) {

View File

@@ -1,11 +1,14 @@
export * from './calloutDocx';
export * from './calloutODT';
export * from './calloutPDF';
export * from './headingPDF';
export * from './imageDocx';
export * from './imageODT';
export * from './imagePDF';
export * from './paragraphPDF';
export * from './quoteDocx';
export * from './quotePDF';
export * from './tablePDF';
export * from './uploadLoaderPDF';
export * from './uploadLoaderDocx';
export * from './uploadLoaderODT';
export * from './uploadLoaderPDF';

View File

@@ -23,8 +23,9 @@ export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping
});
}
}
return (
<Text key={block.id}>
<Text key={'paragraph' + block.id}>
{exporter.transformInlineContent(block.content)}
</Text>
);

View File

@@ -2,7 +2,7 @@
* We use mainly the Blocknotes code, mixed with @ag-media/react-pdf-table
* to have a better Table support.
* See:
* https://github.com/TypeCellOS/BlockNote/blob/004c0bf720fe1415c497ad56449015c5f4dd7ba0/packages/xl-pdf-exporter/src/pdf/util/table/Table.tsx
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/src/pdf/util/table/Table.tsx
*
* We succeeded to manage the colspan, but rowspan is not supported yet.
*/
@@ -92,18 +92,27 @@ export const blockMappingTablePDF: DocsExporterPDF['mappings']['blockMapping']['
color:
cellProps.textColor === 'default'
? undefined
: options.colors[cellProps.textColor].text,
: options.colors?.[cellProps.textColor]?.text,
backgroundColor:
cellProps.backgroundColor === 'default'
? undefined
: options.colors[cellProps.backgroundColor].background,
: options.colors?.[cellProps.backgroundColor]
?.background,
textAlign: cellProps.textAlignment,
},
];
return (
<TD key={colIndex} style={arrayStyle}>
<Text style={styles.cell}>
<Text
style={[
styles.cell,
{
width: '100%',
textAlign: cellProps.textAlignment ?? 'left',
},
]}
>
{exporter.transformInlineContent(cell)}
</Text>
</TD>

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { DocsExporterODT } from '../types';
export const blockMappingUploadLoaderODT: DocsExporterODT['mappings']['blockMapping']['uploadLoader'] =
(block) => {
// Map uploadLoader to paragraph with information text
const information = block.props.information || '';
const type = block.props.type || 'loading';
const prefix = type === 'warning' ? '⚠️ ' : '⏳ ';
return React.createElement(
'text:p',
{ 'text:style-name': 'Text_20_body' },
`${prefix}${information}`,
);
};

View File

@@ -1,4 +1,5 @@
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
import { ODTExporter } from '@blocknote/xl-odt-exporter';
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
import {
Button,
@@ -23,12 +24,14 @@ import { Doc, useTrans } from '@/docs/doc-management';
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import { downloadFile } from '../utils';
enum DocDownloadFormat {
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
}
interface ModalExportProps {
@@ -124,7 +127,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
: rawPdfDocument;
blobExport = await pdf(pdfDocument).toBlob();
} else {
} else if (format === DocDownloadFormat.DOCX) {
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
@@ -133,6 +136,16 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
documentOptions: { title: documentTitle },
sectionOptions: {},
});
} else if (format === DocDownloadFormat.ODT) {
const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
blobExport = await exporter.toODTDocument(exportDocument);
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
return;
}
downloadFile(blobExport, `${filename}.${format}`);
@@ -213,7 +226,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
className="--docs--modal-export-content"
>
<Text $variation="600" $size="sm" as="p">
{t('Download your document in a .docx or .pdf format.')}
{t('Download your document in a .docx, .odt or .pdf format.')}
</Text>
<Select
clearable={false}
@@ -231,6 +244,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
label={t('Format')}
options={[
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('PDF'), value: DocDownloadFormat.PDF },
]}
value={format}

View File

@@ -1,2 +1,3 @@
export * from './interlinkingLinkPDF';
export * from './interlinkingLinkDocx';
export * from './interlinkingLinkODT';

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { DocsExporterODT } from '../types';
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
const url = window.location.origin + inline.props.url;
const title = inline.props.title;
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
// Uses the same structure as BlockNote's default link mapping
return React.createElement(
'text:a',
{
xlinkType: 'simple',
'text:style-name': 'Internet_20_link',
'office:target-frame-name': '_top',
xlinkShow: 'replace',
xlinkHref: url,
},
`📄${title}`,
);
};

View File

@@ -0,0 +1,33 @@
import { odtDefaultSchemaMappings } from '@blocknote/xl-odt-exporter';
import {
blockMappingCalloutODT,
blockMappingImageODT,
blockMappingUploadLoaderODT,
} from './blocks-mapping';
import { inlineContentMappingInterlinkingLinkODT } from './inline-content-mapping';
import { DocsExporterODT } from './types';
// Align default inline mappings to our editor inline schema without using `any`
const baseInlineMappings =
odtDefaultSchemaMappings.inlineContentMapping as unknown as DocsExporterODT['mappings']['inlineContentMapping'];
export const odtDocsSchemaMappings: DocsExporterODT['mappings'] = {
...odtDefaultSchemaMappings,
blockMapping: {
...odtDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutODT,
image: blockMappingImageODT,
// We're reusing the file block mapping for PDF blocks
// The types don't match exactly but the implementation is compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: odtDefaultSchemaMappings.blockMapping.file as any,
uploadLoader: blockMappingUploadLoaderODT,
},
inlineContentMapping: {
...baseInlineMappings,
interlinkingSearchInline: () => null,
interlinkingLinkInline: inlineContentMappingInterlinkingLinkODT,
},
};

View File

@@ -51,3 +51,13 @@ export type DocsExporterDocx = Exporter<
IRunPropertiesOptions,
TextRun
>;
export type DocsExporterODT = Exporter<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
React.ReactNode,
React.ReactNode,
Record<string, string>,
React.ReactNode
>;

View File

@@ -5,6 +5,7 @@ import {
} from '@blocknote/core';
import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
import React from 'react';
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
@@ -19,18 +20,21 @@ export function downloadFile(blob: Blob, filename: string) {
}
/**
* Converts an SVG string into a PNG image and returns it as a data URL.
* Converts an SVG string into a PNG image and returns it as a data URL with dimensions.
*
* This function creates a canvas, parses the SVG, calculates the appropriate height
* to preserve the aspect ratio, and renders the SVG onto the canvas using Canvg.
*
* @param {string} svgText - The raw SVG markup to convert.
* @param {number} width - The desired width of the output PNG (height is auto-calculated to preserve aspect ratio).
* @returns {Promise<string>} A Promise that resolves to a PNG image encoded as a base64 data URL.
* @returns {Promise<{ png: string; width: number; height: number }>} A Promise that resolves to an object containing the PNG data URL and its dimensions.
*
* @throws Will throw an error if the canvas context cannot be initialized.
*/
export async function convertSvgToPng(svgText: string, width: number) {
export async function convertSvgToPng(
svgText: string,
width: number,
): Promise<{ png: string; width: number; height: number }> {
// Create a canvas and render the SVG onto it
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', {
@@ -63,7 +67,11 @@ export async function convertSvgToPng(svgText: string, width: number) {
svg.resize(width, height, true);
await svg.render();
return canvas.toDataURL('image/png');
return {
png: canvas.toDataURL('image/png'),
width,
height: height || width,
};
}
export function docxBlockPropsToStyles(
@@ -98,3 +106,76 @@ export function docxBlockPropsToStyles(
})(),
};
}
// ODT helpers
type OdtExporterLike = {
options?: { colors?: typeof COLORS_DEFAULT };
registerStyle: (fn: (name: string) => React.ReactNode) => string;
};
function isOdtExporterLike(value: unknown): value is OdtExporterLike {
return (
!!value &&
typeof (value as { registerStyle?: unknown }).registerStyle === 'function'
);
}
export function odtRegisterParagraphStyleForBlock(
exporter: unknown,
props: Partial<DefaultProps>,
options?: { paddingCm?: number; parentStyleName?: string },
) {
if (!isOdtExporterLike(exporter)) {
throw new Error('Invalid ODT exporter: missing registerStyle');
}
const colors = exporter.options?.colors;
const bgColorHex =
props.backgroundColor && props.backgroundColor !== 'default' && colors
? colors[props.backgroundColor].background
: undefined;
const textColorHex =
props.textColor && props.textColor !== 'default' && colors
? colors[props.textColor].text
: undefined;
const foTextAlign =
!props.textAlignment || props.textAlignment === 'left'
? 'start'
: props.textAlignment === 'center'
? 'center'
: props.textAlignment === 'right'
? 'end'
: 'justify';
const paddingCm = options?.paddingCm ?? 0.42; // ~1rem (16px)
const parentStyleName = options?.parentStyleName;
// registerStyle is available on ODT exporter; call through with React elements
const styleName = exporter.registerStyle((name: string) =>
React.createElement(
'style:style',
{
'style:name': name,
'style:family': 'paragraph',
...(parentStyleName
? { 'style:parent-style-name': parentStyleName }
: {}),
},
React.createElement('style:paragraph-properties', {
'fo:text-align': foTextAlign,
'fo:padding': `${paddingCm}cm`,
...(bgColorHex ? { 'fo:background-color': bgColorHex } : {}),
}),
textColorHex
? React.createElement('style:text-properties', {
'fo:color': textColorHex,
})
: undefined,
),
);
return styleName;
}

View File

@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, test, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
// Force mobile layout so the children count is rendered
vi.mock('@/stores', () => ({
useResponsiveStore: () => ({ isDesktop: false }),
}));
// Provide stable mocks for hooks used by the component
vi.mock('../../doc-management', async () => {
const actual = await vi.importActual<any>('../../doc-management');
return {
...actual,
useTrans: () => ({ transRole: vi.fn((r) => String(r)) }),
useIsCollaborativeEditable: () => ({ isEditable: true }),
};
});
vi.mock('@/core', () => ({
useConfig: () => ({ data: {} }),
}));
vi.mock('@/hook', () => ({
useDate: () => ({
relativeDate: () => 'yesterday',
calculateDaysLeft: () => 5,
}),
}));
import { DocHeaderInfo } from '../components/DocHeaderInfo';
describe('DocHeaderInfo', () => {
test('renders the number of sub-documents when numchild is provided (mobile layout)', () => {
const doc = {
numchild: 3,
updated_at: new Date().toISOString(),
} as any;
render(<DocHeaderInfo doc={doc} />, { wrapper: AppWrapper });
expect(screen.getByText(/Contains 3 sub-documents/i)).toBeInTheDocument();
});
});

View File

@@ -44,7 +44,7 @@ describe('DocToolBox - Licence', () => {
await userEvent.click(optionsButton);
expect(
await screen.findByText(
'Download your document in a .docx or .pdf format.',
'Download your document in a .docx, .odt or .pdf format.',
),
).toBeInTheDocument();
}, 10000);

View File

@@ -1,23 +1,20 @@
import { useTranslation } from 'react-i18next';
import { Box, HorizontalSeparator, Text } from '@/components';
import { useConfig } from '@/core';
import { Box, HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
LinkReach,
Role,
getDocLinkReach,
useIsCollaborativeEditable,
useTrans,
} from '@/docs/doc-management';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { AlertNetwork } from './AlertNetwork';
import { AlertPublic } from './AlertPublic';
import { AlertRestore } from './AlertRestore';
import { BoutonShare } from './BoutonShare';
import { DocHeaderInfo } from './DocHeaderInfo';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
@@ -29,27 +26,11 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { transRole } = useTrans();
const { isEditable } = useIsCollaborativeEditable(doc);
const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC;
const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED;
const { relativeDate, calculateDaysLeft } = useDate();
const { data: config } = useConfig();
const isDeletedDoc = !!doc.deleted_at;
let dateToDisplay = t('Last update: {{update}}', {
update: relativeDate(doc.updated_at),
});
if (config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) {
const daysLeft = calculateDaysLeft(
doc.deleted_at,
config.TRASHBIN_CUTOFF_DAYS,
);
dateToDisplay = `${t('Days remaining:')} ${daysLeft} ${t('days', { count: daysLeft })}`;
}
return (
<>
<Box
@@ -80,33 +61,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
>
<Box $gap={spacingsTokens['3xs']} $overflow="auto">
<DocTitle doc={doc} />
<Box $direction="row">
{isDesktop && (
<>
<Text
$variation="600"
$size="s"
$weight="bold"
$theme={isEditable ? 'greyscale' : 'warning'}
>
{transRole(
isEditable
? doc.user_role || doc.link_role
: Role.READER,
)}
&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{dateToDisplay}
</Text>
</>
)}
{!isDesktop && (
<Text $variation="400" $size="s">
{dateToDisplay}
</Text>
)}
<DocHeaderInfo doc={doc} />
</Box>
</Box>
{!isDeletedDoc && <DocToolBox doc={doc} />}

View File

@@ -0,0 +1,80 @@
import { t } from 'i18next';
import React from 'react';
import { Text } from '@/components';
import { useConfig } from '@/core';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import {
Doc,
Role,
useIsCollaborativeEditable,
useTrans,
} from '../../doc-management';
interface DocHeaderInfoProps {
doc: Doc;
}
export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
const { isDesktop } = useResponsiveStore();
const { transRole } = useTrans();
const { isEditable } = useIsCollaborativeEditable(doc);
const { relativeDate, calculateDaysLeft } = useDate();
const { data: config } = useConfig();
const childrenCount = doc.numchild ?? 0;
const relativeOnly = relativeDate(doc.updated_at);
let dateToDisplay = t('Last update: {{update}}', {
update: relativeOnly,
});
if (config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) {
const daysLeft = calculateDaysLeft(
doc.deleted_at,
config.TRASHBIN_CUTOFF_DAYS,
);
dateToDisplay = `${t('Days remaining:')} ${daysLeft} ${t('days', { count: daysLeft })}`;
}
const hasChildren = childrenCount > 0;
if (isDesktop) {
return (
<>
<Text
$variation="600"
$size="s"
$weight="bold"
$theme={isEditable ? 'greyscale' : 'warning'}
>
{transRole(isEditable ? doc.user_role || doc.link_role : Role.READER)}
&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{dateToDisplay}
</Text>
</>
);
}
return (
<>
<Text $variation="400" $size="s">
{hasChildren ? relativeOnly : dateToDisplay}
</Text>
{hasChildren && (
<Text $variation="400" $size="s">
&nbsp;&nbsp;
{t('Contains {{count}} sub-documents', {
count: childrenCount,
})}
</Text>
)}
</>
);
};

View File

@@ -1,16 +1,19 @@
import {
Button,
ButtonElement,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { useConfig } from '@/core';
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid';
import { useKeyboardAction } from '@/hooks';
import { KEY_LIST_DOC } from '../api/useDocs';
import { useRemoveDoc } from '../api/useRemoveDoc';
@@ -34,6 +37,7 @@ export const ModalRemoveDoc = ({
const trashBinCutoffDays = config?.TRASHBIN_CUTOFF_DAYS || 30;
const { push } = useRouter();
const { hasChildren } = useDocUtils(doc);
const cancelButtonRef = useRef<ButtonElement>(null);
const {
mutate: removeDoc,
isError,
@@ -57,20 +61,47 @@ export const ModalRemoveDoc = ({
},
});
useEffect(() => {
const TIMEOUT_MODAL_MOUNTING = 100;
const timeoutId = setTimeout(() => {
const buttonElement = cancelButtonRef.current;
if (buttonElement) {
buttonElement.focus();
}
}, TIMEOUT_MODAL_MOUNTING);
return () => clearTimeout(timeoutId);
}, []);
const keyboardAction = useKeyboardAction();
const handleClose = () => {
onClose();
};
const handleDelete = () => {
removeDoc({ docId: doc.id });
};
const handleCloseKeyDown = keyboardAction(handleClose);
const handleDeleteKeyDown = keyboardAction(handleDelete);
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
onClose={() => onClose()}
onClose={handleClose}
aria-describedby="modal-remove-doc-title"
rightActions={
<>
<Button
ref={cancelButtonRef}
aria-label={t('Cancel the deletion')}
color="secondary"
fullWidth
onClick={() => onClose()}
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
>
{t('Cancel')}
</Button>
@@ -78,11 +109,8 @@ export const ModalRemoveDoc = ({
aria-label={t('Delete document')}
color="danger"
fullWidth
onClick={() =>
removeDoc({
docId: doc.id,
})
}
onClick={handleDelete}
onKeyDown={handleDeleteKeyDown}
>
{t('Delete')}
</Button>
@@ -108,7 +136,8 @@ export const ModalRemoveDoc = ({
</Text>
<ButtonCloseModal
aria-label={t('Close the delete modal')}
onClick={() => onClose()}
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
/>
</Box>
}

View File

@@ -80,6 +80,7 @@ export interface Doc {
children_create: boolean;
children_list: boolean;
collaboration_auth: boolean;
comment: boolean;
destroy: boolean;
duplicate: boolean;
favorite: boolean;
@@ -132,5 +133,6 @@ export interface AccessRequest {
partial_update: boolean;
retrieve: boolean;
accept: boolean;
set_role_to: Role[];
};
}

View File

@@ -90,12 +90,14 @@ export const DocRoleDropdown = ({
const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map(
(key, index) => {
const isLast = index === Object.keys(translatedRoles).length - 1;
const isRoleAllowed = rolesAllowed?.includes(key as Role) ?? true;
return {
label: transRole(key as Role),
callback: () => onSelectRole?.(key as Role),
isSelected: currentRole === (key as Role),
showSeparator: isLast,
disabled: isLastOwner && key !== 'owner',
disabled: (isLastOwner && key !== 'owner') || !isRoleAllowed,
};
},
);

View File

@@ -77,6 +77,7 @@ const DocShareAccessRequestItem = ({ doc, accessRequest }: Props) => {
currentRole={role}
onSelectRole={setRole}
canUpdate={doc.abilities.accesses_manage}
rolesAllowed={accessRequest.abilities.set_role_to}
/>
<Button
color="tertiary"

View File

@@ -108,6 +108,12 @@ export const DocShareAddMemberList = ({
afterInvite?.();
setIsLoading(false);
};
const inviteLabel =
selectedUsers.length === 1
? t('Invite {{name}}', {
name: selectedUsers[0].full_name || selectedUsers[0].email,
})
: t('Invite {{count}} members', { count: selectedUsers.length });
return (
<Box
@@ -143,7 +149,11 @@ export const DocShareAddMemberList = ({
currentRole={invitationRole}
onSelectRole={setInvitationRole}
/>
<Button onClick={() => void onInvite()} disabled={isLoading}>
<Button
onClick={() => void onInvite()}
disabled={isLoading}
aria-label={inviteLabel}
>
{t('Invite')}
</Button>
</Box>

View File

@@ -1,4 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
@@ -10,6 +11,7 @@ type Props = {
onRemoveUser?: (user: User) => void;
};
export const DocShareAddMemberListItem = ({ user, onRemoveUser }: Props) => {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens, fontSizesTokens } =
useCunninghamTheme();
@@ -42,6 +44,9 @@ export const DocShareAddMemberListItem = ({ user, onRemoveUser }: Props) => {
size="nano"
onClick={() => onRemoveUser?.(user)}
icon={<Icon $variation="600" $size="sm" iconName="close" />}
aria-label={t('Remove {{name}} from the invite list', {
name: user.full_name || user.email,
})}
/>
</Box>
);

View File

@@ -4,9 +4,7 @@ import {
QuickSearchItemContentProps,
} from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { User } from '@/features/auth';
import { UserAvatar } from './UserAvatar';
import { User, UserAvatar } from '@/features/auth';
type Props = {
user: User;
@@ -36,7 +34,7 @@ export const SearchUserRow = ({
className="--docs--search-user-row"
>
<UserAvatar
user={user}
fullName={user.full_name || user.email}
background={
isInvitation ? colorsTokens['greyscale-400'] : undefined
}

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