mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-07 07:32:33 +02:00
Compare commits
2 Commits
feature/ov
...
release/3.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e8dc8f6b3 | ||
|
|
a714d5da4c |
11
.github/workflows/docker-hub.yml
vendored
11
.github/workflows/docker-hub.yml
vendored
@@ -59,14 +59,6 @@ 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
|
||||
@@ -84,7 +76,7 @@ jobs:
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production --build-arg CUSTOM_CODE=docs-custom'
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
@@ -95,7 +87,6 @@ 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 }}
|
||||
|
||||
6
.github/workflows/helmfile-linter.yaml
vendored
6
.github/workflows/helmfile-linter.yaml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
HELMFILE=src/helm/helmfile.yaml.gotmpl
|
||||
HELMFILE=src/helm/helmfile.yaml
|
||||
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 lint -f $HELMFILE || exit 1
|
||||
helmfile -e $env -f $HELMFILE lint || exit 1
|
||||
echo -e "\n"
|
||||
done
|
||||
done
|
||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -6,73 +6,15 @@ 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
|
||||
|
||||
- ✨(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
|
||||
|
||||
- ♻️(frontend) adapt custom blocks to new implementation #1375
|
||||
- ♻️(backend) increase user short_name field length #1510
|
||||
- 🚸(frontend) separate viewers from editors #1509
|
||||
- ♻️(backend) increase user short_name field length
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix duplicate document entries in grid #1479
|
||||
- 🐛(backend) fix trashbin list #1520
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
|
||||
- 🐛(backend) fix s3 version_id validation #1543
|
||||
- 🐛(frontend) retry check media status after page reload #1555
|
||||
- 🐛(frontend) fix Interlinking memory leak #1560
|
||||
- 🐛(frontend) button new doc UI fix #1557
|
||||
- 🐛(frontend) interlinking UI fix #1557
|
||||
- 🐛(frontend) show full nested doc names with ajustable bar #1456
|
||||
- 🐛(backend) fix trashbin list
|
||||
|
||||
## [3.8.2] - 2025-10-17
|
||||
|
||||
@@ -101,6 +43,10 @@ and this project adheres to
|
||||
- ✨(frontend) add pdf block to the editor #1293
|
||||
- ✨List and restore deleted docs #1450
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) show full nested doc names with ajustable bar #1456
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
|
||||
@@ -143,18 +89,11 @@ and this project adheres to
|
||||
- ✨(frontend) load docs logo from public folder via url #1462
|
||||
- 🔧(keycloak) Fix https required issue in dev mode #1286
|
||||
|
||||
## Removed
|
||||
|
||||
- 🔥(frontend) remove custom DividerBlock ##1375
|
||||
|
||||
## [3.7.0] - 2025-09-12
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(api) add API route to fetch document content #1206
|
||||
- ✨(frontend) doc emojis improvements #1381
|
||||
- add an EmojiPicker in the document tree and document title
|
||||
- remove emoji buttons in menus
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -168,8 +107,6 @@ and this project adheres to
|
||||
- ✨unify tab focus style for better visual consistency #1341
|
||||
- ♿hide decorative icons, label menus, avoid accessible name… #1362
|
||||
- ♻️(tilt) use helm dev-backend chart
|
||||
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
|
||||
- 🩹(frontend) handle properly emojis in interlinking #1381
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -183,7 +120,6 @@ 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
|
||||
|
||||
@@ -886,9 +822,7 @@ 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.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
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.8.2...main
|
||||
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
|
||||
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
|
||||
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
|
||||
|
||||
@@ -19,17 +19,13 @@
|
||||
"matchPackageNames": ["redis"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed pylint versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["pylint"],
|
||||
"allowedVersions": "<4.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"@hocuspocus/provider",
|
||||
"@hocuspocus/server",
|
||||
"docx",
|
||||
"fetch-mock",
|
||||
"node",
|
||||
|
||||
@@ -171,19 +171,3 @@ 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)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Client serializers for the impress core app."""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import binascii
|
||||
import mimetypes
|
||||
@@ -25,13 +24,22 @@ 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 = ["id", "email", "full_name", "short_name", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["full_name", "short_name"]
|
||||
read_only_fields = ["full_name", "short_name"]
|
||||
|
||||
def get_full_name(self, instance):
|
||||
"""Return the full name of the user."""
|
||||
@@ -50,15 +58,6 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
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."""
|
||||
|
||||
@@ -787,9 +786,7 @@ class DocumentAskForAccessCreateSerializer(serializers.Serializer):
|
||||
"""Serializer for creating a document ask for access."""
|
||||
|
||||
role = serializers.ChoiceField(
|
||||
choices=[
|
||||
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
|
||||
],
|
||||
choices=models.RoleChoices.choices,
|
||||
required=False,
|
||||
default=models.RoleChoices.READER,
|
||||
)
|
||||
@@ -813,11 +810,11 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
|
||||
|
||||
def get_abilities(self, instance) -> dict:
|
||||
def get_abilities(self, invitation) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return instance.get_abilities(request.user)
|
||||
return invitation.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -892,124 +889,3 @@ 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 {}
|
||||
|
||||
@@ -21,7 +21,6 @@ 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 _
|
||||
@@ -1106,7 +1105,7 @@ class DocumentViewSet(
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get", "delete"],
|
||||
url_path=r"versions/(?P<version_id>[A-Za-z0-9._+\-=~]{1,1024})",
|
||||
url_path="versions/(?P<version_id>[0-9a-z-]+)",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def versions_detail(self, request, pk, version_id, *args, **kwargs):
|
||||
@@ -1832,7 +1831,10 @@ class DocumentAccessViewSet(
|
||||
|
||||
|
||||
class TemplateViewSet(
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.RetrieveModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Template ViewSet"""
|
||||
@@ -1888,6 +1890,100 @@ 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,
|
||||
@@ -2066,18 +2162,7 @@ class DocumentAskForAccessViewSet(
|
||||
serializer = serializers.RoleSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
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)
|
||||
document_ask_for_access.accept(role=serializer.validated_data.get("role"))
|
||||
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -2151,132 +2236,3 @@ 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)
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
|
||||
|
||||
@@ -41,7 +40,6 @@ 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")
|
||||
|
||||
@@ -256,49 +256,3 @@ 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)
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
# 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.",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -756,7 +756,6 @@ 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
|
||||
@@ -787,7 +786,6 @@ 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,
|
||||
@@ -1148,12 +1146,7 @@ class DocumentAccess(BaseAccess):
|
||||
set_role_to = []
|
||||
if is_owner_or_admin:
|
||||
set_role_to.extend(
|
||||
[
|
||||
RoleChoices.READER,
|
||||
RoleChoices.COMMENTER,
|
||||
RoleChoices.EDITOR,
|
||||
RoleChoices.ADMIN,
|
||||
]
|
||||
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
|
||||
)
|
||||
if role == RoleChoices.OWNER:
|
||||
set_role_to.append(RoleChoices.OWNER)
|
||||
@@ -1212,14 +1205,23 @@ class DocumentAskForAccess(BaseModel):
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user."""
|
||||
user_role = self.document.get_role(user)
|
||||
is_admin_or_owner = user_role in PRIVILEGED_ROLES
|
||||
roles = []
|
||||
|
||||
set_role_to = [
|
||||
role
|
||||
for role in RoleChoices.values
|
||||
if RoleChoices.get_priority(role) <= RoleChoices.get_priority(user_role)
|
||||
]
|
||||
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})
|
||||
)
|
||||
|
||||
return {
|
||||
"destroy": is_admin_or_owner,
|
||||
@@ -1227,7 +1229,6 @@ 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):
|
||||
@@ -1277,153 +1278,6 @@ 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."""
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(4): # user by sub, user by mail, update sub
|
||||
with django_assert_num_queries(3): # user by sub, user by mail, update sub
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(3):
|
||||
with django_assert_num_queries(2):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(3):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
@@ -293,7 +293,6 @@ def test_api_document_accesses_retrieve_set_role_to_child():
|
||||
}
|
||||
assert result_dict[str(document_access_other_user.id)] == [
|
||||
"reader",
|
||||
"commenter",
|
||||
"editor",
|
||||
"administrator",
|
||||
"owner",
|
||||
@@ -302,7 +301,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="commenter"
|
||||
document=parent, user=other_user, role="editor"
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||
@@ -315,7 +314,6 @@ 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",
|
||||
@@ -323,7 +321,6 @@ 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",
|
||||
@@ -336,28 +333,28 @@ def test_api_document_accesses_retrieve_set_role_to_child():
|
||||
[
|
||||
["administrator", "reader", "reader", "reader"],
|
||||
[
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
["reader", "editor", "administrator"],
|
||||
[],
|
||||
[],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
["reader", "editor", "administrator"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["owner", "reader", "reader", "reader"],
|
||||
[
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["owner", "reader", "reader", "owner"],
|
||||
[
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -418,44 +415,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
|
||||
[
|
||||
["administrator", "reader", "reader", "reader"],
|
||||
[
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
["reader", "editor", "administrator"],
|
||||
[],
|
||||
[],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
["reader", "editor", "administrator"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["owner", "reader", "reader", "reader"],
|
||||
[
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["owner", "reader", "reader", "owner"],
|
||||
[
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["reader", "reader", "reader", "owner"],
|
||||
[
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
[],
|
||||
[],
|
||||
["reader", "commenter", "editor", "administrator", "owner"],
|
||||
["reader", "editor", "administrator", "owner"],
|
||||
],
|
||||
],
|
||||
[
|
||||
["reader", "administrator", "reader", "editor"],
|
||||
[
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
["reader", "editor", "administrator"],
|
||||
["reader", "editor", "administrator"],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
@@ -463,7 +460,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
|
||||
[
|
||||
["editor", "editor", "administrator", "editor"],
|
||||
[
|
||||
["reader", "commenter", "editor", "administrator"],
|
||||
["reader", "editor", "administrator"],
|
||||
[],
|
||||
["editor", "administrator"],
|
||||
[],
|
||||
|
||||
@@ -115,10 +115,7 @@ def test_api_documents_ask_for_access_create_authenticated_non_root_document():
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@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):
|
||||
def test_api_documents_ask_for_access_create_authenticated_specific_role():
|
||||
"""
|
||||
Authenticated users should be able to create a document ask for access with a specific role.
|
||||
"""
|
||||
@@ -130,35 +127,17 @@ def test_api_documents_ask_for_access_create_authenticated_specific_role(role):
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id}/ask-for-access/",
|
||||
data={"role": role},
|
||||
data={"role": RoleChoices.EDITOR},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert DocumentAskForAccess.objects.filter(
|
||||
document=document,
|
||||
user=user,
|
||||
role=role,
|
||||
role=RoleChoices.EDITOR,
|
||||
).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()
|
||||
@@ -287,7 +266,6 @@ def test_api_documents_ask_for_access_list_authenticated_own_request():
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"retrieve": False,
|
||||
"set_role_to": [],
|
||||
},
|
||||
}
|
||||
],
|
||||
@@ -357,16 +335,6 @@ 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,
|
||||
@@ -386,7 +354,6 @@ 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
|
||||
@@ -479,14 +446,6 @@ 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),
|
||||
@@ -501,7 +460,6 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -791,53 +749,6 @@ 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):
|
||||
"""
|
||||
|
||||
@@ -1,878 +0,0 @@
|
||||
"""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()
|
||||
@@ -36,7 +36,6 @@ 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,
|
||||
@@ -47,8 +46,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": False,
|
||||
@@ -114,7 +113,6 @@ 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,
|
||||
@@ -222,7 +220,6 @@ 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,
|
||||
@@ -232,8 +229,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
@@ -307,7 +304,6 @@ 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,
|
||||
@@ -498,14 +494,13 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"abilities": {
|
||||
"accesses_manage": access.role in ["administrator", "owner"],
|
||||
"accesses_view": True,
|
||||
"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"],
|
||||
"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",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"comment": access.role != "reader",
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
@@ -521,11 +516,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 not in ["reader", "commenter"],
|
||||
"partial_update": access.role != "reader",
|
||||
"restore": access.role == "owner",
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": access.role not in ["reader", "commenter"],
|
||||
"update": access.role != "reader",
|
||||
"versions_destroy": access.role in ["administrator", "owner"],
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,7 +81,6 @@ def test_api_documents_trashbin_format():
|
||||
"collaboration_auth": False,
|
||||
"descendants": False,
|
||||
"cors_proxy": False,
|
||||
"comment": False,
|
||||
"content": False,
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
@@ -89,8 +88,8 @@ def test_api_documents_trashbin_format():
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": False,
|
||||
@@ -307,12 +306,9 @@ def test_api_documents_trashbin_empty_queryset_bug():
|
||||
|
||||
# Create some deleted documents owned by other users
|
||||
other_user = factories.UserFactory()
|
||||
item1 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item1.soft_delete()
|
||||
item2 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item2.soft_delete()
|
||||
item3 = factories.DocumentFactory(users=[(other_user, "owner")])
|
||||
item3.soft_delete()
|
||||
factories.DocumentFactory(users=[(other_user, "owner")], deleted_at=timezone.now())
|
||||
factories.DocumentFactory(users=[(other_user, "owner")], deleted_at=timezone.now())
|
||||
factories.DocumentFactory(users=[(other_user, "owner")], deleted_at=timezone.now())
|
||||
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
|
||||
799
src/backend/core/tests/templates/test_api_template_accesses.py
Normal file
799
src/backend/core/tests/templates/test_api_template_accesses.py
Normal file
@@ -0,0 +1,799 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
@@ -42,5 +42,7 @@ def test_api_templates_create_authenticated():
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert not Template.objects.exists()
|
||||
assert response.status_code == 201
|
||||
template = Template.objects.get()
|
||||
assert template.title == "my template"
|
||||
assert template.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
|
||||
@@ -24,7 +25,7 @@ def test_api_templates_delete_anonymous():
|
||||
assert models.Template.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_templates_delete_not_implemented():
|
||||
def test_api_templates_delete_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a template to which they are not
|
||||
related.
|
||||
@@ -35,11 +36,72 @@ def test_api_templates_delete_not_implemented():
|
||||
client.force_login(user)
|
||||
|
||||
is_public = random.choice([True, False])
|
||||
template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")])
|
||||
template = factories.TemplateFactory(is_public=is_public)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/templates/{template.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == 403 if is_public else 404
|
||||
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
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
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
|
||||
|
||||
@@ -14,6 +17,7 @@ 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()
|
||||
@@ -24,18 +28,145 @@ 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_not_implemented():
|
||||
def test_api_templates_update_authenticated_unrelated():
|
||||
"""
|
||||
Authenticated users should not be allowed to update a template.
|
||||
Authenticated users should not be allowed to update a template to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(users=[(user, "owner")])
|
||||
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
|
||||
|
||||
new_template_values = serializers.TemplateSerializer(
|
||||
instance=factories.TemplateFactory()
|
||||
@@ -45,10 +176,55 @@ def test_api_templates_update_not_implemented():
|
||||
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
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]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
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
|
||||
|
||||
@@ -278,35 +278,6 @@ 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()
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -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", "commenter", "editor", "administrator", "owner"],
|
||||
"set_role_to": ["reader", "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", "commenter", "editor", "administrator", "owner"],
|
||||
"set_role_to": ["reader", "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", "commenter", "editor", "administrator", "owner"],
|
||||
"set_role_to": ["reader", "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", "commenter", "editor", "administrator", "owner"],
|
||||
"set_role_to": ["reader", "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", "commenter", "editor", "administrator", "owner"],
|
||||
"set_role_to": ["reader", "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", "commenter", "editor", "administrator", "owner"],
|
||||
"set_role_to": ["reader", "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", "commenter", "editor", "administrator"],
|
||||
"set_role_to": ["reader", "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", "commenter", "editor", "administrator"],
|
||||
"set_role_to": ["reader", "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", "commenter", "editor", "administrator"],
|
||||
"set_role_to": ["reader", "editor", "administrator"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -134,13 +134,10 @@ 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(
|
||||
@@ -168,7 +165,6 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
"favorite": False,
|
||||
"comment": False,
|
||||
"invite_owner": False,
|
||||
"mask": False,
|
||||
"media_auth": False,
|
||||
@@ -176,8 +172,8 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"move": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"partial_update": False,
|
||||
@@ -227,7 +223,6 @@ 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,
|
||||
@@ -237,78 +232,8 @@ def test_models_documents_get_abilities_reader(
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"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"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": is_authenticated,
|
||||
@@ -364,7 +289,6 @@ 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,
|
||||
@@ -374,8 +298,8 @@ def test_models_documents_get_abilities_editor(
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": is_authenticated,
|
||||
@@ -420,7 +344,6 @@ 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,
|
||||
@@ -430,8 +353,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
@@ -462,7 +385,6 @@ 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,
|
||||
@@ -472,8 +394,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": False,
|
||||
@@ -508,7 +430,6 @@ 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,
|
||||
@@ -518,8 +439,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"invite_owner": False,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
@@ -564,7 +485,6 @@ 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,
|
||||
@@ -574,8 +494,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
@@ -627,8 +547,6 @@ 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,
|
||||
@@ -638,73 +556,8 @@ def test_models_documents_get_abilities_reader_user(
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"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"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
@@ -754,7 +607,6 @@ 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,
|
||||
@@ -764,8 +616,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
@@ -1468,14 +1320,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
"public",
|
||||
"reader",
|
||||
{
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
"public",
|
||||
"commenter",
|
||||
{
|
||||
"public": ["commenter", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
("public", "editor", {"public": ["editor"]}),
|
||||
@@ -1483,16 +1328,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
"authenticated",
|
||||
"reader",
|
||||
{
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
"authenticated",
|
||||
"commenter",
|
||||
{
|
||||
"authenticated": ["commenter", "editor"],
|
||||
"public": ["commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -1505,17 +1342,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
"reader",
|
||||
{
|
||||
"restricted": None,
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
"restricted",
|
||||
"commenter",
|
||||
{
|
||||
"restricted": None,
|
||||
"authenticated": ["commenter", "editor"],
|
||||
"public": ["commenter", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -1532,15 +1360,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
"public",
|
||||
None,
|
||||
{
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
None,
|
||||
"reader",
|
||||
{
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
),
|
||||
@@ -1548,8 +1376,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
None,
|
||||
None,
|
||||
{
|
||||
"public": ["reader", "commenter", "editor"],
|
||||
"authenticated": ["reader", "commenter", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"restricted": None,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -26,22 +26,20 @@ 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",
|
||||
)
|
||||
|
||||
thread_related_router = DefaultRouter()
|
||||
thread_related_router.register(
|
||||
"comments",
|
||||
viewsets.CommentViewSet,
|
||||
basename="comments",
|
||||
|
||||
# - Routes nested under a template
|
||||
template_related_router = DefaultRouter()
|
||||
template_related_router.register(
|
||||
"accesses",
|
||||
viewsets.TemplateAccessViewSet,
|
||||
basename="template_accesses",
|
||||
)
|
||||
|
||||
|
||||
@@ -57,8 +55,8 @@ urlpatterns = [
|
||||
include(document_related_router.urls),
|
||||
),
|
||||
re_path(
|
||||
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
|
||||
include(thread_related_router.urls),
|
||||
r"^templates/(?P<resource_id>[0-9a-z-]*)/",
|
||||
include(template_related_router.urls),
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Breton\n"
|
||||
"Language: br_FR\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Titouroù personel"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Aotreoù"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Deiziadoù a-bouez"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Gwezennadur"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr "Doare korf"
|
||||
msgid "Format"
|
||||
msgstr "Stumm"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "eilenn {title}"
|
||||
@@ -178,228 +178,228 @@ msgstr ""
|
||||
msgid "full name"
|
||||
msgstr "anv klok"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "anv berr"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "postel identelezh"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "postel ar merour"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "yezh"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Ar yezh a vo implijet evit etrefas an implijer."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Ar gwerzhid-eur a vo implijet evit etrefas an implijer."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "trevnad"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Pe vefe an implijer un aparailh pe un implijer gwirion."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "statud ar skipailh"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ma c'hall an implijer kevreañ ouzh al lec'hienn verañ-mañ."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "oberiant"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "implijer"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "implijerien"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "titl"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr "bomm"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "Restr"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "Restroù"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "Restr hep titl"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Roud liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Roudoù liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "Restr muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "Restroù muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr "Liamm restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr "Liammoù restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "An implijer-mañ a zo dija er restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
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:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr "Goulenn tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Goulennoù tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
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:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, 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:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, 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:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, 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:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "deskrivadur"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "kod"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "publik"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
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:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "Patrom"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "Patromoù"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr "Liamm patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr "Liammoù patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "An implijer-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "postel"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Pedadenn d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Pedadennoù d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Daten"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Baumstruktur"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr "Typ"
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "Kopie von {title}"
|
||||
@@ -178,228 +178,228 @@ msgstr ""
|
||||
msgid "full name"
|
||||
msgstr "Name"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "Kurzbezeichnung"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "Admin E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "Status des Teammitgliedes"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "aktiviert"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr "Auszug"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
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:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Dieses Team ist bereits in diesem Template."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
@@ -178,228 +178,228 @@ msgstr ""
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Información Personal"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Permisos"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Fechas importantes"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Estructura en árbol"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr "Tipo de Cuerpo"
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia de {title}"
|
||||
@@ -178,228 +178,228 @@ msgstr "Obligatorio. 255 caracteres o menos. Solo caracteres ASCII."
|
||||
msgid "full name"
|
||||
msgstr "nombre completo"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "nombre abreviado"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "correo electrónico de identidad"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "correo electrónico del administrador"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "idioma"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "El idioma en el que el usuario desea ver la interfaz."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "La zona horaria en la que el usuario quiere ver los tiempos."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "dispositivo"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si el usuario es un dispositivo o un usuario real."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "rol en el equipo"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si el usuario puede iniciar sesión en esta página web de administración."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "activo"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "usuario"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "usuarios"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "título"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr "resumen"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "Documento"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "Documentos"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento sin título"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "¡{name} ha compartido un documento contigo!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha compartido un documento contigo: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Traza del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Trazas del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Ya existe una traza de enlace para este documento/usuario."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento favorito"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "Documentos favoritos"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relación documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relaciones documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Este usuario ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
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:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Debe establecerse un usuario o un equipo, no ambos."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr "Solicitud de acceso"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Solicitud de accesos"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
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:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "¡{name} desea acceder a un documento!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} desea acceso al siguiente documento:"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, 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:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "descripción"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "código"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "público"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
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:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "Plantilla"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "Plantillas"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relación plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relaciones plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Este usuario ya forma parte de la plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Este equipo ya se encuentra en esta plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "dirección de correo electrónico"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitación al documento"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitaciones a documentos"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Este correo electrónico está asociado a un usuario registrado."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Infos Personnelles"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Arborescence"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr "Type de corps"
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copie de {title}"
|
||||
@@ -178,228 +178,228 @@ msgstr "Obligatoire. 255 caractères ou moins. Caractères ASCII uniquement."
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail de l'administrateur"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "statut d'équipe"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "titre"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr "extrait"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "Document"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "Documents"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "Document sans titre"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous : {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Trace du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Traces du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favori"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "Documents favoris"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ce document est déjà un favori de cet utilisateur."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relation document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relations document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Cet utilisateur est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
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:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
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:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} souhaiterait accéder au document suivant !"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, 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:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, 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:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
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:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "Modèle"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "Modèles"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relation modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relations modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Cet utilisateur est déjà dans ce modèle."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Cette équipe est déjà modèle."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "adresse e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitation à un document"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitations à un document"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Informazioni personali"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Permessi"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Date importanti"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Struttura ad albero"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia di {title}"
|
||||
@@ -178,228 +178,228 @@ msgstr ""
|
||||
msgid "full name"
|
||||
msgstr "nome completo"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "nome"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "indirizzo email di identità"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "Indirizzo email dell'amministratore"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "lingua"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La lingua in cui l'utente vuole vedere l'interfaccia."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Il fuso orario in cui l'utente vuole vedere gli orari."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "dispositivo"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Se l'utente è un dispositivo o un utente reale."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "stato del personale"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Indica se l'utente può accedere a questo sito amministratore."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "attivo"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "utente"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "utenti"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "titolo"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "Documento"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "Documenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento senza titolo"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ha condiviso un documento con te!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha condiviso un documento con te: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento preferito"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "Documenti preferiti"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Questo utente è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
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:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "descrizione"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "pubblico"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Indica se questo modello è pubblico per chiunque."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "Modello"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "Modelli"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Questo utente è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Questo team è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "indirizzo e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Invito al documento"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Inviti al documento"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Questa email è già associata a un utente registrato."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -17,22 +17,22 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Machtigingen"
|
||||
msgstr "Toestemmingen"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke data"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Boomstructuur"
|
||||
msgstr "Document structuur"
|
||||
|
||||
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
|
||||
msgid "Title"
|
||||
@@ -40,7 +40,7 @@ msgstr "Titel"
|
||||
|
||||
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
|
||||
msgid "Creator is me"
|
||||
msgstr "Ik ben eigenaar"
|
||||
msgstr "Ik ben Eigenaar"
|
||||
|
||||
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
|
||||
msgid "Masked"
|
||||
@@ -48,15 +48,15 @@ msgstr "Gemaskeerd"
|
||||
|
||||
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriet"
|
||||
msgstr "Favoriete"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Een nieuw document is namens u gemaakt!"
|
||||
msgstr "Een nieuw document was gecreëerd voor u!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
|
||||
msgstr "U heeft eigenaarschap van een nieuw document:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
|
||||
msgid "This field is required."
|
||||
@@ -79,7 +79,7 @@ msgstr "Text type"
|
||||
msgid "Format"
|
||||
msgstr "Formaat"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "kopie van {title}"
|
||||
@@ -92,11 +92,11 @@ msgstr "Lezer"
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
msgid "Editor"
|
||||
msgstr "Redacteur"
|
||||
msgstr "Bewerker"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
msgid "Administrator"
|
||||
msgstr "Beheerder"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
msgid "Owner"
|
||||
@@ -104,7 +104,7 @@ msgstr "Eigenaar"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
msgid "Restricted"
|
||||
msgstr "Beperkt"
|
||||
msgstr "Niet toegestaan"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
msgid "Authenticated"
|
||||
@@ -148,11 +148,11 @@ msgstr "primaire sleutel voor dossier als UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
msgid "created on"
|
||||
msgstr "gecreëerd op"
|
||||
msgstr "gemaakt op"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop dossier is gecreeërd"
|
||||
msgstr "datum en tijd wanneer dossier was gecreëerd"
|
||||
|
||||
#: build/lib/core/models.py:93 core/models.py:93
|
||||
msgid "updated on"
|
||||
@@ -164,7 +164,7 @@ msgstr "datum en tijd waarop dossier laatst was gewijzigd"
|
||||
|
||||
#: build/lib/core/models.py:130 core/models.py:130
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Wij konden geen gebruiker vinden met dit id, maar de email is al geassocieerd met een geregistreerde gebruiker."
|
||||
msgstr "Wij konden geen gebruiker vinden met deze id, maar de email is al geassocieerd met een geregistreerde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:141 core/models.py:141
|
||||
msgid "sub"
|
||||
@@ -172,234 +172,234 @@ msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:142 core/models.py:142
|
||||
msgid "Required. 255 characters or fewer. ASCII characters only."
|
||||
msgstr "Vereist. 255 tekens of minder. Alleen ASCII tekens."
|
||||
msgstr "Vereist. Minder dan 255 ASCII tekens."
|
||||
|
||||
#: build/lib/core/models.py:150 core/models.py:150
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "gebruikersnaam"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit emailadres"
|
||||
msgstr "identiteit email adres"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "admin emailadres"
|
||||
msgstr "admin email adres"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
msgstr "De taal waarin de gebruiker de interface wilt zien."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
|
||||
msgstr "De tijdzone waarin de gebruiker de tijden wilt zien."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "beheerder status"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen in het beheer gedeelte."
|
||||
msgstr "Of de gebruiker kan inloggen in het admin gedeelte."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "titel"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr "uittreksel"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "Document"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "Documenten"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "Naamloos Document"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} heeft een document met u gedeeld!"
|
||||
msgstr "{name} heeft een document met gedeeld!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} heeft een document met u gedeeld: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Document/gebruiker link"
|
||||
msgstr "Document/gebruiker url"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Document/gebruiker link"
|
||||
msgstr "Document/gebruiker url"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Een link bestaat al voor dit document/deze gebruiker."
|
||||
msgstr "Een url bestaat al voor dit document/deze gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favoriet"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "Document favorieten"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
|
||||
msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr "Document/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr "Document/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "De gebruiker bestaat al in dit document."
|
||||
msgstr "De gebruiker is al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dit team bestaat al in dit document."
|
||||
msgstr "Het team is al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr "Document verzoekt om toegang"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Document verzoekt om toegangen"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
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:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} verzoekt toegang tot een document!"
|
||||
|
||||
#: 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:"
|
||||
msgstr "{name} wenst toegang tot een document!"
|
||||
|
||||
#: 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}"
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} wenst toegang tot het volgende document:"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} vraagt toegang tot het document: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "omschrijving"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "publiek"
|
||||
|
||||
#: 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:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Of dit template als publiek is en door iedereen te gebruiken is."
|
||||
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "Sjabloon"
|
||||
msgstr "Template"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "Sjabloon"
|
||||
msgstr "Templates"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr "Sjabloon/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
msgid "Template/user relations"
|
||||
msgstr "Sjabloon/gebruiker relaties"
|
||||
msgstr "Template/gebruiker relatie"
|
||||
|
||||
#: 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."
|
||||
msgid "Template/user relations"
|
||||
msgstr "Template/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "De gebruiker bestaat al in dit template."
|
||||
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Het team bestaat al in dit sjabloon."
|
||||
msgstr "Het team bestaat al in dit template."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "e-mailadres"
|
||||
msgstr "email adres"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Document uitnodiging"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Document uitnodigingen"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Language: pt_PT\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Informações Pessoais"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Permissões"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Datas importantes"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Estrutura de árvore"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr "Tipo de corpo"
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "cópia de {title}"
|
||||
@@ -178,228 +178,228 @@ msgstr ""
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Личная информация"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Разрешения"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Важные даты"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Древовидная структура"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr "Тип сообщения"
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копия {title}"
|
||||
@@ -178,228 +178,228 @@ msgstr "Обязательно. 255 символов или меньше. Тол
|
||||
msgid "full name"
|
||||
msgstr "полное имя"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "короткое имя"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "личный адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "e-mail администратора"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "язык"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "устройство"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Пользователь является устройством или человеком."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "статус сотрудника"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Может ли пользователь войти на этот административный сайт."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "активный"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "пользователь"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "пользователи"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr "отрывок"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "Документ"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "Документы"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "Безымянный документ"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} делится с вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} делится с вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трассировка связи документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трассировка связей документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "Избранный документ"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "Избранные документы"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Этот документ уже помечен как избранный для этого пользователя."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr "Отношение документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr "Отношения документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Этот пользователь уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Эта команда уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr "Документ запрашивает доступ"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Документ запрашивает доступы"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Этот пользователь уже запросил доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хочет получить доступ к документу!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} хочет получить доступ к следующему документу:"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запрашивает доступ к документу: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "описание"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "доступно всем"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Этот шаблон доступен всем пользователям."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "Шаблоны"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr "Отношение шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr "Отношения шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Этот пользователь уже указан в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Эта команда уже указана в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Приглашение для документа"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Приглашения для документов"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Osebni podatki"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Dovoljenja"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Pomembni datumi"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Drevesna struktura"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr "Vrsta telesa"
|
||||
msgid "Format"
|
||||
msgstr "Oblika"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
@@ -178,228 +178,228 @@ msgstr ""
|
||||
msgid "full name"
|
||||
msgstr "polno ime"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "kratko ime"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "elektronski naslov identitete"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "elektronski naslov skrbnika"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "jezik"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Jezik, v katerem uporabnik želi videti vmesnik."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Časovni pas, v katerem želi uporabnik videti uro."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "naprava"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ali je uporabnik naprava ali pravi uporabnik."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "kadrovski status"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ali se uporabnik lahko prijavi na to skrbniško mesto."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "aktivni"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Ali je treba tega uporabnika obravnavati kot aktivnega. Namesto brisanja računov počistite to izbiro."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "uporabniki"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "naslov"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr "odlomek"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "Dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "Dokument brez naslova"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} je delil dokument z vami!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} je delil dokument z vami: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/sled povezave uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Sledi povezav dokumenta/uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "Priljubljeni dokument"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "Priljubljeni dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr "Odnos dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr "Odnosi dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Ta uporabnik je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
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:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "opis"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "koda"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "javno"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ali je ta predloga javna za uporabo."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "Predloga"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "Predloge"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr "Odnos predloga/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr "Odnosi med predlogo in uporabnikom"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Ta uporabnik je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ta ekipa je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "elektronski naslov"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Vabilo na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Vabila na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Language: sv_SE\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Personuppgifter"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Behörigheter"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Viktiga datum"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
@@ -178,228 +178,228 @@ msgstr ""
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "e-postadress"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Bjud in dokument"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Inbjudningar dokument"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
@@ -178,228 +178,228 @@ msgstr ""
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "Особисті дані"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "Дозволи"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "Важливі дати"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "Ієрархічна структура"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr "Тип вмісту"
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копія {title}"
|
||||
@@ -178,228 +178,228 @@ msgstr "Обов'язкове. 255 символів або менше. Тіль
|
||||
msgid "full name"
|
||||
msgstr "повне ім'я"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "коротке ім'я"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "адреса електронної пошти особи"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "електронна адреса адміністратора"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "мова"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Мова, якою користувач хоче бачити інтерфейс."
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Часовий пояс, в якому користувач хоче бачити час."
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "пристрій"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Чи є користувач пристроєм чи реальним користувачем."
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "статус співробітника"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Чи може користувач увійти на цей сайт адміністратора."
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "активний"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "користувач"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "користувачі"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr "уривок"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "Документ"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "Документи"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "Документ без назви"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ділиться з вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ділиться з вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трасування посилання Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трасування посилань Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "Обраний документ"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "Обрані документи"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Цей документ вже вказаний як обраний для одного користувача."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Цей користувач вже має доступ до цього документу."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ця команда вже має доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Вкажіть користувача або команду, а не обох."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr "Запит доступу до документа"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Запит доступу для документа"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Цей користувач вже попросив доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хоче отримати доступ до документа!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} бажає отримати доступ до наступного документа:"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запитує доступ до документа: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "опис"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "публічне"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "Шаблони"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Цей користувач вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ця команда вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "електронна адреса"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "Запрошення до редагування документа"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "Запрошення до редагування документів"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
|
||||
"PO-Revision-Date: 2025-10-14 13:09\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Language: zh_CN\n"
|
||||
@@ -17,20 +17,20 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:36 core/admin.py:36
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr "个人信息"
|
||||
|
||||
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
|
||||
#: core/admin.py:137
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr "权限"
|
||||
|
||||
#: build/lib/core/admin.py:61 core/admin.py:61
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr "重要日期"
|
||||
|
||||
#: build/lib/core/admin.py:147 core/admin.py:147
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr "树状结构"
|
||||
|
||||
@@ -79,7 +79,7 @@ msgstr "正文类型"
|
||||
msgid "Format"
|
||||
msgstr "格式"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "{title} 的副本"
|
||||
@@ -178,228 +178,228 @@ msgstr "必填项。限255个字符以内。仅支持ASCII字符。"
|
||||
msgid "full name"
|
||||
msgstr "全名"
|
||||
|
||||
#: build/lib/core/models.py:152 core/models.py:152
|
||||
#: build/lib/core/models.py:151 core/models.py:151
|
||||
msgid "short name"
|
||||
msgstr "简称"
|
||||
|
||||
#: build/lib/core/models.py:155 core/models.py:155
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "身份电子邮件地址"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
#: build/lib/core/models.py:158 core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "管理员电子邮件地址"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
#: build/lib/core/models.py:165 core/models.py:165
|
||||
msgid "language"
|
||||
msgstr "语言"
|
||||
|
||||
#: build/lib/core/models.py:168 core/models.py:168
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "用户希望看到的界面语言。"
|
||||
|
||||
#: build/lib/core/models.py:176 core/models.py:176
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "用户查看时间希望的时区。"
|
||||
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
#: build/lib/core/models.py:177 core/models.py:177
|
||||
msgid "device"
|
||||
msgstr "设备"
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
#: build/lib/core/models.py:179 core/models.py:179
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "用户是设备还是真实用户。"
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:182 core/models.py:182
|
||||
msgid "staff status"
|
||||
msgstr "员工状态"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "用户是否可以登录该管理员站点。"
|
||||
|
||||
#: build/lib/core/models.py:189 core/models.py:189
|
||||
#: build/lib/core/models.py:187 core/models.py:187
|
||||
msgid "active"
|
||||
msgstr "激活"
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
#: build/lib/core/models.py:190 core/models.py:190
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
|
||||
|
||||
#: build/lib/core/models.py:204 core/models.py:204
|
||||
#: build/lib/core/models.py:202 core/models.py:202
|
||||
msgid "user"
|
||||
msgstr "用户"
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "users"
|
||||
msgstr "个用户"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
|
||||
#: core/models.py:359 core/models.py:1282
|
||||
msgid "title"
|
||||
msgstr "标题"
|
||||
|
||||
#: build/lib/core/models.py:362 core/models.py:362
|
||||
#: build/lib/core/models.py:360 core/models.py:360
|
||||
msgid "excerpt"
|
||||
msgstr "摘要"
|
||||
|
||||
#: build/lib/core/models.py:411 core/models.py:411
|
||||
#: build/lib/core/models.py:409 core/models.py:409
|
||||
msgid "Document"
|
||||
msgstr "文档"
|
||||
|
||||
#: build/lib/core/models.py:412 core/models.py:412
|
||||
#: build/lib/core/models.py:410 core/models.py:410
|
||||
msgid "Documents"
|
||||
msgstr "个文档"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
|
||||
#: core/models.py:820
|
||||
msgid "Untitled Document"
|
||||
msgstr "未命名文档"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:855 core/models.py:855
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} 与您共享了一个文档!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} 与您共享了一个文档:{title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:965 core/models.py:965
|
||||
msgid "Document/user link trace"
|
||||
msgstr "文档/用户链接跟踪"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document/user link traces"
|
||||
msgstr "个文档/用户链接跟踪"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:972 core/models.py:972
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "此文档/用户的链接跟踪已存在。"
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:995 core/models.py:995
|
||||
msgid "Document favorite"
|
||||
msgstr "文档收藏"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:996 core/models.py:996
|
||||
msgid "Document favorites"
|
||||
msgstr "文档收藏夹"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1002 core/models.py:1002
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "该文档已被同一用户的收藏关系实例关联。"
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1024 core/models.py:1024
|
||||
msgid "Document/user relation"
|
||||
msgstr "文档/用户关系"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1025 core/models.py:1025
|
||||
msgid "Document/user relations"
|
||||
msgstr "文档/用户关系集"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1031 core/models.py:1031
|
||||
msgid "This user is already in this document."
|
||||
msgstr "该用户已在此文档中。"
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1037 core/models.py:1037
|
||||
msgid "This team is already in this document."
|
||||
msgstr "该团队已在此文档中。"
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
|
||||
#: core/models.py:1043 core/models.py:1368
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "必须设置用户或团队之一,不能同时设置两者。"
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1189 core/models.py:1189
|
||||
msgid "Document ask for access"
|
||||
msgstr "文档需要访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1190 core/models.py:1190
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "文档需要访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1196 core/models.py:1196
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "用户已申请该文档的访问权限。"
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1261 core/models.py:1261
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} 申请访问文档!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} 申请访问以下文档:"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1271 core/models.py:1271
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name}申请文档:{title}的访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
msgid "description"
|
||||
msgstr "说明"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1284 core/models.py:1284
|
||||
msgid "code"
|
||||
msgstr "代码"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1285 core/models.py:1285
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1287 core/models.py:1287
|
||||
msgid "public"
|
||||
msgstr "公开"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "该模板是否公开供任何人使用。"
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1295 core/models.py:1295
|
||||
msgid "Template"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1296 core/models.py:1296
|
||||
msgid "Templates"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1349 core/models.py:1349
|
||||
msgid "Template/user relation"
|
||||
msgstr "模板/用户关系"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
msgid "Template/user relations"
|
||||
msgstr "模板/用户关系集"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
msgid "This user is already in this template."
|
||||
msgstr "该用户已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1362 core/models.py:1362
|
||||
msgid "This team is already in this template."
|
||||
msgstr "该团队已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "email address"
|
||||
msgstr "电子邮件地址"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1458 core/models.py:1458
|
||||
msgid "Document invitation"
|
||||
msgstr "文档邀请"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1459 core/models.py:1459
|
||||
msgid "Document invitations"
|
||||
msgstr "文档邀请"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1479 core/models.py:1479
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "此电子邮件已经与现有注册用户关联。"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "3.10.0"
|
||||
version = "3.8.21"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,42 +25,42 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"beautifulsoup4==4.14.2",
|
||||
"boto3==1.40.74",
|
||||
"Brotli==1.2.0",
|
||||
"beautifulsoup4==4.13.4",
|
||||
"boto3==1.39.4",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.5.3",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.1.0",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-csp==4.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.18",
|
||||
"django-filter==25.1",
|
||||
"django-lasuite[all]==0.0.14",
|
||||
"django-parler==2.3",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.8",
|
||||
"django==5.2.7",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"djangorestframework==3.16.0",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.25.1",
|
||||
"lxml==6.0.2",
|
||||
"markdown==3.10",
|
||||
"jsonschema==4.24.0",
|
||||
"lxml==6.0.0",
|
||||
"markdown==3.8.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"openai==2.8.0",
|
||||
"psycopg[binary]==3.2.12",
|
||||
"pycrdt==0.12.43",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.95.0",
|
||||
"psycopg[binary]==3.2.9",
|
||||
"pycrdt==0.12.25",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk==2.44.0",
|
||||
"whitenoise==6.11.0",
|
||||
"requests==2.32.4",
|
||||
"sentry-sdk==2.32.0",
|
||||
"whitenoise==6.9.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -73,21 +73,21 @@ dependencies = [
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"django-test-migrations==1.5.0",
|
||||
"drf-spectacular-sidecar==2025.10.1",
|
||||
"freezegun==1.5.5",
|
||||
"drf-spectacular-sidecar==2025.7.1",
|
||||
"freezegun==1.5.2",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.7.0",
|
||||
"pyfakefs==5.10.2",
|
||||
"ipython==9.4.0",
|
||||
"pyfakefs==5.9.1",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pylint==3.3.7",
|
||||
"pytest-cov==6.2.1",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest==9.0.1",
|
||||
"pytest==8.4.1",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.25.8",
|
||||
"ruff==0.14.5",
|
||||
"types-requests==2.32.4.20250913",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.12.2",
|
||||
"types-requests==2.32.4.20250611",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -47,15 +47,6 @@ 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 ----
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,7 @@ import {
|
||||
} from './utils-common';
|
||||
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
|
||||
import {
|
||||
createRootSubPage,
|
||||
getTreeRow,
|
||||
navigateToPageFromTree,
|
||||
} from './utils-sub-pages';
|
||||
import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -494,7 +490,7 @@ test.describe('Doc Editor', () => {
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
status: requestCount > 1 ? 'ready' : 'processing',
|
||||
status: requestCount ? 'ready' : 'processing',
|
||||
file: '/anything.html',
|
||||
},
|
||||
});
|
||||
@@ -518,12 +514,6 @@ test.describe('Doc Editor', () => {
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.html'));
|
||||
|
||||
await expect(editor.getByText('Analyzing file...')).toBeVisible();
|
||||
|
||||
// To be sure the retry happens even after a page reload
|
||||
await page.reload();
|
||||
|
||||
await expect(editor.getByText('Analyzing file...')).toBeVisible();
|
||||
|
||||
// The retry takes a few seconds
|
||||
await expect(editor.getByText('test.html')).toBeVisible({
|
||||
timeout: 7000,
|
||||
@@ -686,9 +676,10 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await calloutBlock.locator('.inline-content').fill('example text');
|
||||
|
||||
await expect(
|
||||
page.locator('.bn-block-content[data-content-type="callout"]').first(),
|
||||
).toHaveAttribute('data-background-color', 'yellow');
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'yellow',
|
||||
);
|
||||
|
||||
const emojiButton = calloutBlock.getByRole('button');
|
||||
await expect(emojiButton).toHaveText('💡');
|
||||
@@ -712,9 +703,10 @@ test.describe('Doc Editor', () => {
|
||||
await page.locator('.mantine-Menu-dropdown > button').last().click();
|
||||
await page.locator('.bn-color-picker-dropdown > button').last().click();
|
||||
|
||||
await expect(
|
||||
page.locator('.bn-block-content[data-content-type="callout"]').first(),
|
||||
).toHaveAttribute('data-background-color', 'pink');
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'pink',
|
||||
);
|
||||
});
|
||||
|
||||
test('it checks interlink feature', async ({ page, browserName }) => {
|
||||
@@ -738,13 +730,7 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
|
||||
const treeRow = await getTreeRow(page, docChild2);
|
||||
await treeRow.locator('.--docs--doc-icon').click();
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
|
||||
await navigateToPageFromTree({ page, title: docChild1 });
|
||||
|
||||
await openSuggestionMenu({ page });
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
@@ -758,16 +744,6 @@ test.describe('Doc Editor', () => {
|
||||
await expect(searchContainer.getByText(docChild1)).toBeVisible();
|
||||
await expect(searchContainer.getByText(docChild2)).toBeVisible();
|
||||
|
||||
const searchContainerRow = searchContainer
|
||||
.getByRole('option')
|
||||
.filter({
|
||||
hasText: docChild2,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(searchContainerRow).toContainText('😀');
|
||||
await expect(searchContainerRow.locator('svg').first()).toBeHidden();
|
||||
|
||||
await input.pressSequentially('-child');
|
||||
|
||||
await expect(searchContainer.getByText(docChild1)).toBeVisible();
|
||||
@@ -782,36 +758,32 @@ test.describe('Doc Editor', () => {
|
||||
await expect(searchContainer).toBeHidden();
|
||||
|
||||
// Wait for the interlink to be created and rendered
|
||||
const editor = await getEditor({ page });
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
|
||||
const interlinkChild2 = editor.getByRole('button', {
|
||||
const interlink = editor.getByRole('button', {
|
||||
name: docChild2,
|
||||
});
|
||||
|
||||
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild2).toContainText('😀');
|
||||
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
|
||||
await interlinkChild2.click();
|
||||
await expect(interlink).toBeVisible({ timeout: 10000 });
|
||||
await interlink.click();
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
});
|
||||
|
||||
test('it checks interlink shortcut @', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = page.locator('.bn-block-outer').last();
|
||||
await editor.click();
|
||||
|
||||
await page.keyboard.press('@');
|
||||
await input.fill(docChild1);
|
||||
await searchContainer.getByText(docChild1).click();
|
||||
|
||||
const interlinkChild1 = editor.getByRole('button', {
|
||||
name: docChild1,
|
||||
});
|
||||
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();
|
||||
await expect(
|
||||
page.locator(
|
||||
"span[data-inline-content-type='interlinkingSearchInline'] input",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks multiple big doc scroll to the top', async ({
|
||||
@@ -872,10 +844,10 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(pdfBlock).toBeVisible();
|
||||
|
||||
await page.getByText(/Add (PDF|file)/).click();
|
||||
await page.getByText('Add PDF').click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByText(/Upload (PDF|file)/).click();
|
||||
await page.getByText('Upload file').click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test-pdf.pdf'));
|
||||
|
||||
@@ -2,11 +2,12 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
import pdf from 'pdf-parse';
|
||||
|
||||
import {
|
||||
TestLanguage,
|
||||
createDoc,
|
||||
randomName,
|
||||
verifyDocName,
|
||||
waitForLanguageSwitch,
|
||||
} from './utils-common';
|
||||
@@ -31,7 +32,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await expect(page.getByTestId('modal-export-title')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
|
||||
page.getByText('Download your document in a .docx or .pdf format.'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Template' }),
|
||||
@@ -58,16 +59,20 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = await writeInEditor({ page, text: 'Hello' });
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
|
||||
await editor.click();
|
||||
await editor.locator('.bn-block-outer').last().fill('Hello');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await openSuggestionMenu({ page });
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Page Break').click();
|
||||
|
||||
await expect(
|
||||
editor.locator('div[data-content-type="pageBreak"]'),
|
||||
).toBeVisible();
|
||||
await expect(editor.locator('.bn-page-break')).toBeVisible();
|
||||
|
||||
await writeInEditor({ page, text: 'World' });
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('World');
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -85,16 +90,11 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfParse = new PDFParse({ data: pdfBuffer });
|
||||
const pdfInfo = await pdfParse.getInfo();
|
||||
const pdfText = await pdfParse.getText();
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
|
||||
expect(pdfInfo.total).toBe(2);
|
||||
expect(pdfText.pages).toStrictEqual([
|
||||
{ text: 'Hello', num: 1 },
|
||||
{ text: 'World', num: 2 },
|
||||
]);
|
||||
expect(pdfInfo?.info.Title).toBe(randomDoc);
|
||||
expect(pdfData.numpages).toBe(2);
|
||||
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
|
||||
expect(pdfData.info.Title).toBe(randomDoc);
|
||||
});
|
||||
|
||||
test('it exports the doc to docx', async ({ page, browserName }) => {
|
||||
@@ -142,51 +142,6 @@ 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
|
||||
@@ -268,10 +223,10 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfExport = await pdf(pdfBuffer);
|
||||
const pdfText = pdfExport.text;
|
||||
|
||||
const pdfParse = new PDFParse({ data: pdfBuffer });
|
||||
const pdfText = await pdfParse.getText();
|
||||
expect(pdfText.text).toContain('Hello World');
|
||||
expect(pdfText).toContain('Hello World');
|
||||
});
|
||||
|
||||
test('it exports the doc with quotes', async ({ page, browserName }) => {
|
||||
@@ -314,9 +269,52 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfParse = new PDFParse({ data: pdfBuffer });
|
||||
const pdfText = await pdfParse.getText();
|
||||
expect(pdfText.text).toContain('Hello World');
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
|
||||
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
|
||||
});
|
||||
|
||||
/**
|
||||
* We cannot assert the line break is visible in the pdf, but we can assert the
|
||||
* line break is visible in the editor and that the pdf is generated.
|
||||
*/
|
||||
test('it exports the doc with divider', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'export-divider', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello World');
|
||||
|
||||
// Trigger slash menu to show menu
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Add a horizontal line').click();
|
||||
|
||||
await expect(
|
||||
editor.locator('.bn-block-content[data-content-type="divider"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('doc-open-modal-download-button'),
|
||||
).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
void page.getByTestId('doc-export-download-button').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
expect(pdfData.text).toContain('Hello World');
|
||||
});
|
||||
|
||||
test('it exports the doc with multi columns', async ({
|
||||
@@ -369,33 +367,46 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfParse = new PDFParse({ data: pdfBuffer });
|
||||
const pdfText = await pdfParse.getText();
|
||||
expect(pdfText.text).toContain('Column 1');
|
||||
expect(pdfText.text).toContain('Column 2');
|
||||
expect(pdfText.text).toContain('Column 3');
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
expect(pdfData.text).toContain('Column 1');
|
||||
expect(pdfData.text).toContain('Column 2');
|
||||
expect(pdfData.text).toContain('Column 3');
|
||||
});
|
||||
|
||||
test('it injects the correct language attribute into PDF export', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDocFrench] = await createDoc(
|
||||
page,
|
||||
'doc-language-export-french',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
// Wait for the page to be ready after language switch
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await writeInEditor({
|
||||
page,
|
||||
text: 'Contenu de test pour export en français',
|
||||
});
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h1').getByText('Docs').click();
|
||||
|
||||
const randomDocFrench = randomName(
|
||||
'doc-language-export-french',
|
||||
browserName,
|
||||
1,
|
||||
)[0];
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Nouveau doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Titre du document' });
|
||||
await expect(input).toBeVisible();
|
||||
await expect(input).toHaveText('', { timeout: 10000 });
|
||||
await input.click();
|
||||
await input.fill(randomDocFrench);
|
||||
await input.blur();
|
||||
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
await editor.click();
|
||||
await editor.fill('Contenu de test pour export en français');
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -483,72 +494,8 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfParse = new PDFParse({ data: pdfBuffer });
|
||||
const pdfText = await pdfParse.getText();
|
||||
expect(pdfText.text).toContain(randomDoc);
|
||||
});
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
|
||||
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`);
|
||||
expect(pdfData.text).toContain(randomDoc);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, getGridRow, verifyDocName } from './utils-common';
|
||||
import { addNewMember, connectOtherUserToDoc } from './utils-share';
|
||||
import { createDoc, getGridRow } from './utils-common';
|
||||
|
||||
type SmallDoc = {
|
||||
id: string;
|
||||
@@ -12,7 +11,7 @@ test.describe('Documents Grid mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test('it checks the grid when mobile', async ({ page }) => {
|
||||
await page.route(/.*\/documents\/.*/, async (route) => {
|
||||
await page.route('**/documents/**', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET') && request.url().includes('page=')) {
|
||||
await route.fulfill({
|
||||
@@ -134,8 +133,6 @@ test.describe('Document grid item options', () => {
|
||||
test('it deletes the document', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, `delete doc`, browserName);
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText(docTitle)).toBeVisible();
|
||||
@@ -164,7 +161,7 @@ test.describe('Document grid item options', () => {
|
||||
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route(/.*\/api\/v1.0\/documents\/\?page=1/, async (route) => {
|
||||
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
results: [
|
||||
@@ -195,68 +192,90 @@ test.describe('Document grid item options', () => {
|
||||
});
|
||||
await page.goto('/');
|
||||
|
||||
const button = page
|
||||
.getByTestId(`docs-grid-actions-button-mocked-document-id`)
|
||||
.first();
|
||||
const button = page.getByTestId(
|
||||
`docs-grid-actions-button-mocked-document-id`,
|
||||
);
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
const removeButton = page
|
||||
.getByTestId(`docs-grid-actions-remove-mocked-document-id`)
|
||||
.first();
|
||||
const removeButton = page.getByTestId(
|
||||
`docs-grid-actions-remove-mocked-document-id`,
|
||||
);
|
||||
await expect(removeButton).toBeVisible();
|
||||
await removeButton.isDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Documents filters', () => {
|
||||
test('it checks the left panel filters', async ({ page, browserName }) => {
|
||||
test('it checks the prebuild left panel filters', async ({ page }) => {
|
||||
void page.goto('/');
|
||||
|
||||
// Create my doc
|
||||
const [docName] = await createDoc(page, 'my-doc', browserName, 1);
|
||||
await verifyDocName(page, docName);
|
||||
|
||||
// Another user create a doc and share it with me
|
||||
const { cleanup, otherPage, otherBrowserName } =
|
||||
await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl: '/',
|
||||
});
|
||||
|
||||
const [docShareName] = await createDoc(
|
||||
otherPage,
|
||||
'my-share-doc',
|
||||
otherBrowserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(otherPage, docShareName);
|
||||
|
||||
await otherPage.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await addNewMember(otherPage, 0, 'Editor', browserName);
|
||||
|
||||
// Let's check the filters
|
||||
await page.getByRole('button', { name: 'Back to homepage' }).click();
|
||||
|
||||
const row = await getGridRow(page, docName);
|
||||
const rowShare = await getGridRow(page, docShareName);
|
||||
|
||||
// All Docs
|
||||
await expect(row).toBeVisible();
|
||||
await expect(rowShare).toBeVisible();
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const result = await response.json();
|
||||
const allCount = result.count as number;
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
// My Docs
|
||||
await page.getByRole('link', { name: 'My docs' }).click();
|
||||
await expect(row).toBeVisible();
|
||||
await expect(rowShare).toBeHidden();
|
||||
const allDocs = page.getByLabel('All docs');
|
||||
const myDocs = page.getByLabel('My docs');
|
||||
const sharedWithMe = page.getByLabel('Shared with me');
|
||||
|
||||
// Initial state
|
||||
await expect(allDocs).toBeVisible();
|
||||
await expect(allDocs).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
await expect(myDocs).toBeVisible();
|
||||
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
|
||||
await expect(myDocs).not.toHaveAttribute('aria-current');
|
||||
|
||||
await expect(sharedWithMe).toBeVisible();
|
||||
await expect(sharedWithMe).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
await expect(sharedWithMe).not.toHaveAttribute('aria-current');
|
||||
|
||||
await allDocs.click();
|
||||
|
||||
await page.waitForURL('**/?target=all_docs');
|
||||
|
||||
let url = new URL(page.url());
|
||||
let target = url.searchParams.get('target');
|
||||
expect(target).toBe('all_docs');
|
||||
|
||||
// My docs
|
||||
await myDocs.click();
|
||||
url = new URL(page.url());
|
||||
target = url.searchParams.get('target');
|
||||
expect(target).toBe('my_docs');
|
||||
const responseMyDocs = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1&is_creator_me=true') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const resultMyDocs = await responseMyDocs.json();
|
||||
const countMyDocs = resultMyDocs.count as number;
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
expect(countMyDocs).toBeLessThanOrEqual(allCount);
|
||||
|
||||
// Shared with me
|
||||
await page.getByRole('link', { name: 'Shared with me' }).click();
|
||||
await expect(row).toBeHidden();
|
||||
await expect(rowShare).toBeVisible();
|
||||
|
||||
await cleanup();
|
||||
await sharedWithMe.click();
|
||||
url = new URL(page.url());
|
||||
target = url.searchParams.get('target');
|
||||
expect(target).toBe('shared_with_me');
|
||||
const responseSharedWithMe = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('documents/?page=1&is_creator_me=false') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const resultSharedWithMe = await responseSharedWithMe.json();
|
||||
const countSharedWithMe = resultSharedWithMe.count as number;
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
|
||||
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { mockedAccesses, mockedInvitations } from './utils-share';
|
||||
import { createRootSubPage, getTreeRow } from './utils-sub-pages';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -65,61 +65,25 @@ test.describe('Doc Header', () => {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-update-emoji', browserName, 1);
|
||||
|
||||
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
|
||||
const optionMenu = page.getByLabel('Open the document options');
|
||||
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
|
||||
const removeEmojiMenuItem = page.getByRole('menuitem', {
|
||||
name: 'Remove emoji',
|
||||
});
|
||||
|
||||
// Top parent should not have emoji picker
|
||||
await expect(emojiPicker).toBeHidden();
|
||||
await optionMenu.click();
|
||||
await expect(addEmojiMenuItem).toBeHidden();
|
||||
await expect(removeEmojiMenuItem).toBeHidden();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-update-emoji-child',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
// Emoji picker should be hidden initially
|
||||
await expect(emojiPicker).toBeHidden();
|
||||
|
||||
// Add emoji
|
||||
await optionMenu.click();
|
||||
await expect(removeEmojiMenuItem).toBeHidden();
|
||||
await addEmojiMenuItem.click();
|
||||
await expect(emojiPicker).toHaveText('📄');
|
||||
|
||||
// Change emoji
|
||||
await emojiPicker.click({
|
||||
delay: 100,
|
||||
});
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
await expect(emojiPicker).toHaveText('😀');
|
||||
|
||||
// Update title
|
||||
await createDoc(page, 'doc-update', browserName, 1);
|
||||
const docTitle = page.getByRole('textbox', { name: 'Document title' });
|
||||
await docTitle.fill('Hello Emoji World');
|
||||
await expect(docTitle).toBeVisible();
|
||||
await docTitle.fill('👍 Hello Emoji World');
|
||||
await docTitle.blur();
|
||||
await verifyDocName(page, 'Hello Emoji World');
|
||||
await verifyDocName(page, '👍 Hello Emoji World');
|
||||
|
||||
// Check the tree
|
||||
const row = await getTreeRow(page, 'Hello Emoji World');
|
||||
await expect(row.getByText('😀')).toBeVisible();
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
|
||||
await expect(docTree.getByTestId('doc-emoji-icon')).toBeVisible();
|
||||
await expect(docTree.getByTestId('doc-simple-icon')).toBeHidden();
|
||||
|
||||
// Remove emoji
|
||||
await optionMenu.click();
|
||||
await expect(addEmojiMenuItem).toBeHidden();
|
||||
await removeEmojiMenuItem.click();
|
||||
await expect(emojiPicker).toBeHidden();
|
||||
await page.getByTestId('home-button').click();
|
||||
|
||||
// Check the documents grid
|
||||
const gridRow = await getGridRow(page, 'Hello Emoji World');
|
||||
await expect(gridRow.getByTestId('doc-emoji-icon')).toBeVisible();
|
||||
await expect(gridRow.getByTestId('doc-simple-icon')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it deletes the doc', async ({ page, browserName }) => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import { connectOtherUserToDoc, updateRoleUser } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
@@ -84,7 +83,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 +134,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 +153,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 +190,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;
|
||||
@@ -241,7 +240,11 @@ test.describe('Document create member', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
await page
|
||||
.locator('.ProseMirror')
|
||||
.locator('.bn-block-outer')
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
|
||||
const docUrl = page.url();
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe('Document list members', () => {
|
||||
return cleanUrl.split('/').pop() || '';
|
||||
})();
|
||||
|
||||
await page.route(/.*\/documents\/.*\/accesses\//, async (route) => {
|
||||
await page.route('**/documents/**/accesses/', async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pageId = url.searchParams.get('page') ?? '1';
|
||||
@@ -160,9 +160,6 @@ 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
|
||||
@@ -189,17 +186,9 @@ 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({
|
||||
force: true, // Force click to close the dropdown
|
||||
});
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeHidden();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Routing', () => {
|
||||
@@ -59,23 +58,16 @@ test.describe('Doc Routing', () => {
|
||||
|
||||
await createRootSubPage(page, browserName, '401-doc-child');
|
||||
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
const responsePromise = page.route(
|
||||
/.*\/documents\/.*\/$|users\/me\/$/,
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
// When we quit a document, a PATCH request is sent to save the document.
|
||||
// We intercept this request to simulate a 401 error from the backend.
|
||||
// The GET request to users/me is also intercepted to simulate the user
|
||||
// being logged out when trying to fetch user info.
|
||||
// This way we can test the 401 error handling when saving the document
|
||||
if (
|
||||
(request.url().includes('/documents/') &&
|
||||
request.method().includes('PATCH')) ||
|
||||
(request.url().includes('/users/me/') &&
|
||||
request.method().includes('GET'))
|
||||
request.method().includes('PATCH') ||
|
||||
request.method().includes('GET')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
|
||||
@@ -119,10 +119,6 @@ test.describe('Doc Trashbin', () => {
|
||||
await row.getByText(subDocName).click();
|
||||
await verifyDocName(page, subDocName);
|
||||
|
||||
await expect(
|
||||
page.locator('.--docs--editor-container.--docs--doc-deleted'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Alert deleted document')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeDisabled();
|
||||
await expect(page.locator('.bn-editor')).toHaveAttribute(
|
||||
|
||||
@@ -9,11 +9,7 @@ import {
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { addNewMember } from './utils-share';
|
||||
import {
|
||||
clickOnAddRootSubPage,
|
||||
createRootSubPage,
|
||||
getTreeRow,
|
||||
} from './utils-sub-pages';
|
||||
import { clickOnAddRootSubPage, createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Tree', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -302,58 +298,6 @@ test.describe('Doc Tree', () => {
|
||||
// Now test keyboard navigation on sub-document
|
||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||
});
|
||||
|
||||
test('it updates the child icon from the tree', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [docParent] = await createDoc(
|
||||
page,
|
||||
'doc-child-emoji',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, docParent);
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-child-emoji-child',
|
||||
);
|
||||
|
||||
const row = await getTreeRow(page, docChild);
|
||||
|
||||
// Check Remove emoji is not present initially
|
||||
await row.hover();
|
||||
const menu = row.getByText(`more_horiz`);
|
||||
await menu.click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Remove emoji' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Close the menu
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Update the emoji from the tree
|
||||
await row.locator('.--docs--doc-icon').click();
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
|
||||
// Verify the emoji is updated in the tree and in the document title
|
||||
await expect(row.getByText('😀')).toBeVisible();
|
||||
|
||||
const titleEmojiPicker = page
|
||||
.locator('.--docs--doc-title')
|
||||
.getByRole('button');
|
||||
await expect(titleEmojiPicker).toHaveText('😀');
|
||||
|
||||
// Now remove the emoji using the new action
|
||||
await row.hover();
|
||||
await menu.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
|
||||
|
||||
await expect(row.getByText('😀')).toBeHidden();
|
||||
await expect(titleEmojiPicker).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Tree: Inheritance', () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
keyCloakSignIn,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { getEditor, writeInEditor } from './utils-editor';
|
||||
import { addNewMember, connectOtherUserToDoc } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
@@ -152,15 +151,18 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
await page
|
||||
.locator('.ProseMirror')
|
||||
.locator('.bn-block-outer')
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
|
||||
const docUrl = page.url();
|
||||
|
||||
const { otherBrowserName, otherPage, cleanup } =
|
||||
await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
});
|
||||
const { otherBrowserName, otherPage } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
});
|
||||
|
||||
await expect(
|
||||
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||
@@ -173,23 +175,20 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await addNewMember(page, 0, 'Reader', otherBrowserName);
|
||||
|
||||
await otherPage.reload();
|
||||
await expect(otherPage.getByText('Hello World')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await cleanup();
|
||||
await expect(otherPage.getByText('Hello World')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Visibility: Public', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('It checks a public doc in read only mode', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
const [docTitle] = await createDoc(
|
||||
page,
|
||||
'Public read only',
|
||||
@@ -199,8 +198,6 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await writeInEditor({ page, text: 'Hello Public Viewonly' });
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByTestId('doc-visibility');
|
||||
await selectVisibility.click();
|
||||
@@ -242,67 +239,49 @@ test.describe('Doc Visibility: Public', () => {
|
||||
await expect(page.getByTestId('search-docs-button')).toBeVisible();
|
||||
await expect(page.getByTestId('new-doc-button')).toBeVisible();
|
||||
|
||||
const docUrl = page.url();
|
||||
const urlDoc = page.url();
|
||||
|
||||
const { otherPage, cleanup } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
withoutSignIn: true,
|
||||
});
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Logout',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(otherPage.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
|
||||
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
|
||||
await expect(
|
||||
otherPage.getByRole('button', { name: 'Share' }),
|
||||
).toBeVisible();
|
||||
const card = otherPage.getByLabel('It is the card information');
|
||||
await expectLoginPage(page);
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(page.getByTestId('search-docs-button')).toBeHidden();
|
||||
await expect(page.getByTestId('new-doc-button')).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByText('Reader')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(
|
||||
otherPage.locator('.--docs--editor-container.--docs--doc-readonly'),
|
||||
).toBeVisible();
|
||||
|
||||
const otherEditor = await getEditor({ page: otherPage });
|
||||
await expect(otherEditor).toHaveAttribute('contenteditable', 'false');
|
||||
await expect(otherEditor.getByText('Hello Public Viewonly')).toBeVisible();
|
||||
|
||||
// Cursor and selection of the anonymous user are not visible
|
||||
await otherEditor.getByText('Hello Public').selectText();
|
||||
await expect(
|
||||
page.locator('.collaboration-cursor-custom__base'),
|
||||
).toBeHidden();
|
||||
await expect(page.locator('.ProseMirror-yjs-selection')).toBeHidden();
|
||||
|
||||
// Can still see changes made by others
|
||||
await writeInEditor({ page, text: 'Can you see it ?' });
|
||||
await expect(otherEditor.getByText('Can you see it ?')).toBeVisible();
|
||||
|
||||
await otherPage.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(
|
||||
otherPage.getByText(
|
||||
page.getByText(
|
||||
'You can view this document but need additional access to see its members or modify settings.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
otherPage.getByRole('button', { name: 'Request access' }),
|
||||
page.getByRole('button', { name: 'Request access' }),
|
||||
).toBeHidden();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test('It checks a public doc in editable mode', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await writeInEditor({ page, text: 'Hello Public Editable' });
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByTestId('doc-visibility');
|
||||
await selectVisibility.click();
|
||||
@@ -336,47 +315,20 @@ test.describe('Doc Visibility: Public', () => {
|
||||
cardContainer.getByText('Public document', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
const docUrl = page.url();
|
||||
const urlDoc = page.url();
|
||||
|
||||
const { otherPage, cleanup } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
withoutSignIn: true,
|
||||
docTitle,
|
||||
});
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Logout',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
|
||||
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
|
||||
await expectLoginPage(page);
|
||||
|
||||
const otherEditor = await getEditor({ page: otherPage });
|
||||
await expect(otherEditor).toHaveAttribute('contenteditable', 'true');
|
||||
await expect(otherEditor.getByText('Hello Public Editable')).toBeVisible();
|
||||
await page.goto(urlDoc);
|
||||
|
||||
// We can see the collaboration cursor of the anonymous user
|
||||
await otherEditor.getByText('Hello Public').selectText();
|
||||
await expect(
|
||||
page.locator('.collaboration-cursor-custom__base').getByText('Anonymous'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
otherPage.getByRole('button', { name: 'Share' }),
|
||||
).toBeVisible();
|
||||
const card = otherPage.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByText('Editor')).toBeVisible();
|
||||
|
||||
await otherPage.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(
|
||||
otherPage.getByText(
|
||||
'You can view this document but need additional access to see its members or modify settings.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
otherPage.getByRole('button', { name: 'Request access' }),
|
||||
).toBeHidden();
|
||||
|
||||
await cleanup();
|
||||
await verifyDocName(page, docTitle);
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ test.describe('Header: Override configuration', () => {
|
||||
logo: {
|
||||
src: '/assets/logo-gouv.svg',
|
||||
width: '220px',
|
||||
alt: '',
|
||||
alt: 'Gouvernement Logo',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -168,11 +168,8 @@ test.describe('Header: Override configuration', () => {
|
||||
await page.goto('/');
|
||||
const header = page.locator('header').first();
|
||||
|
||||
const logoImage = header.getByTestId('header-icon-docs');
|
||||
await expect(logoImage).toBeVisible();
|
||||
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
|
||||
|
||||
await expect(logoImage).not.toHaveAttribute('src', '/assets/icon-docs.svg');
|
||||
await expect(logoImage).toHaveAttribute('src', '/assets/logo-gouv.svg');
|
||||
await expect(logoImage).toHaveAttribute('alt', '');
|
||||
await expect(header.getByAltText('Docs')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,33 +27,6 @@ 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();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { TestLanguage, createDoc, waitForLanguageSwitch } from './utils-common';
|
||||
import { openSuggestionMenu } from './utils-editor';
|
||||
|
||||
test.describe('Language', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -52,7 +51,6 @@ test.describe('Language', () => {
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
|
||||
await expect(languagePicker).toContainText('English');
|
||||
});
|
||||
|
||||
test('can switch language using only keyboard', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLanguageSwitch(page, TestLanguage.English);
|
||||
@@ -108,18 +106,18 @@ test.describe('Language', () => {
|
||||
}) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
const editor = page.locator('.ProseMirror');
|
||||
|
||||
// Trigger slash menu to show english menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
|
||||
|
||||
await editor.click(); // close the menu
|
||||
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
|
||||
|
||||
// Change language to French
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
// Trigger slash menu to show french menu
|
||||
await openSuggestionMenu({ page });
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export const overrideConfig = async (
|
||||
page: Page,
|
||||
newConfig: { [_K in keyof typeof CONFIG]?: unknown },
|
||||
) =>
|
||||
await page.route(/.*\/api\/v1.0\/config\/.*/, async (route) => {
|
||||
await page.route('**/api/v1.0/config/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
@@ -70,14 +70,6 @@ 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}`;
|
||||
@@ -133,9 +125,7 @@ export const verifyDocName = async (page: Page, docName: string) => {
|
||||
try {
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toContainText(docName, {
|
||||
timeout: 1000,
|
||||
});
|
||||
).toContainText(docName);
|
||||
} catch {
|
||||
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
|
||||
}
|
||||
@@ -214,7 +204,7 @@ export const waitForResponseCreateDoc = (page: Page) => {
|
||||
};
|
||||
|
||||
export const mockedDocument = async (page: Page, data: object) => {
|
||||
await page.route(/\**\/documents\/\**/, async (route) => {
|
||||
await page.route('**/documents/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
@@ -266,7 +256,7 @@ export const mockedDocument = async (page: Page, data: object) => {
|
||||
};
|
||||
|
||||
export const mockedListDocs = async (page: Page, data: object[] = []) => {
|
||||
await page.route(/\**\/documents\/\**/, async (route) => {
|
||||
await page.route('**/documents/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET') && request.url().includes('page=')) {
|
||||
await route.fulfill({
|
||||
@@ -287,7 +277,6 @@ export const expectLoginPage = async (page: Page) =>
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// language helper
|
||||
export const TestLanguage = {
|
||||
English: {
|
||||
@@ -311,7 +300,7 @@ export async function waitForLanguageSwitch(
|
||||
page: Page,
|
||||
lang: TestLanguageValue,
|
||||
) {
|
||||
await page.route(/\**\/api\/v1.0\/users\/\**/, async (route, request) => {
|
||||
await page.route('**/api/v1.0/users/**', async (route, request) => {
|
||||
if (request.method().includes('PATCH')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
|
||||
@@ -9,7 +9,7 @@ export const getEditor = async ({ page }: { page: Page }) => {
|
||||
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
|
||||
const editor = await getEditor({ page });
|
||||
await editor.click();
|
||||
await writeInEditor({ page, text: '/' });
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
|
||||
return editor;
|
||||
};
|
||||
@@ -22,6 +22,6 @@ export const writeInEditor = async ({
|
||||
text: string;
|
||||
}) => {
|
||||
const editor = await getEditor({ page });
|
||||
await editor.locator('.bn-block-outer .bn-inline-content').last().fill(text);
|
||||
await editor.locator('.bn-block-outer').last().fill(text);
|
||||
return editor;
|
||||
};
|
||||
|
||||
@@ -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' | 'Editor' | 'Reader';
|
||||
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
|
||||
export type LinkReach = 'Private' | 'Connected' | 'Public';
|
||||
export type LinkRole = 'Reading' | 'Editing';
|
||||
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
index: number,
|
||||
role: Role,
|
||||
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
|
||||
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,30 +88,21 @@ 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,
|
||||
}: ConnectOtherUserToDocParams) => {
|
||||
const otherBrowserName =
|
||||
_otherBrowserName || getOtherBrowserName(browserName);
|
||||
}: {
|
||||
browserName: BrowserName;
|
||||
docUrl: string;
|
||||
docTitle?: string;
|
||||
withoutSignIn?: boolean;
|
||||
}) => {
|
||||
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
|
||||
if (!otherBrowserName) {
|
||||
throw new Error('No alternative browser found');
|
||||
}
|
||||
|
||||
const otherBrowser = await chromium.launch({ headless: true });
|
||||
const otherContext = await otherBrowser.newContext({
|
||||
@@ -127,16 +118,12 @@ export const connectOtherUserToDoc = async ({
|
||||
await otherPage.goto(docUrl);
|
||||
|
||||
if (!withoutSignIn) {
|
||||
const loginFromApp = otherPage
|
||||
await otherPage
|
||||
.getByRole('main', { name: 'Main content' })
|
||||
.getByLabel('Login');
|
||||
const loginFromHome = otherPage.getByRole('button', {
|
||||
name: 'Start Writing',
|
||||
});
|
||||
|
||||
await loginFromApp.or(loginFromHome).first().click({
|
||||
timeout: 15000,
|
||||
});
|
||||
.getByLabel('Login')
|
||||
.click({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await keyCloakSignIn(otherPage, otherBrowserName, false);
|
||||
}
|
||||
@@ -172,7 +159,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
...json,
|
||||
},
|
||||
];
|
||||
await page.route(/.*\/invitations\/.*/, async (route) => {
|
||||
await page.route('**/invitations/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
@@ -193,7 +180,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
});
|
||||
|
||||
await page.route(
|
||||
/.*\/invitations\/120ec765-43af-4602-83eb-7f4e1224548a\/.*/,
|
||||
'**/invitations/120ec765-43af-4602-83eb-7f4e1224548a/**/',
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('DELETE')) {
|
||||
@@ -208,7 +195,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
};
|
||||
|
||||
export const mockedAccesses = async (page: Page, json?: object) => {
|
||||
await page.route(/.*\/accesses\/.*/, async (route) => {
|
||||
await page.route('**/accesses/**/', async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (
|
||||
|
||||
@@ -107,20 +107,6 @@ export const addChild = async ({
|
||||
return name;
|
||||
};
|
||||
|
||||
export const getTreeRow = async (page: Page, title: string) => {
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
const row = docTree
|
||||
.getByRole('treeitem')
|
||||
.filter({
|
||||
hasText: title,
|
||||
})
|
||||
.first();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
export const navigateToTopParentFromTree = async ({ page }: { page: Page }) => {
|
||||
await page.getByRole('link', { name: /Open root document/ }).click();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "3.10.0",
|
||||
"version": "3.8.21",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@playwright/test": "1.55.0",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.5",
|
||||
"eslint-plugin-docs": "*",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"convert-stream": "1.0.2",
|
||||
"pdf-parse": "2.4.5"
|
||||
"pdf-parse": "1.1.1"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -98,8 +98,8 @@ const dsfrTheme = {
|
||||
},
|
||||
font: {
|
||||
families: {
|
||||
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
|
||||
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
|
||||
base: 'Marianne',
|
||||
accent: 'Marianne',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "3.10.0",
|
||||
"version": "3.8.21",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -19,51 +19,47 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@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",
|
||||
"@blocknote/code-block": "0.37.0",
|
||||
"@blocknote/core": "0.37.0",
|
||||
"@blocknote/mantine": "0.37.0",
|
||||
"@blocknote/react": "0.37.0",
|
||||
"@blocknote/xl-docx-exporter": "0.37.0",
|
||||
"@blocknote/xl-multi-column": "0.37.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.37.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.28",
|
||||
"@fontsource/material-icons": "5.2.7",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.25",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.2",
|
||||
"@hocuspocus/provider": "3.4.0",
|
||||
"@mantine/core": "8.3.6",
|
||||
"@mantine/hooks": "8.3.6",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.1",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@openfun/cunningham-react": "3.2.3",
|
||||
"@react-pdf/renderer": "4.3.1",
|
||||
"@sentry/nextjs": "10.22.0",
|
||||
"@tanstack/react-query": "5.90.6",
|
||||
"@tiptap/extensions": "*",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@sentry/nextjs": "10.11.0",
|
||||
"@tanstack/react-query": "5.87.4",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.26",
|
||||
"docx": "*",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.5.0",
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.6.0",
|
||||
"i18next": "25.6.0",
|
||||
"emoji-regex": "10.5.0",
|
||||
"i18next": "25.5.2",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.7.2",
|
||||
"next": "15.5.4",
|
||||
"posthog-js": "1.284.0",
|
||||
"next": "15.5.3",
|
||||
"posthog-js": "1.264.2",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.13.0",
|
||||
"react-aria-components": "1.12.1",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "16.3.3",
|
||||
"react-intersection-observer": "10.0.0",
|
||||
"react-i18next": "15.7.3",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"react-select": "5.10.2",
|
||||
"styled-components": "6.1.19",
|
||||
@@ -74,9 +70,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.90.2",
|
||||
"@tanstack/react-query-devtools": "5.87.4",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/jest-dom": "6.8.0",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/lodash": "4.17.20",
|
||||
@@ -84,22 +80,22 @@
|
||||
"@types/node": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "5.1.0",
|
||||
"@vitejs/plugin-react": "5.0.2",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.2.3",
|
||||
"cross-env": "10.0.0",
|
||||
"dotenv": "17.2.2",
|
||||
"eslint-plugin-docs": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jsdom": "27.1.0",
|
||||
"jsdom": "26.1.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.6.2",
|
||||
"stylelint": "16.25.0",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint": "16.24.0",
|
||||
"stylelint-config-standard": "39.0.0",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "4.0.6",
|
||||
"webpack": "5.102.1",
|
||||
"vitest": "3.2.4",
|
||||
"webpack": "5.101.3",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -31,7 +31,7 @@ const StyledButton = styled(Button)<StyledButtonProps>`
|
||||
font-weight: 500;
|
||||
font-size: 0.938rem;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
${({ $css }) => $css};
|
||||
&:hover {
|
||||
background-color: var(
|
||||
--c--components--button--primary-text--background--color-hover
|
||||
@@ -41,7 +41,6 @@ const StyledButton = styled(Button)<StyledButtonProps>`
|
||||
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400);
|
||||
border-radius: 4px;
|
||||
}
|
||||
${({ $css }) => $css};
|
||||
`;
|
||||
|
||||
export interface DropButtonProps {
|
||||
|
||||
@@ -13,7 +13,7 @@ export const Icon = ({
|
||||
iconName,
|
||||
disabled,
|
||||
variant = 'outlined',
|
||||
$variation = 'text',
|
||||
$variation,
|
||||
...textProps
|
||||
}: IconProps) => {
|
||||
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
|
||||
@@ -41,19 +41,15 @@ type IconOptionsProps = TextType & {
|
||||
isHorizontal?: boolean;
|
||||
};
|
||||
|
||||
export const IconOptions = ({
|
||||
isHorizontal,
|
||||
$css,
|
||||
...props
|
||||
}: IconOptionsProps) => {
|
||||
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
|
||||
$css={css`
|
||||
user-select: none;
|
||||
${$css}
|
||||
${props.$css}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
|
||||
@@ -58,7 +57,6 @@ 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);
|
||||
@@ -95,14 +93,6 @@ export const DropdownMenu = ({
|
||||
}
|
||||
}, [isOpen, options]);
|
||||
|
||||
const triggerOption = useCallback(
|
||||
(option: DropdownMenuOption) => {
|
||||
onOpenChange?.(false);
|
||||
void option.callback?.();
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
if (disabled) {
|
||||
return children;
|
||||
}
|
||||
@@ -180,9 +170,9 @@ export const DropdownMenu = ({
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
triggerOption(option);
|
||||
onOpenChange?.(false);
|
||||
void option.callback?.();
|
||||
}}
|
||||
onKeyDown={keyboardAction(() => triggerOption(option))}
|
||||
key={option.label}
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
|
||||
@@ -44,11 +44,6 @@
|
||||
contain: content;
|
||||
}
|
||||
|
||||
.c__button--medium {
|
||||
min-height: var(--c--components--button--medium-height);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal
|
||||
*/
|
||||
|
||||
@@ -556,10 +556,8 @@
|
||||
--c--theme--logo--widthHeader: 110px;
|
||||
--c--theme--logo--widthFooter: 220px;
|
||||
--c--theme--logo--alt: gouvernement logo;
|
||||
--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--theme--font--families--base: marianne;
|
||||
--c--theme--font--families--accent: marianne;
|
||||
--c--components--la-gaufre: true;
|
||||
--c--components--home-proconnect: true;
|
||||
--c--components--favicon--ico: /assets/favicon-dsfr.ico;
|
||||
|
||||
@@ -436,12 +436,7 @@ export const tokens = {
|
||||
widthFooter: '220px',
|
||||
alt: 'Gouvernement Logo',
|
||||
},
|
||||
font: {
|
||||
families: {
|
||||
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
|
||||
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
|
||||
},
|
||||
},
|
||||
font: { families: { base: 'Marianne', accent: 'Marianne' } },
|
||||
},
|
||||
components: {
|
||||
'la-gaufre': true,
|
||||
|
||||
@@ -13,5 +13,3 @@ export interface User {
|
||||
short_name: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export type UserLight = Pick<User, 'full_name' | 'short_name'>;
|
||||
|
||||
@@ -44,7 +44,7 @@ export function useAuthQuery(
|
||||
staleTime: 1000 * 60 * 15, // 15 minutes
|
||||
retry: (failureCount, error) => {
|
||||
// we assume that a 401 means the user is not logged in
|
||||
if (error.status === 401) {
|
||||
if (error.status == 401) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < DEFAULT_QUERY_RETRY;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,70 +0,0 @@
|
||||
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)}`;
|
||||
};
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './Auth';
|
||||
export * from './ButtonLogin';
|
||||
export * from './UserAvatar';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { APIError, errorCauses } from '@/api';
|
||||
import { sleep } from '@/utils';
|
||||
|
||||
interface CheckDocMediaStatusResponse {
|
||||
file?: string;
|
||||
@@ -26,28 +25,3 @@ export const checkDocMediaStatus = async ({
|
||||
|
||||
return response.json() as Promise<CheckDocMediaStatusResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload file can be analyzed on the server side,
|
||||
* we had this function to wait for the analysis to be done
|
||||
* before returning the file url. It will keep the loader
|
||||
* on the upload button until the analysis is done.
|
||||
* @param url
|
||||
* @returns Promise<CheckDocMediaStatusResponse> status_code
|
||||
* @description Waits for the upload to be analyzed by checking the status of the file.
|
||||
*/
|
||||
export const loopCheckDocMediaStatus = async (
|
||||
url: string,
|
||||
): Promise<CheckDocMediaStatusResponse> => {
|
||||
const SLEEP_TIME = 5000;
|
||||
const response = await checkDocMediaStatus({
|
||||
urlMedia: url,
|
||||
});
|
||||
|
||||
if (response.status === 'ready') {
|
||||
return response;
|
||||
} else {
|
||||
await sleep(SLEEP_TIME);
|
||||
return await loopCheckDocMediaStatus(url);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
<svg viewBox="0 0 18 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.57954 5.93282C4.44486 5.79021 4.37793 5.61484 4.37793 5.41299C4.37793 5.21112 4.44495 5.03795 4.58154 4.90136C4.72456 4.75833 4.90437 4.6875 5.11367 4.6875H12.8964C13.0991 4.6875 13.2722 4.75857 13.408 4.90133C13.5508 5.03718 13.6219 5.21027 13.6219 5.41299C13.6219 5.61613 13.5506 5.7918 13.409 5.93387C13.273 6.07732 13.0996 6.14873 12.8964 6.14873H5.11367C4.90437 6.14873 4.72456 6.0779 4.58154 5.93487L4.57954 5.93282ZM4.57954 9.51144C4.44486 9.36882 4.37793 9.19346 4.37793 8.9916C4.37793 8.78973 4.44495 8.61656 4.58154 8.47997C4.72456 8.33695 4.90437 8.26611 5.11367 8.26611H12.8964C13.0991 8.26611 13.2722 8.33719 13.408 8.47995C13.5508 8.61579 13.6219 8.78888 13.6219 8.9916C13.6219 9.19475 13.5506 9.37042 13.409 9.51249C13.273 9.65593 13.0996 9.72734 12.8964 9.72734H5.11367C4.90437 9.72734 4.72456 9.65651 4.58154 9.51348L4.57954 9.51144ZM4.57954 13.1003C4.44561 12.9585 4.37793 12.7869 4.37793 12.5907C4.37793 12.3831 4.44414 12.204 4.57954 12.0606L4.58151 12.0586C4.72453 11.9155 4.90437 11.8447 5.11367 11.8447H8.79482C9.00363 11.8447 9.18092 11.9153 9.3177 12.0596C9.46006 12.2024 9.53057 12.3819 9.53057 12.5907C9.53057 12.7887 9.45812 12.9609 9.31671 13.1024C9.17936 13.2397 9.00235 13.306 8.79482 13.306H5.11367C4.90609 13.306 4.72695 13.2397 4.58358 13.1043L4.57954 13.1003ZM1.09476 0.851519C1.65317 0.285946 2.47955 0.0117188 3.55508 0.0117188H14.4447C15.52 0.0117188 16.3433 0.28583 16.895 0.851748C17.4529 1.41698 17.7234 2.24966 17.7234 3.33145V18.8866C17.7234 19.975 17.4531 20.8082 16.8945 21.3668C16.3427 21.9256 15.5196 22.1961 14.4447 22.1961H3.55508C2.47988 22.1961 1.65367 21.9255 1.09521 21.367C0.543652 20.8083 0.276367 19.9747 0.276367 18.8866V3.33145C0.276367 2.24984 0.543796 1.41679 1.09476 0.851519ZM15.5624 20.0351C15.2958 20.3085 14.8959 20.4452 14.3627 20.4452H3.63711C3.10391 20.4452 2.70059 20.3085 2.42715 20.0351L2.49875 19.9652L2.49786 19.9643L2.42715 20.0351C2.16055 19.7616 2.02725 19.3686 2.02725 18.8559V3.36221C2.02725 2.84951 2.16055 2.45645 2.42715 2.18301C2.70059 1.90273 3.10391 1.7626 3.63711 1.7626H14.3627C14.8959 1.7626 15.2958 1.90273 15.5624 2.18301C15.8358 2.45645 15.9726 2.84951 15.9726 3.36221V18.8559C15.9726 19.3686 15.8358 19.7616 15.5624 20.0351Z"
|
||||
fill="#3A3A3A"
|
||||
/>
|
||||
<svg width="18" height="23" viewBox="0 0 18 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.57954 5.93282C4.44486 5.79021 4.37793 5.61484 4.37793 5.41299C4.37793 5.21112 4.44495 5.03795 4.58154 4.90136C4.72456 4.75833 4.90437 4.6875 5.11367 4.6875H12.8964C13.0991 4.6875 13.2722 4.75857 13.408 4.90133C13.5508 5.03718 13.6219 5.21027 13.6219 5.41299C13.6219 5.61613 13.5506 5.7918 13.409 5.93387C13.273 6.07732 13.0996 6.14873 12.8964 6.14873H5.11367C4.90437 6.14873 4.72456 6.0779 4.58154 5.93487L4.57954 5.93282ZM4.57954 9.51144C4.44486 9.36882 4.37793 9.19346 4.37793 8.9916C4.37793 8.78973 4.44495 8.61656 4.58154 8.47997C4.72456 8.33695 4.90437 8.26611 5.11367 8.26611H12.8964C13.0991 8.26611 13.2722 8.33719 13.408 8.47995C13.5508 8.61579 13.6219 8.78888 13.6219 8.9916C13.6219 9.19475 13.5506 9.37042 13.409 9.51249C13.273 9.65593 13.0996 9.72734 12.8964 9.72734H5.11367C4.90437 9.72734 4.72456 9.65651 4.58154 9.51348L4.57954 9.51144ZM4.57954 13.1003C4.44561 12.9585 4.37793 12.7869 4.37793 12.5907C4.37793 12.3831 4.44414 12.204 4.57954 12.0606L4.58151 12.0586C4.72453 11.9155 4.90437 11.8447 5.11367 11.8447H8.79482C9.00363 11.8447 9.18092 11.9153 9.3177 12.0596C9.46006 12.2024 9.53057 12.3819 9.53057 12.5907C9.53057 12.7887 9.45812 12.9609 9.31671 13.1024C9.17936 13.2397 9.00235 13.306 8.79482 13.306H5.11367C4.90609 13.306 4.72695 13.2397 4.58358 13.1043L4.57954 13.1003ZM1.09476 0.851519C1.65317 0.285946 2.47955 0.0117188 3.55508 0.0117188H14.4447C15.52 0.0117188 16.3433 0.28583 16.895 0.851748C17.4529 1.41698 17.7234 2.24966 17.7234 3.33145V18.8866C17.7234 19.975 17.4531 20.8082 16.8945 21.3668C16.3427 21.9256 15.5196 22.1961 14.4447 22.1961H3.55508C2.47988 22.1961 1.65367 21.9255 1.09521 21.367C0.543652 20.8083 0.276367 19.9747 0.276367 18.8866V3.33145C0.276367 2.24984 0.543796 1.41679 1.09476 0.851519ZM15.5624 20.0351C15.2958 20.3085 14.8959 20.4452 14.3627 20.4452H3.63711C3.10391 20.4452 2.70059 20.3085 2.42715 20.0351L2.49875 19.9652L2.49786 19.9643L2.42715 20.0351C2.16055 19.7616 2.02725 19.3686 2.02725 18.8559V3.36221C2.02725 2.84951 2.16055 2.45645 2.42715 2.18301C2.70059 1.90273 3.10391 1.7626 3.63711 1.7626H14.3627C14.8959 1.7626 15.2958 1.90273 15.5624 2.18301C15.8358 2.45645 15.9726 2.84951 15.9726 3.36221V18.8559C15.9726 19.3686 15.8358 19.7616 15.5624 20.0351Z" fill="#3A3A3A"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,6 +1,6 @@
|
||||
<svg viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3.40918 4.70117C3.28613 4.70117 3.18359 4.66016 3.10156 4.57812C3.02409 4.49609 2.98535 4.39583 2.98535 4.27734C2.98535 4.15885 3.02409 4.06087 3.10156 3.9834C3.18359 3.90137 3.28613 3.86035 3.40918 3.86035H8.59766C8.71615 3.86035 8.81413 3.90137 8.8916 3.9834C8.97363 4.06087 9.01465 4.15885 9.01465 4.27734C9.01465 4.39583 8.97363 4.49609 8.8916 4.57812C8.81413 4.66016 8.71615 4.70117 8.59766 4.70117H3.40918ZM3.40918 7.08691C3.28613 7.08691 3.18359 7.0459 3.10156 6.96387C3.02409 6.88184 2.98535 6.78158 2.98535 6.66309C2.98535 6.5446 3.02409 6.44661 3.10156 6.36914C3.18359 6.28711 3.28613 6.24609 3.40918 6.24609H8.59766C8.71615 6.24609 8.81413 6.28711 8.8916 6.36914C8.97363 6.44661 9.01465 6.5446 9.01465 6.66309C9.01465 6.78158 8.97363 6.88184 8.8916 6.96387C8.81413 7.0459 8.71615 7.08691 8.59766 7.08691H3.40918ZM3.40918 9.47266C3.28613 9.47266 3.18359 9.43392 3.10156 9.35645C3.02409 9.27441 2.98535 9.17643 2.98535 9.0625C2.98535 8.93945 3.02409 8.83691 3.10156 8.75488C3.18359 8.67285 3.28613 8.63184 3.40918 8.63184H5.86328C5.98633 8.63184 6.08659 8.67285 6.16406 8.75488C6.24609 8.83691 6.28711 8.93945 6.28711 9.0625C6.28711 9.17643 6.24609 9.27441 6.16406 9.35645C6.08659 9.43392 5.98633 9.47266 5.86328 9.47266H3.40918ZM0.250977 13.2598V2.88965C0.250977 2.17871 0.426432 1.64323 0.777344 1.2832C1.13281 0.923177 1.66374 0.743164 2.37012 0.743164H9.62988C10.3363 0.743164 10.8649 0.923177 11.2158 1.2832C11.5713 1.64323 11.749 2.17871 11.749 2.88965V13.2598C11.749 13.9753 11.5713 14.5107 11.2158 14.8662C10.8649 15.2217 10.3363 15.3994 9.62988 15.3994H2.37012C1.66374 15.3994 1.13281 15.2217 0.777344 14.8662C0.426432 14.5107 0.250977 13.9753 0.250977 13.2598ZM1.35156 13.2393C1.35156 13.5811 1.44043 13.8431 1.61816 14.0254C1.80046 14.2077 2.06934 14.2988 2.4248 14.2988H9.5752C9.93066 14.2988 10.1973 14.2077 10.375 14.0254C10.5573 13.8431 10.6484 13.5811 10.6484 13.2393V2.91016C10.6484 2.56836 10.5573 2.30632 10.375 2.12402C10.1973 1.93717 9.93066 1.84375 9.5752 1.84375H2.4248C2.06934 1.84375 1.80046 1.93717 1.61816 2.12402C1.44043 2.30632 1.35156 2.56836 1.35156 2.91016V13.2393Z"
|
||||
fill="currentColor"
|
||||
fill="#8585F6"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -1,7 +1,6 @@
|
||||
import { codeBlockOptions } from '@blocknote/code-block';
|
||||
import { codeBlock } from '@blocknote/code-block';
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
createCodeBlockSpec,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
withPageBreak,
|
||||
@@ -12,16 +11,13 @@ import { BlockNoteView } from '@blocknote/mantine';
|
||||
import '@blocknote/mantine/style.css';
|
||||
import { useCreateBlockNote } from '@blocknote/react';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect } 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 { avatarUrlFromName, useAuth } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management';
|
||||
import { useAuth } from '@/features/auth';
|
||||
|
||||
import {
|
||||
useHeadings,
|
||||
@@ -37,10 +33,10 @@ import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { cssComments, useComments } from './comments/';
|
||||
import {
|
||||
AccessibleImageBlock,
|
||||
CalloutBlock,
|
||||
DividerBlock,
|
||||
PdfBlock,
|
||||
UploadLoaderBlock,
|
||||
} from './custom-blocks';
|
||||
@@ -57,11 +53,11 @@ const baseBlockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
callout: CalloutBlock(),
|
||||
codeBlock: createCodeBlockSpec(codeBlockOptions),
|
||||
image: AccessibleImageBlock(),
|
||||
pdf: PdfBlock(),
|
||||
uploadLoader: UploadLoaderBlock(),
|
||||
callout: CalloutBlock,
|
||||
divider: DividerBlock,
|
||||
image: AccessibleImageBlock,
|
||||
pdf: PdfBlock,
|
||||
uploadLoader: UploadLoaderBlock,
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
@@ -83,37 +79,31 @@ 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 { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
const isConnectedToCollabServer = provider.isSynced;
|
||||
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
|
||||
const isDeletedDoc = !!doc.deleted_at;
|
||||
|
||||
useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer);
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage;
|
||||
|
||||
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
|
||||
|
||||
const collabName = user?.full_name || user?.email;
|
||||
const cursorName = collabName || t('Anonymous');
|
||||
const collabName = readOnly
|
||||
? 'Reader'
|
||||
: user?.full_name || user?.email || 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(
|
||||
{
|
||||
codeBlock,
|
||||
collaboration: {
|
||||
provider: provider,
|
||||
provider,
|
||||
fragment: provider.document.getXmlFragment('document-store'),
|
||||
user: {
|
||||
name: cursorName,
|
||||
name: collabName,
|
||||
color: randomColor(),
|
||||
},
|
||||
/**
|
||||
@@ -124,6 +114,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
renderCursor: (user: { color: string; name: string }) => {
|
||||
const cursorElement = document.createElement('span');
|
||||
|
||||
if (user.name === 'Reader') {
|
||||
return cursorElement;
|
||||
}
|
||||
|
||||
cursorElement.classList.add('collaboration-cursor-custom__base');
|
||||
const caretElement = document.createElement('span');
|
||||
caretElement.classList.add('collaboration-cursor-custom__caret');
|
||||
@@ -154,28 +148,11 @@ 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,
|
||||
@@ -185,13 +162,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
uploadFile,
|
||||
schema: blockNoteSchema,
|
||||
},
|
||||
[cursorName, lang, provider, uploadFile, threadStore],
|
||||
[collabName, lang, provider, uploadFile],
|
||||
);
|
||||
|
||||
useHeadings(editor);
|
||||
|
||||
useShortcuts(editor, refEditorContainer.current);
|
||||
|
||||
useShortcuts(editor);
|
||||
useUploadStatus(editor);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,11 +179,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(canSeeComment, currentUserAvatarUrl)}
|
||||
`}
|
||||
$padding={{ top: 'md' }}
|
||||
$background="white"
|
||||
$css={cssEditor(readOnly, isDeletedDoc)}
|
||||
className="--docs--editor-container"
|
||||
>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
@@ -219,14 +193,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<BlockNoteView
|
||||
className="--docs--main-editor"
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
editable={!readOnly}
|
||||
theme="light"
|
||||
comments={canSeeComment}
|
||||
aria-label={t('Document editor')}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
<BlockNoteToolbar />
|
||||
@@ -235,19 +208,14 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface BlockNoteReaderProps {
|
||||
docId: Doc['id'];
|
||||
interface BlockNoteEditorVersionProps {
|
||||
initialContent: Y.XmlFragment;
|
||||
}
|
||||
|
||||
export const BlockNoteReader = ({
|
||||
docId,
|
||||
export const BlockNoteEditorVersion = ({
|
||||
initialContent,
|
||||
}: BlockNoteReaderProps) => {
|
||||
const { user } = useAuth();
|
||||
const { setEditor } = useEditorStore();
|
||||
const threadStore = useComments(docId, false, user);
|
||||
const { t } = useTranslation();
|
||||
}: BlockNoteEditorVersionProps) => {
|
||||
const readOnly = true;
|
||||
const editor = useCreateBlockNote(
|
||||
{
|
||||
collaboration: {
|
||||
@@ -259,41 +227,13 @@ export const BlockNoteReader = ({
|
||||
provider: undefined,
|
||||
},
|
||||
schema: blockNoteSchema,
|
||||
comments: { threadStore },
|
||||
resolveUsers: async () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
},
|
||||
[initialContent],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditor(editor);
|
||||
|
||||
return () => {
|
||||
setEditor(undefined);
|
||||
};
|
||||
}, [setEditor, editor]);
|
||||
|
||||
useHeadings(editor);
|
||||
|
||||
return (
|
||||
<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 $css={cssEditor(readOnly, true)} className="--docs--editor-container">
|
||||
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
|
||||
import {
|
||||
DefaultReactSuggestionItem,
|
||||
SuggestionMenuController,
|
||||
getDefaultReactSlashMenuItems,
|
||||
getPageBreakReactSlashMenuItems,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
|
||||
import {
|
||||
getCalloutReactSlashMenuItems,
|
||||
getDividerReactSlashMenuItems,
|
||||
getPdfReactSlashMenuItems,
|
||||
} from './custom-blocks';
|
||||
import { useGetInterlinkingMenuItems } from './custom-inline-content';
|
||||
@@ -42,29 +42,29 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
const getSlashMenuItems = useMemo(() => {
|
||||
// We insert it after the "Code Block" item to have the interlinking block displayed after the basic blocks
|
||||
const defaultMenu = getDefaultReactSlashMenuItems(editor);
|
||||
|
||||
const combinedMenu = combineByGroup(
|
||||
defaultMenu,
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getMultiColumnSlashMenuItems?.(editor) || [],
|
||||
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
|
||||
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
const index = defaultMenu.findIndex(
|
||||
(item) => item.aliases?.includes('code') && item.aliases?.includes('pre'),
|
||||
);
|
||||
|
||||
const index = combinedMenu.findIndex(
|
||||
(item) =>
|
||||
(item as DefaultReactSuggestionItem & { key: string })?.key ===
|
||||
'callout',
|
||||
);
|
||||
|
||||
const newSlashMenuItems = [
|
||||
...combinedMenu.slice(0, index + 1),
|
||||
...defaultMenu.slice(0, index + 1),
|
||||
...getInterlinkingMenuItems(editor, t),
|
||||
...combinedMenu.slice(index + 1),
|
||||
...defaultMenu.slice(index + 1),
|
||||
];
|
||||
|
||||
return async (query: string) =>
|
||||
Promise.resolve(filterSuggestionItems(newSlashMenuItems, query));
|
||||
Promise.resolve(
|
||||
filterSuggestionItems(
|
||||
combineByGroup(
|
||||
newSlashMenuItems,
|
||||
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getMultiColumnSlashMenuItems?.(editor) || [],
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
|
||||
),
|
||||
query,
|
||||
),
|
||||
);
|
||||
}, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -26,12 +25,10 @@ export const BlockNoteToolbar = () => {
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
const toolbarItems = useMemo(() => {
|
||||
let toolbarItems = getFormattingToolbarItems([
|
||||
const toolbarItems = getFormattingToolbarItems([
|
||||
...blockTypeSelectItems(dict),
|
||||
getCalloutFormattingToolbarItems(t),
|
||||
]);
|
||||
|
||||
// Find the index of the file download button
|
||||
const fileDownloadButtonIndex = toolbarItems.findIndex(
|
||||
(item) =>
|
||||
typeof item === 'object' &&
|
||||
@@ -39,8 +36,6 @@ 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,
|
||||
@@ -55,22 +50,12 @@ 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 */}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
/**
|
||||
* We added some custom logic to the original Blocknote FileDownloadButton
|
||||
* component to handle our file download use case.
|
||||
*
|
||||
* Original source:
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
blockHasType,
|
||||
checkBlockIsFileBlock,
|
||||
checkBlockIsFileBlockWithPlaceholder,
|
||||
} from '@blocknote/core';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
@@ -48,9 +41,7 @@ export const FileDownloadButton = ({
|
||||
|
||||
const block = selectedBlocks[0];
|
||||
|
||||
if (
|
||||
blockHasType(block, editor, block.type, { url: 'string', name: 'string' })
|
||||
) {
|
||||
if (checkBlockIsFileBlock(block, editor)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -62,7 +53,6 @@ export const FileDownloadButton = ({
|
||||
editor.focus();
|
||||
|
||||
const url = fileBlock.props.url as string;
|
||||
const name = fileBlock.props.name as string | undefined;
|
||||
|
||||
/**
|
||||
* If not hosted on our domain, means not a file uploaded by the user,
|
||||
@@ -86,12 +76,16 @@ export const FileDownloadButton = ({
|
||||
|
||||
if (!url.includes('-unsafe')) {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(blob, name || url.split('/').pop() || 'file');
|
||||
downloadFile(
|
||||
blob,
|
||||
fileBlock.props.name || url.split('/').pop() || 'file',
|
||||
);
|
||||
} else {
|
||||
const onConfirm = async () => {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
|
||||
const baseName = name || url.split('/').pop() || 'file';
|
||||
const baseName =
|
||||
fileBlock.props.name || url.split('/').pop() || 'file';
|
||||
|
||||
const regFindLastDot = /(\.[^/.]+)$/;
|
||||
const unsafeName = baseName.includes('.')
|
||||
@@ -106,7 +100,11 @@ export const FileDownloadButton = ({
|
||||
}
|
||||
}, [editor, fileBlock, open]);
|
||||
|
||||
if (!fileBlock || fileBlock.props.url === '' || !Components) {
|
||||
if (
|
||||
!fileBlock ||
|
||||
checkBlockIsFileBlockWithPlaceholder(fileBlock, editor) ||
|
||||
!Components
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,6 @@ 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;
|
||||
@@ -86,18 +83,8 @@ export function MarkdownButton() {
|
||||
mainTooltip={t('Convert Markdown')}
|
||||
onClick={handleConvertMarkdown}
|
||||
className="--docs--editor-markdown-button"
|
||||
label="M"
|
||||
icon={
|
||||
<Text
|
||||
aria-hidden={true}
|
||||
$css={css`
|
||||
font-family: var(--c--theme--font--families--base);
|
||||
`}
|
||||
$weight="bold"
|
||||
>
|
||||
M
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
>
|
||||
M
|
||||
</Components.FormattingToolbar.Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,49 @@
|
||||
import clsx from 'clsx';
|
||||
import { useEffect } from 'react';
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, Loading } from '@/components';
|
||||
import { DocHeader } from '@/docs/doc-header/';
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { DocHeader, DocVersionHeader } from '@/docs/doc-header/';
|
||||
import {
|
||||
Doc,
|
||||
useIsCollaborativeEditable,
|
||||
base64ToBlocknoteXmlFragment,
|
||||
useProviderStore,
|
||||
} from '@/docs/doc-management';
|
||||
import { TableContent } from '@/docs/doc-table-content/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
|
||||
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
|
||||
|
||||
interface DocEditorContainerProps {
|
||||
docHeader: React.ReactNode;
|
||||
docEditor: React.ReactNode;
|
||||
isDeletedDoc: boolean;
|
||||
readOnly: boolean;
|
||||
interface DocEditorProps {
|
||||
doc: Doc;
|
||||
versionId?: Versions['version_id'];
|
||||
}
|
||||
|
||||
export const DocEditorContainer = ({
|
||||
docHeader,
|
||||
docEditor,
|
||||
isDeletedDoc,
|
||||
readOnly,
|
||||
}: DocEditorContainerProps) => {
|
||||
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const isVersion = !!versionId && typeof versionId === 'string';
|
||||
const { provider } = useProviderStore();
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDesktop && !isVersion && (
|
||||
<Box
|
||||
$position="absolute"
|
||||
$css={css`
|
||||
top: 72px;
|
||||
right: 20px;
|
||||
`}
|
||||
>
|
||||
<TableContent />
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$maxWidth="868px"
|
||||
$width="100%"
|
||||
@@ -42,7 +54,7 @@ export const DocEditorContainer = ({
|
||||
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
|
||||
className="--docs--doc-editor-header"
|
||||
>
|
||||
{docHeader}
|
||||
{isVersion ? <DocVersionHeader /> : <DocHeader doc={doc} />}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
@@ -53,17 +65,11 @@ export const DocEditorContainer = ({
|
||||
className="--docs--doc-editor-content"
|
||||
>
|
||||
<Box $css="flex:1;" $position="relative" $width="100%">
|
||||
<Box
|
||||
$padding={{ top: 'md', bottom: '2rem' }}
|
||||
$background="white"
|
||||
className={clsx('--docs--editor-container', {
|
||||
'--docs--doc-readonly': readOnly,
|
||||
'--docs--doc-deleted': isDeletedDoc,
|
||||
})}
|
||||
$height="100%"
|
||||
>
|
||||
{docEditor}
|
||||
</Box>
|
||||
{isVersion ? (
|
||||
<DocVersionEditor docId={doc.id} versionId={versionId} />
|
||||
) : (
|
||||
<BlockNoteEditor doc={doc} provider={provider} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -71,61 +77,69 @@ export const DocEditorContainer = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface DocEditorProps {
|
||||
doc: Doc;
|
||||
interface DocVersionEditorProps {
|
||||
docId: Doc['id'];
|
||||
versionId: Versions['version_id'];
|
||||
}
|
||||
|
||||
export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
const isDeletedDoc = !!doc.deleted_at;
|
||||
const readOnly =
|
||||
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const isProviderReady = isReady && provider;
|
||||
export const DocVersionEditor = ({
|
||||
docId,
|
||||
versionId,
|
||||
}: DocVersionEditorProps) => {
|
||||
const {
|
||||
data: version,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useDocVersion({
|
||||
docId,
|
||||
versionId,
|
||||
});
|
||||
|
||||
const { replace } = useRouter();
|
||||
const [initialContent, setInitialContent] = useState<Y.XmlFragment>();
|
||||
|
||||
useEffect(() => {
|
||||
if (isProviderReady) {
|
||||
setIsSkeletonVisible(false);
|
||||
if (!version?.content) {
|
||||
return;
|
||||
}
|
||||
}, [isProviderReady, setIsSkeletonVisible]);
|
||||
|
||||
if (!isProviderReady) {
|
||||
return <Loading />;
|
||||
setInitialContent(base64ToBlocknoteXmlFragment(version.content));
|
||||
}, [version?.content]);
|
||||
|
||||
if (isError && error) {
|
||||
if (error.status === 404) {
|
||||
void replace(`/404`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box $margin="large" className="--docs--doc-version-editor-error">
|
||||
<TextErrors
|
||||
causes={error.cause}
|
||||
icon={
|
||||
error.status === 502 ? (
|
||||
<Text
|
||||
className="material-icons"
|
||||
$theme="danger"
|
||||
aria-hidden={true}
|
||||
>
|
||||
wifi_off
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDesktop && (
|
||||
<Box
|
||||
$height="100vh"
|
||||
$position="absolute"
|
||||
$css={css`
|
||||
top: 72px;
|
||||
right: 20px;
|
||||
`}
|
||||
>
|
||||
<TableContent />
|
||||
</Box>
|
||||
)}
|
||||
<DocEditorContainer
|
||||
docHeader={<DocHeader doc={doc} />}
|
||||
docEditor={
|
||||
readOnly ? (
|
||||
<BlockNoteReader
|
||||
initialContent={provider.document.getXmlFragment(
|
||||
'document-store',
|
||||
)}
|
||||
docId={doc.id}
|
||||
/>
|
||||
) : (
|
||||
<BlockNoteEditor doc={doc} provider={provider} />
|
||||
)
|
||||
}
|
||||
isDeletedDoc={isDeletedDoc}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
if (isLoading || !version || !initialContent) {
|
||||
return (
|
||||
<Box $align="center" $justify="center" $height="100%">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <BlockNoteEditorVersion initialContent={initialContent} />;
|
||||
};
|
||||
|
||||
@@ -9,18 +9,16 @@ interface EmojiPickerProps {
|
||||
emojiData: EmojiMartData;
|
||||
onClickOutside: () => void;
|
||||
onEmojiSelect: ({ native }: { native: string }) => void;
|
||||
withOverlay?: boolean;
|
||||
}
|
||||
|
||||
export const EmojiPicker = ({
|
||||
emojiData,
|
||||
onClickOutside,
|
||||
onEmojiSelect,
|
||||
withOverlay = false,
|
||||
}: EmojiPickerProps) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const pickerContent = (
|
||||
return (
|
||||
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
|
||||
<Picker
|
||||
data={emojiData}
|
||||
@@ -32,27 +30,4 @@ export const EmojiPicker = ({
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (withOverlay) {
|
||||
return (
|
||||
<>
|
||||
{/* Overlay transparent pour fermer en cliquant à l'extérieur */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 999,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
onClick={onClickOutside}
|
||||
/>
|
||||
{pickerContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return pickerContent;
|
||||
};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,569 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './CommentToolbarButton';
|
||||
export * from './styles';
|
||||
export * from './useComments';
|
||||
@@ -1,214 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,55 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,62 +1,43 @@
|
||||
/**
|
||||
* AccessibleImageBlock.tsx
|
||||
*
|
||||
* This file defines a custom BlockNote block specification for an accessible image block.
|
||||
* It extends the default image block to ensure compliance with accessibility standards,
|
||||
* specifically RGAA 1.9.1, by using <figure> and <figcaption> elements when a caption is provided.
|
||||
*
|
||||
* The accessible image block ensures that:
|
||||
* - Images with captions are wrapped in <figure> and <figcaption> elements.
|
||||
* - The <img> element has an appropriate alt attribute based on the caption.
|
||||
* - Accessibility attributes such as role and aria-label are added for better screen reader support.
|
||||
* - Images without captions have alt="" and are marked as decorative with aria-hidden="true".
|
||||
*
|
||||
* This implementation leverages BlockNote's existing image block functionality while enhancing it for accessibility.
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/blocks/Image/block.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockFromConfig,
|
||||
BlockNoteEditor,
|
||||
ImageOptions,
|
||||
BlockSchemaWithBlock,
|
||||
InlineContentSchema,
|
||||
InlineContentSchemaFromSpecs,
|
||||
StyleSchema,
|
||||
createBlockSpec,
|
||||
createImageBlockConfig,
|
||||
defaultInlineContentSpecs,
|
||||
imageBlockConfig,
|
||||
imageParse,
|
||||
imageRender,
|
||||
imageToExternalHTML,
|
||||
} from '@blocknote/core';
|
||||
import { t } from 'i18next';
|
||||
|
||||
type CreateImageBlockConfig = ReturnType<typeof createImageBlockConfig>;
|
||||
type ImageBlockConfig = typeof imageBlockConfig;
|
||||
|
||||
export const accessibleImageRender =
|
||||
(config: ImageOptions) =>
|
||||
(
|
||||
block: BlockFromConfig<
|
||||
CreateImageBlockConfig,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>,
|
||||
editor: BlockNoteEditor<
|
||||
Record<'image', CreateImageBlockConfig>,
|
||||
InlineContentSchemaFromSpecs<typeof defaultInlineContentSpecs>,
|
||||
StyleSchema
|
||||
>,
|
||||
) => {
|
||||
const imageRenderComputed = imageRender(config);
|
||||
const dom = imageRenderComputed(block, editor).dom;
|
||||
const imgSelector = dom.querySelector('img');
|
||||
export const accessibleImageRender = (
|
||||
block: BlockFromConfig<ImageBlockConfig, InlineContentSchema, StyleSchema>,
|
||||
editor: BlockNoteEditor<
|
||||
BlockSchemaWithBlock<ImageBlockConfig['type'], ImageBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>,
|
||||
) => {
|
||||
const imageRenderComputed = imageRender(block, editor);
|
||||
const dom = imageRenderComputed.dom;
|
||||
const imgSelector = dom.querySelector('img');
|
||||
|
||||
const withCaption =
|
||||
block.props.caption && dom.querySelector('.bn-file-caption');
|
||||
|
||||
const accessibleImageWithCaption = () => {
|
||||
imgSelector?.setAttribute('alt', block.props.caption);
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
|
||||
// Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists
|
||||
const accessibleImageWithCaption = () => {
|
||||
imgSelector?.setAttribute('alt', block.props.caption);
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
const captionElement = dom.querySelector('.bn-file-caption');
|
||||
|
||||
if (captionElement) {
|
||||
const figureElement = document.createElement('figure');
|
||||
|
||||
// Copy all attributes from the original div
|
||||
@@ -95,36 +76,32 @@ export const accessibleImageRender =
|
||||
...imageRenderComputed,
|
||||
dom: figureElement,
|
||||
};
|
||||
};
|
||||
|
||||
const accessibleImage = () => {
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom,
|
||||
};
|
||||
};
|
||||
|
||||
const withCaption =
|
||||
block.props.caption && dom.querySelector('.bn-file-caption');
|
||||
|
||||
// Set accessibility attributes for the image
|
||||
return withCaption ? accessibleImageWithCaption() : accessibleImage();
|
||||
}
|
||||
};
|
||||
|
||||
export const AccessibleImageBlock = createBlockSpec(
|
||||
createImageBlockConfig,
|
||||
(config) => ({
|
||||
meta: {
|
||||
fileBlockAccept: ['image/*'],
|
||||
},
|
||||
render: accessibleImageRender(config),
|
||||
parse: imageParse(config),
|
||||
toExternalHTML: imageToExternalHTML(config),
|
||||
runsBefore: ['file'],
|
||||
}),
|
||||
);
|
||||
const accessibleImage = () => {
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
};
|
||||
|
||||
// Set accessibility attributes for the image
|
||||
const result = withCaption ? accessibleImageWithCaption() : accessibleImage();
|
||||
|
||||
// Return the result if accessibleImageWithCaption created a figure, otherwise return original
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom,
|
||||
};
|
||||
};
|
||||
|
||||
export const AccessibleImageBlock = createBlockSpec(imageBlockConfig, {
|
||||
render: accessibleImageRender,
|
||||
parse: imageParse,
|
||||
toExternalHTML: imageToExternalHTML,
|
||||
});
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import {
|
||||
BlockConfig,
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
defaultProps,
|
||||
insertOrUpdateBlock,
|
||||
} from '@blocknote/core';
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, Icon } from '@/components';
|
||||
|
||||
@@ -19,131 +12,90 @@ import { EmojiPicker } from '../EmojiPicker';
|
||||
|
||||
import emojidata from './initEmojiCallout';
|
||||
|
||||
const CalloutBlockStyle = createGlobalStyle`
|
||||
.bn-block-content[data-content-type="callout"][data-background-color] {
|
||||
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
`;
|
||||
|
||||
type CreateCalloutBlockConfig = BlockConfig<
|
||||
'callout',
|
||||
{
|
||||
textAlignment: typeof defaultProps.textAlignment;
|
||||
backgroundColor: typeof defaultProps.backgroundColor;
|
||||
emoji: { default: '💡' };
|
||||
},
|
||||
'inline'
|
||||
>;
|
||||
|
||||
interface CalloutComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'callout', CreateCalloutBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'callout', CreateCalloutBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
const CalloutComponent = ({
|
||||
block,
|
||||
editor,
|
||||
contentRef,
|
||||
}: CalloutComponentProps) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
};
|
||||
|
||||
const onClickOutside = () => setOpenEmojiPicker(false);
|
||||
|
||||
const onEmojiSelect = ({ native }: { native: string }) => {
|
||||
editor.updateBlock(block, { props: { emoji: native } });
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
// Temporary: sets a yellow background color to a callout block when added by
|
||||
// the user, while keeping the colors menu on the drag handler usable for
|
||||
// this custom block.
|
||||
useEffect(() => {
|
||||
if (!block.content.length && block.props.backgroundColor === 'default') {
|
||||
// Delay the update to avoid interfering with the block insertion process
|
||||
setTimeout(() => {
|
||||
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
|
||||
}, 0);
|
||||
}
|
||||
}, [block, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding="1rem"
|
||||
$gap="0.625rem"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$css={css`
|
||||
flex-grow: 1;
|
||||
`}
|
||||
>
|
||||
<CalloutBlockStyle />
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
`}
|
||||
$align="center"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
withOverlay={true}
|
||||
/>
|
||||
)}
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const CalloutBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'callout',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
backgroundColor: { default: 'default' as const },
|
||||
backgroundColor: defaultProps.backgroundColor,
|
||||
emoji: { default: '💡' },
|
||||
},
|
||||
content: 'inline',
|
||||
},
|
||||
{
|
||||
render: ({ block, editor, contentRef }) => (
|
||||
<CalloutComponent block={block} editor={editor} contentRef={contentRef} />
|
||||
),
|
||||
render: ({ block, editor, contentRef }) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
};
|
||||
|
||||
const onClickOutside = () => setOpenEmojiPicker(false);
|
||||
|
||||
const onEmojiSelect = ({ native }: { native: string }) => {
|
||||
editor.updateBlock(block, { props: { emoji: native } });
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
// Temporary: sets a yellow background color to a callout block when added by
|
||||
// the user, while keeping the colors menu on the drag handler usable for
|
||||
// this custom block.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!block.content.length &&
|
||||
block.props.backgroundColor === 'default'
|
||||
) {
|
||||
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
|
||||
}
|
||||
}, [block, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding="1rem"
|
||||
$gap="0.625rem"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
`}
|
||||
$align="center"
|
||||
$height="28px"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
)}
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -153,7 +105,6 @@ export const getCalloutReactSlashMenuItems = (
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
key: 'callout',
|
||||
title: t('Callout'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
@@ -173,4 +124,5 @@ export const getCalloutFormattingToolbarItems = (
|
||||
name: t('Callout'),
|
||||
type: 'callout',
|
||||
icon: () => <Icon iconName="lightbulb" $size="16px" />,
|
||||
isSelected: (block) => block.type === 'callout',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { Box, Icon } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
export const DividerBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'divider',
|
||||
propSchema: {},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="hr"
|
||||
$width="100%"
|
||||
$background={colorsTokens['greyscale-300']}
|
||||
$margin="1rem 0"
|
||||
$css={`border: 1px solid ${colorsTokens['greyscale-300']};`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getDividerReactSlashMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
title: t('Divider'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'divider',
|
||||
});
|
||||
},
|
||||
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
|
||||
group,
|
||||
icon: <Icon iconName="remove" $size="18px" />,
|
||||
subtext: t('Add a horizontal line'),
|
||||
},
|
||||
];
|
||||
@@ -1,19 +1,11 @@
|
||||
import {
|
||||
BlockConfig,
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
insertOrUpdateBlock,
|
||||
} from '@blocknote/core';
|
||||
import * as locales from '@blocknote/core/locales';
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { insertOrUpdateBlock } from '@blocknote/core';
|
||||
import {
|
||||
AddFileButton,
|
||||
ResizableFileBlockWrapper,
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
@@ -22,112 +14,63 @@ import { Box, Icon } from '@/components';
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
const PDFBlockStyle = createGlobalStyle`
|
||||
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper[style*="fit-content"] {
|
||||
width: 100% !important;
|
||||
.bn-block-content[data-content-type="pdf"] {
|
||||
width: fit-content;
|
||||
}
|
||||
`;
|
||||
|
||||
type FileBlockEditor = Parameters<typeof AddFileButton>[0]['editor'];
|
||||
type FileBlockBlock = Parameters<typeof AddFileButton>[0]['block'];
|
||||
|
||||
type CreatePDFBlockConfig = BlockConfig<
|
||||
'pdf',
|
||||
{
|
||||
backgroundColor: { default: 'default' };
|
||||
caption: { default: '' };
|
||||
name: { default: '' };
|
||||
previewWidth: { default: undefined; type: 'number' };
|
||||
showPreview: { default: true };
|
||||
textAlignment: { default: 'left' };
|
||||
url: { default: '' };
|
||||
},
|
||||
'none'
|
||||
>;
|
||||
|
||||
interface PdfBlockComponentProps {
|
||||
block: BlockNoDefaults<
|
||||
Record<'pdf', CreatePDFBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
editor: BlockNoteEditor<
|
||||
Record<'pdf', CreatePDFBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
}
|
||||
|
||||
const PdfBlockComponent = ({
|
||||
editor,
|
||||
block,
|
||||
contentRef,
|
||||
}: PdfBlockComponentProps) => {
|
||||
const pdfUrl = block.props.url;
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage;
|
||||
|
||||
useEffect(() => {
|
||||
if (lang && locales[lang as keyof typeof locales]) {
|
||||
locales[lang as keyof typeof locales].file_blocks.add_button_text['pdf'] =
|
||||
t('Add PDF');
|
||||
(
|
||||
locales[lang as keyof typeof locales].file_panel.embed
|
||||
.embed_button as Record<string, string>
|
||||
)['pdf'] = t('Add PDF');
|
||||
(
|
||||
locales[lang as keyof typeof locales].file_panel.upload
|
||||
.file_placeholder as Record<string, string>
|
||||
)['pdf'] = t('Upload PDF');
|
||||
}
|
||||
}, [lang, t]);
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
<ResizableFileBlockWrapper
|
||||
buttonIcon={
|
||||
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
|
||||
}
|
||||
block={block as unknown as FileBlockBlock}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
>
|
||||
<Box
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
as="embed"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
</ResizableFileBlockWrapper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const PdfBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'pdf',
|
||||
content: 'none',
|
||||
propSchema: {
|
||||
backgroundColor: { default: 'default' as const },
|
||||
caption: { default: '' as const },
|
||||
name: { default: '' as const },
|
||||
previewWidth: { default: undefined, type: 'number' },
|
||||
showPreview: { default: true },
|
||||
textAlignment: { default: 'left' as const },
|
||||
url: { default: '' as const },
|
||||
caption: { default: '' as const },
|
||||
showPreview: { default: true },
|
||||
previewWidth: { default: undefined, type: 'number' },
|
||||
},
|
||||
isFileBlock: true,
|
||||
fileBlockAccept: ['application/pdf'],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
fileBlockAccept: ['application/pdf'],
|
||||
render: ({ editor, block, contentRef }) => {
|
||||
const { t } = useTranslation();
|
||||
const pdfUrl = block.props.url;
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
<ResizableFileBlockWrapper
|
||||
buttonIcon={
|
||||
<Icon
|
||||
iconName="upload"
|
||||
$size="24px"
|
||||
$css="line-height: normal;"
|
||||
/>
|
||||
}
|
||||
block={block}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
buttonText={t('Add PDF')}
|
||||
>
|
||||
<Box
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
as="embed"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
</ResizableFileBlockWrapper>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
render: (props) => <PdfBlockComponent {...props} />,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,146 +1,34 @@
|
||||
import {
|
||||
BlockConfig,
|
||||
BlockNoDefaults,
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
} from '@blocknote/core';
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
import { t } from 'i18next';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useMediaUrl } from '@/core';
|
||||
|
||||
import { loopCheckDocMediaStatus } from '../../api';
|
||||
import Loader from '../../assets/loader.svg';
|
||||
import Warning from '../../assets/warning.svg';
|
||||
|
||||
type UploadLoaderPropSchema = {
|
||||
readonly information: { readonly default: '' };
|
||||
readonly type: {
|
||||
readonly default: 'loading';
|
||||
readonly values: readonly ['loading', 'warning'];
|
||||
};
|
||||
readonly blockUploadName: { readonly default: '' };
|
||||
readonly blockUploadShowPreview: { readonly default: true };
|
||||
readonly blockUploadType: {
|
||||
readonly default: '';
|
||||
};
|
||||
readonly blockUploadUrl: { readonly default: '' };
|
||||
};
|
||||
|
||||
type UploadLoaderBlockConfig = BlockConfig<
|
||||
'uploadLoader',
|
||||
UploadLoaderPropSchema,
|
||||
'none'
|
||||
>;
|
||||
|
||||
type UploadLoaderBlockType = BlockNoDefaults<
|
||||
Record<'uploadLoader', UploadLoaderBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
|
||||
type UploadLoaderEditor = BlockNoteEditor<
|
||||
Record<'uploadLoader', UploadLoaderBlockConfig>,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
>;
|
||||
|
||||
interface UploadLoaderBlockComponentProps {
|
||||
block: UploadLoaderBlockType;
|
||||
editor: UploadLoaderEditor;
|
||||
contentRef: (node: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
const UploadLoaderBlockComponent = ({
|
||||
block,
|
||||
editor,
|
||||
}: UploadLoaderBlockComponentProps) => {
|
||||
const mediaUrl = useMediaUrl();
|
||||
|
||||
useEffect(() => {
|
||||
if (!block.props.blockUploadUrl || block.props.type !== 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = block.props.blockUploadUrl;
|
||||
|
||||
loopCheckDocMediaStatus(url)
|
||||
.then((response) => {
|
||||
// Replace the loading block with the resource block (image, audio, video, pdf ...)
|
||||
try {
|
||||
editor.replaceBlocks(
|
||||
[block.id],
|
||||
[
|
||||
{
|
||||
type: block.props.blockUploadType,
|
||||
props: {
|
||||
url: `${mediaUrl}${response.file}`,
|
||||
showPreview: block.props.blockUploadShowPreview,
|
||||
name: block.props.blockUploadName,
|
||||
caption: '',
|
||||
backgroundColor: 'default',
|
||||
textAlignment: 'left',
|
||||
},
|
||||
} as never,
|
||||
],
|
||||
);
|
||||
} catch {
|
||||
/* During collaboration, another user might have updated the block */
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error analyzing file:', error);
|
||||
|
||||
try {
|
||||
editor.updateBlock(block.id, {
|
||||
type: 'uploadLoader',
|
||||
props: {
|
||||
type: 'warning',
|
||||
information: t(
|
||||
'The antivirus has detected an anomaly in your file.',
|
||||
),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* During collaboration, another user might have updated the block */
|
||||
}
|
||||
});
|
||||
}, [block, editor, mediaUrl]);
|
||||
|
||||
return (
|
||||
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
|
||||
{block.props.type === 'warning' ? (
|
||||
<Warning />
|
||||
) : (
|
||||
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
|
||||
)}
|
||||
<Text>{block.props.information}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const UploadLoaderBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'uploadLoader',
|
||||
propSchema: {
|
||||
information: { default: '' },
|
||||
information: { default: '' as const },
|
||||
type: {
|
||||
default: 'loading',
|
||||
values: ['loading', 'warning'],
|
||||
default: 'loading' as const,
|
||||
values: ['loading', 'warning'] as const,
|
||||
},
|
||||
blockUploadName: { default: '' },
|
||||
blockUploadShowPreview: { default: true },
|
||||
blockUploadType: {
|
||||
default: '',
|
||||
},
|
||||
blockUploadUrl: { default: '' },
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: (props) => <UploadLoaderBlockComponent {...props} />,
|
||||
render: ({ block }) => {
|
||||
return (
|
||||
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
|
||||
{block.props.type === 'warning' ? (
|
||||
<Warning />
|
||||
) : (
|
||||
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
|
||||
)}
|
||||
<Text>{block.props.information}</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './AccessibleImageBlock';
|
||||
export * from './CalloutBlock';
|
||||
export { default as emojidata } from './initEmojiCallout';
|
||||
export * from './DividerBlock';
|
||||
export * from './PdfBlock';
|
||||
export * from './UploadLoaderBlock';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { css } from 'styled-components';
|
||||
import { BoxButton, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
|
||||
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management';
|
||||
import { useDoc } from '@/docs/doc-management';
|
||||
|
||||
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
{
|
||||
@@ -29,9 +29,6 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
render: ({ inlineContent, updateInlineContent }) => {
|
||||
const { data: doc } = useDoc({ id: inlineContent.props.docId });
|
||||
|
||||
/**
|
||||
* Update the content title if the referenced doc title changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (doc?.title && doc.title !== inlineContent.props.title) {
|
||||
updateInlineContent({
|
||||
@@ -42,15 +39,7 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
|
||||
* causing an infinite loop of updates.
|
||||
* To prevent this, we only run this effect when doc?.title changes,
|
||||
* not when inlineContent.props.title changes.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doc?.title]);
|
||||
}, [inlineContent.props, doc?.title, updateInlineContent]);
|
||||
|
||||
return <LinkSelected {...inlineContent.props} />;
|
||||
},
|
||||
@@ -63,8 +52,6 @@ interface LinkSelectedProps {
|
||||
}
|
||||
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -77,7 +64,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
onClick={handleClick}
|
||||
draggable="false"
|
||||
$css={css`
|
||||
display: contents;
|
||||
display: inline;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
& svg {
|
||||
@@ -89,27 +76,11 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
}
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
.--docs--doc-deleted & {
|
||||
pointer-events: none;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{emoji ? (
|
||||
<Text $size="16px">{emoji}</Text>
|
||||
) : (
|
||||
<SelectedPageIcon width={11.5} color={colorsTokens['primary-400']} />
|
||||
)}
|
||||
<Text
|
||||
$weight="500"
|
||||
spellCheck="false"
|
||||
$size="16px"
|
||||
$display="inline"
|
||||
$css={css`
|
||||
margin-left: 2px;
|
||||
`}
|
||||
>
|
||||
{titleWithoutEmoji}
|
||||
<SelectedPageIcon width={11.5} />
|
||||
<Text $weight="500" spellCheck="false" $size="16px" $display="inline">
|
||||
{title}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user