# Copyright (c) 2023-present Plane Software, Inc. and contributors # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. import hashlib import hmac import json import logging import uuid import requests from typing import Any, Dict, List, Optional, Union # Third party imports from celery import shared_task # Django imports from django.conf import settings from django.db.models import Prefetch from django.core.mail import EmailMultiAlternatives, get_connection from django.core.serializers.json import DjangoJSONEncoder from django.template.loader import render_to_string from django.core.exceptions import ObjectDoesNotExist # Module imports from plane.api.serializers import ( CycleIssueSerializer, CycleSerializer, IssueCommentSerializer, IssueExpandSerializer, ModuleIssueSerializer, ModuleSerializer, ProjectSerializer, UserLiteSerializer, IntakeIssueSerializer, ) from plane.db.models import ( Cycle, CycleIssue, Issue, IssueComment, Module, ModuleIssue, Project, User, Webhook, WebhookLog, IntakeIssue, IssueLabel, IssueAssignee, ) from plane.license.utils.instance_value import get_email_configuration from plane.utils.email import generate_plain_text_from_html from plane.utils.exception_logger import log_exception from plane.settings.mongo import MongoConnection SERIALIZER_MAPPER = { "project": ProjectSerializer, "issue": IssueExpandSerializer, "cycle": CycleSerializer, "module": ModuleSerializer, "cycle_issue": CycleIssueSerializer, "module_issue": ModuleIssueSerializer, "issue_comment": IssueCommentSerializer, "user": UserLiteSerializer, "intake_issue": IntakeIssueSerializer, } MODEL_MAPPER = { "project": Project, "issue": Issue, "cycle": Cycle, "module": Module, "cycle_issue": CycleIssue, "module_issue": ModuleIssue, "issue_comment": IssueComment, "user": User, "intake_issue": IntakeIssue, } logger = logging.getLogger("plane.worker") def get_issue_prefetches(): return [ Prefetch("label_issue", queryset=IssueLabel.objects.select_related("label")), Prefetch("issue_assignee", queryset=IssueAssignee.objects.select_related("assignee")), ] def save_webhook_log( webhook: Webhook, request_method: str, request_headers: str, request_body: str, response_status: str, response_headers: str, response_body: str, retry_count: int, event_type: str, ) -> None: # webhook_logs mongo_collection = MongoConnection.get_collection("webhook_logs") log_data = { "workspace_id": str(webhook.workspace_id), "webhook": str(webhook.id), "event_type": str(event_type), "request_method": str(request_method), "request_headers": str(request_headers), "request_body": str(request_body), "response_status": str(response_status), "response_headers": str(response_headers), "response_body": str(response_body), "retry_count": retry_count, } mongo_save_success = False if mongo_collection is not None: try: # insert the log data into the mongo collection mongo_collection.insert_one(log_data) logger.info("Webhook log saved successfully to mongo") mongo_save_success = True except Exception as e: log_exception(e, warning=True) logger.error(f"Failed to save webhook log: {e}") mongo_save_success = False # if the mongo save is not successful, save the log data into the database if not mongo_save_success: try: # insert the log data into the database WebhookLog.objects.create(**log_data) logger.info("Webhook log saved successfully to database") except Exception as e: log_exception(e, warning=True) logger.error(f"Failed to save webhook log: {e}") def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]: """ Retrieve and serialize model data based on the event type. Args: event (str): The type of event/model to retrieve data for event_id (Union[str, List[str]]): The ID or list of IDs of the model instance(s) many (bool): Whether to retrieve multiple instances Returns: Dict[str, Any]: Serialized model data Raises: ValueError: If serializer is not found for the event ObjectDoesNotExist: If model instance is not found """ model = MODEL_MAPPER.get(event) if model is None: raise ValueError(f"Model not found for event: {event}") try: if many: queryset = model.objects.filter(pk__in=event_id) else: queryset = model.objects.get(pk=event_id) serializer = SERIALIZER_MAPPER.get(event) if serializer is None: raise ValueError(f"Serializer not found for event: {event}") issue_prefetches = get_issue_prefetches() if event == "issue": if many: queryset = queryset.prefetch_related(*issue_prefetches) else: issue_id = queryset.id queryset = model.objects.filter(pk=issue_id).prefetch_related(*issue_prefetches).first() return serializer(queryset, many=many, context={"expand": ["labels", "assignees"]}).data else: return serializer(queryset, many=many).data except ObjectDoesNotExist: raise ObjectDoesNotExist(f"No {event} found with id: {event_id}") @shared_task def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_site: str, reason: str) -> None: """ Send an email notification when a webhook is deactivated. Args: webhook_id (str): ID of the deactivated webhook receiver_id (str): ID of the user to receive the notification current_site (str): Current site URL reason (str): Reason for webhook deactivation """ try: ( EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() receiver = User.objects.get(pk=receiver_id) webhook = Webhook.objects.get(pk=webhook_id) # Get the webhook payload subject = "Webhook Deactivated" message = f"Webhook {webhook.url} has been deactivated due to failed requests." # Send the mail context = { "email": receiver.email, "message": message, "webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", } html_content = render_to_string("emails/notifications/webhook-deactivate.html", context) text_content = generate_plain_text_from_html(html_content) # Set the email connection connection = get_connection( host=EMAIL_HOST, port=int(EMAIL_PORT), username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", use_ssl=EMAIL_USE_SSL == "1", ) # Create the email message msg = EmailMultiAlternatives( subject=subject, body=text_content, from_email=EMAIL_FROM, to=[receiver.email], connection=connection, ) msg.attach_alternative(html_content, "text/html") msg.send() logger.info("Email sent successfully.") except Exception as e: log_exception(e, warning=True) logger.error(f"Failed to send email: {e}") @shared_task( bind=True, autoretry_for=(requests.RequestException,), retry_backoff=600, max_retries=5, retry_jitter=True, ) def webhook_send_task( self, webhook_id: str, slug: str, event: str, event_data: Optional[Dict[str, Any]], action: str, current_site: str, activity: Optional[Dict[str, Any]], ) -> None: """ Send webhook notifications to configured endpoints. Args: webhook (str): Webhook ID slug (str): Workspace slug event (str): Event type event_data (Optional[Dict[str, Any]]): Event data to be sent action (str): HTTP method/action current_site (str): Current site URL activity (Optional[Dict[str, Any]]): Activity data """ try: webhook = Webhook.objects.get(id=webhook_id, workspace__slug=slug) headers = { "Content-Type": "application/json", "User-Agent": "Autopilot", "X-Plane-Delivery": str(uuid.uuid4()), "X-Plane-Event": event, } # # Your secret key event_data = json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) if event_data is not None else None activity = json.loads(json.dumps(activity, cls=DjangoJSONEncoder)) if activity is not None else None action = { "POST": "create", "PATCH": "update", "PUT": "update", "DELETE": "delete", }.get(action, action) payload = { "event": event, "action": action, "webhook_id": str(webhook.id), "workspace_id": str(webhook.workspace_id), "data": event_data, "activity": activity, } # Use HMAC for generating signature if webhook.secret_key: hmac_signature = hmac.new( webhook.secret_key.encode("utf-8"), json.dumps(payload).encode("utf-8"), hashlib.sha256, ) signature = hmac_signature.hexdigest() headers["X-Plane-Signature"] = signature except Exception as e: log_exception(e) logger.error(f"Failed to send webhook: {e}") return try: # Send the webhook event response = requests.post(webhook.url, headers=headers, json=payload, timeout=30) # Log the webhook request save_webhook_log( webhook=webhook, request_method=action, request_headers=headers, request_body=payload, response_status=response.status_code, response_headers=response.headers, response_body=response.text, retry_count=self.request.retries, event_type=event, ) logger.info(f"Webhook {webhook.id} sent successfully") except requests.RequestException as e: # Log the failed webhook request save_webhook_log( webhook=webhook, request_method=action, request_headers=headers, request_body=payload, response_status=500, response_headers="", response_body=str(e), retry_count=self.request.retries, event_type=event, ) logger.error(f"Webhook {webhook.id} failed with error: {e}") # Retry logic if self.request.retries >= self.max_retries: Webhook.objects.filter(pk=webhook.id).update(is_active=False) if webhook: # send email for the deactivation of the webhook send_webhook_deactivation_email.delay( webhook_id=webhook.id, receiver_id=webhook.created_by_id, reason=str(e), current_site=current_site, ) return raise requests.RequestException() except Exception as e: log_exception(e) return @shared_task def webhook_activity( event: str, verb: str, field: Optional[str], old_value: Any, new_value: Any, actor_id: str | uuid.UUID, slug: str, current_site: str, event_id: str | uuid.UUID, old_identifier: Optional[str], new_identifier: Optional[str], ) -> None: """ Process and send webhook notifications for various activities in the system. This task filters relevant webhooks based on the event type and sends notifications to all active webhooks for the workspace. Args: event (str): Type of event (project, issue, module, cycle, issue_comment) verb (str): Action performed (created, updated, deleted) field (Optional[str]): Name of the field that was changed old_value (Any): Previous value of the field new_value (Any): New value of the field actor_id (str | uuid.UUID): ID of the user who performed the action slug (str): Workspace slug current_site (str): Current site URL event_id (str | uuid.UUID): ID of the event object old_identifier (Optional[str]): Previous identifier if any new_identifier (Optional[str]): New identifier if any Returns: None Note: The function silently returns on ObjectDoesNotExist exceptions to handle race conditions where objects might have been deleted. """ try: webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) if event == "project": webhooks = webhooks.filter(project=True) if event == "issue": webhooks = webhooks.filter(issue=True) if event == "module" or event == "module_issue": webhooks = webhooks.filter(module=True) if event == "cycle" or event == "cycle_issue": webhooks = webhooks.filter(cycle=True) if event == "issue_comment": webhooks = webhooks.filter(issue_comment=True) for webhook in webhooks: webhook_send_task.delay( webhook_id=webhook.id, slug=slug, event=event, event_data=({"id": event_id} if verb == "deleted" else get_model_data(event=event, event_id=event_id)), action=verb, current_site=current_site, activity={ "field": field, "new_value": new_value, "old_value": old_value, "actor": get_model_data(event="user", event_id=actor_id), "old_identifier": old_identifier, "new_identifier": new_identifier, }, ) return except Exception as e: # Return if a does not exist error occurs if isinstance(e, ObjectDoesNotExist): return if settings.DEBUG: print(e) log_exception(e) return @shared_task def model_activity(model_name, model_id, requested_data, current_instance, actor_id, slug, origin=None): """Function takes in two json and computes differences between keys of both the json""" if current_instance is None: webhook_activity.delay( event=model_name, verb="created", field=None, old_value=None, new_value=None, actor_id=actor_id, slug=slug, current_site=origin, event_id=model_id, old_identifier=None, new_identifier=None, ) return # Load the current instance current_instance = json.loads(current_instance) if current_instance is not None else None # Loop through all keys in requested data and check the current value and requested value for key in requested_data: # Check if key is present in current instance or not if key in current_instance: current_value = current_instance.get(key, None) requested_value = requested_data.get(key, None) if current_value != requested_value: webhook_activity.delay( event=model_name, verb="updated", field=key, old_value=current_value, new_value=requested_value, actor_id=actor_id, slug=slug, current_site=origin, event_id=model_id, old_identifier=None, new_identifier=None, ) return