mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 22:09:12 +02:00
[MOB-1071] feat: Update push notification functionality and add notification count badge (#3923)
* dev: updated push notification workflow * dev: Notification count query * dev: updating the device info mutation * dev: handled unread and read notifications in notifications count and user level unread notification count in push notification * dev: updated relations and comment in push notifications * dev: cleaned up prints * dev: updated notification count
This commit is contained in:
@@ -20,9 +20,9 @@ from plane.db.models import (
|
||||
UserNotificationPreference,
|
||||
ProjectMember,
|
||||
)
|
||||
from django.db.models import Subquery, Q
|
||||
from django.db.models import Subquery
|
||||
from plane.app.serializers import NotificationSerializer
|
||||
from plane.graphql.bgtasks.push_notification import issue_push_notifications
|
||||
from plane.graphql.bgtasks.push_notifications import issue_push_notifications
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .issue import issue_push_notifications
|
||||
125
apps/api/plane/graphql/bgtasks/push_notifications/firebase.py
Normal file
125
apps/api/plane/graphql/bgtasks/push_notifications/firebase.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Firebase Cloud Messaging (FCM) Implementation"""
|
||||
|
||||
import base64
|
||||
|
||||
# Python imports
|
||||
from typing import Optional
|
||||
|
||||
# Third party imports
|
||||
import firebase_admin
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from firebase_admin import credentials, messaging
|
||||
|
||||
# Module imports
|
||||
from .helper import is_mobile_push_notification_disabled
|
||||
|
||||
|
||||
class PushNotification:
|
||||
"""Handles push notifications using Firebase Cloud Messaging."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Firebase credentials on instantiation."""
|
||||
self.initialize_firebase()
|
||||
|
||||
def decode_private_key(self, base64_string):
|
||||
"""Decode the base64 encoded private key."""
|
||||
try:
|
||||
decoded_bytes = base64.b64decode(base64_string)
|
||||
private_key = decoded_bytes.decode("utf-8")
|
||||
|
||||
if "-----BEGIN PRIVATE KEY-----" not in private_key:
|
||||
private_key = (
|
||||
f"-----BEGIN PRIVATE KEY-----\n"
|
||||
f"{private_key}\n"
|
||||
f"-----END PRIVATE KEY-----"
|
||||
)
|
||||
|
||||
return private_key
|
||||
except Exception as e:
|
||||
print(f"Error decoding private key: {str(e)}")
|
||||
return None
|
||||
|
||||
def initialize_firebase(self) -> None:
|
||||
"""Initialize Firebase Cloud Messaging with credentials from settings."""
|
||||
try:
|
||||
if is_mobile_push_notification_disabled():
|
||||
return
|
||||
|
||||
# convert private key from base64 to bytes
|
||||
private_key = (
|
||||
self.decode_private_key(settings.FIREBASE_PRIVATE_KEY)
|
||||
if settings.FIREBASE_PRIVATE_KEY
|
||||
else None
|
||||
)
|
||||
|
||||
if private_key is not None:
|
||||
firebase_credentials = credentials.Certificate(
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": settings.FIREBASE_PROJECT_ID,
|
||||
"private_key_id": settings.FIREBASE_PRIVATE_KEY_ID,
|
||||
"private_key": private_key,
|
||||
"client_email": settings.FIREBASE_CLIENT_EMAIL,
|
||||
"client_id": settings.FIREBASE_CLIENT_ID,
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_x509_cert_url": settings.FIREBASE_CLIENT_CERT_URL,
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"universe_domain": "googleapis.com",
|
||||
}
|
||||
)
|
||||
if not firebase_admin._apps:
|
||||
firebase_admin.initialize_app(firebase_credentials)
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error initializing Firebase: {e}")
|
||||
raise
|
||||
|
||||
def send(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
device_token_id: str,
|
||||
data: Optional[dict] = {},
|
||||
notification_count: Optional[int] = 0,
|
||||
) -> dict:
|
||||
"""Send push notifications to specified devices."""
|
||||
if is_mobile_push_notification_disabled():
|
||||
return "Mobile push notifications are disabled"
|
||||
|
||||
try:
|
||||
if not all([title, body, device_token_id]):
|
||||
raise ValueError("Title, body, and device_token_id are required")
|
||||
|
||||
message = messaging.Message(
|
||||
notification=messaging.Notification(title=title, body=body),
|
||||
data=data or {},
|
||||
token=device_token_id,
|
||||
apns=messaging.APNSConfig(
|
||||
payload=messaging.APNSPayload(
|
||||
aps=messaging.Aps(badge=notification_count, sound="default")
|
||||
)
|
||||
),
|
||||
android=messaging.AndroidConfig(
|
||||
notification=messaging.AndroidNotification(
|
||||
notification_count=notification_count,
|
||||
sound="default",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
response = messaging.send(message)
|
||||
|
||||
return {
|
||||
"notification_success_count": response.success_count
|
||||
if response and hasattr(response, "success_count")
|
||||
else 0,
|
||||
"notification_failure_count": response.failure_count
|
||||
if response and hasattr(response, "failure_count")
|
||||
else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error sending push notification: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
76
apps/api/plane/graphql/bgtasks/push_notifications/helper.py
Normal file
76
apps/api/plane/graphql/bgtasks/push_notifications/helper.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# Python imports
|
||||
from typing import Optional
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Device, Notification, Workspace
|
||||
|
||||
|
||||
def is_mobile_push_notification_disabled() -> bool:
|
||||
"""Check if push notification is disabled."""
|
||||
if hasattr(settings, "IS_MOBILE_PUSH_NOTIFICATION_ENABLED"):
|
||||
return False if settings.IS_MOBILE_PUSH_NOTIFICATION_ENABLED else True
|
||||
return True
|
||||
|
||||
|
||||
def fetch_device_tokens_by_user_id(user_id: str) -> list[str]:
|
||||
"""Fetch device tokens for the user."""
|
||||
device_tokens = []
|
||||
|
||||
try:
|
||||
devices = Device.objects.filter(
|
||||
user_id=user_id,
|
||||
is_active=True,
|
||||
push_token__isnull=False,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
for device in devices:
|
||||
device_tokens.append(device.push_token)
|
||||
except Exception as e:
|
||||
print(f"Error fetching device tokens: {e}")
|
||||
|
||||
return device_tokens
|
||||
|
||||
|
||||
def notification_count(
|
||||
user_id: str,
|
||||
workspace_slug: Optional[str] = None,
|
||||
mentioned: bool = False,
|
||||
combined: bool = False,
|
||||
) -> int:
|
||||
"""Fetch unread notification count for the user."""
|
||||
|
||||
try:
|
||||
notification_query = Notification.objects.filter(
|
||||
receiver_id=user_id,
|
||||
read_at__isnull=True,
|
||||
snoozed_till__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
)
|
||||
|
||||
# filter by workspace
|
||||
if workspace_slug:
|
||||
workspace = Workspace.objects.get(slug=workspace_slug)
|
||||
notification_query = notification_query.filter(workspace=workspace)
|
||||
|
||||
# filter by mentioned
|
||||
if combined:
|
||||
if mentioned:
|
||||
notification_query = notification_query.filter(
|
||||
sender__icontains="mentioned"
|
||||
)
|
||||
else:
|
||||
notification_query = notification_query.exclude(
|
||||
sender__icontains="mentioned"
|
||||
)
|
||||
|
||||
total_notification_count = notification_query.count()
|
||||
|
||||
return total_notification_count
|
||||
except Workspace.DoesNotExist:
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error fetching unread notification count: {e}")
|
||||
return 0
|
||||
280
apps/api/plane/graphql/bgtasks/push_notifications/issue.py
Normal file
280
apps/api/plane/graphql/bgtasks/push_notifications/issue.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# Python imports
|
||||
from enum import Enum
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User, Workspace
|
||||
|
||||
# Local module imports
|
||||
from .helper import (
|
||||
fetch_device_tokens_by_user_id,
|
||||
is_mobile_push_notification_disabled,
|
||||
notification_count,
|
||||
)
|
||||
from .issue_builder import Actor, IssueNotificationBuilder
|
||||
from .firebase import PushNotification
|
||||
|
||||
|
||||
class IssuePushNotificationTypes(Enum):
|
||||
"""Enumeration of supported push notification types."""
|
||||
|
||||
ISSUE_CREATED = "ISSUE_CREATED"
|
||||
ISSUE_UPDATED = "ISSUE_UPDATED"
|
||||
EPIC_CREATED = "EPIC_CREATED"
|
||||
EPIC_UPDATED = "EPIC_UPDATED"
|
||||
INTAKE_CREATED = "INTAKE_CREATED"
|
||||
INTAKE_UPDATED = "INTAKE_UPDATED"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
def construct_issue_notification_details(notification: dict) -> dict:
|
||||
"""Construct the issue push notification details."""
|
||||
try:
|
||||
# notification details
|
||||
notification_id = notification.get("id", "")
|
||||
|
||||
# notification issue and issue activity details
|
||||
notification_issue = notification.get("data", {}).get("issue", {})
|
||||
notification_issue_activity = notification.get("data", {}).get(
|
||||
"issue_activity", {}
|
||||
)
|
||||
|
||||
# sender actor details
|
||||
actor_details = notification.get("triggered_by_details", {})
|
||||
actor_id = actor_details.get("id", "")
|
||||
actor_display_name = actor_details.get("display_name", "")
|
||||
actor_first_name = actor_details.get("first_name", "")
|
||||
actor_last_name = actor_details.get("last_name", "")
|
||||
actor_name = (
|
||||
actor_display_name
|
||||
if actor_display_name
|
||||
else f"{actor_first_name} {actor_last_name}"
|
||||
)
|
||||
|
||||
# receiver actor details
|
||||
receiver_id = notification.get("receiver", None)
|
||||
receiver_name = ""
|
||||
if receiver_id is not None:
|
||||
receiver_details = User.objects.filter(id=receiver_id).first()
|
||||
if receiver_details:
|
||||
receiver_display_name = receiver_details.display_name or ""
|
||||
receiver_first_name = receiver_details.first_name or ""
|
||||
receiver_last_name = receiver_details.last_name or ""
|
||||
receiver_name = (
|
||||
receiver_display_name
|
||||
if receiver_display_name
|
||||
else f"{receiver_first_name} {receiver_last_name}"
|
||||
)
|
||||
|
||||
# workspace details
|
||||
workspace_id = notification.get("workspace", "")
|
||||
workspace_slug = ""
|
||||
workspace = Workspace.objects.filter(id=workspace_id).first()
|
||||
if workspace and workspace.slug:
|
||||
workspace_slug = workspace.slug
|
||||
|
||||
# project details
|
||||
project_id = notification.get("project", "")
|
||||
project_identifier = notification_issue.get("identifier", "")
|
||||
|
||||
# getting the push token from the device with receiver_id
|
||||
receiver_push_tokens = []
|
||||
if receiver_id is not None:
|
||||
receiver_push_tokens = fetch_device_tokens_by_user_id(receiver_id)
|
||||
if len(receiver_push_tokens) == 0:
|
||||
print("Receiver push token is not available")
|
||||
return
|
||||
|
||||
# push notification details
|
||||
push_notification = {
|
||||
"id": notification_id,
|
||||
"workspace": {
|
||||
"id": workspace_id,
|
||||
"slug": workspace_slug,
|
||||
},
|
||||
"project": {
|
||||
"id": project_id,
|
||||
"identifier": project_identifier,
|
||||
},
|
||||
"sender": {"id": actor_id, "name": actor_name},
|
||||
"receiver": {
|
||||
"id": receiver_id,
|
||||
"name": receiver_name,
|
||||
"push_tokens": receiver_push_tokens,
|
||||
},
|
||||
"issue": {
|
||||
"id": notification_issue.get("id", ""),
|
||||
"sequence_id": notification_issue.get("sequence_id", ""),
|
||||
"name": notification_issue.get("name", ""),
|
||||
},
|
||||
"activity": {
|
||||
"id": notification_issue_activity.get("id", ""),
|
||||
"verb": notification_issue_activity.get("verb", ""),
|
||||
"field": notification_issue_activity.get("field", ""),
|
||||
"actor": notification_issue_activity.get("actor", ""),
|
||||
"new_value": notification_issue_activity.get("new_value", ""),
|
||||
"old_value": notification_issue_activity.get("old_value", ""),
|
||||
"old_identifier": notification_issue_activity.get("old_identifier", ""),
|
||||
"issue_comment": notification_issue_activity.get("issue_comment", ""),
|
||||
"new_identifier": notification_issue_activity.get("new_identifier", ""),
|
||||
"old_identifier_type": notification_issue_activity.get(
|
||||
"old_identifier_type", ""
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
return push_notification
|
||||
except Exception as e:
|
||||
print(f"Error constructing issue notification details: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_push_notifications(notification):
|
||||
try:
|
||||
print("=== sending issue properties push notification to mobile ===")
|
||||
|
||||
if is_mobile_push_notification_disabled():
|
||||
return
|
||||
|
||||
notification = construct_issue_notification_details(notification)
|
||||
if notification is None:
|
||||
print("=== Notification is None ===")
|
||||
return
|
||||
|
||||
notification_id = notification.get("id", None)
|
||||
notification_sender = notification.get("sender", None)
|
||||
notification_receiver = notification.get("receiver", None)
|
||||
notification_workspace = notification.get("workspace", None)
|
||||
notification_project = notification.get("project", None)
|
||||
notification_issue = notification.get("issue", None)
|
||||
notification_issue_activity = notification.get("activity", None)
|
||||
|
||||
if not all(
|
||||
[
|
||||
notification_receiver,
|
||||
notification_sender,
|
||||
notification_issue,
|
||||
notification_issue_activity,
|
||||
]
|
||||
):
|
||||
return
|
||||
|
||||
# sender details
|
||||
sender_id = notification_sender.get("id", "")
|
||||
sender_name = notification_sender.get("name", "")
|
||||
|
||||
# receiver details
|
||||
receiver_id = notification_receiver.get("id", "")
|
||||
receiver_name = notification_receiver.get("name", "")
|
||||
|
||||
# workspace details
|
||||
workspace_slug = notification_workspace.get("slug", "")
|
||||
|
||||
# project details
|
||||
project_id = notification_project.get("id", "")
|
||||
project_identifier = notification_project.get("identifier", "")
|
||||
|
||||
# issue details
|
||||
issue_id = notification_issue.get("id", "")
|
||||
issue_name = notification_issue.get("name", "")
|
||||
issue_sequence_id = notification_issue.get("sequence_id", "")
|
||||
|
||||
# issue activity details
|
||||
issue_activity_id = notification_issue_activity.get("id", "")
|
||||
issue_activity_field = notification_issue_activity.get("field", None)
|
||||
issue_activity_new_value = notification_issue_activity.get(
|
||||
"new_identifier", None
|
||||
)
|
||||
issue_comment_id = (
|
||||
issue_activity_new_value
|
||||
if issue_activity_field == "comment" and issue_activity_new_value
|
||||
else ""
|
||||
)
|
||||
|
||||
# unread notification count
|
||||
unread_notification_count = notification_count(
|
||||
user_id=receiver_id, workspace_slug=None, mentioned=False, combined=True
|
||||
)
|
||||
|
||||
# push tokens
|
||||
receiver_push_tokens = notification_receiver.get("push_tokens", [])
|
||||
mention_push_tokens = []
|
||||
|
||||
title = f"{project_identifier}-{issue_sequence_id} - {issue_name}"
|
||||
data = {
|
||||
# deprecated fields
|
||||
"workspaceSlug": workspace_slug,
|
||||
"projectId": project_id,
|
||||
"issueId": issue_id,
|
||||
"userId": receiver_id,
|
||||
"notificationId": notification_id,
|
||||
# new fields
|
||||
"type": IssuePushNotificationTypes.ISSUE_UPDATED.value,
|
||||
"user_id": receiver_id,
|
||||
"workspace_slug": workspace_slug,
|
||||
"project_id": project_id,
|
||||
"issue_id": issue_id,
|
||||
"epic_id": "none",
|
||||
"intake_id": "none",
|
||||
"notification_id": notification_id,
|
||||
"issue_activity_id": issue_activity_id,
|
||||
"issue_comment_id": issue_comment_id,
|
||||
"unread_notification_count": str(unread_notification_count),
|
||||
}
|
||||
|
||||
property_key = notification_issue_activity.get("field", "")
|
||||
old_value = notification_issue_activity.get("old_value", "")
|
||||
new_value = notification_issue_activity.get("new_value", "")
|
||||
old_identifier = notification_issue_activity.get("old_identifier", "")
|
||||
new_identifier = notification_issue_activity.get("new_identifier", "")
|
||||
|
||||
body = None
|
||||
issue_notification_description_builder = IssueNotificationBuilder(
|
||||
sender=Actor(id=sender_id, name=sender_name),
|
||||
receiver=Actor(id=receiver_id, name=receiver_name),
|
||||
property_key=property_key,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
old_identifier=old_identifier,
|
||||
new_identifier=new_identifier,
|
||||
)
|
||||
body = issue_notification_description_builder.build_notification() or ""
|
||||
|
||||
if body is None:
|
||||
print("=== notification body is None ===")
|
||||
return
|
||||
|
||||
# push notification
|
||||
push_notification = PushNotification()
|
||||
|
||||
for token in receiver_push_tokens:
|
||||
push_notification.send(
|
||||
title=title,
|
||||
body=body,
|
||||
device_token_id=token,
|
||||
data=data,
|
||||
notification_count=unread_notification_count,
|
||||
)
|
||||
|
||||
for token in mention_push_tokens:
|
||||
push_notification.send(
|
||||
title=title,
|
||||
body=body,
|
||||
device_token_id=token,
|
||||
data=data,
|
||||
notification_count=unread_notification_count,
|
||||
)
|
||||
|
||||
print(
|
||||
"=== sending issue properties push notification to mobile is successful ==="
|
||||
)
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error sending issue push notification: {e}")
|
||||
raise e
|
||||
@@ -0,0 +1,279 @@
|
||||
# Python imports
|
||||
from typing import TypedDict
|
||||
from uuid import uuid4
|
||||
|
||||
# Third party imports
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
class Actor(TypedDict):
|
||||
id: uuid4
|
||||
name: str | None
|
||||
|
||||
|
||||
def construct_comment_content_with_mentions(html_content: str) -> dict:
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
user_mentions = {}
|
||||
|
||||
for mention_tag in soup.find_all(
|
||||
"mention-component", attrs={"entity_name": "user_mention"}
|
||||
):
|
||||
entity_identifier = mention_tag.get("entity_identifier", "")
|
||||
user = User.objects.filter(id=entity_identifier).first()
|
||||
user_mentions[entity_identifier] = user if user else None
|
||||
mention_tag.replace_with(
|
||||
user.display_name
|
||||
if user.display_name
|
||||
else user.first_name
|
||||
if user.first_name
|
||||
else user.email
|
||||
if user
|
||||
else ""
|
||||
)
|
||||
|
||||
for tag in soup.find_all(True):
|
||||
tag.replace_with(tag.text)
|
||||
|
||||
plain_text = soup.get_text()
|
||||
return {
|
||||
"mention_objects": user_mentions,
|
||||
"content": plain_text,
|
||||
}
|
||||
|
||||
|
||||
class IssueNotificationBuilder:
|
||||
"""Builds notification strings for issue property changes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sender: Actor | None,
|
||||
receiver: Actor | None,
|
||||
property_key: str,
|
||||
old_value: str | None,
|
||||
new_value: str | None,
|
||||
old_identifier: str | None,
|
||||
new_identifier: str | None,
|
||||
):
|
||||
self.sender = sender
|
||||
self.receiver = receiver
|
||||
self.property_key = property_key
|
||||
self.old_value = old_value
|
||||
self.new_value = new_value
|
||||
self.old_identifier = old_identifier
|
||||
self.new_identifier = new_identifier
|
||||
|
||||
def _handle_name_change(self) -> str:
|
||||
return f"set the name to '{self.new_value}'"
|
||||
|
||||
def _handle_state_change(self) -> str:
|
||||
return f"set the state to '{self.new_value}'"
|
||||
|
||||
def _handle_priority_change(self) -> str:
|
||||
return f"set the priority to '{self.new_value}'"
|
||||
|
||||
def _handle_assignees_change(self) -> str:
|
||||
final_string = ""
|
||||
if self.new_identifier:
|
||||
if self.new_identifier == self.receiver["id"]:
|
||||
final_string += "added you"
|
||||
else:
|
||||
final_string += f"added a new assignee '{self.new_value}'"
|
||||
else:
|
||||
if self.old_identifier == self.receiver["id"]:
|
||||
final_string += "removed you"
|
||||
else:
|
||||
final_string += f"removed the assignee '{self.old_value}'"
|
||||
|
||||
return final_string
|
||||
|
||||
def _handle_labels_change(self) -> str:
|
||||
final_string = ""
|
||||
if self.new_value:
|
||||
final_string += f"added a new label '{self.new_value}'"
|
||||
else:
|
||||
final_string += f"removed the label '{self.old_value}'"
|
||||
|
||||
return final_string
|
||||
|
||||
def _handle_start_date_change(self) -> str:
|
||||
final_string = ""
|
||||
if self.new_value:
|
||||
final_string += f"set the start date to '{self.new_value}'"
|
||||
else:
|
||||
final_string += "removed the start date"
|
||||
|
||||
return final_string
|
||||
|
||||
def _handle_target_date_change(self) -> str:
|
||||
final_string = ""
|
||||
if self.new_value:
|
||||
final_string += f"set the due date to '{self.new_value}'"
|
||||
else:
|
||||
final_string += "removed the due date"
|
||||
|
||||
return final_string
|
||||
|
||||
def _handle_parent_change(self) -> str:
|
||||
final_string = ""
|
||||
if self.new_value:
|
||||
final_string += f"set the parent to '{self.new_value}'"
|
||||
else:
|
||||
final_string += "removed the parent"
|
||||
|
||||
return final_string
|
||||
|
||||
def _handle_link_change(self) -> str:
|
||||
final_string = ""
|
||||
if self.new_value:
|
||||
final_string += f"added a new link '{self.new_value}'"
|
||||
else:
|
||||
final_string += f"removed the link '{self.old_value}'"
|
||||
|
||||
return final_string
|
||||
|
||||
def _handle_attachment_change(self) -> str:
|
||||
final_string = ""
|
||||
if self.new_value:
|
||||
final_string += "updated a new attachment"
|
||||
else:
|
||||
final_string += "removed an attachment"
|
||||
|
||||
return final_string
|
||||
|
||||
# Relationship handlers
|
||||
def _handle_relates_to_change(self) -> str:
|
||||
action = (
|
||||
"marked that this work item relates to"
|
||||
if self.new_value
|
||||
else "removed the relation from"
|
||||
)
|
||||
return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}"
|
||||
|
||||
def _handle_duplicate_change(self) -> str:
|
||||
action = (
|
||||
"marked that this work item as duplicate of"
|
||||
if self.new_value
|
||||
else "removed this work item as a duplicate of"
|
||||
)
|
||||
return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}"
|
||||
|
||||
def _handle_blocked_by_change(self) -> str:
|
||||
action = (
|
||||
"marked this work item is being blocked by"
|
||||
if self.new_value
|
||||
else "removed this work item being blocked by"
|
||||
)
|
||||
return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}"
|
||||
|
||||
def _handle_blocking_change(self) -> str:
|
||||
action = (
|
||||
"marked this work item is blocking work item"
|
||||
if self.new_value
|
||||
else "removed the blocking work item"
|
||||
)
|
||||
return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}"
|
||||
|
||||
def _handle_start_before_change(self) -> str:
|
||||
action = (
|
||||
"marked this work item to start before"
|
||||
if self.new_value
|
||||
else "removed the start before relation from work item"
|
||||
)
|
||||
return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}"
|
||||
|
||||
def _handle_start_after_change(self) -> str:
|
||||
action = (
|
||||
"marked this work item to start after"
|
||||
if self.new_value
|
||||
else "removed the start after relation from work item"
|
||||
)
|
||||
return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}"
|
||||
|
||||
def _handle_finish_before_change(self) -> str:
|
||||
action = (
|
||||
"marked this work item to finish before"
|
||||
if self.new_value
|
||||
else "removed the finish before relation from work item"
|
||||
)
|
||||
return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}"
|
||||
|
||||
def _handle_finish_after_change(self) -> str:
|
||||
action = (
|
||||
"marked this work item to finish after"
|
||||
if self.new_value
|
||||
else "removed the finish after relation from work item"
|
||||
)
|
||||
return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}"
|
||||
|
||||
def _handle_comment_change(self) -> str:
|
||||
comment_content = (
|
||||
None
|
||||
if self.new_value == "None" and self.old_value == "None"
|
||||
else self.new_value
|
||||
)
|
||||
constructed_comment = (
|
||||
construct_comment_content_with_mentions(comment_content)
|
||||
if comment_content
|
||||
else None
|
||||
)
|
||||
|
||||
is_receiver_mentioned = False
|
||||
if constructed_comment and constructed_comment["mention_objects"] is not None:
|
||||
is_receiver_mentioned = (
|
||||
self.receiver["id"] in constructed_comment["mention_objects"]
|
||||
)
|
||||
|
||||
action = ""
|
||||
if self.new_value == "None" and self.old_value == "None":
|
||||
action = "removed the comment"
|
||||
elif self.old_value != "None":
|
||||
if is_receiver_mentioned:
|
||||
action = "updated the comment and mentioned you"
|
||||
else:
|
||||
action = "updated the comment"
|
||||
else:
|
||||
if is_receiver_mentioned:
|
||||
action = "mentioned you in a comment"
|
||||
else:
|
||||
action = "commented"
|
||||
|
||||
return f"{action} '{constructed_comment['content']}'"
|
||||
|
||||
def build_notification(self) -> str:
|
||||
"""Build and return the notification string for the property change."""
|
||||
|
||||
property_handlers = {
|
||||
# Properties
|
||||
"name": self._handle_name_change,
|
||||
"state": self._handle_state_change,
|
||||
"priority": self._handle_priority_change,
|
||||
"assignees": self._handle_assignees_change,
|
||||
"labels": self._handle_labels_change,
|
||||
"start_date": self._handle_start_date_change,
|
||||
"target_date": self._handle_target_date_change,
|
||||
"parent": self._handle_parent_change,
|
||||
# Links
|
||||
"link": self._handle_link_change,
|
||||
# Attachments
|
||||
"attachment": self._handle_attachment_change,
|
||||
# Relationships
|
||||
"relates_to": self._handle_relates_to_change,
|
||||
"duplicate": self._handle_duplicate_change,
|
||||
"blocked_by": self._handle_blocked_by_change,
|
||||
"blocking": self._handle_blocking_change,
|
||||
"start_before": self._handle_start_before_change,
|
||||
"start_after": self._handle_start_after_change,
|
||||
"finish_before": self._handle_finish_before_change,
|
||||
"finish_after": self._handle_finish_after_change,
|
||||
# Comments
|
||||
"comment": self._handle_comment_change,
|
||||
}
|
||||
|
||||
handler = property_handlers.get(self.property_key)
|
||||
if handler:
|
||||
return f"{self.sender['name']} {handler()}"
|
||||
else:
|
||||
return f"{self.actor['name']} updated {self.property_key}"
|
||||
@@ -86,7 +86,7 @@ class DeviceInformationMutation:
|
||||
user_device_info.push_token = push_token
|
||||
await sync_to_async(user_device_info.save)()
|
||||
else:
|
||||
user_device_info.is_active = False
|
||||
user_device_info.is_active = True
|
||||
user_device_info.push_token = push_token
|
||||
await sync_to_async(user_device_info.save)()
|
||||
|
||||
|
||||
@@ -20,15 +20,96 @@ from plane.db.models import (
|
||||
IssueAssignee,
|
||||
IssueSubscriber,
|
||||
Notification,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.graphql.bgtasks.push_notifications.helper import notification_count
|
||||
from plane.graphql.helpers.teamspace import project_member_filter_via_teamspaces_async
|
||||
from plane.graphql.permissions.workspace import WorkspaceBasePermission
|
||||
from plane.graphql.types.notification import NotificationType
|
||||
from plane.graphql.permissions.workspace import IsAuthenticated, WorkspaceBasePermission
|
||||
from plane.graphql.types.notification import (
|
||||
NotificationCountBaseType,
|
||||
NotificationCountType,
|
||||
NotificationCountWorkspaceType,
|
||||
NotificationType,
|
||||
)
|
||||
from plane.graphql.types.paginator import PaginatorResponse
|
||||
from plane.graphql.utils.paginator import paginate
|
||||
|
||||
|
||||
@sync_to_async
|
||||
def get_notification_count(user_id: str) -> NotificationCountBaseType:
|
||||
unread_notification_count = notification_count(
|
||||
user_id=user_id, workspace_slug=None, mentioned=False, combined=False
|
||||
)
|
||||
mentioned_notification_count = notification_count(
|
||||
user_id=user_id, workspace_slug=None, mentioned=True, combined=False
|
||||
)
|
||||
|
||||
return NotificationCountBaseType(
|
||||
unread=unread_notification_count, mentioned=mentioned_notification_count
|
||||
)
|
||||
|
||||
|
||||
@sync_to_async
|
||||
def get_notification_count_by_workspaces(
|
||||
user_id: str,
|
||||
) -> NotificationCountWorkspaceType:
|
||||
user_workspaces = Workspace.objects.filter(
|
||||
workspace_member__member=user_id, workspace_member__is_active=True
|
||||
)
|
||||
|
||||
workspaces_notification_counts = []
|
||||
|
||||
if user_workspaces.exists():
|
||||
for workspace in user_workspaces:
|
||||
workspace_id = str(workspace.id)
|
||||
workspace_slug = workspace.slug
|
||||
workspace_name = workspace.name
|
||||
|
||||
unread_notification_count = notification_count(
|
||||
user_id=user_id,
|
||||
workspace_slug=workspace_slug,
|
||||
mentioned=False,
|
||||
combined=False,
|
||||
)
|
||||
mentioned_notification_count = notification_count(
|
||||
user_id=user_id,
|
||||
workspace_slug=workspace_slug,
|
||||
mentioned=True,
|
||||
combined=False,
|
||||
)
|
||||
|
||||
workspace_notification_count = NotificationCountWorkspaceType(
|
||||
id=workspace_id,
|
||||
slug=workspace_slug,
|
||||
name=workspace_name,
|
||||
unread=unread_notification_count,
|
||||
mentioned=mentioned_notification_count,
|
||||
)
|
||||
workspaces_notification_counts.append(workspace_notification_count)
|
||||
|
||||
return workspaces_notification_counts
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class NotificationCountQuery:
|
||||
@strawberry.field(extensions=[PermissionExtension(permissions=[IsAuthenticated()])])
|
||||
async def notification_count(self, info: Info) -> NotificationCountType:
|
||||
user = info.context.user
|
||||
user_id = str(user.id)
|
||||
|
||||
global_notification_count = await get_notification_count(user_id=user_id)
|
||||
workspaces_notification_counts = await get_notification_count_by_workspaces(
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
return NotificationCountType(
|
||||
unread=global_notification_count.unread,
|
||||
mentioned=global_notification_count.mentioned,
|
||||
workspaces=workspaces_notification_counts,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class NotificationQuery:
|
||||
@strawberry.field(
|
||||
@@ -41,7 +122,7 @@ class NotificationQuery:
|
||||
type: Optional[JSON] = "all",
|
||||
snoozed: Optional[bool] = None,
|
||||
archived: Optional[bool] = None,
|
||||
mentioned: Optional[bool] = False,
|
||||
mentioned: Optional[bool] = None,
|
||||
read: Optional[str] = None,
|
||||
cursor: Optional[str] = None,
|
||||
) -> PaginatorResponse[NotificationType]:
|
||||
@@ -101,17 +182,19 @@ class NotificationQuery:
|
||||
)
|
||||
|
||||
# Apply mentioned filter
|
||||
if mentioned:
|
||||
notification_queryset = notification_queryset.filter(
|
||||
sender__icontains="mentioned"
|
||||
)
|
||||
else:
|
||||
notification_queryset = notification_queryset.exclude(
|
||||
sender__icontains="mentioned"
|
||||
)
|
||||
if mentioned is not None:
|
||||
if mentioned:
|
||||
notification_queryset = notification_queryset.filter(
|
||||
sender__icontains="mentioned"
|
||||
)
|
||||
else:
|
||||
notification_queryset = notification_queryset.exclude(
|
||||
sender__icontains="mentioned"
|
||||
)
|
||||
|
||||
# Subscribed issues
|
||||
type_list = type.split(",")
|
||||
# Subscribed issues
|
||||
if "subscribed" in type_list:
|
||||
issue_ids = await sync_to_async(list)(
|
||||
IssueSubscriber.objects.filter(
|
||||
|
||||
@@ -138,7 +138,7 @@ from .queries.module import (
|
||||
ModuleIssueUserPropertyQuery,
|
||||
ModuleQuery,
|
||||
)
|
||||
from .queries.notification import NotificationQuery
|
||||
from .queries.notification import NotificationQuery, NotificationCountQuery
|
||||
from .queries.page import (
|
||||
NestedChildPagesQuery,
|
||||
NestedParentPagesQuery,
|
||||
@@ -205,6 +205,7 @@ class Query(
|
||||
UserProjectRolesQuery,
|
||||
# notification
|
||||
NotificationQuery,
|
||||
NotificationCountQuery,
|
||||
# search
|
||||
GlobalSearchQuery,
|
||||
# workspace
|
||||
|
||||
@@ -20,6 +20,24 @@ from plane.graphql.utils.issue_activity import issue_activity_comment_string
|
||||
from plane.graphql.utils.timezone import user_timezone_converter
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class NotificationCountBaseType:
|
||||
unread: Optional[int]
|
||||
mentioned: Optional[int]
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class NotificationCountWorkspaceType(NotificationCountBaseType):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class NotificationCountType(NotificationCountBaseType):
|
||||
workspaces: Optional[list[NotificationCountWorkspaceType]]
|
||||
|
||||
|
||||
@strawberry_django.type(Notification)
|
||||
class NotificationType:
|
||||
id: strawberry.ID
|
||||
|
||||
Reference in New Issue
Block a user