[MOBIL-518] feat: Mobile push notifications (#1848)

* chore: implemented mobile push notification

* chore: updated the notification job

* chore: handled the response

* chore: updated private key

* chore: issue push notification updates

* chore: resolved the boolean validation on mobile push notification

* chore: added push notification to the notification activity

* chore: updated push notification logic

* chore: removed mutation for push notification
This commit is contained in:
guru_sainath
2024-12-03 19:41:41 +05:30
committed by GitHub
parent ba56a5cfd2
commit a3b62cb0a7
5 changed files with 314 additions and 10 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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]
)

View File

@@ -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", "")

View File

@@ -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