2024-03-07 19:46:46 +01:00
|
|
|
"""Client serializers for the impress core app."""
|
2024-08-20 16:42:27 +02:00
|
|
|
|
2025-03-28 18:15:20 +01:00
|
|
|
import binascii
|
2024-08-19 22:35:48 +02:00
|
|
|
import mimetypes
|
2025-03-28 18:15:20 +01:00
|
|
|
from base64 import b64decode
|
2024-08-19 22:35:48 +02:00
|
|
|
|
|
|
|
|
from django.conf import settings
|
2024-03-03 08:49:27 +01:00
|
|
|
from django.db.models import Q
|
2024-12-01 11:25:01 +01:00
|
|
|
from django.utils.functional import lazy
|
2024-02-09 19:32:12 +01:00
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
|
|
2024-10-07 20:10:42 +02:00
|
|
|
import magic
|
2025-05-06 09:41:16 +02:00
|
|
|
from rest_framework import serializers
|
2024-01-09 15:30:36 +01:00
|
|
|
|
2025-04-23 22:47:24 +02:00
|
|
|
from core import choices, enums, models, utils
|
2024-09-20 22:42:46 +02:00
|
|
|
from core.services.ai_services import AI_ACTIONS
|
2024-12-01 11:25:01 +01:00
|
|
|
from core.services.converter_services import (
|
|
|
|
|
ConversionError,
|
|
|
|
|
YdocConverter,
|
|
|
|
|
)
|
2024-01-09 15:30:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserSerializer(serializers.ModelSerializer):
|
|
|
|
|
"""Serialize users."""
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = models.User
|
2025-03-04 14:00:22 +01:00
|
|
|
fields = ["id", "email", "full_name", "short_name", "language"]
|
2024-09-30 15:13:42 +02:00
|
|
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
2024-01-09 15:30:36 +01:00
|
|
|
|
2024-02-09 19:32:12 +01:00
|
|
|
|
2025-03-24 23:36:24 +01:00
|
|
|
class UserLightSerializer(UserSerializer):
|
|
|
|
|
"""Serialize users with limited fields."""
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = models.User
|
2025-05-02 18:30:12 +02:00
|
|
|
fields = ["full_name", "short_name"]
|
|
|
|
|
read_only_fields = ["full_name", "short_name"]
|
2025-03-24 23:36:24 +01:00
|
|
|
|
|
|
|
|
|
2025-05-06 09:41:16 +02:00
|
|
|
class TemplateAccessSerializer(serializers.ModelSerializer):
|
2024-04-03 18:50:28 +02:00
|
|
|
"""Serialize template accesses."""
|
2024-01-09 15:30:36 +01:00
|
|
|
|
2025-05-06 09:41:16 +02:00
|
|
|
abilities = serializers.SerializerMethodField(read_only=True)
|
|
|
|
|
|
2024-01-09 15:30:36 +01:00
|
|
|
class Meta:
|
2024-04-03 18:50:28 +02:00
|
|
|
model = models.TemplateAccess
|
|
|
|
|
resource_field_name = "template"
|
|
|
|
|
fields = ["id", "user", "team", "role", "abilities"]
|
|
|
|
|
read_only_fields = ["id", "abilities"]
|
2024-01-09 15:30:36 +01:00
|
|
|
|
2025-05-06 09:41:16 +02:00
|
|
|
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)
|
|
|
|
|
|
2024-04-03 18:50:28 +02:00
|
|
|
|
2025-02-16 17:26:51 +01:00
|
|
|
class ListDocumentSerializer(serializers.ModelSerializer):
|
2024-11-09 10:27:21 +01:00
|
|
|
"""Serialize documents with limited fields for display in lists."""
|
2024-04-03 18:50:28 +02:00
|
|
|
|
2024-11-09 10:45:38 +01:00
|
|
|
is_favorite = serializers.BooleanField(read_only=True)
|
2025-02-26 09:27:00 +01:00
|
|
|
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
|
|
|
|
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
2025-04-25 08:03:12 +02:00
|
|
|
user_role = serializers.SerializerMethodField(read_only=True)
|
2025-02-16 17:26:51 +01:00
|
|
|
abilities = serializers.SerializerMethodField(read_only=True)
|
2024-04-06 09:09:46 +02:00
|
|
|
|
2024-04-03 18:50:28 +02:00
|
|
|
class Meta:
|
|
|
|
|
model = models.Document
|
2024-07-02 12:47:22 +02:00
|
|
|
fields = [
|
|
|
|
|
"id",
|
2024-12-17 07:47:23 +01:00
|
|
|
"abilities",
|
2025-04-28 08:03:39 +02:00
|
|
|
"ancestors_link_reach",
|
|
|
|
|
"ancestors_link_role",
|
2025-04-28 21:43:59 +02:00
|
|
|
"computed_link_reach",
|
|
|
|
|
"computed_link_role",
|
2024-11-09 10:27:21 +01:00
|
|
|
"created_at",
|
2024-11-12 16:28:34 +01:00
|
|
|
"creator",
|
2024-12-17 17:53:05 +01:00
|
|
|
"depth",
|
2024-12-18 11:37:01 +01:00
|
|
|
"excerpt",
|
2024-11-09 10:45:38 +01:00
|
|
|
"is_favorite",
|
2024-11-09 10:27:21 +01:00
|
|
|
"link_role",
|
|
|
|
|
"link_reach",
|
2025-02-26 09:27:00 +01:00
|
|
|
"nb_accesses_ancestors",
|
|
|
|
|
"nb_accesses_direct",
|
2024-12-17 17:53:05 +01:00
|
|
|
"numchild",
|
|
|
|
|
"path",
|
2024-07-02 12:47:22 +02:00
|
|
|
"title",
|
2024-11-09 10:27:21 +01:00
|
|
|
"updated_at",
|
2025-04-25 08:03:12 +02:00
|
|
|
"user_role",
|
2024-11-09 10:27:21 +01:00
|
|
|
]
|
|
|
|
|
read_only_fields = [
|
|
|
|
|
"id",
|
2024-07-02 12:47:22 +02:00
|
|
|
"abilities",
|
2025-04-28 08:03:39 +02:00
|
|
|
"ancestors_link_reach",
|
|
|
|
|
"ancestors_link_role",
|
2025-04-28 21:43:59 +02:00
|
|
|
"computed_link_reach",
|
|
|
|
|
"computed_link_role",
|
2024-11-09 10:27:21 +01:00
|
|
|
"created_at",
|
2024-11-12 16:28:34 +01:00
|
|
|
"creator",
|
2024-12-17 17:53:05 +01:00
|
|
|
"depth",
|
2024-12-18 11:37:01 +01:00
|
|
|
"excerpt",
|
2024-11-09 10:45:38 +01:00
|
|
|
"is_favorite",
|
2024-09-08 23:37:49 +02:00
|
|
|
"link_role",
|
|
|
|
|
"link_reach",
|
2025-02-26 09:27:00 +01:00
|
|
|
"nb_accesses_ancestors",
|
|
|
|
|
"nb_accesses_direct",
|
2024-12-17 17:53:05 +01:00
|
|
|
"numchild",
|
|
|
|
|
"path",
|
2024-11-09 10:27:21 +01:00
|
|
|
"updated_at",
|
2025-04-25 08:03:12 +02:00
|
|
|
"user_role",
|
2024-11-09 10:27:21 +01:00
|
|
|
]
|
|
|
|
|
|
2025-04-25 08:03:12 +02:00
|
|
|
def to_representation(self, instance):
|
|
|
|
|
"""Precompute once per instance"""
|
|
|
|
|
paths_links_mapping = self.context.get("paths_links_mapping")
|
2025-02-16 17:26:51 +01:00
|
|
|
|
2025-04-25 08:03:12 +02:00
|
|
|
if paths_links_mapping is not None:
|
|
|
|
|
links = paths_links_mapping.get(instance.path[: -instance.steplen], [])
|
|
|
|
|
instance.ancestors_link_definition = choices.get_equivalent_link_definition(
|
|
|
|
|
links
|
2025-02-17 10:25:07 +01:00
|
|
|
)
|
2025-02-16 17:26:51 +01:00
|
|
|
|
2025-04-25 08:03:12 +02:00
|
|
|
return super().to_representation(instance)
|
2025-02-16 17:26:51 +01:00
|
|
|
|
2025-04-25 08:03:12 +02:00
|
|
|
def get_abilities(self, instance) -> dict:
|
|
|
|
|
"""Return abilities of the logged-in user on the instance."""
|
|
|
|
|
request = self.context.get("request")
|
|
|
|
|
if not request:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
return instance.get_abilities(request.user)
|
|
|
|
|
|
|
|
|
|
def get_user_role(self, instance):
|
2025-01-02 14:02:03 +01:00
|
|
|
"""
|
|
|
|
|
Return roles of the logged-in user for the current document,
|
|
|
|
|
taking into account ancestors.
|
|
|
|
|
"""
|
|
|
|
|
request = self.context.get("request")
|
2025-04-25 08:03:12 +02:00
|
|
|
return instance.get_role(request.user) if request else None
|
2025-01-02 14:02:03 +01:00
|
|
|
|
2024-11-09 10:27:21 +01:00
|
|
|
|
2025-05-07 18:48:08 +02:00
|
|
|
class DocumentLightSerializer(serializers.ModelSerializer):
|
|
|
|
|
"""Minial document serializer for nesting in document accesses."""
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = models.Document
|
|
|
|
|
fields = ["id", "path", "depth"]
|
|
|
|
|
read_only_fields = ["id", "path", "depth"]
|
|
|
|
|
|
|
|
|
|
|
2024-11-09 10:27:21 +01:00
|
|
|
class DocumentSerializer(ListDocumentSerializer):
|
|
|
|
|
"""Serialize documents with all fields for display in detail views."""
|
|
|
|
|
|
|
|
|
|
content = serializers.CharField(required=False)
|
2025-06-25 17:30:33 +02:00
|
|
|
websocket = serializers.BooleanField(required=False, write_only=True)
|
2024-11-09 10:27:21 +01:00
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = models.Document
|
|
|
|
|
fields = [
|
|
|
|
|
"id",
|
|
|
|
|
"abilities",
|
2025-04-28 08:03:39 +02:00
|
|
|
"ancestors_link_reach",
|
|
|
|
|
"ancestors_link_role",
|
2025-04-28 21:43:59 +02:00
|
|
|
"computed_link_reach",
|
|
|
|
|
"computed_link_role",
|
2024-11-09 10:27:21 +01:00
|
|
|
"content",
|
2024-09-08 23:37:49 +02:00
|
|
|
"created_at",
|
2024-11-12 16:28:34 +01:00
|
|
|
"creator",
|
2024-12-17 17:53:05 +01:00
|
|
|
"depth",
|
2024-12-18 11:37:01 +01:00
|
|
|
"excerpt",
|
2024-11-09 10:45:38 +01:00
|
|
|
"is_favorite",
|
2024-11-09 10:27:21 +01:00
|
|
|
"link_role",
|
|
|
|
|
"link_reach",
|
2025-02-26 09:27:00 +01:00
|
|
|
"nb_accesses_ancestors",
|
|
|
|
|
"nb_accesses_direct",
|
2024-12-17 17:53:05 +01:00
|
|
|
"numchild",
|
|
|
|
|
"path",
|
2024-11-09 10:27:21 +01:00
|
|
|
"title",
|
2024-09-08 23:37:49 +02:00
|
|
|
"updated_at",
|
2025-04-25 08:03:12 +02:00
|
|
|
"user_role",
|
2025-06-25 17:30:33 +02:00
|
|
|
"websocket",
|
2024-09-08 23:37:49 +02:00
|
|
|
]
|
|
|
|
|
read_only_fields = [
|
|
|
|
|
"id",
|
|
|
|
|
"abilities",
|
2025-04-28 08:03:39 +02:00
|
|
|
"ancestors_link_reach",
|
|
|
|
|
"ancestors_link_role",
|
2025-04-28 21:43:59 +02:00
|
|
|
"computed_link_reach",
|
|
|
|
|
"computed_link_role",
|
2024-11-09 10:27:21 +01:00
|
|
|
"created_at",
|
2024-11-12 16:28:34 +01:00
|
|
|
"creator",
|
2024-12-17 17:53:05 +01:00
|
|
|
"depth",
|
2025-01-15 11:57:32 +01:00
|
|
|
"is_favorite",
|
2024-09-08 23:37:49 +02:00
|
|
|
"link_role",
|
|
|
|
|
"link_reach",
|
2025-02-26 09:27:00 +01:00
|
|
|
"nb_accesses_ancestors",
|
|
|
|
|
"nb_accesses_direct",
|
2024-12-17 17:53:05 +01:00
|
|
|
"numchild",
|
|
|
|
|
"path",
|
2024-07-02 12:47:22 +02:00
|
|
|
"updated_at",
|
2025-04-25 08:03:12 +02:00
|
|
|
"user_role",
|
2024-07-02 12:47:22 +02:00
|
|
|
]
|
2024-04-03 18:50:28 +02:00
|
|
|
|
2024-09-09 19:31:26 +02:00
|
|
|
def get_fields(self):
|
|
|
|
|
"""Dynamically make `id` read-only on PUT requests but writable on POST requests."""
|
|
|
|
|
fields = super().get_fields()
|
|
|
|
|
|
|
|
|
|
request = self.context.get("request")
|
|
|
|
|
if request and request.method == "POST":
|
|
|
|
|
fields["id"].read_only = False
|
|
|
|
|
|
|
|
|
|
return fields
|
|
|
|
|
|
|
|
|
|
def validate_id(self, value):
|
|
|
|
|
"""Ensure the provided ID does not already exist when creating a new document."""
|
|
|
|
|
request = self.context.get("request")
|
|
|
|
|
|
|
|
|
|
# Only check this on POST (creation)
|
|
|
|
|
if request and request.method == "POST":
|
|
|
|
|
if models.Document.objects.filter(id=value).exists():
|
|
|
|
|
raise serializers.ValidationError(
|
|
|
|
|
"A document with this ID already exists. You cannot override it."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return value
|
|
|
|
|
|
2025-03-28 18:15:20 +01:00
|
|
|
def validate_content(self, value):
|
|
|
|
|
"""Validate the content field."""
|
|
|
|
|
if not value:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
b64decode(value, validate=True)
|
|
|
|
|
except binascii.Error as err:
|
|
|
|
|
raise serializers.ValidationError("Invalid base64 content.") from err
|
|
|
|
|
|
|
|
|
|
return value
|
|
|
|
|
|
2025-01-21 23:56:50 +01:00
|
|
|
def save(self, **kwargs):
|
|
|
|
|
"""
|
|
|
|
|
Process the content field to extract attachment keys and update the document's
|
|
|
|
|
"attachments" field for access control.
|
|
|
|
|
"""
|
|
|
|
|
content = self.validated_data.get("content", "")
|
|
|
|
|
extracted_attachments = set(utils.extract_attachments(content))
|
|
|
|
|
|
|
|
|
|
existing_attachments = (
|
|
|
|
|
set(self.instance.attachments or []) if self.instance else set()
|
|
|
|
|
)
|
|
|
|
|
new_attachments = extracted_attachments - existing_attachments
|
|
|
|
|
|
|
|
|
|
if new_attachments:
|
|
|
|
|
attachments_documents = (
|
|
|
|
|
models.Document.objects.filter(
|
|
|
|
|
attachments__overlap=list(new_attachments)
|
|
|
|
|
)
|
|
|
|
|
.only("path", "attachments")
|
|
|
|
|
.order_by("path")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
user = self.context["request"].user
|
|
|
|
|
readable_per_se_paths = (
|
|
|
|
|
models.Document.objects.readable_per_se(user)
|
|
|
|
|
.order_by("path")
|
|
|
|
|
.values_list("path", flat=True)
|
|
|
|
|
)
|
|
|
|
|
readable_attachments_paths = utils.filter_descendants(
|
|
|
|
|
[doc.path for doc in attachments_documents],
|
|
|
|
|
readable_per_se_paths,
|
|
|
|
|
skip_sorting=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
readable_attachments = set()
|
|
|
|
|
for document in attachments_documents:
|
|
|
|
|
if document.path not in readable_attachments_paths:
|
|
|
|
|
continue
|
|
|
|
|
readable_attachments.update(set(document.attachments) & new_attachments)
|
|
|
|
|
|
|
|
|
|
# Update attachments with readable keys
|
|
|
|
|
self.validated_data["attachments"] = list(
|
|
|
|
|
existing_attachments | readable_attachments
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return super().save(**kwargs)
|
|
|
|
|
|
2024-04-03 18:50:28 +02:00
|
|
|
|
2025-05-07 18:48:08 +02:00
|
|
|
class DocumentAccessSerializer(serializers.ModelSerializer):
|
|
|
|
|
"""Serialize document accesses."""
|
|
|
|
|
|
|
|
|
|
document = DocumentLightSerializer(read_only=True)
|
|
|
|
|
user_id = serializers.PrimaryKeyRelatedField(
|
|
|
|
|
queryset=models.User.objects.all(),
|
|
|
|
|
write_only=True,
|
|
|
|
|
source="user",
|
|
|
|
|
required=False,
|
|
|
|
|
allow_null=True,
|
|
|
|
|
)
|
|
|
|
|
user = UserSerializer(read_only=True)
|
|
|
|
|
team = serializers.CharField(required=False, allow_blank=True)
|
|
|
|
|
abilities = serializers.SerializerMethodField(read_only=True)
|
|
|
|
|
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = models.DocumentAccess
|
|
|
|
|
resource_field_name = "document"
|
|
|
|
|
fields = [
|
|
|
|
|
"id",
|
|
|
|
|
"document",
|
|
|
|
|
"user",
|
|
|
|
|
"user_id",
|
|
|
|
|
"team",
|
|
|
|
|
"role",
|
|
|
|
|
"abilities",
|
|
|
|
|
"max_ancestors_role",
|
|
|
|
|
]
|
|
|
|
|
read_only_fields = ["id", "document", "abilities", "max_ancestors_role"]
|
|
|
|
|
|
|
|
|
|
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 get_max_ancestors_role(self, instance):
|
|
|
|
|
"""Return max_ancestors_role if annotated; else None."""
|
|
|
|
|
return getattr(instance, "max_ancestors_role", None)
|
|
|
|
|
|
|
|
|
|
def update(self, instance, validated_data):
|
|
|
|
|
"""Make "user" field readonly but only on update."""
|
|
|
|
|
validated_data.pop("team", None)
|
|
|
|
|
validated_data.pop("user", None)
|
|
|
|
|
return super().update(instance, validated_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DocumentAccessLightSerializer(DocumentAccessSerializer):
|
|
|
|
|
"""Serialize document accesses with limited fields."""
|
|
|
|
|
|
|
|
|
|
user = UserLightSerializer(read_only=True)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = models.DocumentAccess
|
|
|
|
|
resource_field_name = "document"
|
|
|
|
|
fields = [
|
|
|
|
|
"id",
|
|
|
|
|
"document",
|
|
|
|
|
"user",
|
|
|
|
|
"team",
|
|
|
|
|
"role",
|
|
|
|
|
"abilities",
|
|
|
|
|
"max_ancestors_role",
|
|
|
|
|
]
|
|
|
|
|
read_only_fields = [
|
|
|
|
|
"id",
|
|
|
|
|
"document",
|
|
|
|
|
"team",
|
|
|
|
|
"role",
|
|
|
|
|
"abilities",
|
|
|
|
|
"max_ancestors_role",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2024-12-01 11:25:01 +01:00
|
|
|
class ServerCreateDocumentSerializer(serializers.Serializer):
|
|
|
|
|
"""
|
|
|
|
|
Serializer for creating a document from a server-to-server request.
|
|
|
|
|
|
|
|
|
|
Expects 'content' as a markdown string, which is converted to our internal format
|
|
|
|
|
via a Node.js microservice. The conversion is handled automatically, so third parties
|
|
|
|
|
only need to provide markdown.
|
|
|
|
|
|
|
|
|
|
Both "sub" and "email" are required because the external app calling doesn't know
|
|
|
|
|
if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the
|
|
|
|
|
submitted "email" field and use the email address set on the user account in our database
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# Document
|
|
|
|
|
title = serializers.CharField(required=True)
|
|
|
|
|
content = serializers.CharField(required=True)
|
|
|
|
|
# User
|
|
|
|
|
sub = serializers.CharField(
|
|
|
|
|
required=True, validators=[models.User.sub_validator], max_length=255
|
|
|
|
|
)
|
|
|
|
|
email = serializers.EmailField(required=True)
|
|
|
|
|
language = serializers.ChoiceField(
|
|
|
|
|
required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)()
|
|
|
|
|
)
|
|
|
|
|
# Invitation
|
|
|
|
|
message = serializers.CharField(required=False)
|
|
|
|
|
subject = serializers.CharField(required=False)
|
|
|
|
|
|
|
|
|
|
def create(self, validated_data):
|
|
|
|
|
"""Create the document and associate it with the user or send an invitation."""
|
|
|
|
|
language = validated_data.get("language", settings.LANGUAGE_CODE)
|
|
|
|
|
|
2025-01-10 09:50:48 +01:00
|
|
|
# Get the user on its sub (unique identifier). Default on email if allowed in settings
|
|
|
|
|
email = validated_data["email"]
|
|
|
|
|
|
2024-12-01 11:25:01 +01:00
|
|
|
try:
|
2025-01-10 09:50:48 +01:00
|
|
|
user = models.User.objects.get_user_by_sub_or_email(
|
|
|
|
|
validated_data["sub"], email
|
|
|
|
|
)
|
|
|
|
|
except models.DuplicateEmailError as err:
|
|
|
|
|
raise serializers.ValidationError({"email": [err.message]}) from err
|
|
|
|
|
|
|
|
|
|
if user:
|
2024-12-01 11:25:01 +01:00
|
|
|
email = user.email
|
|
|
|
|
language = user.language or language
|
|
|
|
|
|
|
|
|
|
try:
|
2025-07-04 15:30:32 +02:00
|
|
|
document_content = YdocConverter().convert(validated_data["content"])
|
2024-12-01 11:25:01 +01:00
|
|
|
except ConversionError as err:
|
2025-01-10 09:50:48 +01:00
|
|
|
raise serializers.ValidationError(
|
|
|
|
|
{"content": ["Could not convert content"]}
|
|
|
|
|
) from err
|
2024-12-01 11:25:01 +01:00
|
|
|
|
2024-12-16 16:58:14 +01:00
|
|
|
document = models.Document.add_root(
|
2024-12-01 11:25:01 +01:00
|
|
|
title=validated_data["title"],
|
2024-12-13 12:21:55 +01:00
|
|
|
content=document_content,
|
2024-12-01 11:25:01 +01:00
|
|
|
creator=user,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if user:
|
|
|
|
|
# Associate the document with the pre-existing user
|
|
|
|
|
models.DocumentAccess.objects.create(
|
|
|
|
|
document=document,
|
|
|
|
|
role=models.RoleChoices.OWNER,
|
|
|
|
|
user=user,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# The user doesn't exist in our database: we need to invite him/her
|
|
|
|
|
models.Invitation.objects.create(
|
|
|
|
|
document=document,
|
|
|
|
|
email=email,
|
|
|
|
|
role=models.RoleChoices.OWNER,
|
|
|
|
|
)
|
|
|
|
|
|
2025-01-10 09:50:48 +01:00
|
|
|
self._send_email_notification(document, validated_data, email, language)
|
|
|
|
|
return document
|
|
|
|
|
|
|
|
|
|
def _send_email_notification(self, document, validated_data, email, language):
|
|
|
|
|
"""Notify the user about the newly created document."""
|
2024-12-01 11:25:01 +01:00
|
|
|
subject = validated_data.get("subject") or _(
|
|
|
|
|
"A new document was created on your behalf!"
|
|
|
|
|
)
|
|
|
|
|
context = {
|
|
|
|
|
"message": validated_data.get("message")
|
|
|
|
|
or _("You have been granted ownership of a new document:"),
|
|
|
|
|
"title": subject,
|
|
|
|
|
}
|
|
|
|
|
document.send_email(subject, [email], context, language)
|
|
|
|
|
|
|
|
|
|
def update(self, instance, validated_data):
|
|
|
|
|
"""
|
|
|
|
|
This serializer does not support updates.
|
|
|
|
|
"""
|
|
|
|
|
raise NotImplementedError("Update is not supported for this serializer.")
|
|
|
|
|
|
|
|
|
|
|
2025-02-16 17:26:51 +01:00
|
|
|
class LinkDocumentSerializer(serializers.ModelSerializer):
|
2024-09-08 23:07:47 +02:00
|
|
|
"""
|
|
|
|
|
Serialize link configuration for documents.
|
|
|
|
|
We expose it separately from document in order to simplify and secure access control.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = models.Document
|
|
|
|
|
fields = [
|
|
|
|
|
"link_role",
|
|
|
|
|
"link_reach",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2025-01-20 10:23:18 +01:00
|
|
|
class DocumentDuplicationSerializer(serializers.Serializer):
|
|
|
|
|
"""
|
|
|
|
|
Serializer for duplicating a document.
|
|
|
|
|
Allows specifying whether to keep access permissions.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
with_accesses = serializers.BooleanField(default=False)
|
|
|
|
|
|
|
|
|
|
def create(self, validated_data):
|
|
|
|
|
"""
|
|
|
|
|
This serializer is not intended to create objects.
|
|
|
|
|
"""
|
|
|
|
|
raise NotImplementedError("This serializer does not support creation.")
|
|
|
|
|
|
|
|
|
|
def update(self, instance, validated_data):
|
|
|
|
|
"""
|
|
|
|
|
This serializer is not intended to update objects.
|
|
|
|
|
"""
|
|
|
|
|
raise NotImplementedError("This serializer does not support updating.")
|
|
|
|
|
|
|
|
|
|
|
2024-08-19 22:35:48 +02:00
|
|
|
# Suppress the warning about not implementing `create` and `update` methods
|
|
|
|
|
# since we don't use a model and only rely on the serializer for validation
|
|
|
|
|
# pylint: disable=abstract-method
|
|
|
|
|
class FileUploadSerializer(serializers.Serializer):
|
|
|
|
|
"""Receive file upload requests."""
|
|
|
|
|
|
|
|
|
|
file = serializers.FileField()
|
|
|
|
|
|
|
|
|
|
def validate_file(self, file):
|
|
|
|
|
"""Add file size and type constraints as defined in settings."""
|
|
|
|
|
# Validate file size
|
|
|
|
|
if file.size > settings.DOCUMENT_IMAGE_MAX_SIZE:
|
|
|
|
|
max_size = settings.DOCUMENT_IMAGE_MAX_SIZE // (1024 * 1024)
|
|
|
|
|
raise serializers.ValidationError(
|
|
|
|
|
f"File size exceeds the maximum limit of {max_size:d} MB."
|
|
|
|
|
)
|
|
|
|
|
|
2024-10-07 20:10:42 +02:00
|
|
|
extension = file.name.rpartition(".")[-1] if "." in file.name else None
|
|
|
|
|
|
|
|
|
|
# Read the first few bytes to determine the MIME type accurately
|
|
|
|
|
mime = magic.Magic(mime=True)
|
|
|
|
|
magic_mime_type = mime.from_buffer(file.read(1024))
|
|
|
|
|
file.seek(0) # Reset file pointer to the beginning after reading
|
2025-06-27 17:31:15 +02:00
|
|
|
self.context["is_unsafe"] = False
|
|
|
|
|
if settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED:
|
|
|
|
|
self.context["is_unsafe"] = (
|
|
|
|
|
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
|
|
|
|
)
|
2024-10-07 20:10:42 +02:00
|
|
|
|
2025-06-27 17:31:15 +02:00
|
|
|
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
2024-10-07 20:10:42 +02:00
|
|
|
|
2025-06-27 17:31:15 +02:00
|
|
|
# Try guessing a coherent extension from the mimetype
|
|
|
|
|
if extension_mime_type != magic_mime_type:
|
|
|
|
|
self.context["is_unsafe"] = True
|
2024-10-07 20:10:42 +02:00
|
|
|
|
|
|
|
|
guessed_ext = mimetypes.guess_extension(magic_mime_type)
|
|
|
|
|
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
|
|
|
|
|
# can be) are replaced by the extension we eventually guessed from mimetype.
|
|
|
|
|
if (extension is None or len(extension) > 5) and guessed_ext:
|
|
|
|
|
extension = guessed_ext[1:]
|
|
|
|
|
|
|
|
|
|
if extension is None:
|
|
|
|
|
raise serializers.ValidationError("Could not determine file extension.")
|
|
|
|
|
|
|
|
|
|
self.context["expected_extension"] = extension
|
2025-01-15 15:58:46 +01:00
|
|
|
self.context["content_type"] = magic_mime_type
|
2025-02-27 16:19:17 +01:00
|
|
|
self.context["file_name"] = file.name
|
2024-08-19 22:35:48 +02:00
|
|
|
|
|
|
|
|
return file
|
|
|
|
|
|
2024-10-07 20:10:42 +02:00
|
|
|
def validate(self, attrs):
|
|
|
|
|
"""Override validate to add the computed extension to validated_data."""
|
|
|
|
|
attrs["expected_extension"] = self.context["expected_extension"]
|
|
|
|
|
attrs["is_unsafe"] = self.context["is_unsafe"]
|
2025-01-15 15:58:46 +01:00
|
|
|
attrs["content_type"] = self.context["content_type"]
|
2025-02-27 16:19:17 +01:00
|
|
|
attrs["file_name"] = self.context["file_name"]
|
2024-10-07 20:10:42 +02:00
|
|
|
return attrs
|
|
|
|
|
|
2024-08-19 22:35:48 +02:00
|
|
|
|
2025-02-16 17:26:51 +01:00
|
|
|
class TemplateSerializer(serializers.ModelSerializer):
|
2024-04-03 18:50:28 +02:00
|
|
|
"""Serialize templates."""
|
|
|
|
|
|
2025-02-16 17:26:51 +01:00
|
|
|
abilities = serializers.SerializerMethodField(read_only=True)
|
|
|
|
|
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
|
|
|
|
|
2024-04-03 18:50:28 +02:00
|
|
|
class Meta:
|
|
|
|
|
model = models.Template
|
2024-04-17 17:11:31 +02:00
|
|
|
fields = [
|
|
|
|
|
"id",
|
|
|
|
|
"title",
|
|
|
|
|
"accesses",
|
|
|
|
|
"abilities",
|
|
|
|
|
"css",
|
|
|
|
|
"code",
|
|
|
|
|
"is_public",
|
|
|
|
|
]
|
2024-04-03 18:50:28 +02:00
|
|
|
read_only_fields = ["id", "accesses", "abilities"]
|
|
|
|
|
|
2025-02-16 17:26:51 +01:00
|
|
|
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 {}
|
|
|
|
|
|
2024-04-03 18:50:28 +02:00
|
|
|
|
2024-02-09 19:32:12 +01:00
|
|
|
# pylint: disable=abstract-method
|
|
|
|
|
class DocumentGenerationSerializer(serializers.Serializer):
|
|
|
|
|
"""Serializer to receive a request to generate a document on a template."""
|
|
|
|
|
|
2024-04-16 10:30:10 +02:00
|
|
|
body = serializers.CharField(label=_("Body"))
|
|
|
|
|
body_type = serializers.ChoiceField(
|
|
|
|
|
choices=["html", "markdown"],
|
|
|
|
|
label=_("Body type"),
|
|
|
|
|
required=False,
|
|
|
|
|
default="html",
|
|
|
|
|
)
|
2024-08-07 14:44:18 +02:00
|
|
|
format = serializers.ChoiceField(
|
|
|
|
|
choices=["pdf", "docx"],
|
|
|
|
|
label=_("Format"),
|
|
|
|
|
required=False,
|
|
|
|
|
default="pdf",
|
|
|
|
|
)
|
2024-05-13 23:31:00 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class InvitationSerializer(serializers.ModelSerializer):
|
|
|
|
|
"""Serialize invitations."""
|
|
|
|
|
|
|
|
|
|
abilities = serializers.SerializerMethodField(read_only=True)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = models.Invitation
|
|
|
|
|
fields = [
|
|
|
|
|
"id",
|
|
|
|
|
"abilities",
|
|
|
|
|
"created_at",
|
|
|
|
|
"email",
|
|
|
|
|
"document",
|
|
|
|
|
"role",
|
|
|
|
|
"issuer",
|
|
|
|
|
"is_expired",
|
|
|
|
|
]
|
|
|
|
|
read_only_fields = [
|
|
|
|
|
"id",
|
|
|
|
|
"abilities",
|
|
|
|
|
"created_at",
|
|
|
|
|
"document",
|
|
|
|
|
"issuer",
|
|
|
|
|
"is_expired",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def get_abilities(self, invitation) -> dict:
|
|
|
|
|
"""Return abilities of the logged-in user on the instance."""
|
|
|
|
|
request = self.context.get("request")
|
|
|
|
|
if request:
|
|
|
|
|
return invitation.get_abilities(request.user)
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
def validate(self, attrs):
|
2024-10-22 00:28:16 +02:00
|
|
|
"""Validate invitation data."""
|
2024-05-13 23:31:00 +02:00
|
|
|
request = self.context.get("request")
|
|
|
|
|
user = getattr(request, "user", None)
|
|
|
|
|
|
2024-10-22 00:28:16 +02:00
|
|
|
attrs["document_id"] = self.context["resource_id"]
|
2024-05-13 23:31:00 +02:00
|
|
|
|
2024-10-22 00:28:16 +02:00
|
|
|
# Only set the issuer if the instance is being created
|
|
|
|
|
if self.instance is None:
|
|
|
|
|
attrs["issuer"] = user
|
2024-05-13 23:31:00 +02:00
|
|
|
|
2024-10-22 00:28:16 +02:00
|
|
|
return attrs
|
2024-05-13 23:31:00 +02:00
|
|
|
|
2024-10-22 00:28:16 +02:00
|
|
|
def validate_role(self, role):
|
|
|
|
|
"""Custom validation for the role field."""
|
|
|
|
|
request = self.context.get("request")
|
|
|
|
|
user = getattr(request, "user", None)
|
|
|
|
|
document_id = self.context["resource_id"]
|
|
|
|
|
|
|
|
|
|
# If the role is OWNER, check if the user has OWNER access
|
|
|
|
|
if role == models.RoleChoices.OWNER:
|
|
|
|
|
if not models.DocumentAccess.objects.filter(
|
2024-09-06 16:12:02 +02:00
|
|
|
Q(user=user) | Q(team__in=user.teams),
|
2024-05-13 23:31:00 +02:00
|
|
|
document=document_id,
|
|
|
|
|
role=models.RoleChoices.OWNER,
|
2024-10-22 00:28:16 +02:00
|
|
|
).exists():
|
|
|
|
|
raise serializers.ValidationError(
|
|
|
|
|
"Only owners of a document can invite other users as owners."
|
|
|
|
|
)
|
2024-05-13 23:31:00 +02:00
|
|
|
|
2024-10-22 00:28:16 +02:00
|
|
|
return role
|
2024-07-17 09:10:05 +02:00
|
|
|
|
|
|
|
|
|
2025-06-18 15:50:12 +02:00
|
|
|
class RoleSerializer(serializers.Serializer):
|
|
|
|
|
"""Serializer validating role choices."""
|
|
|
|
|
|
|
|
|
|
role = serializers.ChoiceField(
|
|
|
|
|
choices=models.RoleChoices.choices, required=False, allow_null=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-06-18 15:13:48 +02:00
|
|
|
class DocumentAskForAccessCreateSerializer(serializers.Serializer):
|
|
|
|
|
"""Serializer for creating a document ask for access."""
|
|
|
|
|
|
2025-06-18 15:50:12 +02:00
|
|
|
role = serializers.ChoiceField(
|
|
|
|
|
choices=models.RoleChoices.choices,
|
|
|
|
|
required=False,
|
|
|
|
|
default=models.RoleChoices.READER,
|
|
|
|
|
)
|
2025-06-18 15:13:48 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class DocumentAskForAccessSerializer(serializers.ModelSerializer):
|
|
|
|
|
"""Serializer for document ask for access model"""
|
|
|
|
|
|
|
|
|
|
abilities = serializers.SerializerMethodField(read_only=True)
|
|
|
|
|
user = UserSerializer(read_only=True)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = models.DocumentAskForAccess
|
|
|
|
|
fields = [
|
|
|
|
|
"id",
|
|
|
|
|
"document",
|
|
|
|
|
"user",
|
|
|
|
|
"role",
|
|
|
|
|
"created_at",
|
|
|
|
|
"abilities",
|
|
|
|
|
]
|
|
|
|
|
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
|
|
|
|
|
|
|
|
|
|
def get_abilities(self, invitation) -> dict:
|
|
|
|
|
"""Return abilities of the logged-in user on the instance."""
|
|
|
|
|
request = self.context.get("request")
|
|
|
|
|
if request:
|
|
|
|
|
return invitation.get_abilities(request.user)
|
|
|
|
|
return {}
|
2025-06-18 15:50:12 +02:00
|
|
|
|
|
|
|
|
|
2024-09-16 19:27:48 +02:00
|
|
|
class VersionFilterSerializer(serializers.Serializer):
|
|
|
|
|
"""Validate version filters applied to the list endpoint."""
|
2024-07-17 09:10:05 +02:00
|
|
|
|
2024-09-16 19:27:48 +02:00
|
|
|
version_id = serializers.CharField(required=False, allow_blank=True)
|
|
|
|
|
page_size = serializers.IntegerField(
|
|
|
|
|
required=False, min_value=1, max_value=50, default=20
|
|
|
|
|
)
|
2024-09-20 22:42:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class AITransformSerializer(serializers.Serializer):
|
|
|
|
|
"""Serializer for AI transform requests."""
|
|
|
|
|
|
|
|
|
|
action = serializers.ChoiceField(choices=AI_ACTIONS, required=True)
|
|
|
|
|
text = serializers.CharField(required=True)
|
|
|
|
|
|
|
|
|
|
def validate_text(self, value):
|
|
|
|
|
"""Ensure the text field is not empty."""
|
|
|
|
|
|
|
|
|
|
if len(value.strip()) == 0:
|
|
|
|
|
raise serializers.ValidationError("Text field cannot be empty.")
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AITranslateSerializer(serializers.Serializer):
|
|
|
|
|
"""Serializer for AI translate requests."""
|
|
|
|
|
|
|
|
|
|
language = serializers.ChoiceField(
|
|
|
|
|
choices=tuple(enums.ALL_LANGUAGES.items()), required=True
|
|
|
|
|
)
|
|
|
|
|
text = serializers.CharField(required=True)
|
|
|
|
|
|
|
|
|
|
def validate_text(self, value):
|
|
|
|
|
"""Ensure the text field is not empty."""
|
|
|
|
|
|
|
|
|
|
if len(value.strip()) == 0:
|
|
|
|
|
raise serializers.ValidationError("Text field cannot be empty.")
|
|
|
|
|
return value
|
2025-01-02 23:15:03 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class MoveDocumentSerializer(serializers.Serializer):
|
|
|
|
|
"""
|
|
|
|
|
Serializer for validating input data to move a document within the tree structure.
|
|
|
|
|
|
|
|
|
|
Fields:
|
|
|
|
|
- target_document_id (UUIDField): The ID of the target parent document where the
|
|
|
|
|
document should be moved. This field is required and must be a valid UUID.
|
|
|
|
|
- position (ChoiceField): Specifies the position of the document in relation to
|
|
|
|
|
the target parent's children.
|
|
|
|
|
Choices:
|
|
|
|
|
- "first-child": Place the document as the first child of the target parent.
|
|
|
|
|
- "last-child": Place the document as the last child of the target parent (default).
|
|
|
|
|
- "left": Place the document as the left sibling of the target parent.
|
|
|
|
|
- "right": Place the document as the right sibling of the target parent.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
Input payload for moving a document:
|
|
|
|
|
{
|
|
|
|
|
"target_document_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
|
|
|
"position": "first-child"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Notes:
|
|
|
|
|
- The `target_document_id` is mandatory.
|
|
|
|
|
- The `position` defaults to "last-child" if not provided.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
target_document_id = serializers.UUIDField(required=True)
|
|
|
|
|
position = serializers.ChoiceField(
|
|
|
|
|
choices=enums.MoveNodePositionChoices.choices,
|
|
|
|
|
default=enums.MoveNodePositionChoices.LAST_CHILD,
|
|
|
|
|
)
|