Merge branch 'sbl/user-reconciliation' of github.com:suitenumerique/docs into sbl/user-reconciliation
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,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"""
|
||||
|
||||
|
||||
+1
-158
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"]}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
Binary file not shown.
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
+3
-2
@@ -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[] = [
|
||||
|
||||
+6
-5
@@ -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
@@ -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==
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: []
|
||||
|
||||
Reference in New Issue
Block a user