Skip to content

Commit 0b301b9

Browse files
committed
✨(backend) allow masking documents from the list view
Once users have visited a document to which they have access, they can't remove it from their list view anymore. Several users reported that this is annoying because a document that gets a lot of updates keeps popping up at the top of their list view. They want to be able to mask the document in a click. We propose to add a "masked documents" section in the left side bar where the masked documents can still be found.
1 parent 228bdf7 commit 0b301b9

12 files changed

+560
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to
1010

1111
### Added
1212

13+
- ✨(backend) allow masking documents from the list view #1171
1314
- ✨(frontend) add duplicate action to doc tree #1175
1415

1516
### Changed
@@ -33,7 +34,6 @@ and this project adheres to
3334

3435
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
3536

36-
3737
## [3.4.1] - 2025-07-15
3838

3939
### Fixed
@@ -58,7 +58,7 @@ and this project adheres to
5858
- ✨(backend) add ancestors links reach and role to document API #846
5959
- 📝(project) add troubleshoot doc #1066
6060
- 📝(project) add system-requirement doc #1066
61-
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
61+
- 🔧(frontend) configure x-frame-options to DENY in nginx conf #1084
6262
- ✨(backend) allow to disable checking unsafe mimetype on
6363
attachment upload #1099
6464
- ✨(doc) add documentation to install with compose #855

src/backend/core/api/filters.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ class ListDocumentFilter(DocumentFilter):
6060
is_creator_me = django_filters.BooleanFilter(
6161
method="filter_is_creator_me", label=_("Creator is me")
6262
)
63+
is_masked = django_filters.BooleanFilter(
64+
method="filter_is_masked", label=_("Masked")
65+
)
6366
is_favorite = django_filters.BooleanFilter(
6467
method="filter_is_favorite", label=_("Favorite")
6568
)
@@ -106,3 +109,22 @@ def filter_is_favorite(self, queryset, name, value):
106109
return queryset
107110

108111
return queryset.filter(is_favorite=bool(value))
112+
113+
# pylint: disable=unused-argument
114+
def filter_is_masked(self, queryset, name, value):
115+
"""
116+
Filter documents based on whether they are masked by the current user.
117+
118+
Example:
119+
- /api/v1.0/documents/?is_masked=true
120+
→ Filters documents marked as masked by the logged-in user
121+
- /api/v1.0/documents/?is_masked=false
122+
→ Filters documents not marked as masked by the logged-in user
123+
"""
124+
user = self.request.user
125+
126+
if not user.is_authenticated:
127+
return queryset
128+
129+
queryset_method = queryset.filter if bool(value) else queryset.exclude
130+
return queryset_method(link_traces__user=user, link_traces__is_masked=True)

src/backend/core/api/viewsets.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -455,9 +455,8 @@ def list(self, request, *args, **kwargs):
455455

456456
# Annotate favorite status and filter if applicable as late as possible
457457
queryset = queryset.annotate_is_favorite(user)
458-
queryset = filterset.filters["is_favorite"].filter(
459-
queryset, filter_data["is_favorite"]
460-
)
458+
for field in ["is_favorite", "is_masked"]:
459+
queryset = filterset.filters[field].filter(queryset, filter_data[field])
461460

462461
# Apply ordering only now that everything is filtered and annotated
463462
queryset = filters.OrderingFilter().filter_queryset(
@@ -1109,15 +1108,50 @@ def favorite(self, request, *args, **kwargs):
11091108
document=document, user=user
11101109
).delete()
11111110
if deleted:
1112-
return drf.response.Response(
1113-
{"detail": "Document unmarked as favorite"},
1114-
status=drf.status.HTTP_204_NO_CONTENT,
1115-
)
1111+
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
11161112
return drf.response.Response(
11171113
{"detail": "Document was already not marked as favorite"},
11181114
status=drf.status.HTTP_200_OK,
11191115
)
11201116

1117+
@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="mask")
1118+
def mask(self, request, *args, **kwargs):
1119+
"""Mask or unmask the document for the logged-in user based on the HTTP method."""
1120+
# Check permissions first
1121+
document = self.get_object()
1122+
user = request.user
1123+
1124+
try:
1125+
link_trace = models.LinkTrace.objects.get(document=document, user=user)
1126+
except models.LinkTrace.DoesNotExist:
1127+
return drf.response.Response(
1128+
{"detail": "User never accessed this document before."},
1129+
status=status.HTTP_400_BAD_REQUEST,
1130+
)
1131+
1132+
if request.method == "POST":
1133+
if link_trace.is_masked:
1134+
return drf.response.Response(
1135+
{"detail": "Document was already masked"},
1136+
status=drf.status.HTTP_200_OK,
1137+
)
1138+
link_trace.is_masked = True
1139+
link_trace.save(update_fields=["is_masked"])
1140+
return drf.response.Response(
1141+
{"detail": "Document was masked"},
1142+
status=drf.status.HTTP_201_CREATED,
1143+
)
1144+
1145+
# Handle DELETE method to unmask document
1146+
if not link_trace.is_masked:
1147+
return drf.response.Response(
1148+
{"detail": "Document was already not masked"},
1149+
status=drf.status.HTTP_200_OK,
1150+
)
1151+
link_trace.is_masked = False
1152+
link_trace.save(update_fields=["is_masked"])
1153+
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
1154+
11211155
@drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
11221156
def attachment_upload(self, request, *args, **kwargs):
11231157
"""Upload a file related to a given document"""

src/backend/core/factories.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def link_traces(self, create, extracted, **kwargs):
150150
"""Add link traces to document from a given list of users."""
151151
if create and extracted:
152152
for item in extracted:
153-
models.LinkTrace.objects.create(document=self, user=item)
153+
models.LinkTrace.objects.update_or_create(document=self, user=item)
154154

