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
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
65 changes: 63 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,67 @@ 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.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