Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow organizing documents in a tree structure #516

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
45 changes: 44 additions & 1 deletion src/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _

from treebeard.admin import TreeAdmin

from . import models


class TemplateAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""

autocomplete_fields = ["user"]
model = models.TemplateAccess
extra = 0

Expand Down Expand Up @@ -111,14 +114,46 @@ class TemplateAdmin(admin.ModelAdmin):
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""

autocomplete_fields = ["user"]
model = models.DocumentAccess
extra = 0


@admin.register(models.Document)
class DocumentAdmin(admin.ModelAdmin):
class DocumentAdmin(TreeAdmin):
"""Document admin interface declaration."""

fieldsets = (
(
None,
{
"fields": (
"id",
"title",
)
},
),
(
_("Permissions"),
{
"fields": (
"creator",
"link_reach",
"link_role",
)
},
),
(
_("Tree structure"),
{
"fields": (
"path",
"depth",
"numchild",
)
},
),
)
inlines = (DocumentAccessInline,)
list_display = (
"id",
Expand All @@ -128,6 +163,14 @@ class DocumentAdmin(admin.ModelAdmin):
"created_at",
"updated_at",
)
readonly_fields = (
"creator",
"depth",
"id",
"numchild",
"path",
)
search_fields = ("id", "title")


@admin.register(models.Invitation)
Expand Down
3 changes: 2 additions & 1 deletion src/backend/core/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from core.models import DocumentAccess, RoleChoices

ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"},
}


Expand Down
20 changes: 15 additions & 5 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,26 +152,29 @@ class Meta:
model = models.Document
fields = [
"id",
"abilities",
"content",
"created_at",
"creator",
"depth",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of path ?

"title",
"updated_at",
]
read_only_fields = [
"id",
"abilities",
Copy link
Collaborator

@AntoLC AntoLC Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will break the frontend if you remove it. We use the abilities in the data grid, to know if the current user can delete or not a document.
We use it as well to compute the current role of the user on the document (not sure we still do it with the new design though).

image

"created_at",
"creator",
"depth",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"updated_at",
]

Expand All @@ -189,10 +192,14 @@ class Meta:
"content",
"created_at",
"creator",
"depth",
"excerpt",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"title",
"updated_at",
]
Expand All @@ -201,10 +208,13 @@ class Meta:
"abilities",
"created_at",
"creator",
"is_avorite",
"depth",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"numchild",
"path",
"updated_at",
]

Expand Down Expand Up @@ -281,7 +291,7 @@ def create(self, validated_data):
except ConversionError as err:
raise exceptions.APIException(detail="could not convert content") from err

document = models.Document.objects.create(
document = models.Document.add_root(
title=validated_data["title"],
content=document_content,
creator=user,
Expand Down
122 changes: 100 additions & 22 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models as db
from django.db import transaction
from django.db.models import (
Count,
Exists,
F,
Func,
OuterRef,
Q,
Subquery,
Value,
)
from django.db.models.functions import Left, Length
from django.http import Http404

import rest_framework as drf
Expand Down Expand Up @@ -344,13 +347,22 @@ def get_serializer_class(self):
return serializers.ListDocumentSerializer
return self.serializer_class

def get_queryset(self):
"""Optimize queryset to include favorite status for the current user."""
queryset = super().get_queryset()
def annotate_queryset(self, queryset):
"""Annotate document queryset with favorite and number of accesses."""
user = self.request.user

# Annotate the number of accesses associated with each document
queryset = queryset.annotate(nb_accesses=Count("accesses", distinct=True))
# Annotate the number of accesses taking into account ancestors
ancestor_accesses_query = (
models.DocumentAccess.objects.filter(
document__path=Left(OuterRef("path"), Length("document__path")),
)
.order_by()
.annotate(total_accesses=Func(Value("id"), function="COUNT"))
.values("total_accesses")
)

# Annotate with the number of accesses, default to 0 if no accesses exist
queryset = queryset.annotate(nb_accesses=Subquery(ancestor_accesses_query))

if not user.is_authenticated:
# If the user is not authenticated, annotate `is_favorite` as False
Expand All @@ -360,19 +372,13 @@ def get_queryset(self):
favorite_exists = models.DocumentFavorite.objects.filter(
document_id=OuterRef("pk"), user=user
)
queryset = queryset.annotate(is_favorite=Exists(favorite_exists))
return queryset.annotate(is_favorite=Exists(favorite_exists))

# Annotate the queryset with the logged-in user roles
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document_id=OuterRef("pk"),
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def get_queryset(self):
"""Optimize queryset to include favorite status for the current user."""
queryset = super().get_queryset()
queryset = self.annotate_queryset(queryset)
return queryset.distinct()

def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
Expand All @@ -388,6 +394,24 @@ def list(self, request, *args, **kwargs):
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)

# Among the results, we may have documents that are ancestors/children of each other
# In this case we want to keep only the highest ancestor. Let's annotate, each document
# with the path of its highest ancestor within results so we can use it to filter
shortest_path = Subquery(
queryset.filter(path=Left(OuterRef("path"), Length("path")))
.order_by("path") # Get the shortest (root) path
.values("path")[:1]
)
queryset = queryset.annotate(root_path=shortest_path)

# Filter documents based on their shortest path (root path)
queryset = queryset.filter(
root_path=F(
"path"
) # Keep only documents who are the annotated highest ancestor
)

else:
queryset = queryset.none()

Expand Down Expand Up @@ -424,7 +448,11 @@ def retrieve(self, request, *args, **kwargs):

def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
obj = serializer.save(creator=self.request.user)
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
)
serializer.instance = obj
models.DocumentAccess.objects.create(
document=obj,
user=self.request.user,
Expand Down Expand Up @@ -455,6 +483,53 @@ def create_for_owner(self, request):
{"id": str(document.id)}, status=status.HTTP_201_CREATED
)

@drf.decorators.action(
detail=True,
methods=["get", "post"],
serializer_class=serializers.ListDocumentSerializer,
url_path="children",
)
def children(self, request, *args, **kwargs):
"""Handle listing and creating children of a document"""
document = self.get_object()

if request.method == "POST":
# Create a child document
serializer = serializers.DocumentSerializer(
data=request.data, context=self.get_serializer_context()
)
serializer.is_valid(raise_exception=True)

with transaction.atomic():
child_document = document.add_child(
creator=request.user,
**serializer.validated_data,
)
models.DocumentAccess.objects.create(
document=child_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
# Set the created instance to the serializer
serializer.instance = child_document

headers = self.get_success_headers(serializer.data)
return drf.response.Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)

# GET: List children
queryset = document.get_children()
queryset = self.annotate_queryset(queryset)

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)

@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
Expand All @@ -473,8 +548,9 @@ def versions_list(self, request, *args, **kwargs):

# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = document.accesses.filter(
db.Q(user=user) | db.Q(team__in=user.teams)
access_queryset = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(Value(document.path), Length("document__path")),
).aggregate(min_date=db.Min("created_at"))

# Handle the case where the user has no accesses
Expand Down Expand Up @@ -512,10 +588,12 @@ def versions_detail(self, request, pk, version_id, *args, **kwargs):
user = request.user
min_datetime = min(
access.created_at
for access in document.accesses.filter(
for access in models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(Value(document.path), Length("document__path")),
)
)

if response["LastModified"] < min_datetime:
raise Http404

Expand Down
Loading
Loading