Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to
- ✨(buildpack) add PaaS deployment support, tested with Scalingo #2293
- 🔧(backend) allow configuring settings OIDC_OP_USER_ENDPOINT_FORMAT
- ⚡️(helm) create a dedicated svc and deployment for yprovider converter #2368
- ✨(backend) allow to leave a document #2365

### Changed

Expand Down
22 changes: 0 additions & 22 deletions src/backend/core/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,6 @@ class ListDocumentFilter(DocumentFilter):
is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me")
)
is_masked = django_filters.BooleanFilter(
method="filter_is_masked", label=_("Masked")
)
is_favorite = django_filters.BooleanFilter(
method="filter_is_favorite", label=_("Favorite")
)
Expand Down Expand Up @@ -114,25 +111,6 @@ def filter_is_favorite(self, queryset, name, value):

return queryset.filter(is_favorite=bool(value))

# pylint: disable=unused-argument
def filter_is_masked(self, queryset, name, value):
"""
Filter documents based on whether they are masked by the current user.

Example:
- /api/v1.0/documents/?is_masked=true
→ Filters documents marked as masked by the logged-in user
- /api/v1.0/documents/?is_masked=false
→ Filters documents not marked as masked by the logged-in user
"""
user = self.request.user

if not user.is_authenticated:
return queryset

queryset_method = queryset.filter if bool(value) else queryset.exclude
return queryset_method(link_traces__user=user, link_traces__is_masked=True)


class UserSearchFilter(django_filters.FilterSet):
"""
Expand Down
101 changes: 53 additions & 48 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import URLValidator
from django.db import connection, transaction
from django.db import DatabaseError, connection, transaction
from django.db import models as db
from django.db.models.expressions import RawSQL
from django.db.models.functions import Greatest, Left, Length
Expand Down Expand Up @@ -598,6 +598,8 @@ def filter_queryset(self, queryset):
user = self.request.user
queryset = queryset.annotate_is_favorite(user)
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_user_has_link_trace(user)

return queryset

def get_response_for_queryset(self, queryset, context=None):
Expand Down Expand Up @@ -638,6 +640,7 @@ def _compute_parents(self, documents):
for parent in (
models.Document.objects.annotate_user_roles(self.request.user)
.annotate_is_favorite(self.request.user)
.annotate_user_has_link_trace(self.request.user)
.filter(path__in=missing_parent_paths)
.iterator()
):
Expand Down Expand Up @@ -675,7 +678,7 @@ def list(self, request, *args, **kwargs):
for field in ["is_creator_me", "title", "q"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])

queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_user_roles(user).annotate_user_has_link_trace(user)

# 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.
Expand All @@ -687,8 +690,9 @@ def list(self, request, *args, **kwargs):

# Annotate favorite status and filter if applicable as late as possible
queryset = queryset.annotate_is_favorite(user)
for field in ["is_favorite", "is_masked"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = filterset.filters["is_favorite"].filter(
queryset, filter_data["is_favorite"]
)

# Apply ordering only now that everything is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
Expand All @@ -705,7 +709,6 @@ def retrieve(self, request, *args, **kwargs):
"""
user = self.request.user
instance = self.get_object()
serializer = self.get_serializer(instance)

# The `create` query generates 5 db queries which are much less efficient than an
# `exists` query. The user will visit the document many times after the first visit
Expand All @@ -716,6 +719,11 @@ def retrieve(self, request, *args, **kwargs):
):
models.LinkTrace.objects.create(document=instance, user=request.user)

# To avoid N+1 query, we force the `user_has_link_trace` normally set by the
# queryset.annotate_user_has_link_trace method. If the user is connected, it must be True.
instance.user_has_link_trace = user.is_authenticated

serializer = self.get_serializer(instance)
return drf.response.Response(serializer.data)

def _apply_uploaded_file_conversion(self, serializer):
Expand Down Expand Up @@ -879,7 +887,7 @@ def favorite_list(self, request, *args, **kwargs):
queryset = queryset.filter(id__in=favorite_documents_ids)
queryset = queryset.filter(ancestors_deleted_at__isnull=True)
queryset = queryset.order_by("-updated_at")
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_user_roles(user).annotate_user_has_link_trace(user)
queryset = queryset.annotate(
is_favorite=db.Value(True, output_field=db.BooleanField())
)
Expand Down Expand Up @@ -921,7 +929,9 @@ def trashbin(self, request, *args, **kwargs):
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = queryset.annotate_user_roles(self.request.user)
queryset = queryset.annotate_user_roles(
self.request.user
).annotate_user_has_link_trace(self.request.user)

return self.get_response_for_queryset(queryset)

Expand Down Expand Up @@ -1173,12 +1183,13 @@ def all(self, request, *args, **kwargs):
for field in ["is_creator_me", "title", "q"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])

queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_user_roles(user).annotate_user_has_link_trace(user)

# Annotate favorite status and filter if applicable as late as possible
queryset = queryset.annotate_is_favorite(user)
for field in ["is_favorite", "is_masked"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = filterset.filters["is_favorite"].filter(
queryset, filter_data["is_favorite"]
)

# Apply ordering only now that everything is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
Expand Down Expand Up @@ -1271,6 +1282,7 @@ def tree(self, request, pk, *args, **kwargs):
queryset = queryset.order_by("path")
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_is_favorite(user)
queryset = queryset.annotate_user_has_link_trace(user)

# Pass ancestors' links paths mapping to the serializer as a context variable
# in order to allow saving time while computing abilities on the instance
Expand Down Expand Up @@ -1588,6 +1600,7 @@ def _search_using_database(self, request, validated_data, *args, **kwargs):
.filter(ancestors_deleted_at__isnull=True)
.annotate_user_roles(user)
.annotate_is_favorite(user)
.annotate_user_has_link_trace(user)
)

