diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index ade2479096..61ecccb57e 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -21,6 +21,8 @@ from plane.db.models import ( ProjectMember, ) from django.db.models import Subquery +from plane.app.serializers import NotificationSerializer +from plane.graphql.bgtasks.push_notification import issue_push_notifications # Third Party imports from celery import shared_task @@ -257,7 +259,9 @@ def notifications( ) new_mentions = [ - str(mention) for mention in new_mentions if mention in set(project_members) + str(mention) + for mention in new_mentions + if mention in set(project_members) ] removed_mention = get_removed_mentions( requested_instance=requested_data, current_instance=current_instance @@ -769,10 +773,33 @@ def notifications( removed_mention=removed_mention, ) # Bulk create notifications - Notification.objects.bulk_create(bulk_notifications, batch_size=100) + notifications = Notification.objects.bulk_create( + bulk_notifications, batch_size=100 + ) EmailNotificationLog.objects.bulk_create( bulk_email_logs, batch_size=100, ignore_conflicts=True ) + + """ + # Send Mobile Push Notifications for state, assignee, priority, + # start_date, target_date, and parent issue changes + """ + if notifications and len(notifications) > 0: + serialized_notifications = NotificationSerializer( + notifications, many=True + ).data + + # converting the uuid to string + for notification in serialized_notifications: + if notification is not None: + for key in ["id", "workspace", "project", "receiver"]: + if key in notification: + notification[key] = str(notification[key]) + if "triggered_by_details" in notification: + notification["triggered_by_details"]["id"] = str( + notification["triggered_by_details"]["id"] + ) + issue_push_notifications.delay(notification) return except Exception as e: print(e) diff --git a/apiserver/plane/graphql/bgtasks/push_notification.py b/apiserver/plane/graphql/bgtasks/push_notification.py new file mode 100644 index 0000000000..fa549d75a7 --- /dev/null +++ b/apiserver/plane/graphql/bgtasks/push_notification.py @@ -0,0 +1,269 @@ +"""Firebase Cloud Messaging (FCM) Implementation""" + +import base64 + +# Python imports +from enum import Enum +from typing import Optional + +# Django imports +from django.conf import settings + +# Third party imports +from celery import shared_task +import firebase_admin +from firebase_admin import credentials, messaging + +# Module imports +from plane.db.models import Workspace, Device + + +def is_push_notification_disabled(): + if hasattr(settings, "IS_MOBILE_PUSH_NOTIFICATION_ENABLED"): + return False if settings.IS_MOBILE_PUSH_NOTIFICATION_ENABLED else True + return True + + +class PushNotificationTypes(Enum): + """Enumeration of supported push notification types.""" + + ISSUE_CREATED = "ISSUE_CREATED" + ISSUE_UPDATED = "ISSUE_UPDATED" + + def __str__(self) -> str: + return self.value + + +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_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] = {} + ) -> dict: + """Send push notifications to specified devices.""" + if is_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, + ) + + 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)} + + +@shared_task +def construct_issue_push_notification(notification): + """Construct the issue push notification.""" + # getting the push token from the device with receiver_id + + receiver_id = notification.get("receiver", None) + receiver_push_token = None + + if receiver_id is not None: + device_info = Device.objects.filter( + user_id=str(receiver_id), is_active=True, push_token__isnull=False + ).first() + + if device_info: + receiver_push_token = device_info.push_token + + if receiver_push_token is None: + print("Receiver push token is not available") + return + + # 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}" + ) + + notification_issue = notification.get("data", {}).get("issue", {}) + notification_issue_activity = notification.get("data", {}).get("issue_activity", {}) + + workspace_id = notification.get("workspace", "") + workspace_slug = "" + project_id = notification.get("project", "") + + workspace = Workspace.objects.filter(id=workspace_id).first() + if workspace and workspace.slug: + workspace_slug = workspace.slug + + push_notification = { + "receiver": {"id": receiver_id, "push_token": receiver_push_token}, + "actor": {"id": actor_id, "name": actor_name}, + "issue": { + "workspace_id": workspace_id, + "workspace_slug": workspace_slug, + "project_id": project_id, + "project_identifier": notification_issue.get("identifier", ""), + "id": notification_issue.get("id", ""), + "name": notification_issue.get("name", ""), + "sequence_id": notification_issue.get("sequence_id", ""), + }, + "issue_activity": notification_issue_activity, + } + + return push_notification + + +@shared_task +def issue_push_notifications(notification): + print("====== sending issue properties push notification to mobile ======") + + if is_push_notification_disabled(): + return + + notification = construct_issue_push_notification(notification) + + if notification is None: + print("Notification is None") + return + + notification_receiver = notification.get("receiver", None) + notification_actor = notification.get("actor", None) + notification_issue = notification.get("issue", None) + notification_issue_activity = notification.get("issue_activity", None) + + if not all( + [ + notification_receiver, + notification_actor, + notification_issue, + notification_issue_activity, + ] + ): + return + + actor_name = notification_actor.get("name", "") + + workspace_slug = notification_issue.get("workspace_slug", "") + project_id = notification_issue.get("project_id", "") + project_identifier = notification_issue.get("project_identifier", "") + issue_id = notification_issue.get("id", "") + issue_name = notification_issue.get("name", "") + issue_sequence_id = notification_issue.get("sequence_id", "") + + receiver_id = notification_receiver.get("id", "") + receiver_push_token = notification_receiver.get("push_token", "") + + title = f"{project_identifier}-" f"{issue_sequence_id} - " f"{issue_name}" + data = { + "type": PushNotificationTypes.ISSUE_UPDATED.value, + "workspaceSlug": workspace_slug, + "projectId": project_id, + "issueId": issue_id, + "userId": receiver_id, + } + + property_key = notification_issue_activity.get("field", "") + old_value = notification_issue_activity.get("old_value", "") + new_value = notification_issue_activity.get("new_value", "") + + if property_key == "assignees": + body = ( + f"{actor_name} " + f"{'added a new' if new_value else 'removed the'} assignee " + f"{new_value if new_value else old_value}" + ) + elif property_key in ["start_date", "target_date"]: + body = ( + f"{actor_name} " + f"{'set the' if new_value else 'removed the'} " + f"{'start' if property_key == 'start_date' else 'due'} date " + f"{f'to {new_value}' if new_value else ''}" + ) + else: + body = f"{actor_name} set the {property_key} to {new_value}" + + push_notification = PushNotification() + push_notification.send( + title=title, body=body, device_token_id=receiver_push_token, data=data + ) + + print( + "===== sending issue properties push notification to mobile is successful =====" + ) + + return diff --git a/apiserver/plane/graphql/schema.py b/apiserver/plane/graphql/schema.py index 9b257e6eb9..f45e061ee7 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -66,11 +66,7 @@ from .mutations.issue import ( ) from .mutations.notification import NotificationMutation from .mutations.user import ProfileMutation -from .mutations.page import ( - PageMutation, - PageFavoriteMutation, - WorkspacePageMutation, -) +from .mutations.page import PageMutation, PageFavoriteMutation, WorkspacePageMutation from .mutations.cycle import ( CycleIssueMutation, CycleFavoriteMutation, @@ -178,7 +174,5 @@ class Mutation( schema = strawberry.Schema( - query=Query, - mutation=Mutation, - extensions=[DjangoOptimizerExtension], + query=Query, mutation=Mutation, extensions=[DjangoOptimizerExtension] ) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 653f5a6915..1db8e06cae 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -462,3 +462,15 @@ SIMPLE_JWT = { "USER_ID_FIELD": "id", "USER_ID_CLAIM": "user_id", } + + +# firebase settings +IS_MOBILE_PUSH_NOTIFICATION_ENABLED = ( + os.environ.get("IS_MOBILE_PUSH_NOTIFICATION_ENABLED", "0") == "1" +) +FIREBASE_PROJECT_ID = os.environ.get("FIREBASE_PROJECT_ID", "") +FIREBASE_PRIVATE_KEY_ID = os.environ.get("FIREBASE_PRIVATE_KEY_ID", "") +FIREBASE_PRIVATE_KEY = os.environ.get("FIREBASE_PRIVATE_KEY", "") +FIREBASE_CLIENT_EMAIL = os.environ.get("FIREBASE_CLIENT_EMAIL", "") +FIREBASE_CLIENT_ID = os.environ.get("FIREBASE_CLIENT_ID", "") +FIREBASE_CLIENT_CERT_URL = os.environ.get("FIREBASE_CLIENT_CERT_URL", "") diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 53d4a875d1..1912829b7c 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -77,3 +77,5 @@ openfeature-sdk==0.7.0 strawberry-graphql-django==0.48.0 # django rest framework jwt djangorestframework-simplejwt==5.3.1 +# firebase admin +firebase-admin==6.6.0 \ No newline at end of file