mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 05:49:40 +02:00
[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:
@@ -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)
|
||||
|
||||
269
apiserver/plane/graphql/bgtasks/push_notification.py
Normal file
269
apiserver/plane/graphql/bgtasks/push_notification.py
Normal 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
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user