Merge branch 'main' into sbl/user-reconciliation

This commit is contained in:
Sylvain Boissel
2026-01-28 10:38:39 +01:00
committed by GitHub
84 changed files with 2296 additions and 2362 deletions
+39
View File
@@ -19,6 +19,8 @@ jobs:
test-front:
needs: install-dependencies
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -41,6 +43,8 @@ jobs:
lint-front:
runs-on: ubuntu-latest
needs: install-dependencies
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -193,3 +197,38 @@ jobs:
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
omit-unchanged: true
install-script: "yarn install --frozen-lockfile"
uikit-theme-checker:
runs-on: ubuntu-latest
needs: install-dependencies
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Build theme
run: cd src/frontend/apps/impress && yarn build-theme
- name: Ensure theme is up to date
shell: bash
run: |
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: build-theme produced git changes (tracked or untracked)."
echo "--- git status --porcelain ---"
git status --porcelain
echo "--- git diff ---"
git --no-pager diff
exit 1
fi
+16 -7
View File
@@ -9,12 +9,9 @@ and this project adheres to
### Added
- ✨(frontend) integrate configurable Waffle #1795
### Fixed
- ✅(e2e) fix e2e test for other browsers #1799
- 🐛(frontend) add fallback for unsupported Blocknote languages #1810
- 🐛(frontend) fix emojipicker closing in tree #1808
- ✨ Import of documents #1609
- 🚨(CI) gives warning if theme not updated #1811
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
### Changed
@@ -22,11 +19,23 @@ and this project adheres to
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
- ✨(backend) add field for button label in email template #1817
### Fixed
- ✅(e2e) fix e2e test for other browsers #1799
- 🐛(export) fix export column NaN #1819
- 🐛(frontend) add fallback for unsupported Blocknote languages #1810
- 🐛(frontend) fix emojipicker closing in tree #1808
- 🐛(frontend) display children in favorite #1782
- 🐛(frontend) preserve typed text after @ on escape #1833
### Removed
- 🔥(project) remove all code related to template #1780
### Security
- 🔒️(trivy) fix vulnerability about jaraco.context #1806
## [4.4.0] - 2026-01-13
### Added
+1
View File
@@ -213,6 +213,7 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d docspec
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development
@$(COMPOSE) up --force-recreate -d nginx
+3
View File
@@ -8,6 +8,7 @@ docker_build(
dockerfile='../Dockerfile',
only=['./src/backend', './src/mail', './docker'],
target = 'backend-production',
build_args={'DOCKER_USER': '1000:1000'},
live_update=[
sync('../src/backend', '/app'),
run(
@@ -23,6 +24,7 @@ docker_build(
dockerfile='../src/frontend/servers/y-provider/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-provider',
build_args={'DOCKER_USER': '1000:1000'},
live_update=[
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
]
@@ -34,6 +36,7 @@ docker_build(
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'impress',
build_args={'DOCKER_USER': '1000:1000'},
live_update=[
sync('../src/frontend', '/home/frontend'),
]
+5
View File
@@ -231,6 +231,11 @@ services:
condition: service_healthy
restart: true
docspec:
image: ghcr.io/docspecio/api:2.6.3
ports:
- "4000:4000"
networks:
lasuite:
name: lasuite-network
+4
View File
@@ -32,6 +32,8 @@ These are the environment variables you can set for the `impress-backend` contai
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CONVERSION_FILE_MAX_SIZE | The file max size allowed when uploaded to convert it | 20971520 (20MB) |
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"]
| CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost |
@@ -54,10 +56,12 @@ These are the environment variables you can set for the `impress-backend` contai
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
+1
View File
@@ -27,6 +27,7 @@ backend:
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://docs.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_URL_APP: https://docs.127.0.0.1.nip.io
DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO
+1
View File
@@ -127,6 +127,7 @@ DJANGO_EMAIL_FROM=<your email address>
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
DJANGO_EMAIL_URL_APP=<url used in email templates to go to the app> # e.g. "https://docs.yourdomain.tld"
```
### AI
+3
View File
@@ -20,6 +20,7 @@ DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
DJANGO_EMAIL_URL_APP="http://localhost:3000"
# Backend url
IMPRESS_BASE_URL="http://localhost:8072"
@@ -76,6 +77,8 @@ DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
DOCSPEC_API_URL=http://docspec:4000/conversion
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
+1 -1
View File
@@ -6,4 +6,4 @@ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# Throttle
API_DOCUMENT_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min
+2 -1
View File
@@ -24,7 +24,8 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_URL_APP="https://${DOCS_HOST}"
# Media
AWS_S3_ENDPOINT_URL=https://${S3_HOST}
+1 -10
View File
@@ -11,14 +11,6 @@ from core import models
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
class TemplateAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
autocomplete_fields = ["user"]
model = models.TemplateAccess
extra = 0
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
@@ -71,7 +63,6 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
)
inlines = (TemplateAccessInline,)
list_display = (
"id",
"sub",
@@ -179,7 +170,7 @@ class TemplateAdmin(admin.ModelAdmin):
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
"""Inline admin class for document accesses."""
autocomplete_fields = ["user"]
model = models.DocumentAccess
+2 -2
View File
@@ -98,10 +98,10 @@ class CanCreateInvitationPermission(permissions.BasePermission):
class ResourceWithAccessPermission(permissions.BasePermission):
"""A permission class for templates and invitations."""
"""A permission class for invitations."""
def has_permission(self, request, view):
"""check create permission for templates."""
"""check create permission."""
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
+34 -72
View File
@@ -4,6 +4,7 @@
import binascii
import mimetypes
from base64 import b64decode
from os.path import splitext
from django.conf import settings
from django.db.models import Q
@@ -15,10 +16,11 @@ import magic
from rest_framework import serializers
from core import choices, enums, models, utils, validators
from core.services import mime_types
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
YdocConverter,
Converter,
)
@@ -59,30 +61,6 @@ class UserLightSerializer(UserSerializer):
read_only_fields = ["full_name", "short_name"]
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.TemplateAccess
resource_field_name = "template"
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists."""
@@ -188,6 +166,9 @@ class DocumentSerializer(ListDocumentSerializer):
content = serializers.CharField(required=False)
websocket = serializers.BooleanField(required=False, write_only=True)
file = serializers.FileField(
required=False, write_only=True, allow_null=True, max_length=255
)
class Meta:
model = models.Document
@@ -204,6 +185,7 @@ class DocumentSerializer(ListDocumentSerializer):
"deleted_at",
"depth",
"excerpt",
"file",
"is_favorite",
"link_role",
"link_reach",
@@ -273,6 +255,30 @@ class DocumentSerializer(ListDocumentSerializer):
return value
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
if not file:
return None
# Validate file size
if file.size > settings.CONVERSION_FILE_MAX_SIZE:
max_size = settings.CONVERSION_FILE_MAX_SIZE // (1024 * 1024)
raise serializers.ValidationError(
f"File size exceeds the maximum limit of {max_size:d} MB."
)
_name, extension = splitext(file.name)
if extension.lower() not in settings.CONVERSION_FILE_EXTENSIONS_ALLOWED:
raise serializers.ValidationError(
(
f"File extension {extension} is not allowed. Allowed extensions"
f" are: {settings.CONVERSION_FILE_EXTENSIONS_ALLOWED}."
)
)
return file
def save(self, **kwargs):
"""
Process the content field to extract attachment keys and update the document's
@@ -461,7 +467,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
language = user.language or language
try:
document_content = YdocConverter().convert(validated_data["content"])
document_content = Converter().convert(
validated_data["content"], mime_types.MARKDOWN, mime_types.YJS
)
except ConversionError as err:
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
@@ -660,52 +668,6 @@ class FileUploadSerializer(serializers.Serializer):
return attrs
class TemplateSerializer(serializers.ModelSerializer):
"""Serialize templates."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TemplateAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Template
fields = [
"id",
"title",
"accesses",
"abilities",
"css",
"code",
"is_public",
]
read_only_fields = ["id", "accesses", "abilities"]
def get_abilities(self, document) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return document.get_abilities(request.user)
return {}
# pylint: disable=abstract-method
class DocumentGenerationSerializer(serializers.Serializer):
"""Serializer to receive a request to generate a document on a template."""
body = serializers.CharField(label=_("Body"))
body_type = serializers.ChoiceField(
choices=["html", "markdown"],
label=_("Body type"),
required=False,
default="html",
)
format = serializers.ChoiceField(
choices=["pdf", "docx"],
label=_("Format"),
required=False,
default="pdf",
)
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
+52 -67
View File
@@ -45,17 +45,19 @@ from rest_framework.views import APIView
from core import authentication, choices, enums, models
from core.api.filters import remove_accents
from core.services import mime_types
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.services.converter_services import (
ConversionError,
Converter,
)
from core.services.converter_services import (
ServiceUnavailableError as YProviderServiceUnavailableError,
)
from core.services.converter_services import (
ValidationError as YProviderValidationError,
)
from core.services.converter_services import (
YdocConverter,
)
from core.services.search_indexers import (
get_document_indexer,
get_visited_document_ids_of,
@@ -582,6 +584,28 @@ class DocumentViewSet(
"IN SHARE ROW EXCLUSIVE MODE;"
)
# Remove file from validated_data as it's not a model field
# Process it if present
uploaded_file = serializer.validated_data.pop("file", None)
# If a file is uploaded, convert it to Yjs format and set as content
if uploaded_file:
try:
file_content = uploaded_file.read()
converter = Converter()
converted_content = converter.convert(
file_content,
content_type=uploaded_file.content_type,
accept=mime_types.YJS,
)
serializer.validated_data["content"] = converted_content
serializer.validated_data["title"] = uploaded_file.name
except ConversionError as err:
raise drf.exceptions.ValidationError(
{"file": ["Could not convert file content"]}
) from err
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
@@ -683,12 +707,29 @@ class DocumentViewSet(
"""Get list of favorite documents for the current user."""
user = request.user
queryset = self.get_queryset()
# Among the results, we may have documents that are ancestors/descendants
# of each other. In this case we want to keep only the highest ancestors.
root_paths = utils.filter_root_paths(
queryset.order_by("path").values_list("path", flat=True),
skip_sorting=True,
)
path_list = db.Q()
for path in root_paths:
path_list |= db.Q(path__startswith=path)
favorite_documents_ids = models.DocumentFavorite.objects.filter(
user=user
).values_list("document_id", flat=True)
queryset = self.filter_queryset(self.get_queryset())
queryset = self.queryset.filter(path_list)
queryset = queryset.filter(id__in=favorite_documents_ids)
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate(
is_favorite=db.Value(True, output_field=db.BooleanField())
)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
@@ -1919,14 +1960,14 @@ class DocumentViewSet(
if base64_content is not None:
# Convert using the y-provider service
try:
yprovider = YdocConverter()
yprovider = Converter()
result = yprovider.convert(
base64.b64decode(base64_content),
"application/vnd.yjs.doc",
mime_types.YJS,
{
"markdown": "text/markdown",
"html": "text/html",
"json": "application/json",
"markdown": mime_types.MARKDOWN,
"html": mime_types.HTML,
"json": mime_types.JSON,
}[content_format],
)
content = result
@@ -2147,64 +2188,6 @@ class DocumentAccessViewSet(
)
class TemplateViewSet(
drf.mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""Template ViewSet"""
filter_backends = [drf.filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.ResourceWithAccessPermission,
]
throttle_scope = "template"
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
serializer_class = serializers.TemplateSerializer
queryset = models.Template.objects.all()
def get_queryset(self):
"""Custom queryset to get user related templates."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
models.TemplateAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
template_id=db.OuterRef("pk"),
)
.values("template")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=db.Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| db.Q(is_public=True)
)
else:
queryset = queryset.filter(is_public=True)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
class InvitationViewset(
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
@@ -2412,6 +2395,8 @@ class ConfigView(drf.views.APIView):
"AI_FEATURE_ENABLED",
"COLLABORATION_WS_URL",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
"CONVERSION_FILE_MAX_SIZE",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
-53
View File
@@ -53,15 +53,6 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner")
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
Create a template for which the user is owner to check
that there is no interference
"""
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
class ParentNodeFactory(factory.declarations.ParameteredAttribute):
"""Custom factory attribute for setting the parent node."""
@@ -202,50 +193,6 @@ class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TemplateFactory(factory.django.DjangoModelFactory):
"""A factory to create templates"""
class Meta:
model = models.Template
django_get_or_create = ("title",)
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"template{n}")
is_public = factory.Faker("boolean")
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to template from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
UserTemplateAccessFactory(template=self, user=item)
else:
UserTemplateAccessFactory(template=self, user=item[0], role=item[1])
class UserTemplateAccessFactory(factory.django.DjangoModelFactory):
"""Create fake template user accesses for testing."""
class Meta:
model = models.TemplateAccess
template = factory.SubFactory(TemplateFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TeamTemplateAccessFactory(factory.django.DjangoModelFactory):
"""Create fake template team accesses for testing."""
class Meta:
model = models.TemplateAccess
template = factory.SubFactory(TemplateFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""
@@ -0,0 +1,26 @@
# Generated by Django 5.2.9 on 2026-01-09 14:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0027_auto_20251120_0956"),
]
operations = [
migrations.RemoveField(
model_name="templateaccess",
name="template",
),
migrations.RemoveField(
model_name="templateaccess",
name="user",
),
migrations.DeleteModel(
name="Template",
),
migrations.DeleteModel(
name="TemplateAccess",
),
]
+1 -158
View File
@@ -1098,7 +1098,7 @@ class Document(MP_Node, BaseModel):
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email from a template."""
context = context or {}
domain = Site.objects.get_current().domain
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
@@ -1711,163 +1711,6 @@ class Reaction(BaseModel):
return f"Reaction {self.emoji} on comment {self.comment.id}"
class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""
title = models.CharField(_("title"), max_length=255)
description = models.TextField(_("description"), blank=True)
code = models.TextField(_("code"), blank=True)
css = models.TextField(_("css"), blank=True)
is_public = models.BooleanField(
_("public"),
default=False,
help_text=_("Whether this template is public for anyone to use."),
)
class Meta:
db_table = "impress_template"
ordering = ("title",)
verbose_name = _("Template")
verbose_name_plural = _("Templates")
def __str__(self):
return self.title
def get_role(self, user):
"""Return the roles a user has on a resource as an iterable."""
if not user.is_authenticated:
return None
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return RoleChoices.max(*roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template.
"""
role = self.get_role(user)
is_owner_or_admin = role in PRIVILEGED_ROLES
can_get = self.is_public or bool(role)
can_update = is_owner_or_admin or role == RoleChoices.EDITOR
return {
"destroy": role == RoleChoices.OWNER,
"generate_document": can_get,
"accesses_manage": is_owner_or_admin,
"update": can_update,
"partial_update": can_update,
"retrieve": can_get,
}
class TemplateAccess(BaseAccess):
"""Relation model to give access to a template for a user or a team with a role."""
template = models.ForeignKey(
Template,
on_delete=models.CASCADE,
related_name="accesses",
)
class Meta:
db_table = "impress_template_access"
ordering = ("-created_at",)
verbose_name = _("Template/user relation")
verbose_name_plural = _("Template/user relations")
constraints = [
models.UniqueConstraint(
fields=["user", "template"],
condition=models.Q(user__isnull=False), # Exclude null users
name="unique_template_user",
violation_error_message=_("This user is already in this template."),
),
models.UniqueConstraint(
fields=["team", "template"],
condition=models.Q(team__gt=""), # Exclude empty string teams
name="unique_template_team",
violation_error_message=_("This team is already in this template."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_template_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
),
]
def __str__(self):
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
def get_role(self, user):
"""
Get the role a user has on a resource.
"""
if not user.is_authenticated:
return None
try:
roles = self.user_roles or []
except AttributeError:
teams = user.teams
try:
roles = self.template.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (Template.DoesNotExist, IndexError):
roles = []
return RoleChoices.max(*roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template access.
"""
role = self.get_role(user)
is_owner_or_admin = role in PRIVILEGED_ROLES
if self.role == RoleChoices.OWNER:
can_delete = (role == RoleChoices.OWNER) and self.template.accesses.filter(
role=RoleChoices.OWNER
).count() > 1
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(role),
"set_role_to": set_role_to,
}
class Invitation(BaseModel):
"""User invitation to a document."""
+97 -16
View File
@@ -1,11 +1,17 @@
"""Y-Provider API services."""
import logging
import typing
from base64 import b64encode
from django.conf import settings
import requests
from core.services import mime_types
logger = logging.getLogger(__name__)
class ConversionError(Exception):
"""Base exception for conversion-related errors."""
@@ -19,8 +25,81 @@ class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable."""
class ConverterProtocol(typing.Protocol):
"""Protocol for converter classes."""
def convert(self, data, content_type, accept):
"""Convert content from one format to another."""
class Converter:
"""Orchestrates conversion between different formats using specialized converters."""
docspec: ConverterProtocol
ydoc: ConverterProtocol
def __init__(self):
self.docspec = DocSpecConverter()
self.ydoc = YdocConverter()
def convert(self, data, content_type, accept):
"""Convert input into other formats using external microservices."""
if content_type == mime_types.DOCX and accept == mime_types.YJS:
blocknote_data = self.docspec.convert(
data, mime_types.DOCX, mime_types.BLOCKNOTE
)
return self.ydoc.convert(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
return self.ydoc.convert(data, content_type, accept)
class DocSpecConverter:
"""Service class for DocSpec conversion-related operations."""
def _request(self, url, data, content_type):
"""Make a request to the DocSpec API."""
response = requests.post(
url,
headers={"Accept": mime_types.BLOCKNOTE},
files={"file": ("document.docx", data, content_type)},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
if not response.ok:
logger.error(
"DocSpec API error: url=%s, status=%d, response=%s",
url,
response.status_code,
response.text[:200] if response.text else "empty",
)
response.raise_for_status()
return response
def convert(self, data, content_type, accept):
"""Convert a Document to BlockNote."""
if not data:
raise ValidationError("Input data cannot be empty")
if content_type != mime_types.DOCX or accept != mime_types.BLOCKNOTE:
raise ValidationError(
f"Conversion from {content_type} to {accept} is not supported."
)
try:
return self._request(settings.DOCSPEC_API_URL, data, content_type).content
except requests.RequestException as err:
logger.exception("DocSpec service error: url=%s", settings.DOCSPEC_API_URL)
raise ServiceUnavailableError(
"Failed to connect to DocSpec conversion service",
) from err
class YdocConverter:
"""Service class for conversion-related operations."""
"""Service class for YDoc conversion-related operations."""
@property
def auth_header(self):
@@ -41,32 +120,34 @@ class YdocConverter:
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
if not response.ok:
logger.error(
"Y-Provider API error: url=%s, status=%d, response=%s",
url,
response.status_code,
response.text[:200] if response.text else "empty",
)
response.raise_for_status()
return response
def convert(
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
):
def convert(self, data, content_type=mime_types.MARKDOWN, accept=mime_types.YJS):
"""Convert a Markdown text into our internal format using an external microservice."""
if not text:
raise ValidationError("Input text cannot be empty")
if not data:
raise ValidationError("Input data cannot be empty")
url = f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/"
try:
response = self._request(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
text,
content_type,
accept,
)
if accept == "application/vnd.yjs.doc":
response = self._request(url, data, content_type, accept)
if accept == mime_types.YJS:
return b64encode(response.content).decode("utf-8")
if accept in {"text/markdown", "text/html"}:
if accept in {mime_types.MARKDOWN, "text/html"}:
return response.text
if accept == "application/json":
if accept == mime_types.JSON:
return response.json()
raise ValidationError("Unsupported format")
except requests.RequestException as err:
logger.exception("Y-Provider service error: url=%s", url)
raise ServiceUnavailableError(
"Failed to connect to conversion service",
f"Failed to connect to YDoc conversion service {content_type}, {accept}",
) from err
+8
View File
@@ -0,0 +1,8 @@
"""MIME type constants for document conversion."""
BLOCKNOTE = "application/vnd.blocknote+json"
YJS = "application/vnd.yjs.doc"
MARKDOWN = "text/markdown"
JSON = "application/json"
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
HTML = "text/html"
@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>
+1 -1
View File
@@ -1,4 +1,4 @@
"""Custom template tags for the core application of People."""
"""Custom template tags for the core application of Docs."""
import base64
@@ -16,6 +16,7 @@ from rest_framework.test import APIClient
from core import factories
from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User
from core.services import mime_types
from core.services.converter_services import ConversionError, YdocConverter
pytestmark = pytest.mark.django_db
@@ -191,7 +192,9 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -236,7 +239,9 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -297,7 +302,9 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -393,7 +400,9 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -474,7 +483,9 @@ def test_api_documents_create_for_owner_with_default_language(
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert mock_send.call_args[0][3] == "de-de"
@@ -501,7 +512,9 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -537,7 +550,9 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -571,7 +586,9 @@ def test_api_documents_create_for_owner_with_converter_exception(
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}
@@ -0,0 +1,413 @@
"""
Tests for Documents API endpoint in impress's core app: create with file upload
"""
from base64 import b64decode, binascii
from io import BytesIO
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Document
from core.services import mime_types
from core.services.converter_services import (
ConversionError,
ServiceUnavailableError,
)
pytestmark = pytest.mark.django_db
def test_api_documents_create_with_file_anonymous():
"""Anonymous users should not be allowed to create documents with file upload."""
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "test_document.docx"
response = APIClient().post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 401
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_docx_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a DOCX file.
The file should be converted to YJS format and the title should be set from filename.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "My Important Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "My Important Document.docx"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.DOCX,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_markdown_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a Markdown file.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake Markdown file
file_content = b"# Test Document\n\nThis is a test."
file = BytesIO(file_content)
file.name = "readme.md"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "readme.md"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.MARKDOWN,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_and_explicit_title(mock_convert):
"""
When both file and title are provided, the filename should override the title.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "Uploaded Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
"title": "This should be overridden",
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# The filename should take precedence
assert document.title == "Uploaded Document.docx"
def test_api_documents_create_with_empty_file():
"""
Creating a document with an empty file should fail with a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create an empty file
file = BytesIO(b"")
file.name = "empty.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_conversion_error(mock_convert):
"""
When conversion fails, the API should return a 400 error with appropriate message.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise an error
mock_convert.side_effect = ConversionError("Failed to convert document")
# Create a fake DOCX file
file_content = b"fake invalid docx content"
file = BytesIO(file_content)
file.name = "corrupted.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_service_unavailable(mock_convert):
"""
When the conversion service is unavailable, appropriate error should be returned.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise ServiceUnavailableError
mock_convert.side_effect = ServiceUnavailableError(
"Failed to connect to conversion service"
)
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
def test_api_documents_create_without_file_still_works():
"""
Creating a document without a file should still work as before (backward compatibility).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Regular document without file",
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Regular document without file"
assert document.content is None
assert document.accesses.filter(role="owner", user=user).exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_null_value(mock_convert):
"""
Passing file=null should be treated as no file upload.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Document with null file",
"file": None,
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Document with null file"
# Converter should not have been called
mock_convert.assert_not_called()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_preserves_content_format(mock_convert):
"""
Verify that the converted content is stored correctly in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion with realistic base64-encoded YJS data
converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA="
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx with complex formatting"
file = BytesIO(file_content)
file.name = "complex_document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# Verify the content is stored as returned by the converter
assert document.content == converted_yjs
# Verify it's valid base64 (can be decoded)
try:
b64decode(converted_yjs)
except binascii.Error:
pytest.fail("Content should be valid base64-encoded data")
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_unicode_filename(mock_convert):
"""
Test that Unicode characters in filenames are handled correctly.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a file with Unicode characters in the name
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "文档-télécharger-документ.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "文档-télécharger-документ.docx"
def test_api_documents_create_with_file_max_size_exceeded(settings):
"""
The uploaded file should not exceed the maximum size in settings.
"""
settings.CONVERSION_FILE_MAX_SIZE = 1 # 1 byte for test
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = BytesIO(b"a" * (10))
file.name = "test.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 0 MB."]}
def test_api_documents_create_with_file_extension_not_allowed(settings):
"""
The uploaded file should not have an allowed extension.
"""
settings.CONVERSION_FILE_EXTENSIONS_ALLOWED = [".docx"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = BytesIO(b"fake docx content")
file.name = "test.md"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {
"file": [
"File extension .md is not allowed. Allowed extensions are: ['.docx']."
]
}
@@ -83,3 +83,34 @@ def test_api_document_favorite_list_authenticated_with_favorite():
}
],
}
def test_api_document_favorite_list_with_favorite_children():
"""Authenticated users should receive their favorite documents, including children."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
root = factories.DocumentFactory(creator=user, users=[user])
children = factories.DocumentFactory.create_batch(
2, parent=root, favorited_by=[user]
)
access = factories.UserDocumentAccessFactory(
user=user, role=models.RoleChoices.READER, document__favorited_by=[user]
)
other_root = factories.DocumentFactory(creator=user, users=[user])
factories.DocumentFactory.create_batch(2, parent=other_root)
response = client.get("/api/v1.0/documents/favorite_list/")
assert response.status_code == 200
assert response.json()["count"] == 3
content = response.json()["results"]
assert content[0]["id"] == str(children[0].id)
assert content[1]["id"] == str(children[1].id)
assert content[2]["id"] == str(access.document.id)
@@ -1,46 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: create
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Template
pytestmark = pytest.mark.django_db
def test_api_templates_create_anonymous():
"""Anonymous users should not be allowed to create templates."""
response = APIClient().post(
"/api/v1.0/templates/",
{
"title": "my template",
},
)
assert response.status_code == 401
assert not Template.objects.exists()
def test_api_templates_create_authenticated():
"""
Authenticated users should be able to create templates and should automatically be declared
as the owner of the newly created template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/templates/",
{
"title": "my template",
},
format="json",
)
assert response.status_code == 405
assert not Template.objects.exists()
@@ -1,45 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: delete
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_templates_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template."""
template = factories.TemplateFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 401
assert models.Template.objects.count() == 1
def test_api_templates_delete_not_implemented():
"""
Authenticated users should not be allowed to delete a template to which they are not
related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
is_public = random.choice([True, False])
template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")])
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 405
assert models.Template.objects.count() == 1
@@ -1,237 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: list
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_templates_list_anonymous():
"""Anonymous users should only be able to list public templates."""
factories.TemplateFactory.create_batch(2, is_public=False)
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
expected_ids = {str(template.id) for template in public_templates}
response = APIClient().get("/api/v1.0/templates/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_templates_list_authenticated_direct():
"""
Authenticated users should be able to list templates they are a direct
owner/administrator/member of or that are public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
related_templates = [
access.template
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
factories.TemplateFactory.create_batch(2, is_public=False)
expected_ids = {
str(template.id) for template in related_templates + public_templates
}
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_templates_list_authenticated_via_team(mock_user_teams):
"""
Authenticated users should be able to list templates they are a
owner/administrator/member of via a team or that are public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
templates_team1 = [
access.template
for access in factories.TeamTemplateAccessFactory.create_batch(2, team="team1")
]
templates_team2 = [
access.template
for access in factories.TeamTemplateAccessFactory.create_batch(3, team="team2")
]
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
factories.TemplateFactory.create_batch(2, is_public=False)
expected_ids = {
str(template.id)
for template in templates_team1 + templates_team2 + public_templates
}
response = client.get("/api/v1.0/templates/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_templates_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template_ids = [
str(access.template_id)
for access in factories.UserTemplateAccessFactory.create_batch(3, user=user)
]
# Get page 1
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/templates/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
template_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/templates/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/templates/"
assert len(content["results"]) == 1
template_ids.remove(content["results"][0]["id"])
assert template_ids == []
def test_api_templates_list_authenticated_distinct():
"""A template with several related users should only be listed once."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory(users=[user, other_user], is_public=True)
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(template.id)
def test_api_templates_list_order_default():
"""The templates list should be sorted by 'created_at' in descending order by default."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template_ids = [
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
response_data = response.json()
response_template_ids = [template["id"] for template in response_data["results"]]
template_ids.reverse()
assert response_template_ids == template_ids, (
"created_at values are not sorted from newest to oldest"
)
def test_api_templates_list_order_param():
"""
The templates list is sorted by 'created_at' in ascending order when setting
the "ordering" query parameter.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
templates_ids = [
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
response = client.get(
"/api/v1.0/templates/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_template_ids = [template["id"] for template in response_data["results"]]
assert response_template_ids == templates_ids, (
"created_at values are not sorted from oldest to newest"
)
def test_api_template_throttling(settings):
"""Test api template throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/templates/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/templates/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = current_rate
@@ -1,522 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_templates_retrieve_anonymous_public():
"""Anonymous users should be allowed to retrieve public templates."""
template = factories.TemplateFactory(is_public=True)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(template.id),
"abilities": {
"destroy": False,
"generate_document": True,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
},
"accesses": [],
"title": template.title,
"is_public": True,
"code": template.code,
"css": template.css,
}
def test_api_templates_retrieve_anonymous_not_public():
"""Anonymous users should not be able to retrieve a template that is not public."""
template = factories.TemplateFactory(is_public=False)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_templates_retrieve_authenticated_unrelated_public():
"""
Authenticated users should be able to retrieve a public template to which they are
not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(template.id),
"abilities": {
"destroy": False,
"generate_document": True,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
},
"accesses": [],
"title": template.title,
"is_public": True,
"code": template.code,
"css": template.css,
}
def test_api_templates_retrieve_authenticated_unrelated_not_public():
"""
Authenticated users should not be allowed to retrieve a template that is not public and
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_templates_retrieve_authenticated_related_direct():
"""
Authenticated users should be allowed to retrieve a template to which they
are directly related whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access1 = factories.UserTemplateAccessFactory(template=template, user=user)
access2 = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
[
{
"id": str(access1.id),
"user": str(user.id),
"team": "",
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["user"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": template.is_public,
"code": template.code,
"css": template.css,
}
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams):
"""
Authenticated users should not be able to retrieve a template related to teams in
which the user is not.
"""
mock_user_teams.return_value = []
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
factories.TeamTemplateAccessFactory(template=template, team="owners", role="owner")
factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"teams",
[
["readers"],
["unknown", "readers"],
["editors"],
["unknown", "editors"],
],
)
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
access_owner = factories.TeamTemplateAccessFactory(
template=template, team="owners", role="owner"
)
other_access = factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
content = response.json()
expected_abilities = {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"abilities": expected_abilities,
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": access_administrator.role,
"abilities": expected_abilities,
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": access_owner.role,
"abilities": expected_abilities,
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": expected_abilities,
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": False,
"code": template.code,
"css": template.css,
}
@pytest.mark.parametrize(
"teams",
[
["administrators"],
["members", "administrators"],
["unknown", "administrators"],
],
)
def test_api_templates_retrieve_authenticated_related_team_administrators(
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
access_owner = factories.TeamTemplateAccessFactory(
template=template, team="owners", role="owner"
)
other_access = factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": False,
"code": template.code,
"css": template.css,
}
@pytest.mark.parametrize(
"teams",
[
["owners"],
["owners", "administrators"],
["members", "administrators", "owners"],
["unknown", "owners"],
],
)
def test_api_templates_retrieve_authenticated_related_team_owners(
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
access_owner = factories.TeamTemplateAccessFactory(
template=template, team="owners", role="owner"
)
other_access = factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": False,
"code": template.code,
"css": template.css,
}
@@ -1,54 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: update
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_templates_update_anonymous():
"""Anonymous users should not be allowed to update a template."""
template = factories.TemplateFactory()
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = APIClient().put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 401
def test_api_templates_update_not_implemented():
"""
Authenticated users should not be allowed to update a template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(users=[(user, "owner")])
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 405
response = client.patch(
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 405
@@ -46,6 +46,8 @@ def test_api_config(is_authenticated):
"AI_FEATURE_ENABLED": False,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
"CONVERSION_FILE_MAX_SIZE": 20971520,
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
@@ -1024,6 +1024,39 @@ def test_models_documents__email_invitation__success():
assert f"docs/{document.id}/" in email_content
@pytest.mark.parametrize(
"email_url_app",
[
"https://test-example.com", # Test with EMAIL_URL_APP set
None, # Test fallback to Site domain
],
)
def test_models_documents__email_invitation__url_app_param(email_url_app):
"""
Test that email invitation uses EMAIL_URL_APP when set, or falls back to Site domain.
"""
with override_settings(EMAIL_URL_APP=email_url_app):
document = factories.DocumentFactory()
sender = factories.UserFactory(
full_name="Test Sender", email="[email protected]"
)
document.send_invitation_email(
"[email protected]", models.RoleChoices.EDITOR, sender, "en"
)
# pylint: disable-next=no-member
email = mail.outbox[0]
email_content = " ".join(email.body.split())
# Determine expected domain
if email_url_app:
assert f"https://test-example.com/docs/{document.id}/" in email_content
else:
# Default Site domain is example.com
assert f"example.com/docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_empty_title():
"""
The email invitation is sent successfully.
@@ -1,419 +0,0 @@
"""
Unit tests for the TemplateAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_template_accesses_str():
"""
The str representation should include user email, template title and role.
"""
user = factories.UserFactory(email="[email protected]")
access = factories.UserTemplateAccessFactory(
role="reader",
user=user,
template__title="admins",
)
assert str(access) == "[email protected] is reader in template admins"
def test_models_template_accesses_unique_user():
"""Template accesses should be unique for a given couple of user and template."""
access = factories.UserTemplateAccessFactory()
with pytest.raises(
ValidationError,
match="This user is already in this template.",
):
factories.UserTemplateAccessFactory(user=access.user, template=access.template)
def test_models_template_accesses_several_empty_teams():
"""A template can have several template accesses with an empty team."""
access = factories.UserTemplateAccessFactory()
factories.UserTemplateAccessFactory(template=access.template)
def test_models_template_accesses_unique_team():
"""Template accesses should be unique for a given couple of team and template."""
access = factories.TeamTemplateAccessFactory()
with pytest.raises(
ValidationError,
match="This team is already in this template.",
):
factories.TeamTemplateAccessFactory(team=access.team, template=access.template)
def test_models_template_accesses_several_null_users():
"""A template can have several template accesses with a null user."""
access = factories.TeamTemplateAccessFactory()
factories.TeamTemplateAccessFactory(template=access.template)
def test_models_template_accesses_user_and_team_set():
"""User and team can't both be set on a template access."""
with pytest.raises(
ValidationError,
match="Either user or team must be set, not both.",
):
factories.UserTemplateAccessFactory(team="my-team")
def test_models_template_accesses_user_and_team_empty():
"""User and team can't both be empty on a template access."""
with pytest.raises(
ValidationError,
match="Either user or team must be set, not both.",
):
factories.UserTemplateAccessFactory(user=None)
# get_abilities
def test_models_template_access_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
access = factories.UserTemplateAccessFactory()
abilities = access.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
access = factories.UserTemplateAccessFactory()
user = factories.UserFactory()
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
# - for owner
def test_models_template_access_get_abilities_for_owner_of_self_allowed():
"""
Check abilities of self access for the owner of a template when
there is more than one owner left.
"""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template, role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
}
def test_models_template_access_get_abilities_for_owner_of_self_last():
"""
Check abilities of self access for the owner of a template when there is only one owner left.
"""
access = factories.UserTemplateAccessFactory(role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_owner_of_owner():
"""Check abilities of owner access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
}
def test_models_template_access_get_abilities_for_owner_of_administrator():
"""Check abilities of administrator access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "editor", "reader"],
}
def test_models_template_access_get_abilities_for_owner_of_editor():
"""Check abilities of editor access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "reader"],
}
def test_models_template_access_get_abilities_for_owner_of_reader():
"""Check abilities of reader access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "editor"],
}
# - for administrator
def test_models_template_access_get_abilities_for_administrator_of_owner():
"""Check abilities of owner access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_administrator_of_administrator():
"""Check abilities of administrator access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["editor", "reader"],
}
def test_models_template_access_get_abilities_for_administrator_of_editor():
"""Check abilities of editor access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "reader"],
}
def test_models_template_access_get_abilities_for_administrator_of_reader():
"""Check abilities of reader access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor"],
}
# - For editor
def test_models_template_access_get_abilities_for_editor_of_owner():
"""Check abilities of owner access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="editor"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_editor_of_administrator():
"""Check abilities of administrator access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="editor"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_editor_of_editor_user(
django_assert_num_queries,
):
"""Check abilities of editor access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="editor"
).user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
# - For reader
def test_models_template_access_get_abilities_for_reader_of_owner():
"""Check abilities of owner access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_reader_of_administrator():
"""Check abilities of administrator access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries,
):
"""Check abilities of reader access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset, e.g., with a query annotation."""
access = factories.UserTemplateAccessFactory(role="reader")
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
access.user_roles = ["reader"]
with django_assert_num_queries(0):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@@ -1,187 +0,0 @@
"""
Unit tests for the Template model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_templates_str():
"""The str representation should be the title of the template."""
template = factories.TemplateFactory(title="admins")
assert str(template) == "admins"
def test_models_templates_id_unique():
"""The "id" field should be unique."""
template = factories.TemplateFactory()
with pytest.raises(ValidationError, match="Template with this Id already exists."):
factories.TemplateFactory(id=template.id)
def test_models_templates_title_null():
"""The "title" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Template.objects.create(title=None)
def test_models_templates_title_empty():
"""The "title" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Template.objects.create(title="")
def test_models_templates_title_max_length():
"""The "title" field should be 100 characters maximum."""
factories.TemplateFactory(title="a" * 255)
with pytest.raises(
ValidationError,
match=r"Ensure this value has at most 255 characters \(it has 256\)\.",
):
factories.TemplateFactory(title="a" * 256)
# get_abilities
def test_models_templates_get_abilities_anonymous_public():
"""Check abilities returned for an anonymous user if the template is public."""
template = factories.TemplateFactory(is_public=True)
abilities = template.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
def test_models_templates_get_abilities_anonymous_not_public():
"""Check abilities returned for an anonymous user if the template is private."""
template = factories.TemplateFactory(is_public=False)
abilities = template.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
def test_models_templates_get_abilities_authenticated_public():
"""Check abilities returned for an authenticated user if the user is public."""
template = factories.TemplateFactory(is_public=True)
abilities = template.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
def test_models_templates_get_abilities_authenticated_not_public():
"""Check abilities returned for an authenticated user if the template is private."""
template = factories.TemplateFactory(is_public=False)
abilities = template.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
def test_models_templates_get_abilities_owner():
"""Check abilities returned for the owner of a template."""
user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(role="owner", user=user)
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
def test_models_templates_get_abilities_administrator():
"""Check abilities returned for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"""Check abilities returned for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
with django_assert_num_queries(1):
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": True,
"accesses_manage": False,
"partial_update": True,
"generate_document": True,
}
def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"""Check abilities returned for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
with django_assert_num_queries(1):
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset e.g. with query annotation."""
access = factories.UserTemplateAccessFactory(role="reader")
access.template.user_roles = ["reader"]
with django_assert_num_queries(0):
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -0,0 +1,93 @@
"""Test Converter orchestration services."""
from unittest.mock import MagicMock, patch
from core.services import mime_types
from core.services.converter_services import Converter
@patch("core.services.converter_services.DocSpecConverter")
@patch("core.services.converter_services.YdocConverter")
def test_converter_docx_to_yjs_orchestration(mock_ydoc_class, mock_docspec_class):
"""Test that DOCX to YJS conversion uses both DocSpec and Ydoc converters."""
# Setup mocks
mock_docspec = MagicMock()
mock_ydoc = MagicMock()
mock_docspec_class.return_value = mock_docspec
mock_ydoc_class.return_value = mock_ydoc
# Mock the conversion chain: DOCX -> BlockNote -> YJS
blocknote_data = b'[{"type": "paragraph", "content": "test"}]'
yjs_data = "base64encodedyjs"
mock_docspec.convert.return_value = blocknote_data
mock_ydoc.convert.return_value = yjs_data
# Execute conversion
converter = Converter()
docx_data = b"fake docx data"
result = converter.convert(docx_data, mime_types.DOCX, mime_types.YJS)
# Verify the orchestration
mock_docspec.convert.assert_called_once_with(
docx_data, mime_types.DOCX, mime_types.BLOCKNOTE
)
mock_ydoc.convert.assert_called_once_with(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
assert result == yjs_data
@patch("core.services.converter_services.YdocConverter")
def test_converter_markdown_to_yjs_delegation(mock_ydoc_class):
"""Test that Markdown to YJS conversion is delegated to YdocConverter."""
mock_ydoc = MagicMock()
mock_ydoc_class.return_value = mock_ydoc
yjs_data = "base64encodedyjs"
mock_ydoc.convert.return_value = yjs_data
converter = Converter()
markdown_data = "# Test Document"
result = converter.convert(markdown_data, mime_types.MARKDOWN, mime_types.YJS)
mock_ydoc.convert.assert_called_once_with(
markdown_data, mime_types.MARKDOWN, mime_types.YJS
)
assert result == yjs_data
@patch("core.services.converter_services.YdocConverter")
def test_converter_yjs_to_html_delegation(mock_ydoc_class):
"""Test that YJS to HTML conversion is delegated to YdocConverter."""
mock_ydoc = MagicMock()
mock_ydoc_class.return_value = mock_ydoc
html_data = "<p>Test Document</p>"
mock_ydoc.convert.return_value = html_data
converter = Converter()
yjs_data = b"yjs binary data"
result = converter.convert(yjs_data, mime_types.YJS, mime_types.HTML)
mock_ydoc.convert.assert_called_once_with(yjs_data, mime_types.YJS, mime_types.HTML)
assert result == html_data
@patch("core.services.converter_services.YdocConverter")
def test_converter_blocknote_to_yjs_delegation(mock_ydoc_class):
"""Test that BlockNote to YJS conversion is delegated to YdocConverter."""
mock_ydoc = MagicMock()
mock_ydoc_class.return_value = mock_ydoc
yjs_data = "base64encodedyjs"
mock_ydoc.convert.return_value = yjs_data
converter = Converter()
blocknote_data = b'[{"type": "paragraph"}]'
result = converter.convert(blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS)
mock_ydoc.convert.assert_called_once_with(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
assert result == yjs_data
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services import mime_types
from core.services.converter_services import (
ServiceUnavailableError,
ValidationError,
@@ -21,9 +22,9 @@ def test_auth_header(settings):
def test_convert_empty_text():
"""Should raise ValidationError when text is empty."""
"""Should raise ValidationError when data is empty."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert("")
@@ -36,7 +37,7 @@ def test_convert_service_unavailable(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
match="Failed to connect to YDoc conversion service",
):
converter.convert("test text")
@@ -52,7 +53,7 @@ def test_convert_http_error(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
match="Failed to connect to YDoc conversion service",
):
converter.convert("test text")
@@ -83,8 +84,8 @@ def test_convert_full_integration(mock_post, settings):
data="test markdown",
headers={
"Authorization": "Bearer test-key",
"Content-Type": "text/markdown",
"Accept": "application/vnd.yjs.doc",
"Content-Type": mime_types.MARKDOWN,
"Accept": mime_types.YJS,
},
timeout=5,
verify=False,
@@ -108,9 +109,7 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
result = converter.convert(
b"test_content", "application/vnd.yjs.doc", "text/markdown"
)
result = converter.convert(b"test_content", mime_types.YJS, mime_types.MARKDOWN)
assert result == expected_response
mock_post.assert_called_once_with(
@@ -118,8 +117,8 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
data=b"test_content",
headers={
"Authorization": "Bearer test-key",
"Content-Type": "application/vnd.yjs.doc",
"Accept": "text/markdown",
"Content-Type": mime_types.YJS,
"Accept": mime_types.MARKDOWN,
},
timeout=5,
verify=False,
@@ -135,7 +134,7 @@ def test_convert_timeout(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
match="Failed to connect to YDoc conversion service",
):
converter.convert("test text")
@@ -144,5 +143,5 @@ def test_convert_none_input():
"""Should raise ValidationError when input is None."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert(None)
@@ -0,0 +1,117 @@
"""Test DocSpec converter services."""
from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services import mime_types
from core.services.converter_services import (
DocSpecConverter,
ServiceUnavailableError,
ValidationError,
)
def test_docspec_convert_empty_data():
"""Should raise ValidationError when data is empty."""
converter = DocSpecConverter()
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert("", mime_types.DOCX, mime_types.BLOCKNOTE)
def test_docspec_convert_none_input():
"""Should raise ValidationError when input is None."""
converter = DocSpecConverter()
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert(None, mime_types.DOCX, mime_types.BLOCKNOTE)
def test_docspec_convert_unsupported_content_type():
"""Should raise ValidationError when content type is not DOCX."""
converter = DocSpecConverter()
with pytest.raises(
ValidationError, match="Conversion from text/plain to .* is not supported"
):
converter.convert(b"test data", "text/plain", mime_types.BLOCKNOTE)
def test_docspec_convert_unsupported_accept():
"""Should raise ValidationError when accept type is not BLOCKNOTE."""
converter = DocSpecConverter()
with pytest.raises(
ValidationError,
match=f"Conversion from {mime_types.DOCX} to {mime_types.YJS} is not supported",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.YJS)
@patch("requests.post")
def test_docspec_convert_service_unavailable(mock_post):
"""Should raise ServiceUnavailableError when service is unavailable."""
converter = DocSpecConverter()
mock_post.side_effect = requests.RequestException("Connection error")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to DocSpec conversion service",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
@patch("requests.post")
def test_docspec_convert_http_error(mock_post):
"""Should raise ServiceUnavailableError when HTTP error occurs."""
converter = DocSpecConverter()
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error")
mock_post.return_value = mock_response
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to DocSpec conversion service",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
@patch("requests.post")
def test_docspec_convert_timeout(mock_post):
"""Should raise ServiceUnavailableError when request times out."""
converter = DocSpecConverter()
mock_post.side_effect = requests.Timeout("Request timed out")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to DocSpec conversion service",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
@patch("requests.post")
def test_docspec_convert_success(mock_post, settings):
"""Test successful DOCX to BlockNote conversion."""
settings.DOCSPEC_API_URL = "http://docspec.test/convert"
settings.CONVERSION_API_TIMEOUT = 5
settings.CONVERSION_API_SECURE = False
converter = DocSpecConverter()
expected_content = b'[{"type": "paragraph", "content": "test"}]'
mock_response = MagicMock()
mock_response.content = expected_content
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
docx_data = b"fake docx binary data"
result = converter.convert(docx_data, mime_types.DOCX, mime_types.BLOCKNOTE)
assert result == expected_content
# Verify the request was made correctly
mock_post.assert_called_once_with(
"http://docspec.test/convert",
headers={"Accept": mime_types.BLOCKNOTE},
files={"file": ("document.docx", docx_data, mime_types.DOCX)},
timeout=5,
verify=False,
)
-1
View File
@@ -11,7 +11,6 @@ from core.api.viewsets import ReconciliationConfirmView
# - Main endpoints
router = DefaultRouter()
router.register("templates", viewsets.TemplateViewSet, basename="templates")
router.register("documents", viewsets.DocumentViewSet, basename="documents")
router.register("users", viewsets.UserViewSet, basename="users")
-2
View File
@@ -1,2 +0,0 @@
<img width="200" src="http://localhost:3000/assets/logo-gouv.png" />
<br/>
@@ -216,29 +216,6 @@ def create_demo(stdout):
queue.flush()
with Timeit(stdout, "Creating Template"):
with open(
file="demo/data/template/code.txt", mode="r", encoding="utf-8"
) as text_file:
code_data = text_file.read()
with open(
file="demo/data/template/css.txt", mode="r", encoding="utf-8"
) as text_file:
css_data = text_file.read()
queue.push(
models.Template(
id="baca9e2a-59fb-42ef-b5c6-6f6b05637111",
title="Demo Template",
description="This is the demo template",
code=code_data,
css=css_data,
is_public=True,
)
)
queue.flush()
class Command(BaseCommand):
"""A management command to create a demo database."""
@@ -25,7 +25,6 @@ def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.Template.objects.count() == 1
assert models.User.objects.count() >= 10
assert models.Document.objects.count() >= 10
assert models.DocumentAccess.objects.count() > 10
+25 -11
View File
@@ -29,6 +29,10 @@ from sentry_sdk.integrations.logging import ignore_logger
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data"))
KB = 1024
MB = KB * KB
GB = MB * KB
def get_release():
"""
@@ -168,7 +172,7 @@ class Base(Configuration):
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
10 * (2**20), # 10MB
10 * MB, # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
)
@@ -406,16 +410,6 @@ class Base(Configuration):
environ_name="API_DOCUMENT_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"template": values.Value(
default="30/minute",
environ_name="API_TEMPLATE_THROTTLE_RATE",
environ_prefix=None,
),
"template_access": values.Value(
default="30/minute",
environ_name="API_TEMPLATE_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"invitation": values.Value(
default="60/minute",
environ_name="API_INVITATION_THROTTLE_RATE",
@@ -465,6 +459,7 @@ class Base(Configuration):
EMAIL_HOST_PASSWORD = SecretFileValue(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_URL_APP = values.Value(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("[email protected]")
@@ -719,6 +714,22 @@ class Base(Configuration):
environ_prefix=None,
)
# DocSpec API microservice
DOCSPEC_API_URL = values.Value(environ_name="DOCSPEC_API_URL", environ_prefix=None)
# Imported file settings
CONVERSION_FILE_MAX_SIZE = values.IntegerValue(
20 * MB, # 10MB
environ_name="CONVERSION_FILE_MAX_SIZE",
environ_prefix=None,
)
CONVERSION_FILE_EXTENSIONS_ALLOWED = values.ListValue(
default=[".docx", ".md"],
environ_name="CONVERSION_FILE_EXTENSIONS_ALLOWED",
environ_prefix=None,
)
# Conversion endpoint
CONVERSION_API_ENDPOINT = values.Value(
default="convert",
@@ -1064,6 +1075,9 @@ class Production(Base):
# Privacy
SECURE_REFERRER_POLICY = "same-origin"
# Conversion API: Always verify SSL in production
CONVERSION_API_SECURE = True
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
+1 -1
View File
@@ -19,7 +19,7 @@ classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
]
description = "An application to print markdown to pdf from a set of managed templates."
description = "Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
+1 -1
View File
@@ -1,6 +1,6 @@
# e2e
test-results/
report/
report*/
blob-report/
playwright/.auth/
playwright/.cache/
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
![473389927-e4ff1794-69f3-460a-85f8-fec993cd74d6.png](http://localhost:3000/assets/logo-suite-numerique.png)![497094770-53e5f8e2-c93e-4a0b-a82f-cd184fd03f51.svg](http://localhost:3000/assets/icon-docs.svg)
# Lorem Ipsum import Document
## Introduction
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.
### Subsection 1.1
* **Bold text**: Lorem ipsum dolor sit amet.
* *Italic text*: Consectetur adipiscing elit.
* ~~Strikethrough text~~: Nullam auctor, nisl eget ultricies tincidunt.
1. First item in an ordered list.
2. Second item in an ordered list.
* Indented bullet point.
* Another indented bullet point.
3. Third item in an ordered list.
### Subsection 1.2
**Code block:**
```js
const hello_world = () => {
console.log("Hello, world!");
}
```
**Blockquote:**
> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt.
**Horizontal rule:**
***
**Table:**
| Syntax | Description |
| --------- | ----------- |
| Header | Title |
| Paragraph | Text |
**Inline code:**
Use the `printf()` function.
**Link:** [Example](http://localhost:3000/)
## Conclusion
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.
@@ -890,6 +890,9 @@ test.describe('Doc Editor', () => {
await expect(interlinkChild.locator('svg').first()).toBeHidden();
await interlinkChild.click();
// wait for navigation to complete
await page.waitForTimeout(1000);
await verifyDocName(page, docChild2);
await editor.click();
@@ -35,9 +35,6 @@ test.describe('Doc Export', () => {
await expect(
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
page.getByRole('button', {
@@ -236,20 +233,6 @@ test.describe('Doc Export', () => {
})
.click();
await page
.getByRole('combobox', {
name: 'Template',
})
.click();
await page
.getByRole('option', {
name: 'Demo Template',
})
.click({
delay: 100,
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
@@ -464,6 +447,8 @@ export const overrideDocContent = async ({
await verifyDocName(page, randomDoc);
await page.waitForTimeout(1000);
// Add Image SVG
await page.keyboard.press('Enter');
const { suggestionMenu } = await openSuggestionMenu({ page });
@@ -0,0 +1,181 @@
import { readFileSync } from 'fs';
import path from 'path';
import { Page, expect, test } from '@playwright/test';
import { getEditor } from './utils-editor';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Import', () => {
test('it imports 2 docs with the import icon', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByLabel('Open the upload dialog').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([
path.join(__dirname, 'assets/test_import.docx'),
path.join(__dirname, 'assets/test_import.md'),
]);
await expect(
page.getByText(
'The document "test_import.docx" has been successfully imported',
),
).toBeVisible();
await expect(
page.getByText(
'The document "test_import.md" has been successfully imported',
),
).toBeVisible();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible();
await expect(docsGrid.getByText('test_import.md').first()).toBeVisible();
// Check content of imported md
await docsGrid.getByText('test_import.md').first().click();
const editor = await getEditor({ page });
const contentCheck = async (isMDCheck = false) => {
await expect(
editor.getByRole('heading', {
name: 'Lorem Ipsum import Document',
level: 1,
}),
).toBeVisible();
await expect(
editor.getByRole('heading', {
name: 'Introduction',
level: 2,
}),
).toBeVisible();
await expect(
editor.getByRole('heading', {
name: 'Subsection 1.1',
level: 3,
}),
).toBeVisible();
await expect(
editor
.locator('div[data-content-type="bulletListItem"] strong')
.getByText('Bold text'),
).toBeVisible();
await expect(
editor
.locator('div[data-content-type="codeBlock"]')
.getByText('hello_world'),
).toBeVisible();
await expect(
editor
.locator('div[data-content-type="table"] td')
.getByText('Paragraph'),
).toBeVisible();
await expect(
editor.locator('a[href="http://localhost:3000/"]').getByText('Example'),
).toBeVisible();
/* eslint-disable playwright/no-conditional-expect */
if (isMDCheck) {
await expect(
editor.locator(
'img[src="http://localhost:3000/assets/logo-suite-numerique.png"]',
),
).toBeVisible();
await expect(
editor.locator(
'img[src="http://localhost:3000/assets/icon-docs.svg"]',
),
).toBeVisible();
} else {
await expect(editor.locator('img')).toHaveCount(2);
}
/* eslint-enable playwright/no-conditional-expect */
/**
* Divider are not supported in docx import in DocSpec 2.4.4
*/
/* eslint-disable playwright/no-conditional-expect */
if (isMDCheck) {
await expect(
editor.locator('div[data-content-type="divider"] hr'),
).toBeVisible();
}
/* eslint-enable playwright/no-conditional-expect */
};
await contentCheck(true);
// Check content of imported docx
await page.getByLabel('Back to homepage').first().click();
await docsGrid.getByText('test_import.docx').first().click();
await contentCheck();
});
test('it imports 2 docs with the drag and drop area', async ({ page }) => {
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await dragAndDropFiles(page, "[data-testid='docs-grid']", [
{
filePath: path.join(__dirname, 'assets/test_import.docx'),
fileName: 'test_import.docx',
fileType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
{
filePath: path.join(__dirname, 'assets/test_import.md'),
fileName: 'test_import.md',
fileType: 'text/markdown',
},
]);
// Wait for success messages
await expect(
page.getByText(
'The document "test_import.docx" has been successfully imported',
),
).toBeVisible();
await expect(
page.getByText(
'The document "test_import.md" has been successfully imported',
),
).toBeVisible();
await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible();
await expect(docsGrid.getByText('test_import.md').first()).toBeVisible();
});
});
const dragAndDropFiles = async (
page: Page,
selector: string,
files: Array<{ filePath: string; fileName: string; fileType?: string }>,
) => {
const filesData = files.map((file) => ({
bufferData: `data:application/octet-stream;base64,${readFileSync(file.filePath).toString('base64')}`,
fileName: file.fileName,
fileType: file.fileType || '',
}));
const dataTransfer = await page.evaluateHandle(async (filesInfo) => {
const dt = new DataTransfer();
for (const fileInfo of filesInfo) {
const blobData = await fetch(fileInfo.bufferData).then((res) =>
res.blob(),
);
const file = new File([blobData], fileInfo.fileName, {
type: fileInfo.fileType,
});
dt.items.add(file);
}
return dt;
}, filesData);
await page.dispatchEvent(selector, 'drop', { dataTransfer });
};
@@ -27,47 +27,32 @@ test.describe('Doc Tree', () => {
const docTree = page.getByTestId('doc-tree');
// Create first sub page
const firstResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await clickOnAddRootSubPage(page);
const firstResponse = await firstResponsePromise;
expect(firstResponse.ok()).toBeTruthy();
await updateDocTitle(page, 'first');
const secondResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await updateDocTitle(page, 'first move');
// Create second sub page
await clickOnAddRootSubPage(page);
const secondResponse = await secondResponsePromise;
expect(secondResponse.ok()).toBeTruthy();
await updateDocTitle(page, 'second');
await updateDocTitle(page, 'second move');
const secondSubPageJson = await secondResponse.json();
const firstSubPageJson = await firstResponse.json();
const firstSubPageItem = docTree
.getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`)
.first();
const secondSubPageItem = docTree
.getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`)
.first();
const firstSubPageItem = docTree.getByText('first move').first();
const secondSubPageItem = docTree.getByText('second move').first();
// check that the sub pages are visible in the tree
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// get the bounding boxes of the sub pages
// Check the position of the sub pages
const allSubPageItems = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
// Check that elements are in the correct order
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
// Will move the first sub page to the second position
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
const secondSubPageBoundingBox = await secondSubPageItem.boundingBox();
@@ -75,10 +60,9 @@ test.describe('Doc Tree', () => {
expect(secondSubPageBoundingBox).toBeDefined();
if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) {
throw new Error('Impossible de déterminer la position des éléments');
throw new Error('unable to determine the position of the elements');
}
// move the first sub page to the second position
await page.mouse.move(
firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2,
firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2,
@@ -105,24 +89,19 @@ test.describe('Doc Tree', () => {
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages
const allSubPageItems = await docTree
// Check that elements are in the correct order
const allSubPageItemsAfterReload = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
expect(allSubPageItemsAfterReload.length).toBe(2);
// Check that the first element has the ID of the second sub page after the drag and drop
await expect(allSubPageItems[0]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${secondSubPageJson.id}`,
);
// Check that the second element has the ID of the first sub page after the drag and drop
await expect(allSubPageItems[1]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${firstSubPageJson.id}`,
);
await expect(
allSubPageItemsAfterReload[0].getByText('second move'),
).toBeVisible();
await expect(
allSubPageItemsAfterReload[1].getByText('first move'),
).toBeVisible();
});
test('it detaches a document', async ({ page, browserName }) => {
@@ -8,6 +8,8 @@ export const CONFIG = {
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'],
CONVERSION_FILE_MAX_SIZE: 20971520,
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_JS_URL: null,
@@ -160,7 +162,7 @@ export const verifyDocName = async (page: Page, docName: string) => {
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName, {
timeout: 1000,
timeout: 3000,
});
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
+2 -1
View File
@@ -55,13 +55,14 @@
"i18next": "25.7.3",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "15.5.9",
"posthog-js": "1.312.0",
"react": "*",
"react-aria-components": "1.14.0",
"react-dom": "*",
"react-dropzone": "14.3.8",
"react-i18next": "16.5.1",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
@@ -20,7 +20,7 @@ export type DefinedInitialDataInfiniteOptionsAPI<
QueryKey,
TPageParam
>;
export type UseInfiniteQueryResultAPI<Q> = InfiniteData<Q>;
export type InfiniteQueryConfig<Q> = Omit<
DefinedInitialDataInfiniteOptionsAPI<Q>,
'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam'
@@ -0,0 +1,20 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.12757 9.8486C5.98657 9.6993 5.91709 9.5143 5.91709 9.30858C5.91709 9.10284 5.98679 8.91775 6.13233 8.77221C6.28262 8.62192 6.47291 8.54842 6.68579 8.54842H13.1697C13.3775 8.54842 13.5623 8.62245 13.7061 8.77215C13.8559 8.91601 13.9299 9.10081 13.9299 9.30858C13.9299 9.51737 13.8553 9.70306 13.7085 9.8511C13.5643 10.0024 13.3787 10.0773 13.1697 10.0773H6.68579C6.47291 10.0773 6.28262 10.0038 6.13233 9.85349L6.13076 9.85192L6.12757 9.8486Z"
fill="currentColor"
/>
<path
d="M6.12757 12.83C5.98657 12.6807 5.91709 12.4957 5.91709 12.29C5.91709 12.0843 5.98679 11.8992 6.13233 11.7536C6.28262 11.6033 6.47291 11.5298 6.68579 11.5298H13.1697C13.3775 11.5298 13.5623 11.6039 13.7061 11.7536C13.8559 11.8974 13.9299 12.0822 13.9299 12.29C13.9299 12.4988 13.8553 12.6845 13.7085 12.8325C13.5643 12.9838 13.3787 13.0587 13.1697 13.0587H6.68579C6.47291 13.0587 6.28262 12.9852 6.13233 12.8349L6.13076 12.8333L6.12757 12.83Z"
fill="currentColor"
/>
<path
d="M5.91709 15.2885C5.91709 15.4912 5.98839 15.6726 6.12757 15.82L6.134 15.8266L6.13723 15.8296C6.28833 15.9723 6.47704 16.0401 6.68579 16.0401H9.75263C9.96123 16.0401 10.1502 15.9722 10.2975 15.8249C10.444 15.6784 10.5213 15.4956 10.5213 15.2885C10.5213 15.0768 10.4486 14.8874 10.2999 14.7374C10.1539 14.5842 9.96433 14.5113 9.75263 14.5113H6.68579C6.47293 14.5113 6.28257 14.5847 6.13226 14.735L6.12757 14.7399C5.98486 14.891 5.91709 15.0797 5.91709 15.2885Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.37975 1.24597C7.88425 0.735004 8.61944 0.5 9.54031 0.5H18.6127C19.533 0.5 20.2661 0.734736 20.7653 1.24652C21.2686 1.75666 21.5 2.49628 21.5 3.42147V16.3808C21.5 17.3112 21.2688 18.0521 20.7638 18.5572C20.2645 19.0624 19.532 19.2937 18.6127 19.2937H17.347V20.5338C17.347 21.4641 17.1158 22.2051 16.6108 22.7102C16.1115 23.2153 15.3789 23.4467 14.4597 23.4467H5.3873C4.46721 23.4467 3.73242 23.2149 3.22781 22.7103C2.72908 22.2051 2.5 21.4635 2.5 20.5338V7.57442C2.5 6.64962 2.72942 5.90915 3.22673 5.39893C3.73123 4.88796 4.46643 4.65295 5.3873 4.65295H6.65302V3.42147C6.65302 2.49666 6.88244 1.7562 7.37975 1.24597ZM8.42319 4.65295H14.4597C15.38 4.65295 16.1131 4.88769 16.6122 5.39947C17.1156 5.90962 17.347 6.64923 17.347 7.57442V17.5236H18.5444C18.9636 17.5236 19.2496 17.4163 19.4324 17.2289L19.4337 17.2275C19.6238 17.0374 19.7298 16.7549 19.7298 16.3552V3.4471C19.7298 3.04734 19.6238 2.76485 19.4337 2.57481L19.431 2.57206C19.248 2.37972 18.9625 2.27017 18.5444 2.27017H9.60866C9.19081 2.27017 8.90126 2.37956 8.71212 2.57341C8.52701 2.76329 8.42319 3.04633 8.42319 3.4471V4.65295ZM5.45564 21.6765C5.03728 21.6765 4.74743 21.5697 4.55844 21.3811C4.37372 21.1913 4.27017 20.9084 4.27017 20.5081V7.60005C4.27017 7.19928 4.37399 6.91625 4.55911 6.72636C4.74825 6.53252 5.03779 6.42313 5.45564 6.42313H14.3913C14.8095 6.42313 15.095 6.53268 15.278 6.72501L15.2807 6.72776C15.4708 6.9178 15.5768 7.20029 15.5768 7.60005V20.5081C15.5768 20.9079 15.4708 21.1904 15.2807 21.3804L15.2793 21.3818C15.0966 21.5693 14.8105 21.6765 14.3913 21.6765H5.45564Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -1,21 +1,34 @@
import clsx from 'clsx';
import React from 'react';
import { css } from 'styled-components';
import { Text, TextType } from '@/components';
type IconProps = TextType & {
type IconBase = TextType & {
disabled?: boolean;
};
type IconMaterialProps = IconBase & {
iconName: string;
variant?: 'filled' | 'outlined' | 'symbols-outlined';
icon?: never;
};
type IconSVGProps = IconBase & {
icon: React.ReactNode;
iconName?: never;
variant?: never;
};
export const Icon = ({
className,
iconName,
disabled,
iconName,
icon,
variant = 'outlined',
$theme = 'neutral',
...textProps
}: IconProps) => {
}: IconMaterialProps | IconSVGProps) => {
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
const ariaHidden =
'aria-hidden' in textProps ? textProps['aria-hidden'] : !hasLabel;
@@ -24,15 +37,15 @@ export const Icon = ({
<Text
aria-hidden={ariaHidden}
className={clsx('--docs--icon-bg', className, {
'material-icons-filled': variant === 'filled',
'material-icons': variant === 'outlined',
'material-symbols-outlined': variant === 'symbols-outlined',
'material-icons-filled': variant === 'filled' && iconName,
'material-icons': variant === 'outlined' && iconName,
'material-symbols-outlined': variant === 'symbols-outlined' && iconName,
})}
$theme={disabled ? 'disabled' : $theme}
aria-disabled={disabled}
{...textProps}
>
{iconName}
{iconName ?? icon}
</Text>
);
};
@@ -18,6 +18,8 @@ export interface ConfigResponse {
AI_FEATURE_ENABLED?: boolean;
COLLABORATION_WS_URL?: string;
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean;
CONVERSION_FILE_EXTENSIONS_ALLOWED: string[];
CONVERSION_FILE_MAX_SIZE: number;
CRISP_WEBSITE_ID?: string;
ENVIRONMENT: string;
FRONTEND_CSS_URL?: string;
@@ -122,8 +122,8 @@ export const SearchPage = ({
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
// Keep the trigger character ('@' or '/') in the editor when closing with Escape
closeSearch(trigger);
// Keep the trigger character and typed text in the editor when closing with Escape
closeSearch(`${trigger}${search}`);
} else if (e.key === 'Backspace' && search.length === 0) {
e.preventDefault();
closeSearch('');
@@ -1,44 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
interface CreateExportParams {
templateId: string;
body: string;
body_type: 'html' | 'markdown';
format: 'pdf' | 'docx';
}
export const createExport = async ({
templateId,
body,
body_type,
format,
}: CreateExportParams): Promise<Blob> => {
const response = await fetchAPI(
`templates/${templateId}/generate-document/`,
{
method: 'POST',
body: JSON.stringify({
body,
body_type,
format,
}),
},
);
if (!response.ok) {
throw new APIError(
'Failed to export the document',
await errorCauses(response),
);
}
return await response.blob();
};
export function useExport() {
return useMutation<Blob, APIError, CreateExportParams>({
mutationFn: createExport,
});
}
@@ -1,74 +0,0 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Template } from '../types';
export enum TemplatesOrdering {
BY_CREATED_ON = 'created_at',
BY_CREATED_ON_DESC = '-created_at',
}
export type TemplatesParams = {
ordering: TemplatesOrdering;
};
type TemplatesAPIParams = TemplatesParams & {
page: number;
};
type TemplatesResponse = APIList<Template>;
export const getTemplates = async ({
ordering,
page,
}: TemplatesAPIParams): Promise<TemplatesResponse> => {
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
const response = await fetchAPI(`templates/?page=${page}${orderingQuery}`);
if (!response.ok) {
throw new APIError(
'Failed to get the templates',
await errorCauses(response),
);
}
return response.json() as Promise<TemplatesResponse>;
};
export const KEY_LIST_TEMPLATE = 'templates';
export function useTemplates(
param: TemplatesParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
TemplatesResponse,
APIError,
InfiniteData<TemplatesResponse>,
QueryKey,
number
>,
) {
return useInfiniteQuery<
TemplatesResponse,
APIError,
InfiniteData<TemplatesResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_TEMPLATE, param],
queryFn: ({ pageParam }) =>
getTemplates({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
}
@@ -135,7 +135,7 @@ export const utilTable = (
columnWidths: (number | undefined)[],
) => {
const totalColumnWidthKnown = columnWidths.reduce(
(sum: number, w) => sum + (w ?? 0),
(sum: number, w) => sum + (w && !isNaN(w) ? w : 0),
0,
);
const nbColumnWidthUnknown = columnWidths.filter((w) => !w).length;
@@ -14,7 +14,7 @@ import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
import JSZip from 'jszip';
import { cloneElement, isValidElement, useMemo, useState } from 'react';
import { cloneElement, isValidElement, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -25,7 +25,6 @@ import { Doc, useTrans } from '@/docs/doc-management';
import { fallbackLng } from '@/i18n/config';
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
@@ -50,12 +49,8 @@ interface ModalExportProps {
export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const { t } = useTranslation();
const { data: templates } = useTemplates({
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
});
const { toast } = useToastProvider();
const { editor } = useEditorStore();
const [templateSelected, setTemplateSelected] = useState<string>('');
const [isExporting, setIsExporting] = useState(false);
const [format, setFormat] = useState<DocDownloadFormat>(
DocDownloadFormat.PDF,
@@ -63,24 +58,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const { untitledDocument } = useTrans();
const mediaUrl = useMediaUrl();
const templateOptions = useMemo(() => {
const templateOptions = (templates?.pages || [])
.map((page) =>
page.results.map((template) => ({
label: template.title,
value: template.code,
})),
)
.flat();
templateOptions.unshift({
label: t('Empty template'),
value: '',
});
return templateOptions;
}, [t, templates?.pages]);
async function onSubmit() {
if (!editor) {
toast(t('The export failed'), VariantType.ERROR);
@@ -97,13 +74,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const documentTitle = doc.title || untitledDocument;
const html = templateSelected;
let exportDocument = editor.document;
if (html) {
const blockTemplate = await editor.tryParseHTMLToBlocks(html);
exportDocument = [...blockTemplate, ...editor.document];
}
const exportDocument = editor.document;
let blobExport: Blob;
if (format === DocDownloadFormat.PDF) {
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
@@ -290,17 +261,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
setFormat(options.target.value as DocDownloadFormat)
}
/>
<Select
clearable={false}
fullWidth
label={t('Template')}
options={templateOptions}
value={templateSelected}
disabled={format === DocDownloadFormat.HTML}
onChange={(options) =>
setTemplateSelected(options.target.value as string)
}
/>
{isExporting && (
<Box
@@ -22,6 +22,29 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
quote: blockMappingQuoteDocx,
image: blockMappingImageDocx,
uploadLoader: blockMappingUploadLoaderDocx,
table: (block, exporter, nestedLevel, numberedListIndex, children) => {
/**
* Nan values are not supported, so we need to replace them with undefined
* to avoid issues during the export.
*/
const { columnWidths } = block.content;
const hasNaN = columnWidths.some(
(width) => typeof width === 'number' && Number.isNaN(width),
);
if (hasNaN) {
block.content.columnWidths = columnWidths.map((width) =>
typeof width === 'number' && Number.isNaN(width) ? undefined : width,
);
}
return docxDefaultSchemaMappings.blockMapping.table(
block,
exporter,
nestedLevel,
numberedListIndex,
children,
);
},
},
inlineContentMapping: {
...docxDefaultSchemaMappings.inlineContentMapping,
@@ -13,24 +13,6 @@ import {
DocsInlineContentSchema,
DocsStyleSchema,
} from '../doc-editor';
import { Access } from '../doc-management';
export interface Template {
id: string;
abilities: {
destroy: boolean;
generate_document: boolean;
accesses_manage: boolean;
retrieve: boolean;
update: boolean;
partial_update: boolean;
};
accesses: Access[];
title: string;
is_public: boolean;
css: string;
code: string;
}
export type DocsExporterPDF = Exporter<
NoInfer<DocsBlockSchema>,
@@ -19,6 +19,7 @@ import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
KEY_LIST_FAVORITE_DOC,
ModalRemoveDoc,
getEmojiAndTitle,
useCopyDocLink,
@@ -67,10 +68,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
});
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC, KEY_LIST_FAVORITE_DOC],
});
useEffect(() => {
@@ -5,6 +5,7 @@ export * from './useDeleteFavoriteDoc';
export * from './useDoc';
export * from './useDocOptions';
export * from './useDocs';
export * from './useDocsFavorite';
export * from './useDuplicateDoc';
export * from './useRestoreDoc';
export * from './useSubDocs';
@@ -0,0 +1,72 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
APIError,
APIList,
InfiniteQueryConfig,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { Doc } from '../types';
export type DocsFavoriteParams = {
page: number;
};
export type DocsFavoriteResponse = APIList<Doc>;
export const getDocsFavorite = async (
params: DocsFavoriteParams,
): Promise<DocsFavoriteResponse> => {
const searchParams = new URLSearchParams();
if (params.page) {
searchParams.set('page', params.page.toString());
}
const response = await fetchAPI(
`documents/favorite_list/?${searchParams.toString()}`,
);
if (!response.ok) {
throw new APIError(
'Failed to get the favorite docs',
await errorCauses(response),
);
}
return response.json() as Promise<DocsFavoriteResponse>;
};
export const KEY_LIST_FAVORITE_DOC = 'docs_favorite_list';
type UseDocsOptions = UseQueryOptions<
DocsFavoriteResponse,
APIError,
DocsFavoriteResponse
>;
type UseInfiniteDocsOptions = InfiniteQueryConfig<DocsFavoriteResponse>;
export function useDocsFavorite(
params: DocsFavoriteParams,
queryConfig?: UseDocsOptions,
) {
return useQuery<DocsFavoriteResponse, APIError, DocsFavoriteResponse>({
queryKey: [KEY_LIST_FAVORITE_DOC, params],
queryFn: () => getDocsFavorite(params),
...queryConfig,
});
}
export const useInfiniteDocsFavorite = (
params: DocsFavoriteParams,
queryConfig?: UseInfiniteDocsOptions,
) => {
return useAPIInfiniteQuery(
KEY_LIST_FAVORITE_DOC,
getDocsFavorite,
params,
queryConfig,
);
};
@@ -0,0 +1,125 @@
import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
APIError,
UseInfiniteQueryResultAPI,
errorCauses,
fetchAPI,
} from '@/api';
import { Doc, DocsResponse, KEY_LIST_DOC } from '@/docs/doc-management';
export enum ContentTypes {
Docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
Markdown = 'text/markdown',
OctetStream = 'application/octet-stream',
}
export const importDoc = async ([file, mimeType]: [
File,
string,
]): Promise<Doc> => {
const form = new FormData();
form.append(
'file',
new File([file], file.name, {
type: mimeType,
lastModified: file.lastModified,
}),
);
const response = await fetchAPI(`documents/`, {
method: 'POST',
body: form,
withoutContentType: true,
});
if (!response.ok) {
throw new APIError('Failed to import the doc', await errorCauses(response));
}
return response.json() as Promise<Doc>;
};
type UseImportDocOptions = UseMutationOptions<Doc, APIError, [File, string]>;
export function useImportDoc(props?: UseImportDocOptions) {
const { toast } = useToastProvider();
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<Doc, APIError, [File, string]>({
mutationFn: importDoc,
...props,
onSuccess: (...successProps) => {
const importedDoc = successProps[0];
const updateDocsListCache = (isCreatorMe: boolean | undefined) => {
queryClient.setQueriesData<UseInfiniteQueryResultAPI<DocsResponse>>(
{
queryKey: [
KEY_LIST_DOC,
{
page: 1,
ordering: undefined,
is_creator_me: isCreatorMe,
title: undefined,
is_favorite: undefined,
},
],
},
(oldData) => {
if (!oldData || oldData?.pages.length === 0) {
return oldData;
}
return {
...oldData,
pages: oldData.pages.map((page, index) => {
// Add the new doc to the first page only
if (index === 0) {
return {
...page,
results: [importedDoc, ...page.results],
};
}
return page;
}),
};
},
);
};
updateDocsListCache(undefined);
updateDocsListCache(true);
toast(
t('The document "{{documentName}}" has been successfully imported', {
documentName: importedDoc.title || '',
}),
VariantType.SUCCESS,
);
props?.onSuccess?.(...successProps);
},
onError: (...errorProps) => {
toast(
t(`The document "{{documentName}}" import has failed`, {
documentName: errorProps?.[1][0].name || '',
}),
VariantType.ERROR,
);
props?.onError?.(...errorProps);
},
});
}
@@ -1,14 +1,19 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useMemo } from 'react';
import {
Button,
Tooltip as TooltipBase,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
import { css } from 'styled-components';
import styled, { css } from 'styled-components';
import { Box, Card, Text } from '@/components';
import AllDocs from '@/assets/icons/doc-all.svg';
import { Box, Card, Icon, Text } from '@/components';
import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useInfiniteDocsTrashbin } from '../api';
import { useImport } from '../hooks/useImport';
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
import {
@@ -17,13 +22,36 @@ import {
} from './DocGridContentList';
import { DocsGridLoader } from './DocsGridLoader';
const Tooltip = styled(TooltipBase)`
& {
max-width: 200px;
.c__tooltip__content {
max-width: 200px;
width: max-content;
}
}
`;
type DocsGridProps = {
target?: DocDefaultFilter;
};
export const DocsGrid = ({
target = DocDefaultFilter.ALL_DOCS,
}: DocsGridProps) => {
const { t } = useTranslation();
const [isDragOver, setIsDragOver] = useState(false);
const { getRootProps, getInputProps, open } = useImport({
onDragOver: (dragOver: boolean) => {
setIsDragOver(dragOver);
},
});
const withUpload =
!target ||
target === DocDefaultFilter.ALL_DOCS ||
target === DocDefaultFilter.MY_DOCS;
const { isDesktop } = useResponsiveStore();
const { flexLeft, flexRight } = useResponsiveDocGrid();
@@ -60,21 +88,6 @@ export const DocsGrid = ({
void fetchNextPage();
};
let title = t('All docs');
switch (target) {
case DocDefaultFilter.MY_DOCS:
title = t('My docs');
break;
case DocDefaultFilter.SHARED_WITH_ME:
title = t('Shared with me');
break;
case DocDefaultFilter.TRASHBIN:
title = t('Trashbin');
break;
default:
title = t('All docs');
}
return (
<Box
$position="relative"
@@ -91,16 +104,24 @@ export const DocsGrid = ({
$width="100%"
$css={css`
${!isDesktop ? 'border: none;' : ''}
${isDragOver
? `
border: 2px dashed var(--c--contextuals--border--semantic--brand--primary);
background-color: var(--c--contextuals--background--semantic--brand--tertiary);
`
: ''}
`}
$padding={{
top: 'base',
horizontal: isDesktop ? 'md' : 'xs',
bottom: 'md',
}}
{...(withUpload ? getRootProps({ className: 'dropzone' }) : {})}
>
<Text as="h2" $size="h4" $margin={{ top: '0px', bottom: '10px' }}>
{title}
</Text>
{withUpload && <input {...getInputProps()} />}
<DocGridTitleBar
target={target}
onUploadClick={open}
withUpload={withUpload}
/>
{!hasDocs && !loading && (
<Box $padding={{ vertical: 'sm' }} $align="center" $justify="center">
@@ -110,7 +131,11 @@ export const DocsGrid = ({
</Box>
)}
{hasDocs && (
<Box $gap="6px" $overflow="auto">
<Box
$gap="6px"
$overflow="auto"
$padding={{ vertical: 'sm', horizontal: isDesktop ? 'md' : 'xs' }}
>
<Box role="grid" aria-label={t('Documents grid')}>
<Box role="rowgroup">
<Box
@@ -172,6 +197,75 @@ export const DocsGrid = ({
);
};
const DocGridTitleBar = ({
target,
onUploadClick,
withUpload,
}: {
target: DocDefaultFilter;
onUploadClick: () => void;
withUpload: boolean;
}) => {
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
let title = t('All docs');
let icon = <Icon icon={<AllDocs width={24} height={24} />} />;
if (target === DocDefaultFilter.MY_DOCS) {
icon = <Icon iconName="lock" />;
title = t('My docs');
} else if (target === DocDefaultFilter.SHARED_WITH_ME) {
icon = <Icon iconName="group" />;
title = t('Shared with me');
} else if (target === DocDefaultFilter.TRASHBIN) {
icon = <Icon iconName="delete" />;
title = t('Trashbin');
}
return (
<Box
$direction="row"
$padding={{
vertical: 'md',
horizontal: isDesktop ? 'md' : 'xs',
}}
$css={css`
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
`}
$align="center"
$justify="space-between"
>
<Box $direction="row" $gap="xs" $align="center">
{icon}
<Text as="h2" $size="h4" $margin="none">
{title}
</Text>
</Box>
{withUpload && (
<Tooltip
content={
<Text $textAlign="center" $theme="neutral" $variation="tertiary">
{t('Import Docx or Markdown files')}
</Text>
}
>
<Button
color="brand"
variant="tertiary"
onClick={(e) => {
e.stopPropagation();
onUploadClick();
}}
aria-label={t('Open the upload dialog')}
>
<Icon iconName="upload_file" $withThemeInherited />
</Button>
</Tooltip>
)}
</Box>
);
};
const useDocsQuery = (target: DocDefaultFilter) => {
const trashbinQuery = useInfiniteDocsTrashbin(
{
@@ -6,6 +6,7 @@ import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import {
Doc,
KEY_LIST_DOC,
KEY_LIST_FAVORITE_DOC,
ModalRemoveDoc,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
@@ -27,10 +28,10 @@ export const DocsGridActions = ({
const { mutate: duplicateDoc } = useDuplicateDoc();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC],
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_FAVORITE_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC],
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_FAVORITE_DOC],
});
const options: DropdownMenuOption[] = [
@@ -0,0 +1,116 @@
import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { t } from 'i18next';
import { useMemo } from 'react';
import { useDropzone } from 'react-dropzone';
import { useConfig } from '@/core';
import { ContentTypes, useImportDoc } from '../api/useImportDoc';
interface UseImportProps {
onDragOver: (isDragOver: boolean) => void;
}
export const useImport = ({ onDragOver }: UseImportProps) => {
const { toast } = useToastProvider();
const { data: config } = useConfig();
const MAX_FILE_SIZE = useMemo(() => {
const maxSizeInBytes = config?.CONVERSION_FILE_MAX_SIZE ?? 10 * 1024 * 1024; // Default to 10MB
const units = ['bytes', 'KB', 'MB', 'GB'];
let size = maxSizeInBytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return {
bytes: maxSizeInBytes,
text: `${Math.round(size * 10) / 10}${units[unitIndex]}`,
};
}, [config?.CONVERSION_FILE_MAX_SIZE]);
const ACCEPT = useMemo(() => {
const extensions = config?.CONVERSION_FILE_EXTENSIONS_ALLOWED;
const accept: { [key: string]: string[] } = {};
if (extensions && extensions.length > 0) {
extensions.forEach((ext) => {
switch (ext.toLowerCase()) {
case '.docx':
accept[ContentTypes.Docx] = ['.docx'];
break;
case '.md':
case '.markdown':
accept[ContentTypes.Markdown] = ['.md'];
break;
default:
break;
}
});
} else {
// Default to docx and md if no configuration is provided
accept[ContentTypes.Docx] = ['.docx'];
accept[ContentTypes.Markdown] = ['.md'];
}
return accept;
}, [config?.CONVERSION_FILE_EXTENSIONS_ALLOWED]);
const { getRootProps, getInputProps, open } = useDropzone({
accept: ACCEPT,
maxSize: MAX_FILE_SIZE.bytes,
onDrop(acceptedFiles) {
onDragOver(false);
for (const file of acceptedFiles) {
importDoc([file, file.type]);
}
},
onDragEnter: () => {
onDragOver(true);
},
onDragLeave: () => {
onDragOver(false);
},
onDropRejected(fileRejections) {
fileRejections.forEach((rejection) => {
const isFileTooLarge = rejection.errors.some(
(error) => error.code === 'file-too-large',
);
if (isFileTooLarge) {
toast(
t(
'The document "{{documentName}}" is too large. Maximum file size is {{maxFileSize}}.',
{
documentName: rejection.file.name,
maxFileSize: MAX_FILE_SIZE.text,
},
),
VariantType.ERROR,
);
} else {
toast(
t(
`The document "{{documentName}}" import has failed (only .docx and .md files are allowed)`,
{
documentName: rejection.file.name,
},
),
VariantType.ERROR,
);
}
});
},
noClick: true,
});
const { mutate: importDoc } = useImportDoc();
return { getRootProps, getInputProps, open };
};
@@ -2,6 +2,7 @@ import { usePathname, useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import AllDocs from '@/assets/icons/doc-all.svg';
import { Box, Icon, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocDefaultFilter } from '@/docs/doc-management';
@@ -21,22 +22,22 @@ export const LeftPanelTargetFilters = () => {
const defaultQueries = [
{
icon: 'apps',
icon: <Icon icon={<AllDocs width={24} height={24} />} />,
label: t('All docs'),
targetQuery: DocDefaultFilter.ALL_DOCS,
},
{
icon: 'lock',
icon: <Icon iconName="lock" />,
label: t('My docs'),
targetQuery: DocDefaultFilter.MY_DOCS,
},
{
icon: 'group',
icon: <Icon iconName="group" />,
label: t('Shared with me'),
targetQuery: DocDefaultFilter.SHARED_WITH_ME,
},
{
icon: 'delete',
icon: <Icon iconName="delete" />,
label: t('Trashbin'),
targetQuery: DocDefaultFilter.TRASHBIN,
},
@@ -96,7 +97,7 @@ export const LeftPanelTargetFilters = () => {
}
`}
>
<Icon iconName={query.icon} />
{query.icon}
<Text $size="sm">{query.label}</Text>
</StyledLink>
);
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Box, HorizontalSeparator, InfiniteScroll, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useInfiniteDocs } from '@/docs/doc-management';
import { useInfiniteDocsFavorite } from '@/docs/doc-management';
import { LeftPanelFavoriteItem } from './LeftPanelFavoriteItem';
@@ -11,9 +11,8 @@ export const LeftPanelFavorites = () => {
const { spacingsTokens } = useCunninghamTheme();
const docs = useInfiniteDocs({
const docs = useInfiniteDocsFavorite({
page: 1,
is_favorite: true,
});
const favoriteDocs = docs.data?.pages.flatMap((page) => page.results) || [];
@@ -69,7 +69,7 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', 'wrong-api-key')
.set('authorization', `Bearer wrong-api-key`)
.set('content-type', 'application/json');
expect(response.status).toBe(401);
@@ -99,7 +99,7 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', apiKey)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/json');
expect(response.status).toBe(400);
@@ -114,7 +114,7 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', apiKey)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/json')
.send('');
@@ -129,9 +129,10 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', apiKey)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'image/png')
.send('randomdata');
expect(response.status).toBe(415);
expect(response.body).toStrictEqual({ error: 'Unsupported Content-Type' });
});
@@ -141,38 +142,73 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', apiKey)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'text/markdown')
.set('accept', 'image/png')
.send('# Header');
expect(response.status).toBe(406);
expect(response.body).toStrictEqual({ error: 'Unsupported format' });
});
test.each([[apiKey], [`Bearer ${apiKey}`]])(
'POST /api/convert with correct content with Authorization: %s',
async (authHeader) => {
const app = initApp();
test('POST /api/convert BlockNote to Markdown', async () => {
const app = initApp();
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.blocknote+json')
.set('accept', 'text/markdown')
.send(expectedBlocks);
const response = await request(app)
.post('/api/convert')
.set('Origin', origin)
.set('Authorization', authHeader)
.set('content-type', 'text/markdown')
.set('accept', 'application/vnd.yjs.doc')
.send(expectedMarkdown);
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe(
'text/markdown; charset=utf-8',
);
expect(typeof response.text).toBe('string');
expect(response.text.trim()).toBe(expectedMarkdown);
});
expect(response.status).toBe(200);
expect(response.body).toBeInstanceOf(Buffer);
test('POST /api/convert BlockNote to Yjs', async () => {
const app = initApp();
const editor = ServerBlockNoteEditor.create();
const blocks = await editor.tryParseMarkdownToBlocks(expectedMarkdown);
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.blocknote+json')
.set('accept', 'application/vnd.yjs.doc')
.send(blocks)
.responseType('blob');
const editor = ServerBlockNoteEditor.create();
const doc = new Y.Doc();
Y.applyUpdate(doc, response.body);
const blocks = editor.yDocToBlocks(doc, 'document-store');
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe('application/vnd.yjs.doc');
expect(blocks).toStrictEqual(expectedBlocks);
},
);
// Decode the Yjs response and verify it contains the correct blocks
const responseBuffer = Buffer.from(response.body as Buffer);
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, responseBuffer);
const decodedBlocks = editor.yDocToBlocks(ydoc, 'document-store');
expect(decodedBlocks).toStrictEqual(expectedBlocks);
});
test('POST /api/convert BlockNote to HTML', async () => {
const app = initApp();
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.blocknote+json')
.set('accept', 'text/html')
.send(expectedBlocks);
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe('text/html; charset=utf-8');
expect(typeof response.text).toBe('string');
expect(response.text).toBe(expectedHTML);
});
test('POST /api/convert Yjs to HTML', async () => {
const app = initApp();
@@ -183,10 +219,11 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', apiKey)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'text/html')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe('text/html; charset=utf-8');
expect(typeof response.text).toBe('string');
@@ -202,10 +239,11 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', apiKey)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'text/markdown')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe(
'text/markdown; charset=utf-8',
@@ -223,15 +261,16 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', apiKey)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'application/json')
.send(Buffer.from(yjsUpdate));
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe(
'application/json; charset=utf-8',
);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toBeInstanceOf(Array);
expect(response.body).toStrictEqual(expectedBlocks);
});
@@ -240,15 +279,16 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', apiKey)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'text/markdown')
.set('accept', 'application/json')
.send(expectedMarkdown);
expect(response.status).toBe(200);
expect(response.header['content-type']).toBe(
'application/json; charset=utf-8',
);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toBeInstanceOf(Array);
expect(response.body).toStrictEqual(expectedBlocks);
});
@@ -257,11 +297,12 @@ describe('Server Tests', () => {
const response = await request(app)
.post('/api/convert')
.set('origin', origin)
.set('authorization', apiKey)
.set('authorization', `Bearer ${apiKey}`)
.set('content-type', 'application/vnd.yjs.doc')
.set('accept', 'application/json')
.send(Buffer.from('notvalidyjs'));
expect(response.status).toBe(400);
expect(response.body).toStrictEqual({ error: 'Invalid Yjs content' });
expect(response.body).toStrictEqual({ error: 'Invalid content' });
});
});
@@ -14,27 +14,115 @@ interface ErrorResponse {
error: string;
}
type ConversionResponseBody = Uint8Array | string | object | ErrorResponse;
interface InputReader {
supportedContentTypes: string[];
read(data: Buffer): Promise<PartialBlock[]>;
}
interface OutputWriter {
supportedContentTypes: string[];
write(blocks: PartialBlock[]): Promise<ConversionResponseBody>;
}
const editor = ServerBlockNoteEditor.create<
DefaultBlockSchema,
DefaultInlineContentSchema,
DefaultStyleSchema
>();
const ContentTypes = {
XMarkdown: 'text/x-markdown',
Markdown: 'text/markdown',
YJS: 'application/vnd.yjs.doc',
FormUrlEncoded: 'application/x-www-form-urlencoded',
OctetStream: 'application/octet-stream',
HTML: 'text/html',
BlockNote: 'application/vnd.blocknote+json',
JSON: 'application/json',
} as const;
const createYDocument = (blocks: PartialBlock[]) =>
editor.blocksToYDoc(blocks, 'document-store');
const readers: InputReader[] = [
{
// application/x-www-form-urlencoded is interpreted as Markdown for backward compatibility
supportedContentTypes: [
ContentTypes.Markdown,
ContentTypes.XMarkdown,
ContentTypes.FormUrlEncoded,
],
read: (data) => editor.tryParseMarkdownToBlocks(data.toString()),
},
{
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
read: async (data) => {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, data);
return editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
},
},
{
supportedContentTypes: [ContentTypes.BlockNote],
read: async (data) => JSON.parse(data.toString()),
},
];
const writers: OutputWriter[] = [
{
supportedContentTypes: [ContentTypes.BlockNote, ContentTypes.JSON],
write: async (blocks) => blocks,
},
{
supportedContentTypes: [ContentTypes.YJS, ContentTypes.OctetStream],
write: async (blocks) => Y.encodeStateAsUpdate(createYDocument(blocks)),
},
{
supportedContentTypes: [ContentTypes.Markdown, ContentTypes.XMarkdown],
write: (blocks) => editor.blocksToMarkdownLossy(blocks),
},
{
supportedContentTypes: [ContentTypes.HTML],
write: (blocks) => editor.blocksToHTMLLossy(blocks),
},
];
const normalizeContentType = (value: string) => value.split(';')[0];
export const convertHandler = async (
req: Request<object, Uint8Array | ErrorResponse, Buffer, object>,
res: Response<Uint8Array | string | object | ErrorResponse>,
res: Response<ConversionResponseBody>,
) => {
if (!req.body || req.body.length === 0) {
res.status(400).json({ error: 'Invalid request: missing content' });
return;
}
const contentType = (req.header('content-type') || 'text/markdown').split(
';',
)[0];
const accept = (req.header('accept') || 'application/vnd.yjs.doc').split(
';',
)[0];
const contentType = normalizeContentType(
req.header('content-type') || ContentTypes.Markdown,
);
const reader = readers.find((reader) =>
reader.supportedContentTypes.includes(contentType),
);
if (!reader) {
res.status(415).json({ error: 'Unsupported Content-Type' });
return;
}
const accept = normalizeContentType(req.header('accept') || ContentTypes.YJS);
const writer = writers.find((writer) =>
writer.supportedContentTypes.includes(accept),
);
if (!writer) {
res.status(406).json({ error: 'Unsupported format' });
return;
}
let blocks:
| PartialBlock<
@@ -44,63 +132,23 @@ export const convertHandler = async (
>[]
| null = null;
try {
// First, convert from the input format to blocks
// application/x-www-form-urlencoded is interpreted as Markdown for backward compatibility
if (
contentType === 'text/markdown' ||
contentType === 'application/x-www-form-urlencoded'
) {
blocks = await editor.tryParseMarkdownToBlocks(req.body.toString());
} else if (
contentType === 'application/vnd.yjs.doc' ||
contentType === 'application/octet-stream'
) {
try {
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, req.body);
blocks = editor.yDocToBlocks(ydoc, 'document-store') as PartialBlock[];
} catch (e) {
logger('Invalid Yjs content:', e);
res.status(400).json({ error: 'Invalid Yjs content' });
return;
}
} else {
res.status(415).json({ error: 'Unsupported Content-Type' });
try {
blocks = await reader.read(req.body);
} catch (e) {
logger('Invalid content:', e);
res.status(400).json({ error: 'Invalid content' });
return;
}
if (!blocks || blocks.length === 0) {
res.status(500).json({ error: 'No valid blocks were generated' });
return;
}
// Then, convert from blocks to the output format
if (accept === 'application/json') {
res.status(200).json(blocks);
} else {
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
if (
accept === 'application/vnd.yjs.doc' ||
accept === 'application/octet-stream'
) {
res
.status(200)
.setHeader('content-type', 'application/octet-stream')
.send(Y.encodeStateAsUpdate(yDocument));
} else if (accept === 'text/markdown') {
res
.status(200)
.setHeader('content-type', 'text/markdown')
.send(await editor.blocksToMarkdownLossy(blocks));
} else if (accept === 'text/html') {
res
.status(200)
.setHeader('content-type', 'text/html')
.send(await editor.blocksToHTMLLossy(blocks));
} else {
res.status(406).json({ error: 'Unsupported format' });
}
}
res
.status(200)
.setHeader('content-type', accept)
.send(await writer.write(blocks));
} catch (e) {
logger('conversion failed:', e);
res.status(500).json({ error: 'An error occurred' });
+28 -2
View File
@@ -7858,6 +7858,11 @@ at-least-node@^1.0.0:
resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
attr-accept@^2.2.4:
version "2.2.5"
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e"
integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==
available-typed-arrays@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846"
@@ -9905,6 +9910,13 @@ file-entry-cache@^8.0.0:
dependencies:
flat-cache "^4.0.0"
file-selector@^2.1.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4"
integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==
dependencies:
tslib "^2.7.0"
filelist@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5"
@@ -12002,7 +12014,12 @@ lodash.truncate@^4.4.2:
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==
[email protected]1, lodash@^4.17.20:
[email protected]3:
version "4.17.23"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
lodash@^4.17.20:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -13927,6 +13944,15 @@ react-dom@*, [email protected]:
dependencies:
scheduler "^0.27.0"
[email protected]:
version "14.3.8"
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.8.tgz#a7eab118f8a452fe3f8b162d64454e81ba830582"
integrity sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==
dependencies:
attr-accept "^2.2.4"
file-selector "^2.1.0"
prop-types "^15.8.1"
[email protected]:
version "16.5.1"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-16.5.1.tgz#a299224b6d9c054dc92d7d65b642e48964b318ee"
@@ -15732,7 +15758,7 @@ [email protected]:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.8.0:
tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.7.0, tslib@^2.8.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+19 -2
View File
@@ -27,6 +27,7 @@ backend:
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://docs.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_URL_APP: https://docs.127.0.0.1.nip.io
DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO
@@ -67,7 +68,8 @@ backend:
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: docs-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
DOCSPEC_API_URL: http://impress-docs-docspec:4000/conversion
Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
migrate:
@@ -156,11 +158,26 @@ yProvider:
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
docSpec:
enabled: true
replicas: 1
image:
repository: ghcr.io/docspecio/api
pullPolicy: IfNotPresent
tag: "2.6.3"
probes:
liveness:
path: /health
readiness:
path: /health
ingress:
enabled: true
host: docs.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-body-size: 20m
ingressCollaborationWS:
enabled: true
@@ -28,6 +28,7 @@ backend:
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://{{ .Values.feature }}-docs.{{ .Values.domain }}/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_URL_APP: https://{{ .Values.feature }}-docs.{{ .Values.domain }}
DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO
@@ -68,7 +69,8 @@ backend:
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: docs-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
DOCSPEC_API_URL: http://impress-docs-docspec:4000/conversion
Y_PROVIDER_API_BASE_URL: http://impress-docs-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
migrate:
@@ -142,11 +144,26 @@ yProvider:
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
docSpec:
enabled: true
replicas: 1
image:
repository: ghcr.io/docspecio/api
pullPolicy: IfNotPresent
tag: "2.6.3"
probes:
liveness:
path: /health
readiness:
path: /health
ingress:
enabled: true
host: {{ .Values.feature }}-docs.{{ .Values.domain }}
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-body-size: 20m
cert-manager.io/cluster-issuer: letsencrypt
ingressCollaborationWS:
+10
View File
@@ -167,6 +167,16 @@ Requires top level scope
{{- end }}
{{/*
Full name for the docSpec
Requires top level scope
*/}}
{{- define "impress.docSpec.fullname" -}}
{{ include "impress.fullname" . }}-docspec
{{- end }}
{{/*
Full name for the Celery Worker
@@ -0,0 +1,108 @@
{{- if .Values.docSpec.enabled -}}
{{- $envVars := include "impress.common.env" (list . .Values.docSpec) -}}
{{- $fullName := include "impress.docSpec.fullname" . -}}
{{- $component := "docspec" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "impress.common.labels" (list . $component) | nindent 4 }}
spec:
replicas: {{ .Values.docSpec.replicas }}
selector:
matchLabels:
{{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }}
template:
metadata:
labels:
{{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }}
spec:
{{- if $.Values.image.credentials }}
imagePullSecrets:
- name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }}
{{- end}}
containers:
- name: {{ .Chart.Name }}-docspec
image: "{{ .Values.docSpec.image.repository }}:{{ .Values.docSpec.image.tag }}"
imagePullPolicy: {{ .Values.docSpec.image.pullPolicy }}
{{- with .Values.docSpec.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.docSpec.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if $envVars }}
env:
{{- $envVars | indent 12 }}
{{- end }}
{{- with .Values.docSpec.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.docSpec.service.targetPort }}
protocol: TCP
{{- if .Values.docSpec.probes.liveness }}
livenessProbe:
{{- include "impress.probes.abstract" (merge .Values.docSpec.probes.liveness (dict "targetPort" .Values.docSpec.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.docSpec.probes.readiness }}
readinessProbe:
{{- include "impress.probes.abstract" (merge .Values.docSpec.probes.readiness (dict "targetPort" .Values.docSpec.service.targetPort )) | nindent 12 }}
{{- end }}
{{- with .Values.docSpec.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.docSpec.extraVolumeMounts }}
volumeMounts:
{{- range .Values.docSpec.extraVolumeMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
{{- if .subPath }}
subPath: {{ .subPath }}
{{- end }}
{{- if .readOnly }}
readOnly: {{ .readOnly }}
{{- end }}
{{- end }}
{{- end }}
{{- with .Values.docSpec.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.docSpec.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.docSpec.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.docSpec.extraVolumes }}
volumes:
{{- range .Values.docSpec.extraVolumes }}
- name: {{ .name }}
{{- if .persistentVolumeClaim }}
persistentVolumeClaim:
{{- toYaml .persistentVolumeClaim | nindent 12 }}
{{- else if .emptyDir }}
emptyDir:
{{- toYaml .emptyDir | nindent 12 }}
{{- else if .configMap }}
configMap:
{{- toYaml .configMap | nindent 12 }}
{{- else if .secret }}
secret:
{{- toYaml .secret | nindent 12 }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if .Values.docSpec.enabled -}}
{{- $fullName := include "impress.docSpec.fullname" . -}}
{{- $component := "docspec" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "impress.common.labels" (list . $component) | nindent 4 }}
spec:
type: {{ .Values.docSpec.service.type }}
ports:
- port: {{ .Values.docSpec.service.port }}
targetPort: {{ .Values.docSpec.service.targetPort }}
protocol: TCP
name: http
selector:
{{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }}
{{- end }}
+66
View File
@@ -701,3 +701,69 @@ yProvider:
## @param yProvider.serviceAccountName Optional service account name to use for yProvider pods
serviceAccountName: null
## @section docSpec
docSpec:
## @param docSpec.enabled Enable docSpec deployment
enabled: false
## @param docSpec.image.repository Repository to use to pull docSpec container image
## @param docSpec.image.tag docSpec container tag
## @param docSpec.image.pullPolicy docSpec container image pull policy
image:
repository: ghcr.io/docspecio/api
pullPolicy: IfNotPresent
tag: "2.6.3"
## @param docSpec.command Override the docSpec container command
command: []
## @param docSpec.args Override the docSpec container args
args: []
## @param docSpec.replicas Amount of docSpec replicas
replicas: 1
## @param docSpec.securityContext Configure docSpec Pod security context
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
## @param docSpec.envVars Configure docSpec container environment variables
envVars: {}
## @param docSpec.service Configure docSpec service
service:
type: ClusterIP
port: 4000
targetPort: 4000
## @param docSpec.probes Configure docSpec probes
probes:
liveness:
path: /health
readiness:
path: /health
## @param docSpec.resources docSpec resources
resources: {}
## @param docSpec.nodeSelector Node selector for the docSpec Pod
nodeSelector: {}
## @param docSpec.tolerations Tolerations for the docSpec Pod
tolerations: []
## @param docSpec.affinity Affinity for the docSpec Pod
affinity: {}
## @param docSpec.extraVolumeMounts Additional volumes to mount on docSpec
extraVolumeMounts: []
## @param docSpec.extraVolumes Additional volumes to mount on docSpec
extraVolumes: []