Compare commits

...

10 Commits

Author SHA1 Message Date
Manuel Raynaud f51246b392 (backend) add comment viewset
This commit add the CRUD part to manage comment lifeycle. Permissions
are relying on the Document and Comment abilities. Comment viewset
depends on the Document route and is added to the
document_related_router. Dedicated serializer and permission are
created.
2025-08-28 08:26:16 +02:00
Manuel Raynaud a11a1911bc (backend) add Comment model
In order to store the comments on a document, we created a new model
Comment. User is nullable because anonymous users can comment a Document
is this one is public with a link_role commentator.
2025-08-27 16:38:42 +02:00
Manuel Raynaud 604e5e0eb2 (backend) add commentator role
To allow a user to comment a document we added a new role: commentator.
Commentator is higher than reader but lower than editor.
2025-08-26 17:55:53 +02:00
Luca Weiss 0892c05321 📝(compose) Increase attachment upload size to 10 MB
Mostly give this as an example how a person deploying this knows which
knob to turn.

Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:51 +02:00
Luca Weiss 2375bc136c 📝(compose) Whitespace cleanup in default.conf.template
Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:50 +02:00
Luca Weiss e1c2053697 📝(compose) Remove double colon from yaml
It doesn't look like this is supposed to be there.

Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:49 +02:00
Luca Weiss 58f68d86e1 📝(compose) Reindent compose.yaml files
Follow yamlllint's suggestions and use the correct indentation for
lists.

Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:48 +02:00
Luca Weiss 7c97719907 📝(compose) Update deprecated USER_OIDC_FIELD* variables in example
Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:40 +02:00
Cyril d0c9de9d96 (frontend) set empty alt for decorative images in blocknote editor
ensure decorative images have empty alt to comply with RGAA 1.2 accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-14 14:39:42 +02:00
Cyril 81f3997628 (frontend) improve accessibility of search modal for screen readers
added clearer sr-only translations and aria-hidden for non-essential content

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-08 08:58:22 +02:00
39 changed files with 1596 additions and 142 deletions
+6 -1
View File
@@ -8,22 +8,27 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(backend) Comments on text editor #1309
### Changed
- ⚡️(frontend) improve accessibility:
- #1248
- #1235
- #1275
- #1255
- #1262
- #1244
- #1270
- #1282
### Fixed
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1264
- 🐛(minio) fix user permission error with Minio and Windows #1264
## [3.5.0] - 2025-07-31
### Added
@@ -11,6 +11,9 @@ server {
server_name localhost;
charset utf-8;
# increase max upload size
client_max_body_size 10m;
# Disables server version feedback on pages and in headers
server_tokens off;
@@ -68,7 +71,7 @@ server {
proxy_set_header Host $host;
}
location /collaboration/api/ {
location /collaboration/api/ {
# Collaboration server
proxy_pass http://${YPROVIDER_HOST}:4444;
proxy_set_header Host $host;
@@ -95,7 +98,7 @@ server {
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https;
@@ -109,4 +112,4 @@ server {
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
}
}
+15 -15
View File
@@ -7,12 +7,12 @@ services:
timeout: 2s
retries: 300
env_file:
- env.d/postgresql
- env.d/common
- env.d/postgresql
- env.d/common
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:8
@@ -22,12 +22,12 @@ services:
user: ${DOCKER_USER:-1000}
restart: always
environment:
- DJANGO_CONFIGURATION=Production
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/common
- env.d/backend
- env.d/yprovider
- env.d/postgresql
- env.d/common
- env.d/backend
- env.d/yprovider
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
@@ -45,24 +45,24 @@ services:
image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000}
env_file:
- env.d/common
- env.d/yprovider
- env.d/common
- env.d/yprovider
frontend:
image: lasuite/impress-frontend:latest
user: "101"
entrypoint:
- /docker-entrypoint.sh
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- env.d/common
- env.d/common
# Uncomment and set your values if using our nginx proxy example
#environment:
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
volumes:
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
depends_on:
backend:
condition: service_healthy
+7 -7
View File
@@ -7,23 +7,23 @@ services:
timeout: 2s
retries: 300
env_file:
- env.d/kc_postgresql
- env.d/kc_postgresql
volumes:
- ./data/keycloak:/var/lib/postgresql/data/pgdata
- ./data/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:26.1.3
command: ["start"]
env_file:
- env.d/kc_postgresql
- env.d/keycloak
- env.d/kc_postgresql
- env.d/keycloak
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
depends_on:
kc_postgresql::
kc_postgresql:
condition: service_healthy
restart: true
# Uncomment if using our nginx proxy example
@@ -33,4 +33,4 @@ services:
#
#networks:
# proxy-tier:
# external: true
# external: true
+5 -5
View File
@@ -2,8 +2,8 @@ services:
minio:
image: minio/minio
environment:
- MINIO_ROOT_USER=<set minio root username>
- MINIO_ROOT_PASSWORD=<set minio root password>
- MINIO_ROOT_USER=<set minio root username>
- MINIO_ROOT_PASSWORD=<set minio root password>
# Uncomment and set your values if using our nginx proxy example
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=9000 # used by nginx proxy
@@ -16,12 +16,12 @@ services:
entrypoint: ""
command: minio server /data
volumes:
- ./data/minio:/data
- ./data/minio:/data
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - proxy-tier
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
# external: true
+12 -12
View File
@@ -3,28 +3,28 @@ services:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
- "80:80"
- "443:443"
volumes:
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- proxy-tier
- proxy-tier
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
environment:
- DEFAULT_EMAIL=mail@yourdomain.tld
- DEFAULT_EMAIL=mail@yourdomain.tld
volumes_from:
- nginx-proxy
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy-tier
- proxy-tier
networks:
proxy-tier:
+2 -2
View File
@@ -43,8 +43,8 @@ OIDC_RP_CLIENT_ID=<client_id>
OIDC_RP_CLIENT_SECRET=<client secret>
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
#USER_OIDC_FIELD_TO_SHORTNAME
#USER_OIDC_FIELDS_TO_FULLNAME
#OIDC_USERINFO_SHORTNAME_FIELD
#OIDC_USERINFO_FULLNAME_FIELDS
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}
+16
View File
@@ -171,3 +171,19 @@ class ResourceAccessPermission(IsAuthenticated):
action = view.action
return abilities.get(action, False)
class CommentPermission(permissions.BasePermission):
"""Permission class for comments."""
def has_permission(self, request, view):
"""Check permission for a given object."""
if view.action in ["create", "list"]:
document_abilities = view.get_document_or_404().get_abilities(request.user)
return document_abilities["comment"]
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
+44
View File
@@ -801,3 +801,47 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)
class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments."""
user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Comment
fields = [
"id",
"content",
"created_at",
"updated_at",
"user",
"document",
"abilities",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"user",
"document",
"abilities",
]
def get_abilities(self, comment) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return comment.get_abilities(request.user)
return {}
def validate(self, attrs):
"""Validate invitation data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["document_id"] = self.context["resource_id"]
attrs["user_id"] = user.id if user else None
return attrs
+33
View File
@@ -2072,3 +2072,36 @@ class ConfigView(drf.views.APIView):
)
return theme_customization
class CommentViewSet(
viewsets.ModelViewSet,
):
"""API ViewSet for comments."""
permission_classes = [permissions.CommentPermission]
queryset = models.Comment.objects.select_related("user", "document").all()
serializer_class = serializers.CommentSerializer
pagination_class = Pagination
_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
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["resource_id"] = self.kwargs["resource_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
return super().get_queryset().filter(document=self.kwargs["resource_id"])
+2
View File
@@ -33,6 +33,7 @@ class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
COMMENTATOR = "commentator", _("Commentator") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
@@ -40,6 +41,7 @@ class RoleChoices(PriorityTextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
COMMENTATOR = "commentator", _("Commentator") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
+11
View File
@@ -256,3 +256,14 @@ 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 CommentFactory(factory.django.DjangoModelFactory):
"""A factory to create comments for a document"""
class Meta:
model = models.Comment
document = factory.SubFactory(DocumentFactory)
user = factory.SubFactory(UserFactory)
content = factory.Faker("text")
@@ -0,0 +1,146 @@
# Generated by Django 5.2.4 on 2025-08-26 08:11
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_add_is_masked_field_to_link_trace"),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaskforaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="invitation",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="templateaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commentator", "Commentator"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
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",
),
),
("content", models.TextField()),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="core.document",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="comments",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "impress_comment",
"ordering": ("-created_at",),
},
),
]
+50 -1
View File
@@ -762,6 +762,7 @@ class Document(MP_Node, BaseModel):
can_update = (
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
can_comment = (can_update or role == RoleChoices.COMMENTATOR) and not is_deleted
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
@@ -786,6 +787,7 @@ class Document(MP_Node, BaseModel):
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"comment": can_comment,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": is_owner,
@@ -1145,7 +1147,12 @@ class DocumentAccess(BaseAccess):
set_role_to = []
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
[
RoleChoices.READER,
RoleChoices.COMMENTATOR,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
)
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
@@ -1277,6 +1284,48 @@ class DocumentAskForAccess(BaseModel):
self.document.send_email(subject, [email], context, language)
class Comment(BaseModel):
"""User comment on a document."""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="comments",
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="comments",
null=True,
blank=True,
)
content = models.TextField()
class Meta:
db_table = "impress_comment"
ordering = ("-created_at",)
verbose_name = _("Comment")
verbose_name_plural = _("Comments")
def __str__(self):
author = self.user or _("Anonymous")
return f"{author!s} on {self.document!s}"
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
role = self.document.get_role(user)
can_comment = self.document.get_abilities(user)["comment"]
return {
"destroy": self.user == user
or role in [RoleChoices.OWNER, RoleChoices.ADMIN],
"update": self.user == user
or role in [RoleChoices.OWNER, RoleChoices.ADMIN],
"partial_update": self.user == user
or role in [RoleChoices.OWNER, RoleChoices.ADMIN],
"retrieve": can_comment,
}
class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""
@@ -292,6 +292,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
}
assert result_dict[str(document_access_other_user.id)] == [
"reader",
"commentator",
"editor",
"administrator",
"owner",
@@ -300,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="editor"
document=parent, user=other_user, role="commentator"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
@@ -313,6 +314,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"commentator",
"editor",
"administrator",
"owner",
@@ -320,6 +322,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [
"reader",
"commentator",
"editor",
"administrator",
"owner",
@@ -332,28 +335,28 @@ def test_api_document_accesses_retrieve_set_role_to_child():
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
],
@@ -414,44 +417,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
[
["reader", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commentator", "editor", "administrator", "owner"],
],
],
[
["reader", "administrator", "reader", "editor"],
[
["reader", "editor", "administrator"],
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
[],
[],
],
@@ -459,7 +462,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["editor", "editor", "administrator", "editor"],
[
["reader", "editor", "administrator"],
["reader", "commentator", "editor", "administrator"],
[],
["editor", "administrator"],
[],
@@ -0,0 +1,588 @@
"""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.COMMENTATOR
)
comment1, comment2 = factories.CommentFactory.create_batch(2, document=document)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/comments/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment2.id),
"content": comment2.content,
"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,
},
"document": str(comment2.document.id),
"abilities": comment2.get_abilities(AnonymousUser()),
},
{
"id": str(comment1.id),
"content": comment1.content,
"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,
},
"document": str(comment1.document.id),
"abilities": comment1.get_abilities(AnonymousUser()),
},
],
}
@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.COMMENTATOR
)
factories.CommentFactory(document=document)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(f"/api/v1.0/documents/{document.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.COMMENTATOR)]
)
comment1 = factories.CommentFactory(document=document)
comment2 = factories.CommentFactory(document=document, 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}/comments/")
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment2.id),
"content": comment2.content,
"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,
},
"document": str(comment2.document.id),
"abilities": comment2.get_abilities(user),
},
{
"id": str(comment1.id),
"content": comment1.content,
"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,
},
"document": str(comment1.document.id),
"abilities": comment1.get_abilities(user),
},
],
}
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")
factories.CommentFactory(document=document)
# 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}/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)]
)
factories.CommentFactory(document=document)
# 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}/comments/")
assert response.status_code == 403
# Create comment
def test_create_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to create comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTATOR
)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"content": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": None,
"document": str(document.id),
"abilities": {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": True,
},
}
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
)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "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.COMMENTATOR)]
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "test"}
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"content": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": {
"full_name": user.full_name,
"short_name": user.short_name,
},
"document": str(document.id),
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
},
}
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)]
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/comments/", {"content": "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.COMMENTATOR
)
comment = factories.CommentFactory(document=document)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
assert response.json() == {
"id": str(comment.id),
"content": comment.content,
"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,
},
"document": str(comment.document.id),
"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
)
comment = factories.CommentFactory(document=document)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.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.COMMENTATOR)]
)
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.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)]
)
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.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.COMMENTATOR
)
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "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
)
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "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.COMMENTATOR, models.LinkRoleChoices.EDITOR]
),
)
],
)
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "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.COMMENTATOR, models.LinkRoleChoices.EDITOR]
),
)
],
)
comment = factories.CommentFactory(document=document, content="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.content == "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)]
)
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "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")
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "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)])
comment = factories.CommentFactory(document=document, content="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.content == "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)])
comment = factories.CommentFactory(document=document, content="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/",
{"content": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.content == "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.COMMENTATOR
)
comment = factories.CommentFactory(document=document)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.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
)
comment = factories.CommentFactory(document=document)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.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.COMMENTATOR)]
)
comment = factories.CommentFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.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.COMMENTATOR)]
)
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.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)])
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.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)])
comment = factories.CommentFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.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)]
)
comment = factories.CommentFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
@@ -36,6 +36,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commentator", "editor"],
"cors_proxy": True,
"descendants": True,
"destroy": False,
@@ -45,8 +46,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": False,
@@ -111,6 +112,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commentator", "editor"],
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -216,6 +218,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commentator", "editor"],
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -224,8 +227,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -298,6 +301,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commentator", "editor"],
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -488,10 +492,11 @@ def test_api_documents_retrieve_authenticated_related_parent():
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
"can_edit": access.role != "reader",
"can_edit": access.role not in ["reader", "commentator"],
"children_create": access.role != "reader",
"children_list": True,
"collaboration_auth": True,
"comment": access.role != "reader",
"descendants": True,
"cors_proxy": True,
"destroy": access.role == "owner",
@@ -79,16 +79,17 @@ def test_api_documents_trashbin_format():
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"comment": True,
"cors_proxy": True,
"descendants": True,
"destroy": True,
"duplicate": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -0,0 +1,273 @@
"""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.COMMENTATOR, 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(document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": 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(document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": False,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTATOR, 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(document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTATOR, 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(
document=document, user=user if can_comment else None
)
assert comment.get_abilities(user) == {
"destroy": can_comment,
"update": can_comment,
"partial_update": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commentator(link_role, link_reach):
"""Commentators can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTATOR)],
)
comment = factories.CommentFactory(document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commentator_own_comment(link_role, link_reach):
"""Commentators have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTATOR)],
)
comment = factories.CommentFactory(document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTATOR, 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(document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTATOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTATOR, 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(document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": 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(
document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": 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(
document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": 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", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -166,7 +166,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -183,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -200,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -217,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -234,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commentator", "editor", "administrator", "owner"],
}
@@ -271,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commentator", "editor", "administrator"],
}
@@ -288,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commentator", "editor", "administrator"],
}
@@ -305,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commentator", "editor", "administrator"],
}
+195 -26
View File
@@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth):
[
(True, "restricted", "reader"),
(True, "restricted", "editor"),
(True, "restricted", "commentator"),
(False, "restricted", "reader"),
(False, "restricted", "editor"),
(False, "restricted", "commentator"),
(False, "authenticated", "reader"),
(False, "authenticated", "editor"),
(False, "authenticated", "commentator"),
],
)
def test_models_documents_get_abilities_forbidden(
@@ -164,6 +167,7 @@ def test_models_documents_get_abilities_forbidden(
"destroy": False,
"duplicate": False,
"favorite": False,
"comment": False,
"invite_owner": False,
"mask": False,
"media_auth": False,
@@ -171,8 +175,8 @@ def test_models_documents_get_abilities_forbidden(
"move": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"partial_update": False,
@@ -222,6 +226,7 @@ def test_models_documents_get_abilities_reader(
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -230,8 +235,77 @@ def test_models_documents_get_abilities_reader(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "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_commentator(
is_authenticated, reach, django_assert_num_queries
):
"""
Check abilities returned for a document giving commentator 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="commentator")
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,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": is_authenticated,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -287,6 +361,7 @@ def test_models_documents_get_abilities_editor(
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -295,8 +370,8 @@ def test_models_documents_get_abilities_editor(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -341,6 +416,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": True,
@@ -349,8 +425,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -392,6 +468,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -400,8 +477,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"invite_owner": False,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -446,6 +523,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -454,8 +532,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -507,6 +585,8 @@ def test_models_documents_get_abilities_reader_user(
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_reach != "restricted"
and document.link_role in ["commentator", "editor"],
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -515,8 +595,72 @@ def test_models_documents_get_abilities_reader_user(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "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_commentator_user(
ai_access_setting, django_assert_num_queries
):
"""Check abilities returned for the commentator of a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "commentator")])
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,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -566,6 +710,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"destroy": False,
@@ -574,8 +719,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
"restricted": None,
},
"mask": True,
@@ -1198,7 +1343,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
"reader",
{
"public": ["reader", "editor"],
"public": ["reader", "commentator", "editor"],
},
),
(
"public",
"commentator",
{
"public": ["commentator", "editor"],
},
),
("public", "editor", {"public": ["editor"]}),
@@ -1206,8 +1358,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"authenticated",
"reader",
{
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
},
),
(
"authenticated",
"commentator",
{
"authenticated": ["commentator", "editor"],
"public": ["commentator", "editor"],
},
),
(
@@ -1220,8 +1380,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"reader",
{
"restricted": None,
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"public": ["reader", "commentator", "editor"],
},
),
(
"restricted",
"commentator",
{
"restricted": None,
"authenticated": ["commentator", "editor"],
"public": ["commentator", "editor"],
},
),
(
@@ -1238,15 +1407,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
None,
{
"public": ["reader", "editor"],
"public": ["reader", "commentator", "editor"],
},
),
(
None,
"reader",
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commentator", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"restricted": None,
},
),
@@ -1254,8 +1423,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
None,
None,
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commentator", "editor"],
"authenticated": ["reader", "commentator", "editor"],
"restricted": None,
},
),
+5 -1
View File
@@ -26,7 +26,11 @@ document_related_router.register(
viewsets.InvitationViewset,
basename="invitations",
)
document_related_router.register(
"comments",
viewsets.CommentViewSet,
basename="comments",
)
document_related_router.register(
"ask-for-access",
viewsets.DocumentAskForAccessViewSet,
@@ -43,7 +43,9 @@ test.describe('Config', () => {
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
@@ -93,7 +93,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
// When the visibility is changed, the ws should close the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
@@ -272,7 +272,9 @@ test.describe('Doc Editor', () => {
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
@@ -284,6 +286,11 @@ test.describe('Doc Editor', () => {
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
await expect(image).toHaveAttribute('role', 'presentation');
await expect(image).toHaveAttribute('alt', '');
await expect(image).toHaveAttribute('tabindex', '-1');
await expect(image).toHaveAttribute('aria-hidden', 'true');
});
test('it checks the AI buttons', async ({ page, browserName }) => {
@@ -561,7 +568,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility', { exact: true }).click();
await page.getByTestId('doc-visibility').click();
await page
.getByRole('menuitem', {
@@ -573,7 +580,7 @@ test.describe('Doc Editor', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
// Close the modal
@@ -655,7 +662,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility mode').click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Reading' }).click();
// Close the modal
@@ -122,7 +122,9 @@ test.describe('Doc Export', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page.getByRole('img', { name: 'test.svg' });
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
@@ -182,7 +184,9 @@ test.describe('Doc Export', () => {
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page.getByRole('img', { name: 'test.svg' });
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
@@ -30,7 +30,7 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility', { exact: true }).click();
await page.getByTestId('doc-visibility').click();
await page
.getByRole('menuitem', {
@@ -259,6 +259,10 @@ test.describe('Doc Tree: Inheritance', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A child inherit from the parent', async ({ page, browserName }) => {
// test.slow() to extend timeout since this scenario chains Keycloak login + redirects,
// doc creation/navigation and async doc-tree loading (/documents/:id/tree), which can exceed 30s (especially in CI).
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
@@ -271,7 +275,7 @@ test.describe('Doc Tree: Inheritance', () => {
await verifyDocName(page, docParent);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
@@ -307,6 +311,7 @@ test.describe('Doc Tree: Inheritance', () => {
await expect(page.locator('h2').getByText(docChild)).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible({ timeout: 10000 });
await expect(docTree.getByText(docParent)).toBeVisible();
});
});
@@ -41,7 +41,7 @@ test.describe('Doc Visibility', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await expect(selectVisibility.getByText('Private')).toBeVisible();
@@ -51,13 +51,13 @@ test.describe('Doc Visibility', () => {
await selectVisibility.click();
await page.getByLabel('Connected').click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await page.getByLabel('Public', { exact: true }).click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
});
@@ -205,7 +205,7 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
@@ -218,8 +218,8 @@ test.describe('Doc Visibility: Public', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await page.getByLabel('Visibility mode').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page
.getByRole('menuitem', {
name: 'Reading',
@@ -289,7 +289,7 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
@@ -302,7 +302,7 @@ test.describe('Doc Visibility: Public', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await expect(
@@ -358,7 +358,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
@@ -410,7 +410,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
@@ -495,6 +495,7 @@ test.describe('Doc Visibility: Authenticated', () => {
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
@@ -508,7 +509,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
@@ -521,7 +522,7 @@ test.describe('Doc Visibility: Authenticated', () => {
).toBeVisible();
const urlDoc = page.url();
await page.getByLabel('Visibility mode').click();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await expect(
@@ -539,13 +540,17 @@ test.describe('Doc Visibility: Authenticated', () => {
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
await expect(page.getByText('Link Copied !')).toBeVisible({
timeout: 10000,
});
});
});
@@ -45,7 +45,7 @@ export const updateShareLink = async (
linkReach: LinkReach,
linkRole?: LinkRole | null,
) => {
await page.getByRole('button', { name: 'Visibility', exact: true }).click();
await page.getByTestId('doc-visibility').click();
await page.getByRole('menuitem', { name: linkReach }).click();
const visibilityUpdatedText = page
@@ -55,9 +55,7 @@ export const updateShareLink = async (
await expect(visibilityUpdatedText).toBeVisible();
if (linkRole) {
await page
.getByRole('button', { name: 'Visibility mode', exact: true })
.click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: linkRole }).click();
await expect(visibilityUpdatedText).toBeVisible();
}
@@ -48,6 +48,7 @@ export interface DropButtonProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
testId?: string;
}
export const DropButton = ({
@@ -57,6 +58,7 @@ export const DropButton = ({
onOpenChange,
children,
label,
testId,
}: PropsWithChildren<DropButtonProps>) => {
const { themeTokens } = useCunninghamTheme();
const font = themeTokens['font']?.['families']['base'];
@@ -79,6 +81,7 @@ export const DropButton = ({
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
data-testid={testId}
$css={css`
font-family: ${font};
${buttonCss};
@@ -38,6 +38,7 @@ export type DropdownMenuProps = {
topMessage?: string;
selectedValues?: string[];
afterOpenChange?: (isOpen: boolean) => void;
testId?: string;
};
export const DropdownMenu = ({
@@ -52,6 +53,7 @@ export const DropdownMenu = ({
topMessage,
afterOpenChange,
selectedValues,
testId,
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(opened ?? false);
@@ -100,6 +102,7 @@ export const DropdownMenu = ({
onOpenChange={onOpenChange}
label={label}
buttonCss={buttonCss}
testId={testId}
button={
showArrow ? (
<Box
@@ -46,7 +46,9 @@ export const QuickSearchInput = ({
$gap={spacingsTokens['2xs']}
$padding={{ horizontal: 'base', vertical: 'sm' }}
>
{!loading && <Icon iconName="search" $variation="600" />}
{!loading && (
<Icon iconName="search" $variation="600" aria-hidden="true" />
)}
{loading && (
<div>
<Loader size="small" />
@@ -33,7 +33,11 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { CalloutBlock, DividerBlock } from './custom-blocks';
import {
AccessibleImageBlock,
CalloutBlock,
DividerBlock,
} from './custom-blocks';
import {
InterlinkingLinkInlineContent,
InterlinkingSearchInlineContent,
@@ -50,6 +54,7 @@ const baseBlockNoteSchema = withPageBreak(
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
image: AccessibleImageBlock,
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
@@ -119,7 +119,11 @@ export const DocVersionEditor = ({
causes={error.cause}
icon={
error.status === 502 ? (
<Text className="material-icons" $theme="danger">
<Text
className="material-icons"
$theme="danger"
aria-hidden={true}
>
wifi_off
</Text>
) : undefined
@@ -0,0 +1,43 @@
import {
BlockFromConfig,
BlockNoteEditor,
BlockSchemaWithBlock,
InlineContentSchema,
StyleSchema,
createBlockSpec,
imageBlockConfig,
imageParse,
imageRender,
imageToExternalHTML,
} from '@blocknote/core';
type ImageBlockConfig = typeof imageBlockConfig;
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');
imgSelector?.setAttribute('alt', '');
imgSelector?.setAttribute('role', 'presentation');
imgSelector?.setAttribute('aria-hidden', 'true');
imgSelector?.setAttribute('tabindex', '-1');
return {
...imageRenderComputed,
dom,
};
};
export const AccessibleImageBlock = createBlockSpec(imageBlockConfig, {
render: accessibleImageRender,
parse: imageParse,
toExternalHTML: imageToExternalHTML,
});
@@ -1,2 +1,3 @@
export * from './AccessibleImageBlock';
export * from './CalloutBlock';
export * from './DividerBlock';
@@ -39,7 +39,11 @@ export const DocShareModalFooter = ({
fullWidth={false}
onClick={copyDocLink}
color="tertiary"
icon={<span className="material-icons">add_link</span>}
icon={
<span className="material-icons" aria-hidden={true}>
add_link
</span>
}
>
{t('Copy link')}
</Button>
@@ -129,7 +129,8 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
$gap={canManage ? spacingsTokens['3xs'] : spacingsTokens['base']}
>
<DropdownMenu
label={t('Visibility')}
testId="doc-visibility"
label={t('Document visibility')}
arrowCss={css`
color: ${colorsTokens['primary-800']} !important;
`}
@@ -170,6 +171,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
{docLinkReach !== LinkReach.RESTRICTED && (
<DropdownMenu
testId="doc-access-mode"
disabled={!canManage}
showArrow={true}
options={linkRoleOptions}
@@ -180,7 +182,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
)
: undefined
}
label={t('Visibility mode')}
label={t('Document access mode')}
>
<Text $weight="initial" $variation="600">
{linkModeTranslations[docLinkRole]}
@@ -152,6 +152,8 @@
"Version restored successfully": "Stumm assavet gant berzh",
"Visibility": "Gwelusted",
"Visibility mode": "Mod gwelusted",
"Document visibility": "Gwelusted an teul",
"Document access mode": "Mod aotreet an teul",
"Warning": "Diwallit",
"Why you can't edit the document?": "Perak ne c'hellit ket aozañ ar restr?",
"Write": "Skrivañ"
@@ -354,6 +356,8 @@
"Version restored successfully": "Version erfolgreich wiederhergestellt",
"Visibility": "Sichtbarkeit",
"Visibility mode": "Sichtbarkeitseinstellungen",
"Document visibility": "Dokumentensichtbarkeit",
"Document access mode": "Dokumentenzugriffsmodus",
"Warning": "Warnung",
"Why you can't edit the document?": "Warum können Sie dieses Dokument nicht bearbeiten?",
"Write": "Schreiben",
@@ -561,6 +565,8 @@
"Version restored successfully": "Versión restaurada con éxito",
"Visibility": "Visibilidad",
"Visibility mode": "Modo de visibilidad",
"Document visibility": "Visibilidad del documento",
"Document access mode": "Modo de acceso al documento",
"Warning": "Aviso",
"Write": "Escribe",
"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.": "Eres el único propietario de este grupo, haz que otro miembro sea el propietario del grupo para poder cambiar tu propio rol o ser eliminado del documento.",
@@ -792,6 +798,8 @@
"Version restored successfully": "Version restaurée avec succès",
"Visibility": "Visibilité",
"Visibility mode": "Mode de visibilité",
"Document visibility": "Visibilité du document",
"Document access mode": "Mode d'accès au document",
"Warning": "Attention",
"Why you can't edit the document?": "Pourquoi vous ne pouvez pas modifier le document ?",
"Write": "Écrire",
@@ -951,6 +959,8 @@
"Version restored successfully": "Revisione ripristinata correttamente",
"Visibility": "Visibilità",
"Visibility mode": "Modalità visibilità",
"Document visibility": "Visibilità del documento",
"Document access mode": "Modalità di accesso al documento",
"Warning": "Attenzione",
"Write": "Scrivi",
"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.": "Sei l'unico proprietario di questo gruppo, devi nominare proprietario un altro membro del gruppo prima di poter cambiare il proprio ruolo o di essere rimosso dal documento.",
@@ -1128,6 +1138,8 @@
"Version restored successfully": "Versie teruggezet",
"Visibility": "Zichtbaarheid",
"Visibility mode": "Zichtbaarheid modus",
"Document visibility": "Document zichtbaarheid",
"Document access mode": "Document toegangsmodus",
"Warning": "Waarschuwing",
"Write": "Schrijf",
"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.": "U bent de enige eigenaar van deze groep, maak een ander lid de groepseigenaar voordat u uw eigen rol kunt wijzigen of kan worden verwijderd van het document.",
@@ -1415,6 +1427,8 @@
"Version restored successfully": "已成功还原版本",
"Visibility": "可见性",
"Visibility mode": "可见模式",
"Document visibility": "文档可见性",
"Document access mode": "文档访问模式",
"Warning": "警告",
"Write": "写入",
"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.": "您是当前群组唯一所有者,需先指定另一管理员,才能更改自身角色或退出文档。",