155155
@factory.post_generation
156156
def favorited_by(self, create, extracted, **kwargs):
@@ -159,6 +159,15 @@ def favorited_by(self, create, extracted, **kwargs):
159159
for item in extracted:
160160
models.DocumentFavorite.objects.create(document=self, user=item)
161161

162+
@factory.post_generation
163+
def masked_by(self, create, extracted, **kwargs):
164+
"""Mark document as masked by a list of users."""
165+
if create and extracted:
166+
for item in extracted:
167+
models.LinkTrace.objects.update_or_create(
168+
document=self, user=item, defaults={"is_masked": True}
169+
)
170+
162171

163172
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
164173
"""Create fake document user accesses for testing."""
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Generated by Django 5.2.3 on 2025-07-13 08:22
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("core", "0023_remove_document_is_public_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="linktrace",
14+
name="is_masked",
15+
field=models.BooleanField(default=False),
16+
),
17+
migrations.AlterField(
18+
model_name="user",
19+
name="language",
20+
field=models.CharField(
21+
blank=True,
22+
choices=[
23+
("en-us", "English"),
24+
("fr-fr", "Français"),
25+
("de-de", "Deutsch"),
26+
("nl-nl", "Nederlands"),
27+
("es-es", "Español"),
28+
],
29+
default=None,
30+
help_text="The language in which the user wants to see the interface.",
31+
max_length=10,
32+
null=True,
33+
verbose_name="language",
34+
),
35+
),
36+
]

src/backend/core/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,7 @@ def get_abilities(self, user):
793793
"favorite": can_get and user.is_authenticated,
794794
"link_configuration": is_owner_or_admin,
795795
"invite_owner": is_owner,
796+
"mask": can_get and user.is_authenticated,
796797
"move": is_owner_or_admin and not self.ancestors_deleted_at,
797798
"partial_update": can_update,
798799
"restore": is_owner,
@@ -958,6 +959,7 @@ class LinkTrace(BaseModel):
958959
related_name="link_traces",
959960
)
960961
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
962+
is_masked = models.BooleanField(default=False)
961963

962964
class Meta:
963965
db_table = "impress_link_trace"

src/backend/core/tests/documents/test_api_documents_favorite_list.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ def test_api_document_favorite_list_authenticated_with_favorite():
4141
client = APIClient()
4242
client.force_login(user)
4343

44-
# User don't have access to this document (e.g the user had access and this
45-
# access was removed. It should not be in the favorite list anymore.
44+
# If the user doesn't have access to this document (e.g the user had access
45+
# and this access was removed), it should not be in the favorite list anymore.
4646
factories.DocumentFactory(favorited_by=[user])
4747

4848
document = factories.UserDocumentAccessFactory(

src/backend/core/tests/documents/test_api_documents_list_filters.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,84 @@ def test_api_documents_list_filter_is_favorite_invalid():
312312
assert len(results) == 5
313313

314314

315+
# Filters: is_masked
316+
317+
318+
def test_api_documents_list_filter_is_masked_true():
319+
"""
320+
Authenticated users should be able to filter documents they marked as masked.
321+
"""
322+
user = factories.UserFactory()
323+
client = APIClient()
324+
client.force_login(user)
325+
326+
factories.DocumentFactory.create_batch(2, users=[user])
327+
masked_documents = factories.DocumentFactory.create_batch(
328+
3, users=[user], masked_by=[user]
329+
)
330+
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
331+
for document in unmasked_documents:
332+
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
333+
334+
response = client.get("/api/v1.0/documents/?is_masked=true")
335+
336+
assert response.status_code == 200
337+
results = response.json()["results"]
338+
assert len(results) == 3
339+
340+
# Ensure all results are marked as masked by the current user
341+
masked_documents_ids = [str(doc.id) for doc in masked_documents]
342+
for result in results:
343+
assert result["id"] in masked_documents_ids
344+
345+
346+
def test_api_documents_list_filter_is_masked_false():
347+
"""
348+
Authenticated users should be able to filter documents they didn't mark as masked.
349+
"""
350+
user = factories.UserFactory()
351+
client = APIClient()
352+
client.force_login(user)
353+
354+
factories.DocumentFactory.create_batch(2, users=[user])
355+
masked_documents = factories.DocumentFactory.create_batch(
356+
3, users=[user], masked_by=[user]
357+
)
358+
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
359+
for document in unmasked_documents:
360+
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
361+
362+
response = client.get("/api/v1.0/documents/?is_masked=false")
363+
364+
assert response.status_code == 200
365+
results = response.json()["results"]
366+
assert len(results) == 4
367+
368+
# Ensure all results are not marked as masked by the current user
369+
masked_documents_ids = [str(doc.id) for doc in masked_documents]
370+
for result in results:
371+
assert result["id"] not in masked_documents_ids
372+
373+
374+
def test_api_documents_list_filter_is_masked_invalid():
375+
"""Filtering with an invalid `is_masked` value should do nothing."""
376+
user = factories.UserFactory()
377+
client = APIClient()
378+
client.force_login(user)
379+
380+
factories.DocumentFactory.create_batch(2, users=[user])
381+
factories.DocumentFactory.create_batch(3, users=[user], masked_by=[user])
382+
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
383+
for document in unmasked_documents:
384+
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
385+
386+
response = client.get("/api/v1.0/documents/?is_masked=invalid")
387+
388+
assert response.status_code == 200
389+
results = response.json()["results"]
390+
assert len(results) == 7
391+
392+
315393
# Filters: title
316394

317395

0 commit comments

Comments
 (0)