queryset = filterset.filter_queryset(queryset)
Expand Down Expand Up @@ -1770,44 +1783,6 @@ def favorite(self, request, *args, **kwargs):
status=drf.status.HTTP_200_OK,
)

@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="mask")
def mask(self, request, *args, **kwargs):
"""Mask or unmask the document for the logged-in user based on the HTTP method."""
# Check permissions first
document = self.get_object()
user = request.user

try:
link_trace = models.LinkTrace.objects.get(document=document, user=user)
except models.LinkTrace.DoesNotExist:
return drf.response.Response(
{"detail": "User never accessed this document before."},
status=status.HTTP_400_BAD_REQUEST,
)

if request.method == "POST":
if link_trace.is_masked:
return drf.response.Response(
{"detail": "Document was already masked"},
status=drf.status.HTTP_200_OK,
)
link_trace.is_masked = True
link_trace.save(update_fields=["is_masked"])
return drf.response.Response(
{"detail": "Document was masked"},
status=drf.status.HTTP_201_CREATED,
)

# Handle DELETE method to unmask document
if not link_trace.is_masked:
return drf.response.Response(
{"detail": "Document was already not masked"},
status=drf.status.HTTP_200_OK,
)
link_trace.is_masked = False
link_trace.save(update_fields=["is_masked"])
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)

@drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
Expand Down Expand Up @@ -2535,6 +2510,36 @@ def formatted_content(self, request, pk=None):
}
)

@drf.decorators.action(
detail=True,
methods=["post"],
)
def leave(self, request, *args, **kwargs):
"""
Remove document_accesses if exists and the link_trace related to the current document
for the connected user.
"""
# Check for permissions.
document = self.get_object()

try:
with transaction.atomic():
models.DocumentAccess.objects.filter(
document__path__startswith=document.path, user=request.user
).delete()
models.LinkTrace.objects.filter(
document__path__startswith=document.path, user=request.user
).delete()
except DatabaseError:
logger.error(
"Impossible to leave document %s for user %s",
str(document.id),
str(request.user.id),
)
raise

return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)


class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
Expand Down
9 changes: 0 additions & 9 deletions src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,6 @@ def favorited_by(self, create, extracted, **kwargs):
for item in extracted:
models.DocumentFavorite.objects.create(document=self, user=item)

@factory.post_generation
def masked_by(self, create, extracted, **kwargs):
"""Mark document as masked by a list of users."""
if create and extracted:
for item in extracted:
models.LinkTrace.objects.update_or_create(
document=self, user=item, defaults={"is_masked": True}
)


class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""
Expand Down
16 changes: 16 additions & 0 deletions src/backend/core/migrations/0032_remove_linktrace_is_masked.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Generated by Django 5.2.14 on 2026-05-28 14:58

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("core", "0031_clean_onboarding_accesses"),
]

operations = [
migrations.RemoveField(
model_name="linktrace",
name="is_masked",
),
]
40 changes: 37 additions & 3 deletions src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,22 @@ def annotate_user_roles(self, user):
user_roles=models.Value([], output_field=output_field),
)

def annotate_user_has_link_trace(self, user):
"""
Annotate document queryset with a boolean to know if the current user
has a link_trace on the current document.
"""

if user.is_authenticated:
link_trace_exists_subquery = LinkTrace.objects.filter(
document_id=models.OuterRef("pk"), user=user
)
return self.annotate(
user_has_link_trace=models.Exists(link_trace_exists_subquery)
)

return self.annotate(user_has_link_trace=models.Value(False))


class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
"""
Expand Down Expand Up @@ -1150,6 +1166,17 @@ def get_role(self, user):

return RoleChoices.max(*roles)

def has_link_trace(self, user):
"""Return if the user has a link trace on this document."""

if not user.is_authenticated:
return False

try:
return self.user_has_link_trace
except AttributeError:
return LinkTrace.objects.filter(document=self, user=user).exists()

def compute_ancestors_links_paths_mapping(self):
"""
Compute the ancestors links for the current document up to the highest readable ancestor.
Expand Down Expand Up @@ -1227,7 +1254,7 @@ def computed_link_role(self):
"""Actual link role on the document."""
return self.computed_link_definition["link_role"]

def get_abilities(self, user):
def get_abilities(self, user): # pylint: disable=too-many-locals
"""
Compute and return abilities for a given user on the document.
"""
Expand All @@ -1248,6 +1275,14 @@ def get_abilities(self, user):
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted

# compute can_leave
# A user can leave a document if it has non privileged role on the document or it has
# access to it with a link_trace
can_leave = user.is_authenticated and (
(has_access_role and not is_owner_or_admin)
or (not has_access_role and self.has_link_trace(user))
)

link_select_options = LinkReachChoices.get_select_options(
**self.ancestors_link_definition
)
Expand Down Expand Up @@ -1312,7 +1347,7 @@ def get_abilities(self, user):
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": is_owner and not is_deleted,
"mask": can_get and user.is_authenticated,
"leave": can_leave,
"move": is_owner_or_admin and not is_deleted,
"partial_update": can_update,
"restore": is_owner,
Expand Down Expand Up @@ -1481,7 +1516,6 @@ class LinkTrace(BaseModel):
related_name="link_traces",
)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
is_masked = models.BooleanField(default=False)

class Meta:
db_table = "impress_link_trace"
Expand Down
Loading
Loading