forked from github/plane
[FEATURE] Enabled User @mentions and @mention-filters in core editor package (#2544)
* feat: created custom mention component * feat: added mention suggestions and suggestion highlights * feat: created mention suggestion list for displaying mention suggestions * feat: created custom mention text component, for handling click event * feat: exposed mention component * feat: integrated and exposed `mentions` componenet with `editor-core` * feat: integrated mentions extension with the core editor package * feat: exposed suggestion types from mentions * feat: added `mention-suggestion` parameters in `r-t-e` and `l-t-e` * feat: added `IssueMention` model in apiserver models * chore: updated activities background job and added bs4 in requirements * feat: added mention removal logic in issue_activity * chore: exposed mention types from `r-t-e` and `l-t-e` * feat: integrated mentions in side peek view description form * feat: added mentions in issue modal form * feat: created custom react-hook for editor suggestions * feat: integrated mention suggestions block in RichTextEditor * feat: added `mentions` integration in `lite-text-editor` instances * fix: tailwind loading nodemodules from packages * feat: added styles for the mention suggestion list * fix: update module import to resolve build failure * feat: added mentions as an issue filter * feat: added UI Changes to Implement `mention` filters * feat: added `mentions` as a filter option in the header * feat: added mentions in the filter list options * feat: added mentions in default display filter options * feat: added filters in applied and issue params in store * feat: modified types for adding mentions as a filter option * feat: modified `notification-card` to display message when it exists in object * feat: rewrote user mention management upon the changes made in develop * chore: merged debounce PR with the current PR for tracing changes * fix: mentions_filters updated with the new setup * feat: updated requirements for bs4 * feat: modified `mentions-filter` to remove many to many dependency * feat: implemented list manipulation instead of for loop * feat: added readonly functionality in `read-only` editor core * feat: added UI Changes for read-only mode * feat: added mentions store in web Root Store * chore: renamed `use-editor-suggestions` hook * feat: UI Improvements for conditional highlights w.r.t readonly in mentionNode * fix: removed mentions from `filter_set` parameters * fix: minor merge fixes * fix: package lock updates --------- Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
@@ -1534,6 +1534,8 @@ def issue_activity(
|
||||
IssueActivitySerializer(issue_activities_created, many=True).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
requested_data=requested_data,
|
||||
current_instance=current_instance
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
@@ -5,16 +5,98 @@ import json
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification
|
||||
from plane.db.models import IssueMention, IssueSubscriber, Project, User, IssueAssignee, Issue, Notification
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
def get_new_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
new_mentions = [
|
||||
mention for mention in mentions_newer if mention not in mentions_older]
|
||||
|
||||
return new_mentions
|
||||
|
||||
# Get Removed Mention
|
||||
|
||||
|
||||
def get_removed_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
removed_mentions = [
|
||||
mention for mention in mentions_older if mention not in mentions_newer]
|
||||
|
||||
return removed_mentions
|
||||
|
||||
# Adds mentions as subscribers
|
||||
|
||||
|
||||
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||
# mentions is an array of User IDs representing the FILTERED set of mentioned users
|
||||
|
||||
bulk_mention_subscribers = []
|
||||
|
||||
for mention_id in mentions:
|
||||
# If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification
|
||||
if not IssueSubscriber.objects.filter(
|
||||
issue_id=issue_id,
|
||||
subscriber=mention_id,
|
||||
project=project_id,
|
||||
).exists():
|
||||
mentioned_user = User.objects.get(pk=mention_id)
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
|
||||
bulk_mention_subscribers.append(IssueSubscriber(
|
||||
workspace=project.workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
subscriber=mentioned_user,
|
||||
))
|
||||
return bulk_mention_subscribers
|
||||
|
||||
# Parse Issue Description & extracts mentions
|
||||
|
||||
|
||||
def extract_mentions(issue_instance):
|
||||
try:
|
||||
# issue_instance has to be a dictionary passed, containing the description_html and other set of activity data.
|
||||
mentions = []
|
||||
# Convert string to dictionary
|
||||
data = json.loads(issue_instance)
|
||||
html = data.get("description_html")
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
mention_tags = soup.find_all(
|
||||
'mention-component', attrs={'target': 'users'})
|
||||
|
||||
mentions = [mention_tag['id'] for mention_tag in mention_tags]
|
||||
|
||||
return list(set(mentions))
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
|
||||
@shared_task
|
||||
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created):
|
||||
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance):
|
||||
issue_activities_created = (
|
||||
json.loads(issue_activities_created) if issue_activities_created is not None else None
|
||||
json.loads(
|
||||
issue_activities_created) if issue_activities_created is not None else None
|
||||
)
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
@@ -33,14 +115,35 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
|
||||
"""
|
||||
Mention Tasks
|
||||
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
|
||||
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
||||
"""
|
||||
|
||||
# Get new mentions from the newer instance
|
||||
new_mentions = get_new_mentions(
|
||||
requested_instance=requested_data, current_instance=current_instance)
|
||||
removed_mention = get_removed_mentions(
|
||||
requested_instance=requested_data, current_instance=current_instance)
|
||||
|
||||
# Get New Subscribers from the mentions of the newer instance
|
||||
requested_mentions = extract_mentions(
|
||||
issue_instance=requested_data)
|
||||
mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id, issue_id=issue_id, mentions=requested_mentions)
|
||||
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id)
|
||||
.exclude(subscriber_id=actor_id)
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id)
|
||||
.exclude(subscriber_id__in=list(new_mentions + [actor_id]))
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue_assignees = list(
|
||||
IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id)
|
||||
IssueAssignee.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id)
|
||||
.exclude(assignee_id=actor_id)
|
||||
.values_list("assignee", flat=True)
|
||||
)
|
||||
@@ -89,7 +192,8 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"issue_comment": str(
|
||||
issue_activity.get("issue_comment").comment_stripped
|
||||
issue_activity.get(
|
||||
"issue_comment").comment_stripped
|
||||
if issue_activity.get("issue_comment") is not None
|
||||
else ""
|
||||
),
|
||||
@@ -98,5 +202,62 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
|
||||
)
|
||||
)
|
||||
|
||||
# Add Mentioned as Issue Subscribers
|
||||
IssueSubscriber.objects.bulk_create(
|
||||
mention_subscribers, batch_size=100)
|
||||
|
||||
for mention_id in new_mentions:
|
||||
if (mention_id != actor_id):
|
||||
for issue_activity in issue_activities_created:
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities:mention",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
message=f"You have been mentioned in the issue {issue.name}",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Create New Mentions Here
|
||||
aggregated_issue_mentions = []
|
||||
|
||||
for mention_id in new_mentions:
|
||||
mentioned_user = User.objects.get(pk=mention_id)
|
||||
aggregated_issue_mentions.append(
|
||||
IssueMention(
|
||||
mention=mentioned_user,
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace=project.workspace
|
||||
)
|
||||
)
|
||||
|
||||
IssueMention.objects.bulk_create(
|
||||
aggregated_issue_mentions, batch_size=100)
|
||||
IssueMention.objects.filter(
|
||||
issue=issue.id, mention__in=removed_mention).delete()
|
||||
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
|
||||
@@ -5,7 +5,6 @@ from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.issue
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@@ -18,4 +17,4 @@ class Migration(migrations.Migration):
|
||||
name='properties',
|
||||
field=models.JSONField(default=plane.db.models.issue.get_default_properties),
|
||||
),
|
||||
]
|
||||
]
|
||||
45
apiserver/plane/db/migrations/0047_issue_mention_and_more.py
Normal file
45
apiserver/plane/db/migrations/0047_issue_mention_and_more.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 4.2.5 on 2023-10-25 05:01
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0046_alter_analyticview_created_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="issue_mentions",
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(
|
||||
auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(
|
||||
auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4,
|
||||
editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='issue_mention', to=settings.AUTH_USER_MODEL)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='issuemention_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='issue_mention', to='db.issue')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='project_issuemention', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='issuemention_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='workspace_issuemention', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'IssueMention',
|
||||
'verbose_name_plural': 'IssueMentions',
|
||||
'db_table': 'issue_mentions',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
)
|
||||
]
|
||||
@@ -33,6 +33,7 @@ from .issue import (
|
||||
Label,
|
||||
IssueBlocker,
|
||||
IssueRelation,
|
||||
IssueMention,
|
||||
IssueLink,
|
||||
IssueSequence,
|
||||
IssueAttachment,
|
||||
|
||||
@@ -226,7 +226,26 @@ class IssueRelation(ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.related_issue.name}"
|
||||
return f"{self.issue.name} {self.related_issue.name}"
|
||||
|
||||
class IssueMention(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_mention"
|
||||
)
|
||||
mention = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_mention",
|
||||
)
|
||||
class Meta:
|
||||
unique_together = ["issue", "mention"]
|
||||
verbose_name = "Issue Mention"
|
||||
verbose_name_plural = "Issue Mentions"
|
||||
db_table = "issue_mentions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.mention.email}"
|
||||
|
||||
|
||||
class IssueAssignee(ProjectBaseModel):
|
||||
|
||||
@@ -150,6 +150,17 @@ def filter_assignees(params, filter, method):
|
||||
filter["assignees__in"] = params.get("assignees")
|
||||
return filter
|
||||
|
||||
def filter_mentions(params, filter, method):
|
||||
if method == "GET":
|
||||
mentions = [item for item in params.get("mentions").split(",") if item != 'null']
|
||||
mentions = filter_valid_uuids(mentions)
|
||||
if len(mentions) and "" not in mentions:
|
||||
filter["issue_mention__mention__id__in"] = mentions
|
||||
else:
|
||||
if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != 'null':
|
||||
filter["issue_mention__mention__id__in"] = params.get("mentions")
|
||||
return filter
|
||||
|
||||
|
||||
def filter_created_by(params, filter, method):
|
||||
if method == "GET":
|
||||
@@ -326,6 +337,7 @@ def issue_filters(query_params, method):
|
||||
"parent": filter_parent,
|
||||
"labels": filter_labels,
|
||||
"assignees": filter_assignees,
|
||||
"mentions": filter_mentions,
|
||||
"created_by": filter_created_by,
|
||||
"name": filter_name,
|
||||
"created_at": filter_created_at,
|
||||
|
||||
@@ -33,4 +33,5 @@ django_celery_beat==2.5.0
|
||||
psycopg-binary==3.1.10
|
||||
psycopg-c==3.1.10
|
||||
scout-apm==2.26.1
|
||||
openpyxl==3.1.2
|
||||
openpyxl==3.1.2
|
||||
beautifulsoup4==4.12.2
|
||||
Reference in New Issue
Block a user