Compare commits

...

3 Commits

5 changed files with 1004 additions and 663 deletions
@@ -12,7 +12,6 @@ from django.utils import timezone
# Module imports
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.db.models import (
CommentReaction,
Cycle,
@@ -32,7 +31,7 @@ from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.uuid import is_valid_uuid
from plane.bgtasks.notification_task import process_issue_notifications
def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]:
if not data:
@@ -1580,18 +1579,18 @@ def issue_activity(
issue_activities_created = IssueActivity.objects.bulk_create(issue_activities)
if notification:
notifications.delay(
type=type,
process_issue_notifications(
issue_id=issue_id,
actor_id=actor_id,
project_id=project_id,
subscriber=subscriber,
issue_activities_created=json.dumps(
actor_id=actor_id,
activities_data=json.dumps(
IssueActivitySerializer(issue_activities_created, many=True).data,
cls=DjangoJSONEncoder,
),
requested_data=requested_data,
current_instance=current_instance,
subscriber=subscriber,
notification_type=type,
)
return
+45 -656
View File
@@ -1,670 +1,59 @@
# Python imports
import json
import uuid
from uuid import UUID
# Third Party imports
from celery import shared_task
# Module imports
from plane.db.models import (
IssueMention,
IssueSubscriber,
Project,
User,
IssueAssignee,
Issue,
State,
EmailNotificationLog,
Notification,
IssueComment,
IssueActivity,
UserNotificationPreference,
ProjectMember,
)
from django.db.models import Subquery
# Third Party imports
from celery import shared_task
from bs4 import BeautifulSoup
# =========== Issue Description Html Parsing and notification Functions ======================
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
aggregated_issue_mentions = []
for mention_id in new_mentions:
aggregated_issue_mentions.append(
IssueMention(
mention_id=mention_id,
issue=issue,
project=project,
workspace_id=project.workspace_id,
)
)
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
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 # noqa: E501
if (
not IssueSubscriber.objects.filter(
issue_id=issue_id, subscriber_id=mention_id, project_id=project_id
).exists()
and not IssueAssignee.objects.filter(
project_id=project_id, issue_id=issue_id, assignee_id=mention_id
).exists()
and not Issue.objects.filter(project_id=project_id, pk=issue_id, created_by_id=mention_id).exists()
and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists()
):
project = Project.objects.get(pk=project_id)
bulk_mention_subscribers.append(
IssueSubscriber(
workspace_id=project.workspace_id,
project_id=project_id,
issue_id=issue_id,
subscriber_id=mention_id,
)
)
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. # noqa: E501
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={"entity_name": "user_mention"})
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
return list(set(mentions))
except Exception:
return []
# =========== Comment Parsing and notification Functions ======================
def extract_comment_mentions(comment_value):
try:
mentions = []
soup = BeautifulSoup(comment_value, "html.parser")
mentions_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
for mention_tag in mentions_tags:
mentions.append(mention_tag["entity_identifier"])
return list(set(mentions))
except Exception:
return []
def get_new_comment_mentions(new_value, old_value):
mentions_newer = extract_comment_mentions(new_value)
if old_value is None:
return mentions_newer
mentions_older = extract_comment_mentions(old_value)
# Getting Set Difference from mentions_newer
new_mentions = [mention for mention in mentions_newer if mention not in mentions_older]
return new_mentions
def create_mention_notification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity):
return Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mentioned",
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
project=project,
message=notification_comment,
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(activity.get("id")),
"verb": str(activity.get("verb")),
"field": str(activity.get("field")),
"actor": str(activity.get("actor_id")),
"new_value": str(activity.get("new_value")),
"old_value": str(activity.get("old_value")),
"old_identifier": (str(activity.get("old_identifier")) if activity.get("old_identifier") else None),
"new_identifier": (str(activity.get("new_identifier")) if activity.get("new_identifier") else None),
},
},
)
from plane.utils.notifications import IssueNotificationHandler, NotificationContext
from plane.utils.exception_logger import log_exception
@shared_task
def notifications(
type,
def process_issue_notifications(
issue_id,
project_id,
actor_id,
subscriber,
issue_activities_created,
requested_data,
current_instance,
activities_data,
requested_data=None,
current_instance=None,
subscriber=False,
notification_type=""
):
"""
Process notifications for issue activities.
"""
try:
issue_activities_created = (
json.loads(issue_activities_created) if issue_activities_created is not None else None
# Let the handler normalize and parse activities
activities = IssueNotificationHandler.parse_activities(activities_data)
project = Project.objects.get(pk=project_id)
workspace_id = project.workspace_id
# Create context
context = NotificationContext(
entity_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
actor_id=actor_id,
activities=activities,
requested_data=requested_data,
current_instance=current_instance,
subscriber=subscriber,
notification_type=notification_type
)
if type not in [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
bulk_email_logs = []
"""
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 the list of active project members
project_members = ProjectMember.objects.filter(project_id=project_id, is_active=True).values_list(
"member_id", flat=True
)
# Get new mentions from the newer instance
new_mentions = get_new_mentions(requested_instance=requested_data, current_instance=current_instance)
new_mentions = list(set(new_mentions) & {str(member) for member in project_members})
removed_mention = get_removed_mentions(requested_instance=requested_data, current_instance=current_instance)
comment_mentions = []
all_comment_mentions = []
# 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
)
for issue_activity in issue_activities_created:
issue_comment = issue_activity.get("issue_comment")
issue_comment_new_value = issue_activity.get("new_value")
issue_comment_old_value = issue_activity.get("old_value")
if issue_comment is not None:
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value)
new_comment_mentions = get_new_comment_mentions(
old_value=issue_comment_old_value,
new_value=issue_comment_new_value,
)
comment_mentions = comment_mentions + new_comment_mentions
comment_mentions = [
mention for mention in comment_mentions if UUID(mention) in set(project_members)
]
comment_mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
)
"""
We will not send subscription activity notification to the below mentioned user sets
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
- When the activity is a comment_created and there exist a mention in the comment,
then we have to send the "mention_in_comment" notification
- When the activity is a comment_updated and there exist a mention change,
then also we have to send the "mention_in_comment" notification
"""
# ---------------------------------------------------------------------------------------------------------
issue_subscribers = list(
IssueSubscriber.objects.filter(
project_id=project_id,
issue_id=issue_id,
subscriber__in=Subquery(project_members),
)
.exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]))
.values_list("subscriber", flat=True)
)
issue = Issue.objects.filter(pk=issue_id).first()
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
)
except Exception:
pass
project = Project.objects.get(pk=project_id)
issue_assignees = IssueAssignee.objects.filter(
issue_id=issue_id,
project_id=project_id,
assignee__in=Subquery(project_members),
).values_list("assignee", flat=True)
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
for subscriber in issue_subscribers:
if issue.created_by_id and issue.created_by_id == subscriber:
sender = "in_app:issue_activities:created"
elif subscriber in issue_assignees and issue.created_by_id not in issue_assignees:
sender = "in_app:issue_activities:assigned"
else:
sender = "in_app:issue_activities:subscribed"
preference = UserNotificationPreference.objects.get(user_id=subscriber)
for issue_activity in issue_activities_created:
# If activity done in blocking then blocked by email should not go
if issue_activity.get("issue_detail").get("id") != issue_id:
continue
# Do not send notification for description update
if issue_activity.get("field") == "description":
continue
# Check if the value should be sent or not
send_email = False
if issue_activity.get("field") == "state" and preference.state_change:
send_email = True
elif (
issue_activity.get("field") == "state"
and preference.issue_completed
and State.objects.filter(
project_id=project_id,
pk=issue_activity.get("new_identifier"),
group="completed",
).exists()
):
send_email = True
elif issue_activity.get("field") == "comment" and preference.comment:
send_email = True
elif preference.property_change:
send_email = True
else:
send_email = False
# If activity is of issue comment fetch the comment
issue_comment = (
IssueComment.objects.filter(
id=issue_activity.get("issue_comment"),
issue_id=issue_id,
project_id=project_id,
workspace_id=project.workspace_id,
).first()
if issue_activity.get("issue_comment")
else None
)
# Create in app notification
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender=sender,
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.get("comment"),
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")),
"issue_comment": str(
issue_comment.comment_stripped if issue_comment is not None else ""
),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
},
},
)
)
# Create email notification
if send_email:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"project_id": str(issue.project.id),
"workspace_slug": str(issue.project.workspace.slug),
"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")),
"issue_comment": str(
issue_comment.comment_stripped if issue_comment is not None else ""
),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
"activity_time": issue_activity.get("created_at"),
},
},
)
)
# -------------------------------------------------------------------------------------------------------- #
# Add Mentioned as Issue Subscribers
IssueSubscriber.objects.bulk_create(
mention_subscribers + comment_mention_subscribers,
batch_size=100,
ignore_conflicts=True,
)
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
actor = User.objects.get(pk=actor_id)
for mention_id in comment_mentions:
if mention_id != actor_id:
preference = UserNotificationPreference.objects.get(user_id=mention_id)
for issue_activity in issue_activities_created:
notification = create_mention_notification(
project=project,
issue=issue,
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", # noqa: E501
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity,
)
# check for email notifications
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
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,
"project_id": str(issue.project.id),
"workspace_slug": str(issue.project.workspace.slug),
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str("mention"),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
"activity_time": issue_activity.get("created_at"),
},
},
)
)
bulk_notifications.append(notification)
for mention_id in new_mentions:
if mention_id != actor_id:
preference = UserNotificationPreference.objects.get(user_id=mention_id)
if (
last_activity is not None
and last_activity.field == "description"
and actor_id == str(last_activity.actor_id)
):
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mentioned",
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,
"project_id": str(issue.project.id),
"workspace_slug": str(issue.project.workspace.slug),
},
"issue_activity": {
"id": str(last_activity.id),
"verb": str(last_activity.verb),
"field": str(last_activity.field),
"actor": str(last_activity.actor_id),
"new_value": str(last_activity.new_value),
"old_value": str(last_activity.old_value),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
},
},
)
)
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
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(last_activity.id),
"verb": str(last_activity.verb),
"field": "mention",
"actor": str(last_activity.actor_id),
"new_value": str(last_activity.new_value),
"old_value": str(last_activity.old_value),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
"activity_time": str(last_activity.created_at),
},
},
)
)
else:
for issue_activity in issue_activities_created:
notification = create_mention_notification(
project=project,
issue=issue,
notification_comment=f"You have been mentioned in the issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity,
)
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
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("mention"),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
"old_identifier": (
str(issue_activity.get("old_identifier"))
if issue_activity.get("old_identifier")
else None
),
"new_identifier": (
str(issue_activity.get("new_identifier"))
if issue_activity.get("new_identifier")
else None
),
"activity_time": issue_activity.get("created_at"),
},
},
)
)
bulk_notifications.append(notification)
# save new mentions for the particular issue and remove the mentions that has been deleted from the description # noqa: E501
update_mentions_for_issue(
issue=issue,
project=project,
new_mentions=new_mentions,
removed_mention=removed_mention,
)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
EmailNotificationLog.objects.bulk_create(bulk_email_logs, batch_size=100, ignore_conflicts=True)
return
# Process notifications
handler = IssueNotificationHandler(context)
payload = handler.process()
return {
"success": True,
"in_app_count": len(payload.in_app_notifications),
"email_count": len(payload.email_logs),
}
except Exception as e:
print(e)
return
log_exception(e)
return {
"success": False,
"error": str(e)
}
@@ -0,0 +1,7 @@
from .base import NotificationContext
from .workitem import WorkItemNotificationHandler
__all__ = [
"NotificationContext",
"WorkItemNotificationHandler",
]
+542
View File
@@ -0,0 +1,542 @@
# Python imports
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from uuid import UUID
import json
# Module imports
from plane.db.models import Notification, EmailNotificationLog, User, UserNotificationPreference
@dataclass
class ActivityData:
"""Represents a single activity/event"""
id: str
verb: str
field: str
actor_id: str
new_value: Any
old_value: Any
new_identifier: Optional[str] = None
old_identifier: Optional[str] = None
created_at: Optional[str] = None
comment: Optional[str] = None
entity_comment: Optional[str] = None
entity_detail: Optional[Dict] = None
@classmethod
def from_dict(cls, data: Dict) -> "ActivityData":
"""Create ActivityData from dictionary"""
return cls(
id=data.get("id"),
verb=data.get("verb"),
field=data.get("field"),
actor_id=data.get("actor_id"),
new_value=data.get("new_value"),
old_value=data.get("old_value"),
new_identifier=data.get("new_identifier"),
old_identifier=data.get("old_identifier"),
created_at=data.get("created_at"),
comment=data.get("comment"),
entity_comment=data.get("entity_comment"),
entity_detail=data.get("entity_detail"),
)
@dataclass
class NotificationContext:
"""Context data for notification processing"""
entity_id: str
project_id: Optional[str]
workspace_id: str
actor_id: str
activities: List[ActivityData]
requested_data: Optional[str] = None
current_instance: Optional[str] = None
subscriber: bool = False
notification_type: str = ""
@dataclass
class NotificationPayload:
"""Container for processed notification data"""
in_app_notifications: List[Notification] = field(default_factory=list)
email_logs: List[EmailNotificationLog] = field(default_factory=list)
push_notifications: List[Dict[str, Any]] = field(default_factory=list)
entity_data: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class MentionData:
"""Data related to mentions in entity"""
new_mentions: List[str] = field(default_factory=list)
removed_mentions: List[str] = field(default_factory=list)
@dataclass
class SubscriberData:
"""Data related to entity subscribers"""
subscribers: List[UUID] = field(default_factory=list)
class BaseNotificationHandler(ABC):
"""
Abstract base class for entity-specific notification handlers.
Each entity type (Issue) should implement this.
"""
# Entity-specific configurations (must be overridden)
ENTITY_NAME: str = "entity"
ENTITY_MODEL = None
SUBSCRIBER_MODEL = None
MENTION_MODEL = None
ACTIVITY_MODEL = None
@classmethod
def normalize_activity_data(cls, activity_dict: Dict) -> Dict:
"""
Normalize entity-specific activity keys to generic keys.
Subclasses should override this to map their specific keys.
Default implementation does nothing (assumes already normalized).
"""
return activity_dict
@classmethod
def parse_activities(cls, activities_data: str) -> List[ActivityData]:
"""
Parse and normalize activities from JSON string.
Uses the subclass's normalize_activity_data method.
"""
parsed_activities = json.loads(activities_data)
normalized = [cls.normalize_activity_data(a) for a in parsed_activities]
return [ActivityData.from_dict(a) for a in normalized]
# Notification filters (can be overridden)
EXCLUDED_ACTIVITY_TYPES: List[str] = []
def __init__(self, context: NotificationContext):
"""Initialize the notification handler"""
self.context = context
self.entity = None
self.project = None
self.workspace = None
self.active_members = []
self.actor = None
self.payload = NotificationPayload()
def process(self) -> NotificationPayload:
"""
Main entry point to process notifications for an entity.
Orchestrates the entire notification flow.
"""
# 1. Load entity and related data
self.load_entity()
self.load_project()
self.load_workspace()
self.load_actor()
self.active_members = self.get_active_members()
# 2. Filter activities that should trigger notifications
if self.should_skip_notification():
return self.payload
# 3. Process mentions - subclasses provide the specific field values
mention_results = self.process_entity_mentions()
# 4. Create subscribers from mentions
all_mention_subscribers = []
for mention_type, mention_data in mention_results.items():
subscribers = self.create_subscribers(mention_data.get("all_mentions", []))
all_mention_subscribers.extend(subscribers)
mention_data["subscribers"] = subscribers
# 5. Bulk create all mention subscribers
if all_mention_subscribers and self.SUBSCRIBER_MODEL:
self.SUBSCRIBER_MODEL.objects.bulk_create(all_mention_subscribers, batch_size=100, ignore_conflicts=True)
# 6. Update mention records (for description mentions, not comments)
self.update_all_mentions(mention_results)
# 7. Handle subscriber opt-in if requested
if self.context.subscriber:
self.add_actor_as_subscriber()
# 8. Get all subscribers (excluding mentioned users to avoid duplicate notifications)
all_new_mentions = []
for mention_data in mention_results.values():
all_new_mentions.extend(mention_data.get("new_mentions", []))
excluded_users = all_new_mentions + [self.context.actor_id]
subscriber_data = self.get_subscribers(excluded_users)
# 9. Create notifications for subscribers
self.create_subscriber_notifications(subscriber_data)
# 10. Create mention notifications
self.create_all_mention_notifications(mention_results)
# 11. Persist all notifications
self.save_notifications()
return self.payload
# ==================== Abstract Methods (Must Implement) ====================
@abstractmethod
def load_entity(self):
"""Load the main entity (issue, initiative, teamspace, etc.)"""
pass
@abstractmethod
def get_entity_display_name(self) -> str:
"""Get display name for the entity"""
pass
@abstractmethod
def get_active_members(self) -> List[UUID]:
"""Get list of active members who can receive notifications"""
pass
@abstractmethod
def get_subscribers(self, exclude_users: List[str]) -> SubscriberData:
"""Get subscribers for this entity"""
pass
@abstractmethod
def create_subscribers(self, mentions: List[str]) -> List:
"""Create new subscribers for mentions"""
pass
@abstractmethod
def process_entity_mentions(self) -> Dict[str, Dict[str, Any]]:
"""
Process mentions for entity-specific fields.
Subclasses call self.process_mentions() with appropriate field values.
Returns:
Dict mapping mention types to their data:
{
'description': {
'new_mentions': [...],
'removed_mentions': [...],
'all_mentions': [...]
},
'comment': {
'new_mentions': [...],
'all_mentions': [...]
}
}
"""
pass
@abstractmethod
def update_all_mentions(self, mention_results: Dict[str, Dict[str, Any]]):
"""Update mention records for this entity (usually only description mentions)"""
pass
@abstractmethod
def should_send_email(self, preference: UserNotificationPreference, activity: ActivityData) -> bool:
"""Determine if email should be sent for this activity"""
pass
# ==================== Entity Loading Methods ====================
def load_project(self):
"""Load project (can be overridden for project-less entities)"""
from plane.db.models import Project
if self.context.project_id:
self.project = Project.objects.get(pk=self.context.project_id)
def load_workspace(self):
"""Load workspace"""
from plane.db.models import Workspace
self.workspace = self.project.workspace if self.project else None
if not self.workspace:
from plane.db.models import Workspace
self.workspace = Workspace.objects.get(pk=self.context.workspace_id)
def load_actor(self):
"""Load the user who triggered the activity"""
self.actor = User.objects.get(pk=self.context.actor_id)
# ==================== Mention Processing Methods ====================
def extract_mentions(self, content: str) -> List[str]:
"""
Extract mentions from HTML content.
"""
try:
from bs4 import BeautifulSoup
# Extract mentions from HTML
soup = BeautifulSoup(content, "html.parser")
mention_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
mentions = [
mention_tag["entity_identifier"] for mention_tag in mention_tags if mention_tag.get("entity_identifier")
]
return list(set(mentions))
except Exception:
return []
def process_mentions(
self, old_value: Optional[str] = None, new_value: Optional[str] = None, filter_to_active: bool = True
) -> MentionData:
"""
Generic mention processing that works for any field.
Subclasses call this method with their specific field values.
Args:
old_value: Old content to extract mentions from
new_value: New content to extract mentions from
filter_to_active: Whether to filter mentions to active members only
Returns:
MentionData with new and removed mentions
"""
if not old_value and not new_value:
return MentionData()
# Extract mentions from both versions
mentions_older = self.extract_mentions(old_value) if old_value else []
mentions_newer = self.extract_mentions(new_value) if new_value else []
# Calculate differences
new_mentions = [m for m in mentions_newer if m not in mentions_older]
removed_mentions = [m for m in mentions_older if m not in mentions_newer]
# Filter to active members if requested
if filter_to_active and self.active_members:
active_member_ids = {str(m) for m in self.active_members}
new_mentions = [m for m in new_mentions if m in active_member_ids]
return MentionData(
new_mentions=new_mentions,
removed_mentions=removed_mentions,
)
# ==================== Notification Creation Methods ====================
def create_subscriber_notifications(self, subscriber_data: SubscriberData):
"""Create notifications for all subscribers"""
for subscriber_id in subscriber_data.subscribers:
# Get user preferences
try:
preference = UserNotificationPreference.objects.get(user_id=subscriber_id)
except UserNotificationPreference.DoesNotExist:
continue
# Determine sender type based on relationship
sender = self.get_sender_type(subscriber_id, subscriber_data)
# Create notifications for each activity
for activity in self.context.activities:
# Skip if activity is for a different entity
if not self.is_activity_for_this_entity(activity):
continue
# Skip description updates
if activity.field == "description":
continue
# Create in-app notification
self.create_in_app_notification(
receiver_id=subscriber_id, activity=activity, sender=sender, message=activity.comment
)
# Create email notification if preference allows
if self.should_send_email(preference, activity):
self.create_email_notification(receiver_id=subscriber_id, activity=activity)
def create_all_mention_notifications(self, mention_results: Dict[str, Dict[str, Any]]):
"""Create notifications for all mention types"""
for mention_type, data in mention_results.items():
new_mentions = data.get("new_mentions", [])
notification_type = data.get("notification_type", mention_type)
for mention_id in new_mentions:
# Skip self-mentions
if mention_id == self.context.actor_id:
continue
# Get user preferences
try:
preference = UserNotificationPreference.objects.get(user_id=mention_id)
except UserNotificationPreference.DoesNotExist:
continue
# Create notification message
entity_name = self.get_entity_display_name()
if notification_type == "comment":
message = (
f"{self.actor.display_name} has mentioned you in a comment in {self.ENTITY_NAME} {entity_name}"
)
else:
message = f"You have been mentioned in the {self.ENTITY_NAME} {entity_name}"
# Create notifications for each activity
for activity in self.context.activities:
# Create in-app notification
self.create_in_app_notification(
receiver_id=mention_id,
activity=activity,
sender=f"in_app:{self.ENTITY_NAME}_activities:mentioned",
message=message,
)
# Create email notification if preference allows
if preference.mention:
self.create_email_notification(
receiver_id=mention_id, activity=activity, field_override="mention"
)
def create_in_app_notification(
self, receiver_id: str, activity: ActivityData, sender: str, message: Optional[str] = None
):
"""Create an in-app notification"""
notification = Notification(
workspace=self.workspace,
sender=sender,
triggered_by_id=self.context.actor_id,
receiver_id=receiver_id,
entity_identifier=self.context.entity_id,
entity_name=self.get_entity_type(),
project=self.project,
title=message or activity.comment,
data=self.build_notification_data(activity),
)
self.payload.in_app_notifications.append(notification)
def create_email_notification(self, receiver_id: str, activity: ActivityData, field_override: Optional[str] = None):
"""Create an email notification log"""
email_log = EmailNotificationLog(
triggered_by_id=self.context.actor_id,
receiver_id=receiver_id,
entity_identifier=self.context.entity_id,
entity_name=self.get_entity_type(),
data=self.build_email_data(activity, field_override),
)
self.payload.email_logs.append(email_log)
# ==================== Notification Data Builders ====================
def build_notification_data(self, activity: ActivityData) -> Dict[str, Any]:
"""Build notification data dictionary (can be overridden for entity-specific data)"""
return {
self.ENTITY_NAME: self.get_entity_data(),
f"{self.ENTITY_NAME}_activity": {
"id": str(activity.id),
"verb": str(activity.verb),
"field": str(activity.field),
"actor": str(activity.actor_id),
"new_value": str(activity.new_value),
"old_value": str(activity.old_value),
"old_identifier": str(activity.old_identifier) if activity.old_identifier else None,
"new_identifier": str(activity.new_identifier) if activity.new_identifier else None,
},
}
def build_email_data(self, activity: ActivityData, field_override: Optional[str] = None) -> Dict[str, Any]:
"""Build email notification data dictionary (can be overridden)"""
data = self.build_notification_data(activity)
# Add email-specific fields
activity_key = f"{self.ENTITY_NAME}_activity"
if activity_key in data:
data[activity_key]["activity_time"] = activity.created_at
# Override field if provided (e.g., for mention notifications)
if field_override:
data[activity_key]["field"] = field_override
return data
# ==================== Helper Methods ====================
def get_sender_type(self, subscriber_id: UUID, subscriber_data: SubscriberData) -> str:
"""Determine sender type based on subscriber relationship"""
# Check if subscriber is the creator
if self.entity and hasattr(self.entity, "created_by_id"):
if self.entity.created_by_id == subscriber_id:
return f"in_app:{self.ENTITY_NAME}_activities:created"
# Check if subscriber is an assignee (if entity supports assignees)
if self.is_assignee(subscriber_id):
# Only use "assigned" if creator is not also an assignee
if not (
self.entity and hasattr(self.entity, "created_by_id") and self.entity.created_by_id == subscriber_id
):
return f"in_app:{self.ENTITY_NAME}_activities:assigned"
return f"in_app:{self.ENTITY_NAME}_activities:subscribed"
def is_assignee(self, user_id: UUID) -> bool:
"""Check if user is an assignee (can be overridden for entities without assignees)"""
return False # Default: no assignees
def get_entity_type(self) -> str:
"""Get entity type string (can be overridden for special cases like epics)"""
return self.ENTITY_NAME
def should_skip_notification(self) -> bool:
"""Check if notification should be skipped for this activity type"""
return self.context.notification_type in self.EXCLUDED_ACTIVITY_TYPES
def should_send_push_notifications(self) -> bool:
"""Determine if push notifications should be sent (can be overridden)"""
return True
def is_activity_for_this_entity(self, activity: ActivityData) -> bool:
"""Check if activity is for this entity (for blocking/blocked_by scenarios)"""
if activity.entity_detail:
return activity.entity_detail.get("id") == self.context.entity_id
return True
def add_actor_as_subscriber(self):
"""Add the actor as a subscriber if requested"""
if not self.SUBSCRIBER_MODEL:
return
try:
subscriber_data = {"subscriber_id": self.context.actor_id}
if self.project:
subscriber_data["project_id"] = self.context.project_id
subscriber_data["workspace_id"] = self.context.workspace_id
subscriber_data[f"{self.ENTITY_NAME}_id"] = self.context.entity_id
self.SUBSCRIBER_MODEL.objects.get_or_create(**subscriber_data)
except Exception:
pass
# ==================== Persistence Methods ====================
def save_notifications(self):
"""Persist all notifications to the database"""
# Bulk create in-app notifications
if self.payload.in_app_notifications:
notifications = Notification.objects.bulk_create(self.payload.in_app_notifications, batch_size=100)
# Update payload with saved notifications (with IDs)
self.payload.in_app_notifications = notifications
# Bulk create email logs
if self.payload.email_logs:
EmailNotificationLog.objects.bulk_create(self.payload.email_logs, batch_size=100, ignore_conflicts=True)
# ==================== External Notification send methods ====================
def send_email_notification(self, template_name: str, context: Dict[str, Any], receiver_data: Dict[str, Any]):
"""Send an email notification"""
pass
@@ -0,0 +1,404 @@
# Python imports
from typing import List, Dict, Any, Optional
from uuid import UUID
import json
# Django imports
from django.db.models import Subquery
# Module imports
from plane.db.models import (
Issue,
IssueSubscriber,
IssueMention,
IssueAssignee,
IssueComment,
IssueActivity,
ProjectMember,
State,
UserNotificationPreference,
)
from plane.utils.notifications.base import (
BaseNotificationHandler,
SubscriberData,
ActivityData,
)
class WorkItemNotificationHandler(BaseNotificationHandler):
"""
Notification handler for Issues and Epics.
Handles all issue-related notifications including mentions, assignments, and property changes.
"""
# Entity configuration
ENTITY_NAME = "issue"
ENTITY_MODEL = Issue
SUBSCRIBER_MODEL = IssueSubscriber
MENTION_MODEL = IssueMention
ACTIVITY_MODEL = IssueActivity
# Activity types that should not trigger notifications
EXCLUDED_ACTIVITY_TYPES = [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]
# Cleaning methods
@classmethod
def normalize_activity_data(cls, activity_dict: Dict) -> Dict:
"""
Normalize issue-specific activity keys to generic keys.
Maps issue_comment -> entity_comment, issue_detail -> entity_detail
"""
normalized = activity_dict.copy()
# Map issue-specific keys to generic keys
if 'issue_comment' in normalized:
normalized['entity_comment'] = normalized.pop('issue_comment')
if 'issue_detail' in normalized:
normalized['entity_detail'] = normalized.pop('issue_detail')
return normalized
# ==================== Entity Loading ====================
def load_entity(self):
"""Load the issue"""
self.entity = Issue.objects.filter(pk=self.context.entity_id).first()
def get_entity_display_name(self) -> str:
"""Get display name for the issue"""
return self.entity.name if self.entity else ""
def get_entity_data(self) -> Dict[str, Any]:
"""Get issue data for notification payload"""
if not self.entity:
return {}
return {
"id": str(self.context.entity_id),
"name": str(self.entity.name),
"identifier": str(self.entity.project.identifier),
"sequence_id": self.entity.sequence_id,
"state_name": self.entity.state.name,
"state_group": self.entity.state.group,
"type_id": str(self.entity.type_id),
}
def get_entity_type(self) -> str:
"""Return 'epic' if issue is an epic, otherwise 'issue'"""
if self.entity and self.entity.type and self.entity.type.is_epic:
return "epic"
return "issue"
# ==================== Member & Subscriber Management ====================
def get_active_members(self) -> List[UUID]:
"""Get list of active project members"""
if not self.context.project_id:
return []
return list(
ProjectMember.objects.filter(
project_id=self.context.project_id,
is_active=True
).values_list("member_id", flat=True)
)
def get_subscribers(self, exclude_users: List[str]) -> SubscriberData:
"""Get issue subscribers, assignees, and creator"""
# Get subscribers (excluding mentioned users and actor)
subscribers = list(
IssueSubscriber.objects.filter(
project_id=self.context.project_id,
issue_id=self.context.entity_id,
subscriber__in=Subquery(
ProjectMember.objects.filter(
project_id=self.context.project_id,
is_active=True
).values("member_id")
)
)
.exclude(subscriber_id__in=exclude_users)
.values_list("subscriber", flat=True)
)
# Get assignees
assignees = list(
IssueAssignee.objects.filter(
issue_id=self.context.entity_id,
project_id=self.context.project_id,
assignee__in=Subquery(
ProjectMember.objects.filter(
project_id=self.context.project_id,
is_active=True
).values("member_id")
)
).values_list("assignee", flat=True)
)
subscribers.extend(assignees)
if self.entity and self.entity.created_by_id:
subscribers.append(self.entity.created_by_id)
# Remove duplicates and convert to UUIDs
subscribers = list(set(subscribers))
# Remove actor from the list
subscribers = [subscriber for subscriber in subscribers if subscriber != UUID(self.context.actor_id)]
return SubscriberData(
subscribers=subscribers,
)
def create_subscribers(self, mentions: List[str]) -> List[IssueSubscriber]:
"""
Create issue subscribers for mentioned users.
Only creates if they're not already a subscriber, assignee, or creator.
"""
if not mentions:
return []
bulk_subscribers = []
for mention_id in mentions:
# Only create subscriber if they're not already involved with the issue
if (
not IssueSubscriber.objects.filter(
issue_id=self.context.entity_id,
subscriber_id=mention_id,
project_id=self.context.project_id
).exists()
and not IssueAssignee.objects.filter(
project_id=self.context.project_id,
issue_id=self.context.entity_id,
assignee_id=mention_id
).exists()
and not Issue.objects.filter(
project_id=self.context.project_id,
pk=self.context.entity_id,
created_by_id=mention_id
).exists()
and ProjectMember.objects.filter(
project_id=self.context.project_id,
member_id=mention_id,
is_active=True
).exists()
):
bulk_subscribers.append(
IssueSubscriber(
workspace_id=self.project.workspace_id,
project_id=self.context.project_id,
issue_id=self.context.entity_id,
subscriber_id=mention_id,
)
)
return bulk_subscribers
# ==================== Mention Processing ====================
def process_entity_mentions(self) -> Dict[str, Dict[str, Any]]:
"""
Process mentions for issue-specific fields (description and comments).
Calls base class process_mentions() with field values.
"""
results = {}
# 1. Process description mentions (JSON format)
if self.context.current_instance and self.context.requested_data:
current_instance = json.loads(self.context.current_instance)
requested_data = json.loads(self.context.requested_data)
description_mentions = self.process_mentions(
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
filter_to_active=True
)
# Get all current mentions for subscriber creation
current_mentions = self.extract_mentions(current_instance.get("description_html"))
results['description'] = {
'new_mentions': description_mentions.new_mentions,
'removed_mentions': description_mentions.removed_mentions,
'all_mentions': current_mentions,
'notification_type': 'description'
}
# 2. Process comment mentions (HTML format)
comment_mentions_new = []
comment_mentions_all = []
for activity in self.context.activities:
# Check if this activity has a comment
if activity.entity_comment is not None:
# Process mentions for this specific comment
mention_data = self.process_mentions(
old_value=activity.old_value,
new_value=activity.new_value,
filter_to_active=True
)
# Collect new mentions from this comment
comment_mentions_new.extend(mention_data.new_mentions)
# Get all mentions from new value
if activity.new_value:
all_mentions = self.extract_mentions(activity.new_value)
comment_mentions_all.extend(all_mentions)
if comment_mentions_new or comment_mentions_all:
results['comment'] = {
'new_mentions': comment_mentions_new,
'all_mentions': comment_mentions_all,
'notification_type': 'comment'
}
return results
def update_all_mentions(self, mention_results: Dict[str, Dict[str, Any]]):
"""
Update issue mention records.
Only updates description mentions (comment mentions are not stored).
"""
# Only update description mentions
if 'description' not in mention_results:
return
data = mention_results['description']
# Create new mentions
bulk_mentions = []
for mention_id in data.get('new_mentions', []):
bulk_mentions.append(
IssueMention(
mention_id=mention_id,
issue_id=self.context.entity_id,
project=self.project,
workspace_id=self.project.workspace_id,
)
)
if bulk_mentions:
IssueMention.objects.bulk_create(bulk_mentions, batch_size=100)
# Delete removed mentions
removed_mentions = data.get('removed_mentions', [])
if removed_mentions:
IssueMention.objects.filter(
issue_id=self.context.entity_id,
mention__in=removed_mentions
).delete()
# ==================== Email Preferences ====================
def should_send_email(
self,
preference: UserNotificationPreference,
activity: ActivityData
) -> bool:
"""
Determine if email should be sent based on user preferences and activity type.
Implements issue-specific email preference logic.
"""
# State changes
if activity.field == "state":
# Check if state change emails are enabled
if preference.state_change:
return True
# Check if issue completion emails are enabled and issue was completed
if (
preference.issue_completed
and activity.new_identifier
and State.objects.filter(
project_id=self.context.project_id,
pk=activity.new_identifier,
group="completed"
).exists()
):
return True
return False
# Comments
elif activity.field == "comment":
return preference.comment
# Page links
elif activity.field == "page":
return True
# All other property changes
elif preference.property_change:
return True
return False
# ==================== Notification Data Building ====================
def build_notification_data(self, activity: ActivityData) -> Dict[str, Any]:
"""Build notification data with issue comment if available"""
data = super().build_notification_data(activity)
# Add comment content if available
if activity.entity_comment:
issue_comment = IssueComment.objects.filter(
id=activity.entity_comment,
issue_id=self.context.entity_id,
project_id=self.context.project_id,
workspace_id=self.workspace.id,
).first()
if issue_comment:
data["issue_activity"]["issue_comment"] = str(
issue_comment.comment_stripped
)
else:
data["issue_activity"]["issue_comment"] = ""
return data
def build_email_data(
self,
activity: ActivityData,
field_override: Optional[str] = None
) -> Dict[str, Any]:
"""Build email data with additional project and workspace info"""
data = super().build_email_data(activity, field_override)
# Add email-specific fields for issues
if "issue" in data and self.project:
data["issue"]["project_id"] = str(self.project.id)
data["issue"]["workspace_slug"] = str(self.project.workspace.slug)
# Add comment content if available
if activity.entity_comment:
issue_comment = IssueComment.objects.filter(
id=activity.entity_comment,
issue_id=self.context.entity_id,
project_id=self.context.project_id,
workspace_id=self.workspace.id,
).first()
if issue_comment:
data["issue_activity"]["issue_comment"] = str(
issue_comment.comment_stripped
)
else:
data["issue_activity"]["issue_comment"] = ""
return data