Compare commits

..

1 Commits

Author SHA1 Message Date
Anthony LC 85a111e8a4 🔖(minor) release 1.3.0
Added:
- Add image attachments with access control
- (frontend) Upload image to a document
- (frontend) Summary

Changed:
- 💄(frontend) code background darkened on editor
- 🔥(frontend) hide markdown button if not text

Fixed:
- 🐛 Fix emoticon in pdf export
- 🐛 Fix collaboration on document

Removed:
- 🔥(frontend) remove saving modal
2024-09-05 09:37:39 +02:00
104 changed files with 1562 additions and 2828 deletions
-8
View File
@@ -149,10 +149,6 @@ jobs:
run: |
make run
- name: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
@@ -227,10 +223,6 @@ jobs:
run: |
make run
- name: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
+1 -23
View File
@@ -9,26 +9,6 @@ and this project adheres to
## [Unreleased]
## Added
- ✨Add link public/authenticated/restricted access with read/editor roles #234
- ✨(frontend) add copy link button #235
- 🛂(frontend) access public docs without being logged #235
- 🌐(frontend) add localization to editor #268
## Changed
- ♻️ Allow null titles on documents for easier creation #234
- 🛂(backend) stop to list public doc to everyone #234
- 🚚(frontend) change visibility in share modal #235
- ⚡️(frontend) Improve summary #244
## Fixed
- 🐛 Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
## Added
@@ -36,7 +16,6 @@ and this project adheres to
- ✨Add image attachments with access control
- ✨(frontend) Upload image to a document #211
- ✨(frontend) Summary #223
- ✨(frontend) update meta title for docs page #231
## Changed
@@ -47,7 +26,6 @@ and this project adheres to
- 🐛 Fix emoticon in pdf export #225
- 🐛 Fix collaboration on document #226
- 🐛 (docker) Fix compatibility with mac #230
## Removed
@@ -157,4 +135,4 @@ and this project adheres to
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
-1
View File
@@ -92,7 +92,6 @@ bootstrap: \
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
@$(COMPOSE) build frontend-dev --no-cache
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
+3 -8
View File
@@ -1,3 +1,5 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -119,14 +121,6 @@ services:
depends_on:
- keycloak
nginx-front:
image: nginx:1.25
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress/conf/default.conf:/etc/nginx/conf.d/default.conf
- ./src/frontend/apps/impress/out:/usr/share/nginx/html
dockerize:
image: jwilder/dockerize
@@ -178,6 +172,7 @@ services:
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:
-8
View File
@@ -92,14 +92,6 @@ class DocumentAdmin(admin.ModelAdmin):
"""Document admin interface declaration."""
inlines = (DocumentAccessInline,)
list_display = (
"id",
"title",
"link_reach",
"link_role",
"created_at",
"updated_at",
)
@admin.register(models.Invitation)
-3
View File
@@ -62,9 +62,6 @@ class IsOwnedOrPublic(IsAuthenticated):
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
+8 -52
View File
@@ -66,8 +66,9 @@ class BaseAccessSerializer(serializers.ModelSerializer):
"You must set a resource ID in kwargs to create a new access."
) from exc
teams = user.get_teams()
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
Q(user=user) | Q(team__in=teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
@@ -77,7 +78,7 @@ class BaseAccessSerializer(serializers.ModelSerializer):
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
Q(user=user) | Q(team__in=teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
@@ -148,57 +149,11 @@ class DocumentSerializer(BaseResourceSerializer):
"title",
"accesses",
"abilities",
"link_role",
"link_reach",
"is_public",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"link_role",
"link_reach",
"created_at",
"updated_at",
]
def get_fields(self):
"""Dynamically make `id` read-only on PUT requests but writable on POST requests."""
fields = super().get_fields()
request = self.context.get("request")
if request and request.method == "POST":
fields["id"].read_only = False
return fields
def validate_id(self, value):
"""Ensure the provided ID does not already exist when creating a new document."""
request = self.context.get("request")
# Only check this on POST (creation)
if request and request.method == "POST":
if models.Document.objects.filter(id=value).exists():
raise serializers.ValidationError(
"A document with this ID already exists. You cannot override it."
)
return value
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.
We expose it separately from document in order to simplify and secure access control.
"""
class Meta:
model = models.Document
fields = [
"link_role",
"link_reach",
]
read_only_fields = ["id", "accesses", "abilities", "created_at", "updated_at"]
# Suppress the warning about not implementing `create` and `update` methods
@@ -317,8 +272,9 @@ class InvitationSerializer(serializers.ModelSerializer):
"Anonymous users are not allowed to create invitations."
)
teams = user.get_teams()
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
Q(user=user) | Q(team__in=teams),
document=document_id,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
@@ -329,7 +285,7 @@ class InvitationSerializer(serializers.ModelSerializer):
if (
role == models.RoleChoices.OWNER
and not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
Q(user=user) | Q(team__in=teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists()
+30 -97
View File
@@ -7,7 +7,6 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db.models import (
OuterRef,
@@ -186,21 +185,28 @@ class ResourceViewsetMixin:
def get_queryset(self):
"""Custom queryset to get user related resources."""
queryset = super().get_queryset()
user = self.request.user
if not self.request.user.is_authenticated:
return queryset.filter(is_public=True)
if not user.is_authenticated:
return queryset
user = self.request.user
teams = user.get_teams()
user_roles_query = (
self.access_model_class.objects.filter(
Q(user=user) | Q(team__in=user.teams),
Q(user=user) | Q(team__in=teams),
**{self.resource_field_name: OuterRef("pk")},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
return (
queryset.filter(
Q(accesses__user=user) | Q(accesses__team__in=teams) | Q(is_public=True)
)
.annotate(user_roles=Subquery(user_roles_query))
.distinct()
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
@@ -239,7 +245,8 @@ class ResourceAccessViewsetMixin:
if self.action == "list":
user = self.request.user
teams = user.teams
teams = user.get_teams()
user_roles_query = (
queryset.filter(
Q(user=user) | Q(team__in=teams),
@@ -307,12 +314,15 @@ class DocumentViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Document ViewSet"""
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
]
serializer_class = serializers.DocumentSerializer
@@ -321,52 +331,18 @@ class DocumentViewSet(
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
| (
Q(link_traces__user=user)
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
else:
queryset = queryset.none()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
def perform_create(self, serializer):
"""
Add a trace that the document was accessed by a user. This is used to list documents
on a user's list view even though the user has no specific role in the document (link
access when the link reach configuration of the document allows it).
Override perform_create to use the provided ID in the payload if it exists
"""
instance = self.get_object()
serializer = self.get_serializer(instance)
document_id = self.request.data.get("id")
document = serializer.save(id=document_id) if document_id else serializer.save()
if self.request.user.is_authenticated:
try:
# Add a trace that the user visited the document (this is needed to include
# the document in the user's list view)
models.LinkTrace.objects.create(
document=instance,
user=self.request.user,
)
except ValidationError:
# The trace already exists, so we just pass without doing anything
pass
return drf_response.Response(serializer.data)
self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: document},
)
@decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
@@ -374,15 +350,11 @@ class DocumentViewSet(
Return the document's versions but only those created after the user got access
to the document
"""
if not request.user.is_authenticated:
raise exceptions.PermissionDenied("Authentication required.")
document = self.get_object()
user = request.user
from_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
Q(user=request.user) | Q(team__in=request.user.get_teams()),
)
)
@@ -414,11 +386,10 @@ class DocumentViewSet(
# Don't let users access versions that were created before they were given access
# to the document
user = request.user
from_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
Q(user=request.user) | Q(team__in=request.user.get_teams()),
)
)
if response["LastModified"] < from_datetime:
@@ -438,24 +409,6 @@ class DocumentViewSet(
}
)
@decorators.action(detail=True, methods=["put"], url_path="link-configuration")
def link_configuration(self, request, *args, **kwargs):
"""Update link configuration with specific rights (cf get_abilities)."""
# Check permissions first
document = self.get_object()
# Deserialize and validate the data
serializer = serializers.LinkDocumentSerializer(
document, data=request.data, partial=True
)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
serializer.save()
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
@@ -576,6 +529,7 @@ class TemplateViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
@@ -591,27 +545,6 @@ class TemplateViewSet(
resource_field_name = "template"
queryset = models.Template.objects.all()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
| Q(is_public=True)
)
else:
queryset = queryset.filter(is_public=True)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
@decorators.action(
detail=True,
methods=["post"],
@@ -738,7 +671,7 @@ class InvitationViewset(
if self.action == "list":
user = self.request.user
teams = user.teams
teams = user.get_teams()
# Determine which role the logged-in user has in the document
user_roles_query = (
+1 -13
View File
@@ -35,13 +35,8 @@ class DocumentFactory(factory.django.DjangoModelFactory):
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"document{n}")
is_public = factory.Faker("boolean")
content = factory.Sequence(lambda n: f"content{n}")
link_reach = factory.fuzzy.FuzzyChoice(
[a[0] for a in models.LinkReachChoices.choices]
)
link_role = factory.fuzzy.FuzzyChoice(
[r[0] for r in models.LinkRoleChoices.choices]
)
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -53,13 +48,6 @@ class DocumentFactory(factory.django.DjangoModelFactory):
else:
UserDocumentAccessFactory(document=self, user=item[0], role=item[1])
@factory.post_generation
def link_traces(self, create, extracted, **kwargs):
"""Add link traces to document from a given list of users."""
if create and extracted:
for item in extracted:
models.LinkTrace.objects.create(document=self, user=item)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""
@@ -1,52 +0,0 @@
# Generated by Django 5.1 on 2024-09-08 16:55
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_create_pg_trgm_extension'),
]
operations = [
migrations.AddField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
),
migrations.AddField(
model_name='document',
name='link_role',
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
),
migrations.AlterField(
model_name='document',
name='is_public',
field=models.BooleanField(null=True),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='LinkTrace',
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')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document/user link trace',
'verbose_name_plural': 'Document/user link traces',
'db_table': 'impress_link_trace',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
},
),
]
@@ -1,35 +0,0 @@
# Generated by Django 5.1 on 2024-09-08 17:04
from django.db import migrations
def migrate_is_public_to_link_reach(apps, schema_editor):
"""
Forward migration: Migrate 'is_public' to 'link_reach'.
If is_public == True, set link_reach to 'public'
"""
Document = apps.get_model('core', 'Document')
Document.objects.filter(is_public=True).update(link_reach='public')
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
"""
Reverse migration: Migrate 'link_reach' back to 'is_public'.
- If link_reach == 'public', set is_public to True
- Else set is_public to False
"""
Document = apps.get_model('core', 'Document')
Document.objects.filter(link_reach='public').update(is_public=True)
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
class Migration(migrations.Migration):
dependencies = [
('core', '0003_document_link_reach_document_link_role_and_more'),
]
operations = [
migrations.RunPython(
migrate_is_public_to_link_reach,
reverse_migrate_link_reach_to_is_public
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2024-09-09 17:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_migrate_is_public_to_link_reach'),
]
operations = [
migrations.AlterField(
model_name='document',
name='title',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
),
]
+18 -86
View File
@@ -21,7 +21,7 @@ from django.http import FileResponse
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
import frontmatter
@@ -42,24 +42,18 @@ def get_resource_roles(resource, user):
try:
roles = resource.user_roles or []
except AttributeError:
teams = user.get_teams()
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
class LinkRoleChoices(models.TextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a resource."""
"""Defines the possible roles a user can have in a template."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
@@ -67,20 +61,6 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the document
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -235,8 +215,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
@cached_property
def teams(self):
def get_teams(self):
"""
Get list of teams in which the user is, as a list of strings.
Must be cached if retrieved remotely.
@@ -268,7 +247,7 @@ class BaseAccess(BaseModel):
"""
roles = []
if user.is_authenticated:
teams = user.teams
teams = user.get_teams()
try:
roles = self.user_roles or []
except AttributeError:
@@ -320,14 +299,11 @@ class BaseAccess(BaseModel):
class Document(BaseModel):
"""Pad document carrying the content."""
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.AUTHENTICATED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
title = models.CharField(_("title"), max_length=255)
is_public = models.BooleanField(
_("public"),
default=False,
help_text=_("Whether this document is public for anyone to use."),
)
_content = None
@@ -339,7 +315,7 @@ class Document(BaseModel):
verbose_name_plural = _("Documents")
def __str__(self):
return str(self.title) if self.title else str(_("Untitled Document"))
return self.title
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
@@ -490,29 +466,17 @@ class Document(BaseModel):
"""
Compute and return abilities for a given user on the document.
"""
roles = set(get_resource_roles(self, user))
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
can_get_versions = bool(roles)
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
self.link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
):
roles.add(self.link_role)
roles = get_resource_roles(self, user)
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = bool(roles)
can_get = self.is_public or bool(roles)
can_get_versions = bool(roles)
return {
"attachment_upload": is_owner_or_admin or is_editor,
"destroy": RoleChoices.OWNER in roles,
"link_configuration": is_owner_or_admin,
"attachment_upload": is_owner_or_admin or is_editor,
"manage_accesses": is_owner_or_admin,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
@@ -523,38 +487,6 @@ class Document(BaseModel):
}
class LinkTrace(BaseModel):
"""
Relation model to trace accesses to a document via a link by a logged-in user.
This is necessary to show the document in the user's list of documents even
though the user does not have a role on the document.
"""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="link_traces",
)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
class Meta:
db_table = "impress_link_trace"
verbose_name = _("Document/user link trace")
verbose_name_plural = _("Document/user link traces")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_link_trace_document_user",
violation_error_message=_(
"A link trace already exists for this document/user."
),
),
]
def __str__(self):
return f"{self.user!s} trace on document {self.document!s}"
class DocumentAccess(BaseAccess):
"""Relation model to give access to a document for a user or a team with a role."""
@@ -846,7 +778,7 @@ class Invitation(BaseModel):
roles = []
if user.is_authenticated:
teams = user.teams
teams = user.get_teams()
try:
roles = self.user_roles or []
except AttributeError:
+4 -6
View File
@@ -10,9 +10,7 @@ VIA = [USER, TEAM]
@pytest.fixture
def mock_user_teams():
"""Mock for the "teams" property on the User model."""
with mock.patch(
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams
def mock_user_get_teams():
"""Mock for the "get_teams" method on the User model."""
with mock.patch("core.models.User.get_teams") as mock_get_teams:
yield mock_get_teams
@@ -57,7 +57,7 @@ def test_api_document_accesses_list_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
def test_api_document_accesses_list_authenticated_related(via, mock_user_get_teams):
"""
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
@@ -76,7 +76,7 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
user_access = models.DocumentAccess.objects.create(
document=document,
team="lasuite",
@@ -181,7 +181,7 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_teams):
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_get_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document user accesses.
@@ -195,7 +195,7 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_tea
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
access = factories.UserDocumentAccessFactory(document=document)
@@ -276,7 +276,7 @@ def test_api_document_accesses_update_authenticated_unrelated():
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_authenticated_reader_or_editor(
via, role, mock_user_teams
via, role, mock_user_get_teams
):
"""Readers or editors of a document should not be allowed to update its accesses."""
user = factories.UserFactory()
@@ -288,7 +288,7 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -316,7 +316,9 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_except_owner(
via, mock_user_get_teams
):
"""
A user who is a direct administrator in a document should be allowed to update a user
access for this document, as long as they don't try to set the role to owner.
@@ -332,7 +334,7 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -373,7 +375,9 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_from_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_from_owner(
via, mock_user_get_teams
):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of an "owner" for this document.
@@ -389,7 +393,7 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -420,7 +424,7 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_get_teams):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of another user to grant document ownership.
@@ -436,7 +440,7 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -474,7 +478,7 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner(via, mock_user_teams):
def test_api_document_accesses_update_owner(via, mock_user_get_teams):
"""
A user who is an owner in a document should be allowed to update
a user access for this document whatever the role.
@@ -488,7 +492,7 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -530,7 +534,7 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
def test_api_document_accesses_update_owner_self(via, mock_user_get_teams):
"""
A user who is owner of a document should be allowed to update
their own user access provided there are other owners in the document.
@@ -547,7 +551,7 @@ def test_api_document_accesses_update_owner_self(via, mock_user_teams):
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -622,7 +626,7 @@ def test_api_document_accesses_delete_authenticated():
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_teams):
def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a document access for a
document in which they are a simple reader or editor.
@@ -636,7 +640,7 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -656,7 +660,7 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrators_except_owners(
via, mock_user_teams
via, mock_user_get_teams
):
"""
Users who are administrators in a document should be allowed to delete an access
@@ -673,7 +677,7 @@ def test_api_document_accesses_delete_administrators_except_owners(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -694,7 +698,7 @@ def test_api_document_accesses_delete_administrators_except_owners(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_teams):
def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_get_teams):
"""
Users who are administrators in a document should not be allowed to delete an ownership
access from the document.
@@ -710,7 +714,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -729,7 +733,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners(via, mock_user_teams):
def test_api_document_accesses_delete_owners(via, mock_user_get_teams):
"""
Users should be able to delete the document access of another user
for a document of which they are owner.
@@ -743,7 +747,7 @@ def test_api_document_accesses_delete_owners(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -762,7 +766,7 @@ def test_api_document_accesses_delete_owners(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_get_teams):
"""
It should not be possible to delete the last owner access from a document
"""
@@ -778,7 +782,7 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -66,7 +66,7 @@ def test_api_document_accesses_create_authenticated_unrelated():
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_reader_or_editor(
via, role, mock_user_teams
via, role, mock_user_get_teams
):
"""Readers or editors of a document should not be allowed to create document accesses."""
user = factories.UserFactory()
@@ -78,7 +78,7 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -101,7 +101,9 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams):
def test_api_document_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
@@ -118,7 +120,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -176,7 +178,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a document should be able to create document accesses whatever the role.
An email should be sent to the accesses to notify them of the adding.
@@ -190,7 +192,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -80,7 +80,7 @@ def test_api_document_invitations__create__authenticated_outsider():
)
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__create__privileged_members(
via, inviting, invited, is_allowed, mock_user_teams
via, inviting, invited, is_allowed, mock_user_get_teams
):
"""
Only owners and administrators should be able to invite new users.
@@ -91,7 +91,7 @@ def test_api_document_invitations__create__privileged_members(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=inviting
)
@@ -291,7 +291,7 @@ def test_api_document_invitations__list__anonymous_user():
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__list__authenticated(
via, mock_user_teams, django_assert_num_queries
via, mock_user_get_teams, django_assert_num_queries
):
"""
Authenticated users should be able to list invitations for documents to which they are
@@ -304,7 +304,7 @@ def test_api_document_invitations__list__authenticated(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -432,7 +432,7 @@ def test_api_document_invitations__retrieve__unrelated_user():
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__retrieve__document_member(via, mock_user_teams):
def test_api_document_invitations__retrieve__document_member(via, mock_user_get_teams):
"""
Authenticated users related to the document should be able to retrieve invitations
whatever their role in the document.
@@ -445,7 +445,7 @@ def test_api_document_invitations__retrieve__document_member(via, mock_user_team
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
@@ -475,7 +475,7 @@ def test_api_document_invitations__retrieve__document_member(via, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__put_authenticated(via, mock_user_teams):
def test_api_document_invitations__put_authenticated(via, mock_user_get_teams):
"""
Authenticated user can put invitations.
"""
@@ -486,7 +486,7 @@ def test_api_document_invitations__put_authenticated(via, mock_user_teams):
document=invitation.document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role="owner"
)
@@ -503,7 +503,7 @@ def test_api_document_invitations__put_authenticated(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__patch_authenticated(via, mock_user_teams):
def test_api_document_invitations__patch_authenticated(via, mock_user_get_teams):
"""
Authenticated user can patch invitations.
"""
@@ -514,7 +514,7 @@ def test_api_document_invitations__patch_authenticated(via, mock_user_teams):
document=invitation.document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role="owner"
)
@@ -546,7 +546,7 @@ def test_api_document_invitations__patch_authenticated(via, mock_user_teams):
["editor", "reader"],
)
def test_api_document_invitations__update__forbidden__not_authenticated(
method, via, role, mock_user_teams
method, via, role, mock_user_get_teams
):
"""
Update of invitations is currently forbidden.
@@ -558,7 +558,7 @@ def test_api_document_invitations__update__forbidden__not_authenticated(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
@@ -607,7 +607,7 @@ def test_api_document_invitations__delete__authenticated_outsider():
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_document_invitations__delete__privileged_members(
role, via, mock_user_teams
role, via, mock_user_get_teams
):
"""Privileged member should be able to cancel invitation."""
user = factories.UserFactory()
@@ -615,7 +615,7 @@ def test_api_document_invitations__delete__privileged_members(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -632,14 +632,16 @@ def test_api_document_invitations__delete__privileged_members(
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user_teams):
def test_api_document_invitations_delete_readers_or_editors(
via, role, mock_user_get_teams
):
"""Readers or editors should not be able to cancel invitation."""
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -14,29 +14,37 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_document_versions_list_anonymous(role, reach):
def test_api_document_versions_list_anonymous_public():
"""
Anonymous users should not be allowed to list document versions for a document
whatever the reach and role.
Anonymous users should not be allowed to list document versions for a public document.
"""
document = factories.DocumentFactory(link_role=role, link_reach=reach)
# Accesses and traces for other users should not interfere
factories.UserDocumentAccessFactory(document=document)
models.LinkTrace.objects.create(document=document, user=factories.UserFactory())
document = factories.DocumentFactory(is_public=True)
factories.UserDocumentAccessFactory.create_batch(2, document=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 403
assert response.json() == {"detail": "Authentication required."}
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_list_authenticated_unrelated(reach):
def test_api_document_versions_list_anonymous_private():
"""
Authenticated users should not be allowed to list document versions for a document
Anonymous users should not be allowed to find document versions for a private document.
"""
document = factories.DocumentFactory(is_public=False)
factories.UserDocumentAccessFactory.create_batch(2, document=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
def test_api_document_versions_list_authenticated_unrelated_public():
"""
Authenticated users should not be allowed to list document versions for a public document
to which they are not related.
"""
user = factories.UserFactory()
@@ -44,7 +52,7 @@ def test_api_document_versions_list_authenticated_unrelated(reach):
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=True)
factories.UserDocumentAccessFactory.create_batch(3, document=document)
# The versions of another document to which the user is related should not be listed either
@@ -59,8 +67,31 @@ def test_api_document_versions_list_authenticated_unrelated(reach):
}
def test_api_document_versions_list_authenticated_unrelated_private():
"""
Authenticated users should not be allowed to find document versions for a private document
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
factories.UserDocumentAccessFactory.create_batch(3, document=document)
# The versions of another document to which the user is related should not be listed either
factories.UserDocumentAccessFactory(user=user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
def test_api_document_versions_list_authenticated_related(via, mock_user_get_teams):
"""
Authenticated users should be able to list document versions for a document
to which they are directly related, whatever their role in the document.
@@ -78,7 +109,7 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
@@ -112,13 +143,11 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_teams):
assert content["count"] == 1
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_retrieve_anonymous(reach):
def test_api_document_versions_retrieve_anonymous_public():
"""
Anonymous users should not be allowed to find specific versions for a document with
restricted or authenticated link reach.
Anonymous users should not be allowed to retrieve specific versions for a public document.
"""
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=True)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
@@ -130,10 +159,23 @@ def test_api_document_versions_retrieve_anonymous(reach):
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_retrieve_authenticated_unrelated(reach):
def test_api_document_versions_retrieve_anonymous_private():
"""
Authenticated users should not be allowed to retrieve specific versions for a
Anonymous users should not be allowed to find specific versions for a private document.
"""
document = factories.DocumentFactory(is_public=False)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
response = APIClient().get(url)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
def test_api_document_versions_retrieve_authenticated_unrelated_public():
"""
Authenticated users should not be allowed to retrieve specific versions for a public
document to which they are not related.
"""
user = factories.UserFactory()
@@ -141,7 +183,7 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=True)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -153,8 +195,28 @@ def test_api_document_versions_retrieve_authenticated_unrelated(reach):
}
def test_api_document_versions_retrieve_authenticated_unrelated_private():
"""
Authenticated users should not be allowed to find specific versions for a private document
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_teams):
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_get_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document user accesses.
@@ -170,10 +232,10 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
# Versions created before the document was shared should not be seen by the user
# Versions created before the document was shared should not be available to the user
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
@@ -205,8 +267,10 @@ def test_api_document_versions_create_anonymous():
format="json",
)
assert response.status_code == 405
assert response.json() == {"detail": 'Method "POST" not allowed.'}
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_document_versions_create_authenticated_unrelated():
@@ -231,7 +295,7 @@ def test_api_document_versions_create_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_create_authenticated_related(via, mock_user_teams):
def test_api_document_versions_create_authenticated_related(via, mock_user_get_teams):
"""
Authenticated users related to a document should not be allowed to create document versions
whatever their role.
@@ -245,7 +309,7 @@ def test_api_document_versions_create_authenticated_related(via, mock_user_teams
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
response = client.post(
@@ -267,7 +331,7 @@ def test_api_document_versions_update_anonymous():
{"foo": "bar"},
format="json",
)
assert response.status_code == 405
assert response.status_code == 401
def test_api_document_versions_update_authenticated_unrelated():
@@ -292,7 +356,7 @@ def test_api_document_versions_update_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_update_authenticated_related(via, mock_user_teams):
def test_api_document_versions_update_authenticated_related(via, mock_user_get_teams):
"""
Authenticated users with access to a document should not be able to update its versions
whatever their role.
@@ -308,7 +372,7 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_teams
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
response = client.put(
@@ -333,8 +397,7 @@ def test_api_document_versions_delete_anonymous():
assert response.status_code == 401
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_delete_authenticated(reach):
def test_api_document_versions_delete_authenticated_public():
"""
Authenticated users should not be allowed to delete a document version for a
public document to which they are not related.
@@ -344,7 +407,7 @@ def test_api_document_versions_delete_authenticated(reach):
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=True)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.delete(
@@ -354,9 +417,30 @@ def test_api_document_versions_delete_authenticated(reach):
assert response.status_code == 403
def test_api_document_versions_delete_authenticated_private():
"""
Authenticated users should not be allowed to find a document version to delete it
for a private document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_teams):
def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a document version for a
document in which they are a simple reader or editor.
@@ -370,7 +454,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -400,7 +484,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_delete_administrator_or_owner(via, mock_user_teams):
def test_api_document_versions_delete_administrator_or_owner(via, mock_user_get_teams):
"""
Users who are administrator or owner of a document should be allowed to delete a version.
"""
@@ -414,7 +498,7 @@ def test_api_document_versions_delete_administrator_or_owner(via, mock_user_team
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -17,22 +17,9 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_attachment_upload_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to upload attachments if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
def test_api_documents_attachment_upload_anonymous():
"""Anonymous users can't upload attachments to a document."""
document = factories.DocumentFactory()
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
@@ -44,47 +31,16 @@ def test_api_documents_attachment_upload_anonymous_forbidden(reach, role):
}
def test_api_documents_attachment_upload_anonymous_success():
def test_api_documents_attachment_upload_authenticated_public():
"""
Anonymous users should be able to upload attachments to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't upload attachments if the
link reach and role don't allow it.
Users who are not related to a public document should not be allowed to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
document = factories.DocumentFactory(is_public=True)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
@@ -96,41 +52,27 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
def test_api_documents_attachment_upload_authenticated_private():
"""
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
Users who are not related to a private document should not be able to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
document = factories.DocumentFactory(is_public=False)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_reader(via, mock_user_teams):
def test_api_documents_attachment_upload_reader(via, mock_user_get_teams):
"""
Users who are simple readers on a document should not be allowed to upload an attachment.
"""
@@ -139,11 +81,11 @@ def test_api_documents_attachment_upload_reader(via, mock_user_teams):
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
@@ -161,7 +103,7 @@ def test_api_documents_attachment_upload_reader(via, mock_user_teams):
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
def test_api_documents_attachment_upload_success(via, role, mock_user_get_teams):
"""
Editors, administrators and owners of a document should be able to upload an attachment.
"""
@@ -174,7 +116,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -2,7 +2,7 @@
Tests for Documents API endpoint in impress's core app: create
"""
from uuid import uuid4
import uuid
import pytest
from rest_framework.test import APIClient
@@ -26,7 +26,7 @@ def test_api_documents_create_anonymous():
assert not Document.objects.exists()
def test_api_documents_create_authenticated_success():
def test_api_documents_create_authenticated():
"""
Authenticated users should be able to create documents and should automatically be declared
as the owner of the newly created document.
@@ -50,64 +50,24 @@ def test_api_documents_create_authenticated_success():
assert document.accesses.filter(role="owner", user=user).exists()
def test_api_documents_create_authenticated_title_null():
"""It should be possible to create several documents with a null title."""
def test_api_documents_create_with_id_from_payload():
"""
We should be able to create a document with an ID from the payload.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory(title=None)
response = client.post("/api/v1.0/documents/", {}, format="json")
assert response.status_code == 201
assert Document.objects.filter(title__isnull=True).count() == 2
def test_api_documents_create_force_id_success():
"""It should be possible to force the document ID when creating a document."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
forced_id = uuid4()
doc_id = uuid.uuid4()
response = client.post(
"/api/v1.0/documents/",
{
"id": str(forced_id),
"title": "my document",
},
{"title": "my document", "id": str(doc_id)},
format="json",
)
assert response.status_code == 201
documents = Document.objects.all()
assert len(documents) == 1
assert documents[0].id == forced_id
def test_api_documents_create_force_id_existing():
"""
It should not be possible to use the ID of an existing document when forcing ID on creation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
response = client.post(
"/api/v1.0/documents/",
{
"id": str(document.id),
"title": "my document",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"id": ["A document with this ID already exists. You cannot override it."]
}
document = Document.objects.get()
assert document.title == "my document"
assert document.id == doc_id
assert document.accesses.filter(role="owner", user=user).exists()
@@ -2,6 +2,8 @@
Tests for Documents API endpoint in impress's core app: delete
"""
import random
import pytest
from rest_framework.test import APIClient
@@ -23,31 +25,30 @@ def test_api_documents_delete_anonymous():
assert models.Document.objects.count() == 1
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_documents_delete_authenticated_unrelated(reach, role):
def test_api_documents_delete_authenticated_unrelated():
"""
Authenticated users should not be allowed to delete a document to which
they are not related.
Authenticated users should not be allowed to delete a document to which they are not
related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
is_public = random.choice([True, False])
document = factories.DocumentFactory(is_public=is_public)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 403
assert response.status_code == 403 if is_public else 404
assert models.Document.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams):
def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a document for which they are
only a reader, editor or administrator.
@@ -61,7 +62,7 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -78,7 +79,7 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
def test_api_documents_delete_authenticated_owner(via, mock_user_get_teams):
"""
Authenticated users should be able to delete a document they own.
"""
@@ -91,7 +92,7 @@ def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -1,152 +0,0 @@
"""Tests for link configuration of documents on API endpoint"""
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
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_link_configuration_update_anonymous(reach, role):
"""Anonymous users should not be allowed to update a link configuration."""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
document.refresh_from_db()
document_values = serializers.LinkDocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_link_configuration_update_authenticated_unrelated(reach, role):
"""
Authenticated users should not be allowed to update the link configuration for
a document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
document_values = serializers.LinkDocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize("role", ["editor", "reader"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_link_configuration_update_authenticated_related_forbidden(
via, role, mock_user_teams
):
"""
Users who are readers or editors of a document should not be allowed to update
the link configuration.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
document_values = serializers.LinkDocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize("role", ["administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_link_configuration_update_authenticated_related_success(
via, role, mock_user_teams
):
"""
A user who is administrator or owner of a document should be allowed to update
the link configuration.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]
@@ -2,71 +2,68 @@
Tests for Documents API endpoint in impress's core app: list
"""
import operator
from unittest import mock
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories, models
from core import factories
fake = Faker()
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_list_anonymous(reach, role):
"""
Anonymous users should not be allowed to list documents whatever the
link reach and the role
"""
factories.DocumentFactory(link_reach=reach, link_role=role)
def test_api_documents_list_anonymous():
"""Anonymous users should only be able to list public documents."""
factories.DocumentFactory.create_batch(2, is_public=False)
documents = factories.DocumentFactory.create_batch(2, is_public=True)
expected_ids = {str(document.id) for document in documents}
response = APIClient().get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 0
def test_api_documents_list_authenticated_direct():
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of or documents that have a link reach other
than restricted.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = [
access.document
for access in factories.UserDocumentAccessFactory.create_batch(2, user=user)
]
# Unrelated and untraced documents
for reach in models.LinkReachChoices:
for role in models.LinkRoleChoices:
factories.DocumentFactory(link_reach=reach, link_role=role)
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(mock_user_teams):
def test_api_documents_list_authenticated_direct():
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
related_documents = [
access.document
for access in factories.UserDocumentAccessFactory.create_batch(5, user=user)
]
public_documents = factories.DocumentFactory.create_batch(2, is_public=True)
factories.DocumentFactory.create_batch(2, is_public=False)
expected_ids = {
str(document.id) for document in related_documents + public_documents
}
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(mock_user_get_teams):
"""
Authenticated users should be able to list documents they are a
owner/administrator/member of via a team.
@@ -76,7 +73,7 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
documents_team1 = [
access.document
@@ -86,71 +83,19 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
access.document
for access in factories.TeamDocumentAccessFactory.create_batch(3, team="team2")
]
public_documents = factories.DocumentFactory.create_batch(2, is_public=True)
factories.DocumentFactory.create_batch(2, is_public=False)
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
expected_ids = {
str(document.id)
for document in documents_team1 + documents_team2 + public_documents
}
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 5
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_documents_list_authenticated_link_reach_restricted():
"""
An authenticated user who has link traces to a document that is restricted should not
see it on the list view
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_traces=[user], link_reach="restricted")
# Link traces for other documents or other users should not interfere
models.LinkTrace.objects.create(document=document, user=factories.UserFactory())
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
# Only the other document is returned but not the restricted document even though the user
# visited it earlier (probably b/c it previously had public or authenticated reach...)
assert len(results) == 1
assert results[0]["id"] == str(other_document.id)
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
"""
An authenticated user who has link traces to a document with public or authenticated
link reach should see it on the list view.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = [
factories.DocumentFactory(link_traces=[user], link_reach=reach)
for reach in models.LinkReachChoices
if reach != "restricted"
]
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@@ -175,7 +120,7 @@ def test_api_documents_list_pagination(
"/api/v1.0/documents/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
@@ -191,7 +136,7 @@ def test_api_documents_list_pagination(
"/api/v1.0/documents/?page=2",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
@@ -212,63 +157,145 @@ def test_api_documents_list_authenticated_distinct():
other_user = factories.UserFactory()
document = factories.DocumentFactory(users=[user, other_user])
document = factories.DocumentFactory(users=[user, other_user], is_public=True)
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
def test_api_documents_order_updated_at_desc_default():
"""
Test that the endpoint GET documents is sorted in 'updated_at' descending order by default.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
# Updated at next year to ensure the order is correct
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(
5, is_public=True, updated_at=fake.date_time_this_year(before_now=False)
)
]
response = client.get("/api/v1.0/documents/")
documents_updated.sort(reverse=True)
response = APIClient().get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check that results are sorted by descending "updated_at" as expected
for i in range(4):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
response_data = response.json()
response_document_updated = [
document["updated_at"] for document in response_data["results"]
]
assert (
response_document_updated == documents_updated
), "updated_at values are not sorted from newest to oldest"
def test_api_documents_list_ordering_by_fields():
"""It should be possible to order by several fields"""
@pytest.mark.parametrize(
"ordering_field, factory_field",
[
("-created_at", "created_at"),
("-updated_at", "updated_at"),
("-title", "title"),
],
)
def test_api_documents_ordering_desc(ordering_field, factory_field):
"""
Test that the specified field is sorted in descending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
if factory_field == "title":
documents_field_values = [
factories.DocumentFactory(
is_public=True, title=fake.sentence(nb_words=4)
).title
for _ in range(5)
]
else:
documents_field_values = [
getattr(document, factory_field).isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
for parameter in [
"created_at",
"-created_at",
"updated_at",
"-updated_at",
"title",
"-title",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
documents_field_values.sort(reverse=True)
response = client.get(f"/api/v1.0/documents/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
response = client.get(
f"/api/v1.0/documents/?ordering={ordering_field}"
if ordering_field != "-created_at"
else "/api/v1.0/documents/",
)
assert response.status_code == 200
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
response_data = response.json()
response_documents_field_values = [
document[factory_field] for document in response_data["results"]
]
assert (
response_documents_field_values == documents_field_values
), f"{factory_field} values are not sorted as expected"
@pytest.mark.parametrize(
"field",
[
("updated_at"),
("title"),
("created_at"),
],
)
def test_api_documents_ordering_asc(field):
"""
Test that the specified field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
if field == "title":
documents_field_values = [
factories.DocumentFactory(
is_public=True, title=fake.sentence(nb_words=4)
).title
for _ in range(5)
]
else:
documents_field_values = [
getattr(document, field).isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_field_values.sort()
response = client.get(
f"/api/v1.0/documents/?ordering={field}",
)
assert response.status_code == 200
response_data = response.json()
response_documents_field_values = [
document[field] for document in response_data["results"]
]
assert (
response_documents_field_values == documents_field_values
), f"{field} values are not sorted as expected"
@@ -5,7 +5,7 @@ Tests for Documents API endpoint in impress's core app: retrieve
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
@@ -13,7 +13,7 @@ pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_anonymous_public():
"""Anonymous users should be allowed to retrieve public documents."""
document = factories.DocumentFactory(link_reach="public")
document = factories.DocumentFactory(is_public=True)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
@@ -21,42 +21,36 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"attachment_upload": document.link_role == "editor",
"destroy": False,
"link_configuration": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"partial_update": False,
"retrieve": True,
"update": document.link_role == "editor",
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": "public",
"link_role": document.link_role,
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
def test_api_documents_retrieve_anonymous_restricted_or_authenticated(reach):
def test_api_documents_retrieve_anonymous_not_public():
"""Anonymous users should not be able to retrieve a document that is not public."""
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=False)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(reach):
def test_api_documents_retrieve_authenticated_unrelated_public():
"""
Authenticated users should be able to retrieve a public document to which they are
not related.
@@ -66,7 +60,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=True)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
@@ -75,62 +69,28 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
assert response.json() == {
"id": str(document.id),
"abilities": {
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": document.link_role == "editor",
"partial_update": False,
"retrieve": True,
"update": document.link_role == "editor",
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_trace_twice(reach):
def test_api_documents_retrieve_authenticated_unrelated_not_public():
"""
Accessing a document several times should not raise any error even though the
trace already exists for this document and user.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
# A second visit should not raise any error
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
def test_api_documents_retrieve_authenticated_unrelated_restricted():
"""
Authenticated users should not be allowed to retrieve a document that is restricted and
Authenticated users should not be allowed to retrieve a document that is not public and
to which they are not related.
"""
user = factories.UserFactory()
@@ -138,15 +98,13 @@ def test_api_documents_retrieve_authenticated_unrelated_restricted():
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
def test_api_documents_retrieve_authenticated_related_direct():
@@ -194,26 +152,25 @@ def test_api_documents_retrieve_authenticated_related_direct():
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"link_reach": document.link_reach,
"link_role": document.link_role,
"is_public": document.is_public,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams):
def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_teams):
"""
Authenticated users should not be able to retrieve a restricted document related to
teams in which the user is not.
Authenticated users should not be able to retrieve a document related to teams in
which the user is not.
"""
mock_user_teams.return_value = []
mock_user_get_teams.return_value = []
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -229,10 +186,8 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize(
@@ -245,20 +200,20 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -332,8 +287,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"link_reach": "restricted",
"link_role": document.link_role,
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -348,20 +302,20 @@ def test_api_documents_retrieve_authenticated_related_team_members(
],
)
def test_api_documents_retrieve_authenticated_related_team_administrators(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -452,8 +406,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"link_reach": "restricted",
"link_role": document.link_role,
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -469,20 +422,20 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
],
)
def test_api_documents_retrieve_authenticated_related_team_owners(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
they are related via a team whatever the role and see all its accesses.
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -576,8 +529,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"link_reach": "restricted",
"link_role": document.link_role,
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document = factories.DocumentFactory(link_reach="public")
document = factories.DocumentFactory(is_public=True)
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
@@ -64,13 +64,12 @@ def test_api_documents_retrieve_auth_anonymous_public():
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach):
def test_api_documents_retrieve_auth_anonymous_not_public():
"""
Anonymous users should not be allowed to retrieve attachments linked to a document
with link reach set to authenticated or restricted.
that is not public.
"""
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=False)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
@@ -83,13 +82,12 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach
assert "Authorization" not in response
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach):
def test_api_documents_retrieve_auth_authenticated_public():
"""
Authenticated users who are not related to a document should be able to retrieve
attachments related to a document with public or authenticated link reach.
Authenticated users who are not related to a document should be able to
retrieve attachments linked to a public document.
"""
document = factories.DocumentFactory(link_reach=reach)
document = factories.DocumentFactory(is_public=True)
user = factories.UserFactory()
client = APIClient()
@@ -106,7 +104,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach
)
original_url = f"http://localhost/media/{key:s}"
response = client.get(
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
@@ -135,12 +133,12 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_auth_authenticated_restricted():
def test_api_documents_retrieve_auth_authenticated_not_public():
"""
Authenticated users who are not related to a document should not be allowed to
retrieve attachments linked to a document that is restricted.
retrieve attachments linked to a document that is not public.
"""
document = factories.DocumentFactory(link_reach="restricted")
document = factories.DocumentFactory(is_public=False)
user = factories.UserFactory()
client = APIClient()
@@ -149,7 +147,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = client.get(
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
)
@@ -157,21 +155,22 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
assert "Authorization" not in response
@pytest.mark.parametrize("is_public", [True, False])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_retrieve_auth_related(via, mock_user_teams):
def test_api_documents_retrieve_auth_related(via, is_public, mock_user_get_teams):
"""
Users who have a specific access to a document, whatever the role, should be able to
Users who have a role on a document, whatever the role, should be able to
retrieve related attachments.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
document = factories.DocumentFactory(is_public=is_public)
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
filename = f"{uuid.uuid4()!s}.jpg"
@@ -4,8 +4,6 @@ Tests for Documents API endpoint in impress's core app: update
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
@@ -16,22 +14,9 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_update_anonymous_forbidden(reach, role):
"""
Anonymous users should not be allowed to update a document when link
configuration does not allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
def test_api_documents_update_anonymous():
"""Anonymous users should not be allowed to update a document."""
document = factories.DocumentFactory()
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -52,26 +37,16 @@ def test_api_documents_update_anonymous_forbidden(reach, role):
assert document_values == old_document_values
@pytest.mark.parametrize(
"reach,role",
[
("public", "reader"),
("authenticated", "reader"),
("restricted", "reader"),
("restricted", "editor"),
],
)
def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
def test_api_documents_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a document to which
they are not related if the link configuration does not allow it.
Authenticated users should not be allowed to update a document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
document = factories.DocumentFactory(is_public=False)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -83,67 +58,18 @@ def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
document.refresh_from_db()
document_values = serializers.DocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
(False, "public", "editor"),
(True, "public", "editor"),
(True, "authenticated", "editor"),
],
)
def test_api_documents_update_anonymous_or_authenticated_unrelated(
is_authenticated, reach, role
):
"""
Authenticated users should be able to update a document to which
they are not related if the link configuration allows it.
"""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
else:
user = AnonymousUser()
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_reader(via, mock_user_teams):
def test_api_documents_update_authenticated_reader(via, mock_user_get_teams):
"""
Users who are reader of a document but not administrators should
Users who are editors or reader of a document but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory()
@@ -151,11 +77,11 @@ def test_api_documents_update_authenticated_reader(via, mock_user_teams):
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
@@ -184,7 +110,7 @@ def test_api_documents_update_authenticated_reader(via, mock_user_teams):
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_editor_administrator_or_owner(
via, role, mock_user_teams
via, role, mock_user_get_teams
):
"""A user who is editor, administrator or owner of a document should be allowed to update it."""
user = factories.UserFactory()
@@ -196,7 +122,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -216,7 +142,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -225,7 +151,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_owners(via, mock_user_teams):
def test_api_documents_update_authenticated_owners(via, mock_user_get_teams):
"""Administrators of a document should be allowed to update it."""
user = factories.UserFactory()
@@ -236,7 +162,7 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -255,7 +181,7 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -264,7 +190,9 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
def test_api_documents_update_administrator_or_owner_of_another(
via, mock_user_get_teams
):
"""
Being administrator or owner of a document should not grant authorization to update
another document.
@@ -280,27 +208,28 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
document=document, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
other_document = factories.DocumentFactory(title="Old title", link_role="reader")
old_document_values = serializers.DocumentSerializer(instance=other_document).data
is_public = random.choice([True, False])
document = factories.DocumentFactory(title="Old title", is_public=is_public)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{other_document.id!s}/",
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.status_code == 403 if is_public else 404
other_document.refresh_from_db()
other_document_values = serializers.DocumentSerializer(instance=other_document).data
assert other_document_values == old_document_values
document.refresh_from_db()
document_values = serializers.DocumentSerializer(instance=document).data
assert document_values == old_document_values
@@ -49,7 +49,7 @@ def test_api_templates_delete_authenticated_unrelated():
@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
via, role, mock_user_get_teams
):
"""
Authenticated users should not be allowed to delete a template for which they are
@@ -64,7 +64,7 @@ def test_api_templates_delete_authenticated_member_or_administrator(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -81,7 +81,7 @@ def test_api_templates_delete_authenticated_member_or_administrator(
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
def test_api_templates_delete_authenticated_owner(via, mock_user_get_teams):
"""
Authenticated users should be able to delete a template they own.
"""
@@ -94,7 +94,7 @@ def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -44,10 +44,8 @@ def test_api_templates_generate_document_anonymous_not_public():
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
def test_api_templates_generate_document_authenticated_public():
@@ -89,14 +87,12 @@ def test_api_templates_generate_document_authenticated_not_public():
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
@pytest.mark.parametrize("via", VIA)
def test_api_templates_generate_document_related(via, mock_user_teams):
def test_api_templates_generate_document_related(via, mock_user_get_teams):
"""Users related to a template can generate pdf document."""
user = factories.UserFactory()
@@ -106,7 +102,7 @@ def test_api_templates_generate_document_related(via, mock_user_teams):
if via == USER:
access = factories.UserTemplateAccessFactory(user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(team="lasuite")
data = {"body": "# Test markdown body"}
@@ -6,6 +6,7 @@ from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
@@ -16,12 +17,12 @@ pytestmark = pytest.mark.django_db
def test_api_templates_list_anonymous():
"""Anonymous users should only be able to list public templates."""
factories.TemplateFactory.create_batch(2, is_public=False)
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
expected_ids = {str(template.id) for template in public_templates}
templates = factories.TemplateFactory.create_batch(2, is_public=True)
expected_ids = {str(template.id) for template in templates}
response = APIClient().get("/api/v1.0/templates/")
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
@@ -31,7 +32,7 @@ def test_api_templates_list_anonymous():
def test_api_templates_list_authenticated_direct():
"""
Authenticated users should be able to list templates they are a direct
owner/administrator/member of or that are public.
owner/administrator/member of.
"""
user = factories.UserFactory()
@@ -53,24 +54,24 @@ def test_api_templates_list_authenticated_direct():
"/api/v1.0/templates/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_templates_list_authenticated_via_team(mock_user_teams):
def test_api_templates_list_authenticated_via_team(mock_user_get_teams):
"""
Authenticated users should be able to list templates they are a
owner/administrator/member of via a team or that are public.
owner/administrator/member of via a team.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
templates_team1 = [
access.template
@@ -90,7 +91,7 @@ def test_api_templates_list_authenticated_via_team(mock_user_teams):
response = client.get("/api/v1.0/templates/")
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
@@ -117,7 +118,7 @@ def test_api_templates_list_pagination(
"/api/v1.0/templates/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
@@ -133,7 +134,7 @@ def test_api_templates_list_pagination(
"/api/v1.0/templates/?page=2",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert content["count"] == 3
@@ -160,24 +161,26 @@ def test_api_templates_list_authenticated_distinct():
"/api/v1.0/templates/",
)
assert response.status_code == 200
assert response.status_code == HTTP_200_OK
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(template.id)
def test_api_templates_list_order_default():
"""The templates list should be sorted by 'created_at' in descending order by default."""
def test_api_templates_order():
"""
Test that the endpoint GET templates is sorted in 'created_at' descending order by default.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template_ids = [
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
str(template.id)
for template in factories.TemplateFactory.create_batch(5, is_public=True)
]
response = client.get(
response = APIClient().get(
"/api/v1.0/templates/",
)
@@ -192,21 +195,21 @@ def test_api_templates_list_order_default():
), "created_at values are not sorted from newest to oldest"
def test_api_templates_list_order_param():
def test_api_templates_order_param():
"""
The templates list is sorted by 'created_at' in ascending order when setting
the "ordering" query parameter.
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
templates_ids = [
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
str(template.id)
for template in factories.TemplateFactory.create_batch(5, is_public=True)
]
response = client.get(
response = APIClient().get(
"/api/v1.0/templates/?ordering=created_at",
)
assert response.status_code == 200
@@ -41,10 +41,8 @@ def test_api_templates_retrieve_anonymous_not_public():
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
def test_api_templates_retrieve_authenticated_unrelated_public():
@@ -96,10 +94,8 @@ def test_api_templates_retrieve_authenticated_unrelated_not_public():
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
def test_api_templates_retrieve_authenticated_related_direct():
@@ -150,12 +146,12 @@ def test_api_templates_retrieve_authenticated_related_direct():
}
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams):
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_get_teams):
"""
Authenticated users should not be able to retrieve a template related to teams in
which the user is not.
"""
mock_user_teams.return_value = []
mock_user_get_teams.return_value = []
user = factories.UserFactory()
@@ -178,10 +174,8 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
@pytest.mark.parametrize(
@@ -194,13 +188,13 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams)
],
)
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
@@ -293,13 +287,13 @@ def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
],
)
def test_api_templates_retrieve_authenticated_related_team_administrators(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
@@ -411,13 +405,13 @@ def test_api_templates_retrieve_authenticated_related_team_administrators(
],
)
def test_api_templates_retrieve_authenticated_related_team_owners(
teams, mock_user_teams
teams, mock_user_get_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
mock_user_get_teams.return_value = teams
user = factories.UserFactory()
@@ -58,10 +58,8 @@ def test_api_templates_update_authenticated_unrelated():
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == 404
assert response.json() == {"detail": "No Template matches the given query."}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
@@ -69,7 +67,7 @@ def test_api_templates_update_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
def test_api_templates_update_authenticated_readers(via, mock_user_get_teams):
"""
Users who are readers of a template should not be allowed to update it.
"""
@@ -82,7 +80,7 @@ def test_api_templates_update_authenticated_readers(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="reader"
)
@@ -111,7 +109,7 @@ def test_api_templates_update_authenticated_readers(via, mock_user_teams):
@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
via, role, mock_user_get_teams
):
"""Administrator or owner of a template should be allowed to update it."""
user = factories.UserFactory()
@@ -123,7 +121,7 @@ def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -150,7 +148,7 @@ def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
def test_api_templates_update_authenticated_owners(via, mock_user_get_teams):
"""Administrators of a template should be allowed to update it."""
user = factories.UserFactory()
@@ -161,7 +159,7 @@ def test_api_templates_update_authenticated_owners(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -187,7 +185,9 @@ def test_api_templates_update_authenticated_owners(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
def test_api_templates_update_administrator_or_owner_of_another(
via, mock_user_get_teams
):
"""
Being administrator or owner of a template should not grant authorization to update
another template.
@@ -203,7 +203,7 @@ def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_t
template=template, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template,
team="lasuite",
@@ -57,7 +57,7 @@ def test_api_template_accesses_list_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
def test_api_template_accesses_list_authenticated_related(via, mock_user_get_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.
@@ -76,7 +76,7 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
@@ -178,7 +178,7 @@ def test_api_template_accesses_retrieve_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_get_teams):
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
@@ -192,7 +192,7 @@ def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_tea
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
access = factories.UserTemplateAccessFactory(template=template)
@@ -261,7 +261,7 @@ def test_api_template_accesses_create_authenticated_unrelated():
@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
via, role, mock_user_get_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory()
@@ -273,7 +273,7 @@ def test_api_template_accesses_create_authenticated_editor_or_reader(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -296,7 +296,9 @@ def test_api_template_accesses_create_authenticated_editor_or_reader(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
def test_api_template_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
@@ -312,7 +314,7 @@ def test_api_template_accesses_create_authenticated_administrator(via, mock_user
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -361,7 +363,7 @@ def test_api_template_accesses_create_authenticated_administrator(via, mock_user
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
def test_api_template_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
@@ -374,7 +376,7 @@ def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -464,7 +466,7 @@ def test_api_template_accesses_update_authenticated_unrelated():
@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
via, role, mock_user_get_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
user = factories.UserFactory()
@@ -476,7 +478,7 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -504,7 +506,9 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
def test_api_template_accesses_update_administrator_except_owner(
via, mock_user_get_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.
@@ -520,7 +524,7 @@ def test_api_template_accesses_update_administrator_except_owner(via, mock_user_
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -561,7 +565,9 @@ def test_api_template_accesses_update_administrator_except_owner(via, mock_user_
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
def test_api_template_accesses_update_administrator_from_owner(
via, mock_user_get_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.
@@ -577,7 +583,7 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -608,7 +614,7 @@ def test_api_template_accesses_update_administrator_from_owner(via, mock_user_te
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_get_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.
@@ -624,7 +630,7 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_team
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -662,7 +668,7 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner(via, mock_user_teams):
def test_api_template_accesses_update_owner(via, mock_user_get_teams):
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
@@ -676,7 +682,7 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -718,7 +724,7 @@ def test_api_template_accesses_update_owner(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
def test_api_template_accesses_update_owner_self(via, mock_user_get_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.
@@ -735,7 +741,7 @@ def test_api_template_accesses_update_owner_self(via, mock_user_teams):
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -804,7 +810,7 @@ def test_api_template_accesses_delete_authenticated():
@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):
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_get_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
@@ -818,7 +824,7 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -838,7 +844,7 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
via, mock_user_get_teams
):
"""
Users who are administrators in a template should be allowed to delete an access
@@ -855,7 +861,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -876,7 +882,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_get_teams):
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
@@ -892,7 +898,7 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -911,7 +917,7 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_tea
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners(via, mock_user_teams):
def test_api_template_accesses_delete_owners(via, mock_user_get_teams):
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
@@ -925,7 +931,7 @@ def test_api_template_accesses_delete_owners(via, mock_user_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -944,7 +950,7 @@ def test_api_template_accesses_delete_owners(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_get_teams):
"""
It should not be possible to delete the last owner access from a template
"""
@@ -960,7 +966,7 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
+48 -82
View File
@@ -27,15 +27,15 @@ def test_models_documents_id_unique():
def test_models_documents_title_null():
"""The "title" field can be null."""
document = models.Document.objects.create(title=None)
assert document.title is None
"""The "title" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Document.objects.create(title=None)
def test_models_documents_title_empty():
"""The "title" field can be empty."""
document = models.Document.objects.create(title="")
assert document.title == ""
"""The "title" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Document.objects.create(title="")
def test_models_documents_title_max_length():
@@ -57,29 +57,30 @@ def test_models_documents_file_key():
# get_abilities
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
(True, "restricted", "reader"),
(True, "restricted", "editor"),
(False, "restricted", "reader"),
(False, "restricted", "editor"),
(False, "authenticated", "reader"),
(False, "authenticated", "editor"),
],
)
def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role):
"""
Check abilities returned for a document giving insufficient roles 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=role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
def test_models_documents_get_abilities_anonymous_public():
"""Check abilities returned for an anonymous user if the document is public."""
document = factories.DocumentFactory(is_public=True)
abilities = document.get_abilities(AnonymousUser())
assert abilities == {
"attachment_upload": False,
"link_configuration": False,
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
def test_models_documents_get_abilities_anonymous_not_public():
"""Check abilities returned for an anonymous user if the document is private."""
document = factories.DocumentFactory(is_public=False)
abilities = document.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": False,
@@ -90,26 +91,13 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
}
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_reader(is_authenticated, reach):
"""
Check abilities returned for a document giving reader 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="reader")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
def test_models_documents_get_abilities_authenticated_unrelated_public():
"""Check abilities returned for an authenticated user if the user is public."""
document = factories.DocumentFactory(is_public=True)
abilities = document.get_abilities(factories.UserFactory())
assert abilities == {
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
@@ -120,30 +108,17 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
}
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_editor(is_authenticated, reach):
"""
Check abilities returned for a document giving editor 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="editor")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
def test_models_documents_get_abilities_authenticated_unrelated_not_public():
"""Check abilities returned for an authenticated user if the document is private."""
document = factories.DocumentFactory(is_public=False)
abilities = document.get_abilities(factories.UserFactory())
assert abilities == {
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
"partial_update": False,
"retrieve": False,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -156,9 +131,8 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"attachment_upload": True,
"destroy": True,
"link_configuration": True,
"attachment_upload": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
@@ -174,9 +148,8 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"attachment_upload": True,
"destroy": False,
"link_configuration": True,
"attachment_upload": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
@@ -195,9 +168,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"attachment_upload": True,
"destroy": False,
"link_configuration": False,
"attachment_upload": True,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
@@ -210,17 +182,14 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"""Check abilities returned for the reader of a document."""
access = factories.UserDocumentAccessFactory(
role="reader", document__link_role="reader"
)
access = factories.UserDocumentAccessFactory(role="reader")
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
@@ -233,18 +202,15 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset e.g. with query annotation."""
access = factories.UserDocumentAccessFactory(
role="reader", document__link_role="reader"
)
access = factories.UserDocumentAccessFactory(role="reader")
access.document.user_roles = ["reader"]
with django_assert_num_queries(0):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
@@ -189,7 +189,7 @@ def test_models_document_invitations_get_abilities_authenticated():
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_models_document_invitations_get_abilities_privileged_member(
role, via, mock_user_teams
role, via, mock_user_get_teams
):
"""Check abilities for a document member with a privileged role."""
@@ -198,7 +198,7 @@ def test_models_document_invitations_get_abilities_privileged_member(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -217,7 +217,7 @@ def test_models_document_invitations_get_abilities_privileged_member(
@pytest.mark.parametrize("via", VIA)
def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
def test_models_document_invitations_get_abilities_reader(via, mock_user_get_teams):
"""Check abilities for a document reader with 'reader' role."""
user = factories.UserFactory()
@@ -225,7 +225,7 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
@@ -242,7 +242,7 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
def test_models_document_invitations_get_abilities_editor(via, mock_user_get_teams):
"""Check abilities for a document editor with 'editor' role."""
user = factories.UserFactory()
@@ -250,7 +250,7 @@ def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="editor")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="editor"
)
@@ -130,9 +130,7 @@ def create_demo(stdout):
queue.push(
models.Document(
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
is_public=random_true_with_probability(0.5),
)
)
@@ -55,25 +55,14 @@ export const createDoc = async (
})
.fill(randomDocs[i]);
if (isPublic) {
await page.getByText('Is it public ?').click();
}
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(page.locator('h2').getByText(randomDocs[i])).toBeVisible();
if (isPublic) {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByText('Doc private').click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public'),
).toBeVisible();
}
}
return randomDocs;
@@ -164,7 +153,6 @@ export const mockedDocument = async (page: Page, json: object) => {
accesses: [],
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
@@ -173,7 +161,7 @@ export const mockedDocument = async (page: Page, json: object) => {
partial_update: false, // Means not editor
retrieve: true,
},
link_reach: 'restricted',
is_public: false,
created_at: '2021-09-01T09:00:00Z',
...json,
},
@@ -21,6 +21,8 @@ test.describe('Doc Create', () => {
).toBeVisible();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(card.getByText('Is it public ?')).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Create the document',
@@ -44,11 +46,13 @@ test.describe('Doc Create', () => {
await expect(buttonCreateHomepage).toBeVisible();
});
test('it creates a doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
expect(await page.locator('title').textContent()).toMatch(
/My new doc - Docs/,
test('create a new public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
const header = page.locator('header').first();
@@ -61,5 +65,11 @@ test.describe('Doc Create', () => {
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByText(docTitle)).toBeVisible();
const row = datagrid.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
});
@@ -136,7 +136,6 @@ test.describe('Doc Editor', () => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
@@ -34,7 +34,6 @@ test.describe('Doc Header', () => {
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
@@ -43,7 +42,7 @@ test.describe('Doc Header', () => {
partial_update: true,
retrieve: true,
},
link_reach: 'public',
is_public: true,
created_at: '2021-09-01T09:00:00Z',
});
@@ -66,7 +65,13 @@ test.describe('Doc Header', () => {
});
test('it updates the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
const [randomDoc] = await createDoc(
page,
'doc-update',
browserName,
1,
true,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
@@ -80,7 +85,12 @@ test.describe('Doc Header', () => {
page.locator('h2').getByText(`Update document "${randomDoc}"`),
).toBeVisible();
await expect(
page.getByRole('checkbox', { name: 'Is it public ?' }),
).toBeChecked();
await page.getByText('Document name').fill(`${randomDoc}-updated`);
await page.getByText('Is it public ?').click();
await page
.getByRole('button', {
@@ -106,8 +116,8 @@ test.describe('Doc Header', () => {
.click();
await expect(
page.getByRole('textbox', { name: 'Document name' }),
).toHaveValue(`${randomDoc}-updated`);
page.getByRole('checkbox', { name: 'Is it public ?' }),
).not.toBeChecked();
});
test('it deletes the doc', async ({ page, browserName }) => {
@@ -154,7 +164,6 @@ test.describe('Doc Header', () => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
@@ -186,7 +195,6 @@ test.describe('Doc Header', () => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
@@ -218,7 +226,6 @@ test.describe('Doc Header', () => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
@@ -47,13 +47,4 @@ test.describe('Doc Routing: Not loggued', () => {
await keyCloakSignIn(page, browserName);
await expect(page).toHaveURL(/\/docs\/mocked-document-id\/$/);
});
test('The homepage redirects to login.', async ({ page }) => {
await page.goto('/');
await expect(
page.getByRole('button', {
name: 'Sign In',
}),
).toBeVisible();
});
});
@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Summary', () => {
test('it checks the doc summary', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-summary', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Summary',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 6; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await panel.getByText('Another World').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
});
});
@@ -1,93 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Table of content',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
const hello = panel.getByText('Hello World');
const superW = panel.getByText('Super World');
const another = panel.getByText('Another World');
await expect(hello).toBeVisible();
await expect(hello).toHaveCSS('font-size', '19.2px');
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toBeVisible();
await expect(superW).toHaveCSS('font-size', '16px');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await expect(another).toBeVisible();
await expect(another).toHaveCSS('font-size', '12.8px');
await expect(another).toHaveAttribute('aria-selected', 'false');
await hello.click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await another.click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'false');
await expect(superW).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(superW).toHaveAttribute('aria-selected', 'true');
});
});
@@ -1,96 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn } from './common';
test.describe('Doc Visibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('Make a public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByText(docTitle)).toBeVisible();
const row = datagrid.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
test('It checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
await createDoc(page, 'My button copy doc', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent).toMatch(page.url());
});
});
test.describe('Doc Visibility: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A public doc is accessible even when not authentified.', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
await expect(
page.getByText('The document visiblitity has been updated.'),
).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
});
});
@@ -22,12 +22,6 @@ test.describe('Header', () => {
/Marianne/i,
);
await expect(
header.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(header.getByAltText('Language Icon')).toBeVisible();
await expect(
@@ -74,6 +68,12 @@ test.describe('Header: Log out', () => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
await page
.getByRole('button', {
name: 'My account',
})
.click();
await page
.getByRole('button', {
name: 'Logout',
+1 -1
View File
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.47.1",
"@playwright/test": "1.46.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
+4 -11
View File
@@ -32,10 +32,12 @@ export default defineConfig({
},
webServer: {
command: !process.env.CI ? `cd ../.. && yarn app:dev --port ${PORT}` : '',
command: `cd ../.. && yarn app:${
process.env.CI ? 'start -p ' : 'dev --port '
} ${PORT}`,
url: baseURL,
timeout: 120 * 1000,
reuseExistingServer: true,
reuseExistingServer: !process.env.CI,
},
/* Configure projects for major browsers */
@@ -48,9 +50,6 @@ export default defineConfig({
locale: 'en-US',
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-chromium.json',
contextOptions: {
permissions: ['clipboard-read', 'clipboard-write'],
},
},
dependencies: ['setup'],
},
@@ -71,12 +70,6 @@ export default defineConfig({
locale: 'en-US',
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-firefox.json',
launchOptions: {
firefoxUserPrefs: {
'dom.events.asyncClipboard.readText': true,
'dom.events.testing.asyncClipboard': true,
},
},
},
dependencies: ['setup'],
},
@@ -1,6 +1,5 @@
server {
listen 8080;
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
-4
View File
@@ -340,10 +340,6 @@ const config = {
'forms-checkbox': {
'border-radius': '0',
color: 'var(--c--theme--colors--primary-text)',
text: {
color: 'var(--c--theme--colors--greyscale-text)',
size: 'var(--c--theme--font--sizes--t)',
},
},
'forms-datepicker': {
'border-radius': '0',
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/basic-features/typescript for more information.
+7 -9
View File
@@ -21,34 +21,33 @@
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.5",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.56.2",
"i18next": "23.15.1",
"@tanstack/react-query": "5.53.2",
"i18next": "23.14.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.11",
"next": "14.2.7",
"react": "*",
"react-aria-components": "1.3.3",
"react-dom": "*",
"react-i18next": "15.0.2",
"react-i18next": "15.0.1",
"react-select": "5.8.0",
"styled-components": "6.1.13",
"yjs": "*",
"y-protocols": "1.0.6",
"zustand": "4.5.5"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.56.2",
"@tanstack/react-query-devtools": "5.53.2",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.7",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.6",
"@types/react": "18.3.5",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
@@ -62,7 +61,6 @@
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.94.0",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -1,6 +1,7 @@
import fetchMock from 'fetch-mock';
import { fetchAPI } from '@/api';
import { useAuthStore } from '@/core/auth';
describe('fetchAPI', () => {
beforeEach(() => {
@@ -29,6 +30,19 @@ describe('fetchAPI', () => {
});
});
it('logout if 401 response', async () => {
const logoutMock = jest.fn();
jest
.spyOn(useAuthStore.getState(), 'logout')
.mockImplementation(logoutMock);
fetchMock.mock('http://test.jest/api/v1.0/some/url', 401);
await fetchAPI('some/url');
expect(logoutMock).toHaveBeenCalled();
});
it('check the versionning', () => {
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
@@ -1,4 +1,4 @@
import { baseApiUrl } from '@/core';
import { baseApiUrl, useAuthStore } from '@/core';
import { getCSRFToken } from './utils';
@@ -30,5 +30,10 @@ export const fetchAPI = async (
headers,
});
if (response.status === 401) {
const { logout } = useAuthStore.getState();
logout();
}
return response;
};
@@ -5,7 +5,7 @@ import { Box, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
interface PanelProps {
title?: string;
title: string;
setIsPanelOpen: (isOpen: boolean) => void;
}
@@ -53,14 +53,11 @@ export const Panel = ({
{...closedOverridingStyles}
>
<Box
$overflow="inherit"
$position="sticky"
$overflow="hidden"
$css={`
top: 0;
opacity: ${isOpen ? '1' : '0'};
transition: ${transition};
`}
$maxHeight="100%"
>
<Box
$padding={{ all: 'small' }}
@@ -93,11 +90,9 @@ export const Panel = ({
}}
$radius="2px"
/>
{title && (
<Text $weight="bold" $size="l" $theme="primary">
{title}
</Text>
)}
<Text $weight="bold" $size="l" $theme="primary">
{title}
</Text>
</Box>
{children}
</Box>
@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
import { Auth } from './auth/';
import { Auth } from './auth/Auth';
/**
* QueryClient:
@@ -1,34 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/core/auth';
export const AccountDropdown = () => {
const { t } = useTranslation();
const { logout, authenticated, login } = useAuthStore();
if (!authenticated) {
return (
<Button
onClick={login}
color="primary-text"
icon={<span className="material-icons">login</span>}
aria-label={t('Login')}
>
{t('Login')}
</Button>
);
}
return (
<Button
onClick={logout}
color="primary-text"
icon={<span className="material-icons">logout</span>}
aria-label={t('Logout')}
>
{t('Logout')}
</Button>
);
};
@@ -1,47 +1,18 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { PropsWithChildren, useEffect, useState } from 'react';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useAuthStore } from './useAuthStore';
/**
* TODO: Remove this restriction when we will have a homepage design for non-authenticated users.
*
* We define the paths that are not allowed without authentication.
* Actually, only the home page and the docs page are not allowed without authentication.
* When we will have a homepage design for non-authenticated users, we will remove this restriction to have
* the full website accessible without authentication.
*/
const regexpUrlsAuth = [/\/docs\/$/g, /^\/$/g];
export const Auth = ({ children }: PropsWithChildren) => {
const { initAuth, initiated, authenticated, login } = useAuthStore();
const { asPath } = useRouter();
const [pathAllowed, setPathAllowed] = useState<boolean>(
!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)),
);
const { authenticated, initAuth } = useAuthStore();
useEffect(() => {
initAuth();
}, [initAuth]);
useEffect(() => {
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)));
}, [asPath]);
// We force to login except on allowed paths
useEffect(() => {
if (!initiated || authenticated || pathAllowed) {
return;
}
login();
}, [authenticated, pathAllowed, login, initiated]);
if ((!initiated && pathAllowed) || (!authenticated && !pathAllowed)) {
if (!authenticated) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
@@ -1,4 +1,3 @@
export * from './AccountDropdown';
export * from './api/types';
export * from './Auth';
export * from './useAuthStore';
export * from './api/types';
@@ -6,22 +6,18 @@ import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
interface AuthStore {
initiated: boolean;
authenticated: boolean;
initAuth: () => void;
logout: () => void;
login: () => void;
userData?: User;
}
const initialState = {
initiated: false,
authenticated: false,
userData: undefined,
};
export const useAuthStore = create<AuthStore>((set) => ({
initiated: initialState.initiated,
authenticated: initialState.authenticated,
userData: initialState.userData,
@@ -38,21 +34,20 @@ export const useAuthStore = create<AuthStore>((set) => ({
set({ authenticated: true, userData: data });
})
.catch(() => {})
.finally(() => {
set({ initiated: true });
.catch(() => {
// If we try to access a specific page and we are not authenticated
// we store the path in the local storage to redirect to it after login
if (window.location.pathname !== '/') {
localStorage.setItem(
PATH_AUTH_LOCAL_STORAGE,
window.location.pathname,
);
}
window.location.replace(new URL('authenticate/', baseApiUrl()).href);
});
},
login: () => {
// If we try to access a specific page and we are not authenticated
// we store the path in the local storage to redirect to it after login
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
}
window.location.replace(`${baseApiUrl()}authenticate/`);
},
logout: () => {
window.location.replace(`${baseApiUrl()}logout/`);
window.location.replace(new URL('logout/', baseApiUrl()).href);
},
}));
@@ -308,11 +308,6 @@ input:-webkit-autofill:focus {
transition: all 0.8s ease-in-out;
}
.c__checkbox .c__field__text {
color: var(--c--components--forms-checkbox--text--color);
font-size: var(--c--components--forms-checkbox--text--size);
}
/**
* Button
*/
@@ -463,10 +463,6 @@
);
--c--components--forms-checkbox--border-radius: 0;
--c--components--forms-checkbox--color: var(--c--theme--colors--primary-text);
--c--components--forms-checkbox--text--color: var(
--c--theme--colors--greyscale-text
);
--c--components--forms-checkbox--text--size: var(--c--theme--font--sizes--t);
--c--components--forms-datepicker--border-radius: 0;
--c--components--forms-fileuploader--border-radius: 0;
--c--components--forms-field--color: var(--c--theme--colors--primary-text);
@@ -469,10 +469,6 @@ export const tokens = {
'forms-checkbox': {
'border-radius': '0',
color: 'var(--c--theme--colors--primary-text)',
text: {
color: 'var(--c--theme--colors--greyscale-text)',
size: 'var(--c--theme--font--sizes--t)',
},
},
'forms-datepicker': { 'border-radius': '0' },
'forms-fileuploader': { 'border-radius': '0' },
@@ -1,7 +1,4 @@
import {
BlockNoteEditor as BlockNoteEditorCore,
locales,
} from '@blocknote/core';
import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -21,8 +18,6 @@ import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
import { useTranslation } from 'react-i18next';
const cssEditor = `
&, & > .bn-container, & .ProseMirror {
height:100%
@@ -99,19 +94,6 @@ export const BlockNoteContent = ({
[createDocAttachment, doc.id],
);
const { t, i18n } = useTranslation();
const lang = i18n.language;
const resetStore = () => {
setStore(storeId, { editor: undefined });
};
// Invalidate the stored editor when the language changes
useEffect(() => {
resetStore();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lang]);
const editor = useMemo(() => {
if (storedEditor) {
return storedEditor;
@@ -126,10 +108,9 @@ export const BlockNoteContent = ({
color: randomColor(),
},
},
dictionary: locales[lang as keyof typeof locales],
uploadFile,
});
}, [provider, storedEditor, uploadFile, userData?.email, lang]);
}, [provider, storedEditor, uploadFile, userData?.email]);
useEffect(() => {
setStore(storeId, { editor });
@@ -15,7 +15,6 @@ import {
} from '@blocknote/react';
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const BlockNoteToolbar = () => {
return (
@@ -94,7 +93,6 @@ export function MarkdownButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const { t } = useTranslation();
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
@@ -128,7 +126,7 @@ export function MarkdownButton() {
return (
<Components.FormattingToolbar.Button
mainTooltip={t('Convert Markdown')}
mainTooltip="Convert Markdown"
onClick={handleConvertMarkdown}
>
M
@@ -9,7 +9,7 @@ import { Panel } from '@/components/Panel';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content';
import { Summary, useDocSummaryStore } from '@/features/docs/doc-summary';
import {
VersionList,
Versions,
@@ -28,6 +28,8 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
query: { versionId },
} = useRouter();
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
const { isPanelSummaryOpen, setIsPanelSummaryOpen } = useDocSummaryStore();
const { t } = useTranslation();
const isVersion = versionId && typeof versionId === 'string';
@@ -70,7 +72,11 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
<VersionList doc={doc} />
</Panel>
)}
<TableContent doc={doc} />
{isPanelSummaryOpen && (
<Panel title={t('SUMMARY')} setIsPanelOpen={setIsPanelSummaryOpen}>
<Summary doc={doc} />
</Panel>
)}
</Box>
</>
);
@@ -13,7 +13,6 @@ import {
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
import { useDate } from '@/hook';
import { DocTagPublic } from './DocTagPublic';
import { DocToolBox } from './DocToolBox';
interface DocHeaderProps {
@@ -85,7 +84,18 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
$wrap="wrap"
>
<Box $direction="row" $align="center" $gap="0.5rem 2rem" $wrap="wrap">
<DocTagPublic doc={doc} />
{doc.is_public && (
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
$color="white"
$padding="xtiny"
$radius="3px"
$size="s"
>
{t('Public')}
</Text>
)}
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
</Text>
@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, LinkReach } from '@/features/docs/doc-management';
interface DocTagPublicProps {
doc: Doc;
}
export const DocTagPublic = ({ doc }: DocTagPublicProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
if (doc?.link_reach !== LinkReach.PUBLIC) {
return null;
}
return (
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
$color="white"
$padding="xtiny"
$radius="3px"
$size="s"
>
{t('Public')}
</Text>
);
};
@@ -9,7 +9,7 @@ import {
ModalShare,
ModalUpdateDoc,
} from '@/features/docs/doc-management';
import { useDocTableContentStore } from '@/features/docs/doc-table-content';
import { useDocSummaryStore } from '@/features/docs/doc-summary';
import { useDocVersionStore } from '@/features/docs/doc-versioning';
import { ModalPDF } from './ModalExport';
@@ -26,7 +26,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
const { setIsPanelVersionOpen } = useDocVersionStore();
const { setIsPanelTableContentOpen } = useDocTableContentStore();
const { setIsPanelSummaryOpen } = useDocSummaryStore();
return (
<Box
@@ -83,7 +83,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
)}
<Button
onClick={() => {
setIsPanelTableContentOpen(true);
setIsPanelSummaryOpen(true);
setIsPanelVersionOpen(false);
setIsDropOpen(false);
}}
@@ -91,7 +91,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
icon={<span className="material-icons">summarize</span>}
size="small"
>
<Text $theme="primary">{t('Table of content')}</Text>
<Text $theme="primary">{t('Summary')}</Text>
</Button>
<Button
onClick={() => {
@@ -1,4 +1,3 @@
export * from './useDoc';
export * from './useDocs';
export * from './useUpdateDoc';
export * from './useUpdateDocLink';
@@ -6,13 +6,17 @@ import { Doc } from '../types';
import { KEY_LIST_DOC } from './useDocs';
export type CreateDocParam = Pick<Doc, 'title'>;
export type CreateDocParam = Pick<Doc, 'title' | 'is_public'>;
export const createDoc = async ({ title }: CreateDocParam): Promise<Doc> => {
export const createDoc = async ({
title,
is_public,
}: CreateDocParam): Promise<Doc> => {
const response = await fetchAPI(`documents/`, {
method: 'POST',
body: JSON.stringify({
title,
is_public,
}),
});
@@ -19,7 +19,6 @@ export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
};
export const KEY_DOC = 'doc';
export const KEY_DOC_VISIBILITY = 'doc-visibility';
export function useDoc(
param: DocParams,
@@ -4,7 +4,7 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs';
export type UpdateDocParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'content' | 'title'>>;
Partial<Pick<Doc, 'content' | 'title' | 'is_public'>>;
export const updateDoc = async ({
id,
@@ -1,51 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs';
export type UpdateDocLinkParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'link_role' | 'link_reach'>>;
export const updateDocLink = async ({
id,
...params
}: UpdateDocLinkParams): Promise<Doc> => {
const response = await fetchAPI(`documents/${id}/link-configuration/`, {
method: 'PUT',
body: JSON.stringify({
...params,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to update the doc link',
await errorCauses(response),
);
}
return response.json() as Promise<Doc>;
};
interface UpdateDocLinkProps {
onSuccess?: (data: Doc) => void;
listInvalideQueries?: string[];
}
export function useUpdateDocLink({
onSuccess,
listInvalideQueries,
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.resetQueries({
queryKey: [queryKey],
});
});
onSuccess?.(data);
},
});
}
@@ -1,90 +0,0 @@
import {
Button,
Switch,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, IconBG } from '@/components';
import { KEY_DOC, KEY_LIST_DOC, useUpdateDocLink } from '../api';
import { Doc, LinkReach } from '../types';
interface DocVisibilityProps {
doc: Doc;
}
export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { t } = useTranslation();
const [docPublic, setDocPublic] = useState(
doc.link_reach === LinkReach.PUBLIC,
);
const { toast } = useToastProvider();
const api = useUpdateDocLink({
onSuccess: () => {
toast(
t('The document visiblitity has been updated.'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
},
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
return (
<Card
$margin="tiny"
$padding={{ horizontal: 'small', vertical: 'tiny' }}
aria-label={t('Doc visibility card')}
$direction="row"
$align="center"
$justify="space-between"
>
<Box $direction="row" $gap="1rem">
<IconBG iconName="public" $margin="none" />
<Switch
label={t(docPublic ? 'Doc public' : 'Doc private')}
defaultChecked={docPublic}
onChange={() => {
api.mutate({
id: doc.id,
link_reach: docPublic ? LinkReach.RESTRICTED : LinkReach.PUBLIC,
link_role: 'reader',
});
setDocPublic(!docPublic);
}}
disabled={!doc.abilities.link_configuration}
text={t(
docPublic
? 'Anyone on the internet with the link can view'
: 'Only for people with access',
)}
/>
</Box>
<Button
onClick={() => {
navigator.clipboard
.writeText(window.location.href)
.then(() => {
toast(t('Link Copied !'), VariantType.SUCCESS, {
duration: 3000,
});
})
.catch(() => {
toast(t('Failed to copy link'), VariantType.ERROR, {
duration: 3000,
});
});
}}
color="primary"
icon={<span className="material-icons">copy</span>}
>
{t('Copy link')}
</Button>
</Card>
);
};
@@ -3,6 +3,7 @@ import {
Button,
Modal,
ModalSize,
Switch,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
@@ -43,8 +44,9 @@ export const ModalCreateDoc = ({ onClose }: ModalCreateDocProps) => {
onClose,
isPublic: false,
titleModal: t('Create a new document'),
validate: (title) =>
validate: (title, is_public) =>
api.mutate({
is_public,
title,
}),
...api,
@@ -78,12 +80,14 @@ export const ModalUpdateDoc = ({ onClose, doc }: ModalUpdateDocProps) => {
buttonText: t('Validate the modification'),
onClose,
initialTitle: doc.title,
isPublic: doc.is_public,
infoText: t('Enter the new name of the selected document.'),
titleModal: t('Update document "{{documentTitle}}"', {
documentTitle: doc.title,
}),
validate: (title) =>
validate: (title, is_public) =>
api.mutate({
is_public,
title,
id: doc.id,
}),
@@ -95,9 +99,10 @@ export const ModalUpdateDoc = ({ onClose, doc }: ModalUpdateDocProps) => {
type ModalDoc<T> = {
buttonText: string;
isPublic: boolean;
onClose: () => void;
titleModal: string;
validate: (title: string) => void;
validate: (title: string, is_public: boolean) => void;
initialTitle?: string;
infoText?: string;
} & UseMutationResult<Doc, APIError<unknown>, T, unknown>;
@@ -106,6 +111,7 @@ const ModalDoc = <T,>({
buttonText,
infoText,
initialTitle,
isPublic,
onClose,
titleModal,
validate,
@@ -115,6 +121,8 @@ const ModalDoc = <T,>({
const { t } = useTranslation();
const [title, setTitle] = useState(initialTitle || '');
const [docPublic, setDocPublic] = useState(isPublic);
return (
<Modal
isOpen
@@ -136,7 +144,7 @@ const ModalDoc = <T,>({
aria-label={buttonText}
color="primary"
fullWidth
onClick={() => validate(title)}
onClick={() => validate(title, docPublic)}
>
{buttonText}
</Button>
@@ -169,6 +177,12 @@ const ModalDoc = <T,>({
setDocName: setTitle,
}}
/>
<Switch
label={t('Is it public ?')}
labelSide="right"
defaultChecked={docPublic}
onChange={() => setDocPublic(!docPublic)}
/>
</Box>
</Box>
</Modal>
@@ -10,8 +10,6 @@ import { MemberList } from '@/features/docs/members/members-list';
import { Doc } from '../types';
import { currentDocRole } from '../utils';
import { DocVisibility } from './DocVisibility';
const ModalShareStyle = createGlobalStyle`
& .c__modal__scroller{
background: #FAFAFA;
@@ -46,27 +44,28 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
onClose={onClose}
width="70vw"
$css="min-width: 320px;max-width: 777px;"
title={
<Card
$direction="row"
$align="center"
$margin={{ horizontal: 'tiny', top: 'none', bottom: 'big' }}
$padding="tiny"
$gap="1rem"
>
<Text $isMaterialIcon $size="48px" $theme="primary">
share
</Text>
<Box $align="flex-start">
<Text as="h3" $size="26px" $margin="none">
{t('Share')}
</Text>
<Text $size="small" $weight="normal" $textAlign="left">
{doc.title}
</Text>
</Box>
</Card>
}
>
<Card
$direction="row"
$align="center"
$margin={{ horizontal: 'tiny', top: 'none', bottom: 'big' }}
$padding="tiny"
$gap="1rem"
>
<Text $isMaterialIcon $size="48px" $theme="primary">
share
</Text>
<Box $align="flex-start">
<Text as="h3" $size="26px" $margin="none">
{t('Share')}
</Text>
<Text $size="small" $weight="normal" $textAlign="left">
{doc.title}
</Text>
</Box>
</Card>
<DocVisibility doc={doc} />
<AddMembers doc={doc} currentRole={currentDocRole(doc.abilities)} />
<InvitationList doc={doc} />
<MemberList doc={doc} />
@@ -20,26 +20,18 @@ export enum Role {
OWNER = 'owner',
}
export enum LinkReach {
RESTRICTED = 'restricted',
PUBLIC = 'public',
AUTHENTICATED = 'authenticated',
}
export type Base64 = string;
export interface Doc {
id: string;
title: string;
content: Base64;
link_reach: LinkReach;
link_role: 'reader' | 'editor';
is_public: boolean;
accesses: Access[];
created_at: string;
updated_at: string;
abilities: {
destroy: boolean;
link_configuration: boolean;
manage_accesses: boolean;
partial_update: boolean;
retrieve: boolean;
@@ -0,0 +1,104 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { useDocStore } from '../../doc-editor';
import { Doc } from '../../doc-management';
import { useDocSummaryStore } from '../stores';
interface SummaryProps {
doc: Doc;
}
export const Summary = ({ doc }: SummaryProps) => {
const { docsStore } = useDocStore();
const { t } = useTranslation();
const editor = docsStore?.[doc.id]?.editor;
const headingFiltering = useCallback(
() => editor?.document.filter((block) => block.type === 'heading'),
[editor?.document],
);
const [headings, setHeadings] = useState(headingFiltering());
const { setIsPanelSummaryOpen } = useDocSummaryStore();
useEffect(() => {
return () => {
setIsPanelSummaryOpen(false);
};
}, [setIsPanelSummaryOpen]);
if (!editor) {
return null;
}
editor.onEditorContentChange(() => {
setHeadings(headingFiltering());
});
return (
<Box $overflow="auto" $padding="small">
{headings?.map((heading) => (
<BoxButton
key={heading.id}
onClick={() => {
editor.focus();
editor?.setTextCursorPosition(heading.id, 'end');
document
.querySelector(`[data-id="${heading.id}"]`)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
style={{ textAlign: 'left' }}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{heading.content?.[0]?.type === 'text' && heading.content?.[0]?.text
? `- ${heading.content[0].text}`
: ''}
</Text>
</BoxButton>
))}
<Box
$height="1px"
$width="auto"
$background="#e5e5e5"
$margin={{ vertical: 'small' }}
$css="flex: none;"
/>
<BoxButton
onClick={() => {
editor.focus();
document.querySelector(`.bn-editor`)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Back to top')}
</Text>
</BoxButton>
<BoxButton
onClick={() => {
editor.focus();
document
.querySelector(
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Go to bottom')}
</Text>
</BoxButton>
</Box>
);
};
@@ -0,0 +1 @@
export * from './Summary';
@@ -0,0 +1 @@
export * from './useDocSummaryStore';
@@ -0,0 +1,13 @@
import { create } from 'zustand';
export interface UseDocSummaryStore {
isPanelSummaryOpen: boolean;
setIsPanelSummaryOpen: (isOpen: boolean) => void;
}
export const useDocSummaryStore = create<UseDocSummaryStore>((set) => ({
isPanelSummaryOpen: false,
setIsPanelSummaryOpen: (isPanelSummaryOpen) => {
set(() => ({ isPanelSummaryOpen }));
},
}));
@@ -1,66 +0,0 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useState } from 'react';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
const sizeMap: { [key: number]: string } = {
1: '1.2rem',
2: '1rem',
3: '0.8rem',
};
export type HeadingsHighlight = {
headingId: string;
isVisible: boolean;
}[];
interface HeadingProps {
editor: BlockNoteEditor;
level: number;
text: string;
headingId: string;
isHighlight: boolean;
}
export const Heading = ({
headingId,
editor,
isHighlight,
level,
text,
}: HeadingProps) => {
const [isHover, setIsHover] = useState(isHighlight);
const { colorsTokens } = useCunninghamTheme();
return (
<BoxButton
key={headingId}
onMouseOver={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
onClick={() => {
editor.focus();
editor.setTextCursorPosition(headingId, 'end');
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text
$theme="primary"
$padding={{ vertical: 'xtiny', left: 'tiny' }}
$size={sizeMap[level]}
$hasTransition
$css={
isHover || isHighlight
? `box-shadow: -2px 0px 0px ${colorsTokens()[isHighlight ? 'primary-500' : 'primary-400']};`
: ''
}
aria-selected={isHighlight}
>
{text}
</Text>
</BoxButton>
);
};
@@ -1,180 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { Panel } from '@/components/Panel';
import { useDocStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { useDocTableContentStore } from '../stores';
import { Heading } from './Heading';
type HeadingBlock = {
id: string;
type: string;
text: string;
content: HeadingBlock[];
props: {
level: number;
};
};
interface TableContentProps {
doc: Doc;
}
export const TableContent = ({ doc }: TableContentProps) => {
const { docsStore } = useDocStore();
const { t } = useTranslation();
const editor = docsStore?.[doc.id]?.editor;
const headingFiltering = useCallback(
() =>
editor?.document.filter(
(block) => block.type === 'heading',
) as unknown as HeadingBlock[],
[editor?.document],
);
const [headings, setHeadings] = useState<HeadingBlock[]>();
const { setIsPanelTableContentOpen, isPanelTableContentOpen } =
useDocTableContentStore();
const [hasBeenClose, setHasBeenClose] = useState(false);
const setClosePanel = () => {
setHasBeenClose(true);
setIsPanelTableContentOpen(false);
};
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
// Open the panel if there are more than 1 heading
useEffect(() => {
if (headings?.length && headings.length > 1 && !hasBeenClose) {
setIsPanelTableContentOpen(true);
}
}, [setIsPanelTableContentOpen, headings, hasBeenClose]);
// Close the panel unmount
useEffect(() => {
return () => {
setIsPanelTableContentOpen(false);
};
}, [setIsPanelTableContentOpen]);
// To highlight the first heading in the viewport
useEffect(() => {
const handleScroll = () => {
if (!headings) {
return;
}
for (const heading of headings) {
const elHeading = document.body.querySelector(
`.bn-block-outer[data-id="${heading.id}"]`,
);
if (!elHeading) {
return;
}
const rect = elHeading.getBoundingClientRect();
const isVisible =
rect.top + rect.height >= 1 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight);
if (isVisible) {
setHeadingIdHighlight(heading.id);
break;
}
}
};
window.addEventListener('scroll', () => {
setTimeout(() => {
handleScroll();
}, 300);
});
handleScroll();
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [headings, setHeadingIdHighlight]);
if (!editor) {
return null;
}
// Update the headings when the editor content changes
editor?.onEditorContentChange(() => {
setHeadings(headingFiltering());
});
if (!isPanelTableContentOpen) {
return null;
}
return (
<Panel setIsPanelOpen={setClosePanel}>
<Box $padding="small" $maxHeight="95%">
<Box $overflow="auto">
{headings?.map((heading) => {
const content = heading.content?.[0];
const text = content?.type === 'text' ? content.text : '';
return (
<Heading
editor={editor}
headingId={heading.id}
level={heading.props.level}
text={text}
key={heading.id}
isHighlight={headingIdHighlight === heading.id}
/>
);
})}
</Box>
<Box
$height="1px"
$width="auto"
$background="#e5e5e5"
$margin={{ vertical: 'small' }}
$css="flex: none;"
/>
<BoxButton
onClick={() => {
editor.focus();
document.querySelector(`.bn-editor`)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Back to top')}
</Text>
</BoxButton>
<BoxButton
onClick={() => {
editor.focus();
document
.querySelector(
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Go to bottom')}
</Text>
</BoxButton>
</Box>
</Panel>
);
};
@@ -1 +0,0 @@
export * from './TableContent';
@@ -1 +0,0 @@
export * from './useDocTableContentStore';
@@ -1,15 +0,0 @@
import { create } from 'zustand';
export interface UseDocTableContentStore {
isPanelTableContentOpen: boolean;
setIsPanelTableContentOpen: (isOpen: boolean) => void;
}
export const useDocTableContentStore = create<UseDocTableContentStore>(
(set) => ({
isPanelTableContentOpen: false,
setIsPanelTableContentOpen: (isPanelTableContentOpen) => {
set(() => ({ isPanelTableContentOpen }));
},
}),
);
@@ -8,7 +8,6 @@ import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
DocsOrdering,
LinkReach,
currentDocRole,
isDocsOrdering,
useDocs,
@@ -110,7 +109,7 @@ export const DocsGrid = () => {
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
{row.link_reach === LinkReach.PUBLIC && (
{row.is_public && (
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
@@ -118,7 +117,7 @@ export const DocsGrid = () => {
$padding="xtiny"
$radius="3px"
>
{t('Public')}
{row.is_public ? t('Public') : ''}
</Text>
)}
</StyledLink>
@@ -98,7 +98,7 @@ export const InvitationList = ({ doc }: InvitationListProps) => {
<Card
$margin="tiny"
$padding="tiny"
$maxHeight="40%"
$maxHeight="60%"
$overflow="auto"
aria-label={t('List invitation card')}
>
@@ -138,7 +138,7 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
return (
<Card
$gap="1rem"
$padding={{ horizontal: 'small', vertical: 'tiny' }}
$padding="1rem"
$margin="tiny"
$direction="row"
$align="center"
@@ -93,7 +93,7 @@ export const MemberList = ({ doc }: MemberListProps) => {
<Card
$margin="tiny"
$padding="tiny"
$maxHeight="69%"
$maxHeight="85%"
$overflow="auto"
aria-label={t('List members card')}
>
@@ -0,0 +1,34 @@
import { Button } from '@openfun/cunningham-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, Text } from '@/components';
import { useAuthStore } from '@/core/auth';
export const AccountDropdown = () => {
const { t } = useTranslation();
const { logout } = useAuthStore();
return (
<DropButton
aria-label={t('My account')}
button={
<Box $flex $direction="row" $align="center">
<Text $theme="primary">{t('My account')}</Text>
<Text className="material-icons" $theme="primary">
arrow_drop_down
</Text>
</Box>
}
>
<Button
onClick={logout}
color="primary-text"
icon={<span className="material-icons">logout</span>}
aria-label={t('Logout')}
>
<Text $weight="normal">{t('Logout')}</Text>
</Button>
</DropButton>
);
};
@@ -4,11 +4,11 @@ import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, StyledLink, Text } from '@/components/';
import { AccountDropdown } from '@/core/auth';
import { useCunninghamTheme } from '@/cunningham';
import { LanguagePicker } from '../language/';
import { AccountDropdown } from './AccountDropdown';
import { LaGaufre } from './LaGaufre';
import { default as IconDocs } from './assets/icon-docs.svg?url';
@@ -189,7 +189,6 @@ export class ApiPlugin implements WorkboxPlugin {
updated_at: new Date().toISOString(),
abilities: {
destroy: true,
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
@@ -125,7 +125,7 @@
"Validate the modification": "Valider les modifications",
"Version restored successfully": "Version restaurée avec succès",
"We didn't find a mail matching, try to be more accurate": "Nous n'avons pas trouvé de correspondance par mail, essayez d'être plus précis",
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure d'audience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure daudience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
"We try to respond within 2 working days.": "Nous essayons de répondre dans les 2 jours ouvrables.",
"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.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle ou vous supprimer du document.",
"You can oppose the tracking of your browsing on this website.": "Vous pouvez vous opposer au suivi de votre navigation sur ce site.",
@@ -140,8 +140,7 @@
"accessibility-dinum-services": "<strong>DINUM</strong> s'engage à rendre accessibles ses services numériques, conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.",
"accessibility-form-defenseurdesdroits": "Écrire un message au<1>Défenseur des droits</1>",
"accessibility-not-audit": "<strong>docs.numerique.gouv.fr</strong> n'est pas en conformité avec le RGAA 4.1. Le site n'a <strong>pas encore été audité.</strong>",
"you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante.",
"Convert Markdown": "Convertir Markdown"
"you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante."
}
}
}
+1 -6
View File
@@ -21,12 +21,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
<>
<Head>
<title>{t('Docs')}</title>
<meta
name="description"
content={t(
'Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.',
)}
/>
<meta name="description" content={t('Docs Description')} />
<link rel="icon" href="/favicon.ico" sizes="any" />
</Head>
<AppProvider>{getLayout(<Component {...pageProps} />)}</AppProvider>
@@ -1,8 +1,10 @@
import { Head, Html, Main, NextScript } from 'next/document';
import '@/i18n/initI18n';
export default function RootLayout() {
return (
<Html>
<Html lang="en">
<Head />
<body suppressHydrationWarning={process.env.NODE_ENV === 'development'}>
<Main />
@@ -1,7 +1,6 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Box, Text } from '@/components';
import { TextErrors } from '@/components/TextErrors';
@@ -31,27 +30,9 @@ interface DocProps {
}
const DocPage = ({ id }: DocProps) => {
const { data: docQuery, isError, error } = useDoc({ id });
const [doc, setDoc] = useState(docQuery);
const { data: doc, isLoading, isError, error } = useDoc({ id });
const navigate = useNavigate();
useEffect(() => {
if (doc?.title) {
setTimeout(() => {
document.title = `${doc.title} - Docs`;
}, 100);
}
}, [doc?.title]);
useEffect(() => {
if (!docQuery) {
return;
}
setDoc(docQuery);
}, [docQuery]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
@@ -74,7 +55,7 @@ const DocPage = ({ id }: DocProps) => {
);
}
if (!doc) {
if (isLoading || !doc) {
return (
<Box $align="center" $justify="center" $height="100%">
<Loader />
+6 -9
View File
@@ -25,18 +25,15 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@blocknote/core": "0.15.7",
"@blocknote/mantine": "0.15.7",
"@blocknote/react": "0.15.7",
"@types/node": "20.16.5",
"@blocknote/core": "0.15.6",
"@blocknote/mantine": "0.15.6",
"@blocknote/react": "0.15.6",
"@types/node": "20.16.3",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.5.0",
"@typescript-eslint/parser": "8.5.0",
"cross-env": "7.0.3",
"eslint": "8.57.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"typescript": "5.6.2",
"yjs": "13.6.19"
"typescript": "5.5.4",
"yjs": "13.6.18"
}
}
@@ -6,20 +6,19 @@
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "14.2.11",
"@tanstack/eslint-plugin-query": "5.56.1",
"@typescript-eslint/eslint-plugin": "*",
"@typescript-eslint/parser": "*",
"eslint": "*",
"eslint-config-next": "14.2.11",
"@next/eslint-plugin-next": "14.2.7",
"@tanstack/eslint-plugin-query": "5.53.0",
"@typescript-eslint/eslint-plugin": "8.3.0",
"@typescript-eslint/parser": "8.3.0",
"eslint": "8.57.0",
"eslint-config-next": "14.2.7",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.30.0",
"eslint-plugin-jest": "28.8.3",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "28.8.2",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-playwright": "1.6.2",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.36.1",
"eslint-plugin-testing-library": "6.3.0",
"prettier": "3.3.3"
"eslint-plugin-react": "7.35.0",
"eslint-plugin-testing-library": "6.3.0"
}
}
+2 -2
View File
@@ -11,10 +11,10 @@
"test": "jest"
},
"dependencies": {
"@types/jest": "29.5.13",
"@types/jest": "29.5.12",
"@types/node": "*",
"eslint-config-impress": "*",
"eslint-plugin-import": "2.30.0",
"eslint-plugin-import": "2.29.1",
"i18next-parser": "9.0.2",
"jest": "29.7.0",
"ts-jest": "29.2.5",

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