Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ and this project adheres to
### Added

- ✨(backend) add limit on distinct reactions per comment #1978
- ✨(backend) add mention endpoint with cooldown-limited email notification
- ✨(frontend) leave a document #2410
- ✨(frontend) add top parent on sub docs search #1952
- ✨(frontend) unauthenticated users can search #2407
- ✨(backend) specific user delete method to delete its relations

### Changed

- 🛂(backend) make document access list visible to all collaborators
- 👷(CI) remove test-e2e-other-browser job #2404
- ♿️(frontend) use heading element for pinned documents section title #2380
- ♿️(frontend) use anchor links for table of contents entries #2390
Expand Down
67 changes: 65 additions & 2 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ class UserLightSerializer(UserSerializer):

class Meta:
model = models.User
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
fields = ["id", "full_name", "short_name"]
read_only_fields = ["id", "full_name", "short_name"]


class ListDocumentSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -1005,6 +1005,69 @@ def get_abilities(self, thread):
return {}


class MentionSerializer(serializers.ModelSerializer):
"""Serialize mentions of users in a document body or comment thread.

Expects the document on which the mention is created in the context.
"""

document_id = serializers.PrimaryKeyRelatedField(source="document", read_only=True)
mentioned_user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.filter(is_active=True),
source="mentioned_user",
)
mentioned_by_user_id = serializers.PrimaryKeyRelatedField(
source="mentioned_by_user", read_only=True
)
thread_id = serializers.PrimaryKeyRelatedField(
queryset=models.Thread.objects.all(),
source="thread",
required=False,
allow_null=True,
default=None,
)

class Meta:
model = models.Mention
fields = [
"id",
"document_id",
"anchor_id",
"thread_id",
"mentioned_user_id",
"mentioned_by_user_id",
"created_at",
"notified_at",
]
read_only_fields = [
"id",
"document_id",
"mentioned_by_user_id",
"created_at",
"notified_at",
]

def validate_mentioned_user_id(self, user):
"""Ensure the mentioned user has access to the document."""
document = models.Document.objects.annotate_user_roles(user).get(
pk=self.context["document"].pk
)
if document.get_role(user) is None:
raise serializers.ValidationError(
"This user does not have access to the document."
)
return user

def validate_thread_id(self, thread):
"""Ensure the thread belongs to the document on which the mention is created."""
document = self.context["document"]
if thread is not None and thread.document_id != document.id:
raise serializers.ValidationError(
"The thread does not belong to this document."
)
return thread


class SearchQueryParamDocumentSerializer(serializers.Serializer):
"""Serializer for fulltext search requests through Find application"""

Expand Down
48 changes: 44 additions & 4 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
get_document_indexer,
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.tasks.mail import send_ask_for_access_mail, send_mention_notification_mail
from core.utils.analytics import PosthogEventName, posthog_capture
from core.utils.paths import filter_descendants
from core.utils.s3_response_stream import content_stream
Expand Down Expand Up @@ -517,6 +517,16 @@ class DocumentViewSet(
15. **AI Proxy**: Proxy an AI request to an external AI service.
Example: POST /api/v1.0/documents/<resource_id>/ai-proxy

16. **Mention**: Mention a user on the document and notify them by email.
Example: POST /documents/{id}/mention/
Expected data:
- anchor_id (str): The location of the mention, used for the email deeplink.
- mentioned_user_id (uuid): The user being mentioned, must have access
to the document.
- thread_id (uuid, optional): The comment thread in which the mention
occurs. Omit for mentions in the document body.
Returns: 201 with the created mention.

### Ordering: created_at, updated_at, is_favorite, title

Example:
Expand Down Expand Up @@ -1857,6 +1867,36 @@ def favorite(self, request, *args, **kwargs):
status=drf.status.HTTP_200_OK,
)

@drf.decorators.action(
detail=True,
methods=["post"],
url_path="mention",
throttle_scope="mention",
)
def mention(self, request, *args, **kwargs):
"""Mention a user on the document and notify them by email.

The mention record is created synchronously; the email notification is
sent asynchronously by a Celery task, which suppresses it when the same
user was already notified in the same context (document body or thread)
within the cooldown period.
"""
# Check permissions first
document = self.get_object()

serializer = serializers.MentionSerializer(
data=request.data,
context={**self.get_serializer_context(), "document": document},
)
serializer.is_valid(raise_exception=True)
mention = serializer.save(document=document, mentioned_by_user=request.user)

send_mention_notification_mail.delay(str(mention.id))

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

@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 @@ -2719,11 +2759,11 @@ def list(self, request, *args, **kwargs):
| models.Document.objects.filter(pk=self.document.pk)
).filter(ancestors_deleted_at__isnull=True)

# All users with access see the full list of accesses (with limited
# user details for unprivileged roles) so that any collaborator
# allowed to comment can mention the others.
queryset = self.get_queryset().filter(document__in=ancestors)

if role not in choices.PRIVILEGED_ROLES:
queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES)

accesses = list(queryset.order_by("document__path"))

# Annotate more information on roles
Expand Down
12 changes: 12 additions & 0 deletions src/backend/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,15 @@ def users(self, create, extracted, **kwargs):

# Add the iterable of groups using bulk addition
self.users.add(*extracted)


class MentionFactory(factory.django.DjangoModelFactory):
"""A factory to create mentions of users on a document"""

class Meta:
model = models.Mention

document = factory.SubFactory(DocumentFactory)
anchor_id = factory.Sequence(lambda n: f"block-{n}")
mentioned_user = factory.SubFactory(UserFactory)
mentioned_by_user = factory.SubFactory(UserFactory)
107 changes: 107 additions & 0 deletions src/backend/core/migrations/0033_mention.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Generated by Django 5.2 - create the mention model

import uuid

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0032_remove_linktrace_is_masked"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="Mention",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("anchor_id", models.TextField()),
("notified_at", models.DateTimeField(blank=True, null=True)),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="mentions",
to="core.document",
),
),
(
"mentioned_by_user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="mentions_sent",
to=settings.AUTH_USER_MODEL,
),
),
(
"mentioned_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="mentions_received",
to=settings.AUTH_USER_MODEL,
),
),
(
"thread",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="mentions",
to="core.thread",
),
),
],
options={
"verbose_name": "Mention",
"verbose_name_plural": "Mentions",
"db_table": "impress_mention",
"ordering": ("-created_at",),
},
),
migrations.AddIndex(
model_name="mention",
index=models.Index(
fields=["mentioned_user", "-created_at"],
name="mention_user_created_idx",
),
),
migrations.AddIndex(
model_name="mention",
index=models.Index(
fields=["document", "mentioned_user"],
name="mention_document_user_idx",
),
),
]
Loading