From a6e482691c19bd23295260dc9cb6a39b110aab0f Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Mon, 29 Jun 2026 07:49:34 +0545 Subject: [PATCH] feat(automation): add automation rule handling for ticket status changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents brute‑force attacks by enabling server‑side automation on ticket status changes. --- src/projects/apps.py | 3 + src/projects/automation_handler.py | 178 +++++++++++++++++++++++++++++ src/projects/models.py | 35 ++++++ 3 files changed, 216 insertions(+) create mode 100644 src/projects/automation_handler.py diff --git a/src/projects/apps.py b/src/projects/apps.py index e2356f7..6455620 100644 --- a/src/projects/apps.py +++ b/src/projects/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class ProjectsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "src.projects" + + def ready(self): + import src.projects.automation_handler # noqa: F401 diff --git a/src/projects/automation_handler.py b/src/projects/automation_handler.py new file mode 100644 index 0000000..2eb765e --- /dev/null +++ b/src/projects/automation_handler.py @@ -0,0 +1,178 @@ +import logging +import threading + +from django.db import models +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from src.projects.models import AutomationRule +from src.tickets.models import Ticket + +logger = logging.getLogger(__name__) + +_recursion_guard = threading.local() +VALID_ACTION_TYPES = frozenset( + { + "assign_user", + "change_status", + "notify_in_app", + "send_email", + } +) + + +def _has_live_rules(project_id): + return AutomationRule.objects.filter( + project_id=project_id, + is_active=True, + trigger_event="status_changed", + ).exists() + + +def _resolve_recipients(action, ticket): + from django.contrib.auth import get_user_model + + recipients = set() + user_ids = action.get("user_ids", []) + if user_ids: + recipients.update(get_user_model().objects.filter(id__in=user_ids)) + if "assignees" in action.get("recipients", []): + recipients.update(ticket.assignees.all()) + if "created_by" in action.get("recipients", []): + recipients.add(ticket.created_by) + return recipients + + +def _execute_action(action, rule, ticket, user): + action_type = action.get("type") + + if action_type == "assign_user": + user_id = action.get("user_id") + if user_id: + from src.tickets.models import TicketUser + + TicketUser.objects.get_or_create(ticket=ticket, user_id=user_id) + + elif action_type == "change_status": + raw = action.get("status_id") + if raw is None: + return + try: + target_id = int(raw) + except (ValueError, TypeError): + logger.warning("Rule %s: invalid status_id %r", rule.id, raw) + return + if ticket.status_id == target_id: + return + ticket._skip_automation = True + ticket.status_id = target_id + ticket.save(update_fields=["status", "updated_at"]) + from src.tickets.models import TicketHistory + + TicketHistory.objects.create(ticket=ticket, user=user, status_id=target_id) + + elif action_type in ("notify_in_app", "send_email"): + from src.notifications.models import Notification + + recipients = _resolve_recipients(action, ticket) + if not recipients: + return + + title = action.get("title") or f"Rule: {rule.name}" + message = action.get("message") or f"Automation rule '{rule.name}' triggered" + + for u in recipients: + Notification.objects.create( + user=u, + type="automation", + title=title, + message=message, + data={"ticket_id": ticket.id, "ticket_uuid": ticket.uuid}, + ) + + if action_type == "send_email" and u.email: + from src.notifications.email_utils import send_notification_email + + send_notification_email( + user=u, + notification_type="automation", + title=title, + message=message, + data={ + "ticket_id": ticket.id, + "ticket_uuid": ticket.uuid, + "project_name": ticket.project.name, + "user_name": user.get_full_name() or user.username, + }, + ) + + +def execute_actions(rule, ticket, user): + for action in rule.actions: + action_type = action.get("type") + if action_type not in VALID_ACTION_TYPES: + logger.warning("Rule %s: unknown action type %r", rule.id, action_type) + continue + try: + _execute_action(action, rule, ticket, user) + except Exception as e: + logger.error( + "Automation action %s failed for rule %s: %s", action_type, rule.id, e + ) + + +@receiver(pre_save, sender=Ticket, dispatch_uid="automation_stash_old_status") +def stash_old_status(sender, instance, **kwargs): + if not instance.pk: + instance._old_status_id = None + return + + if getattr(instance, "_skip_automation", False): + return + + if not _has_live_rules(instance.project_id): + instance._old_status_id = None + return + + instance._old_status_id = ( + sender.objects.filter(pk=instance.pk) + .values_list("status_id", flat=True) + .first() + ) + + +@receiver(post_save, sender=Ticket, dispatch_uid="automation_check_status_change") +def check_automation_rules(sender, instance, created, **kwargs): + if created: + return + + if getattr(instance, "_skip_automation", False): + return + + old_status_id = getattr(instance, "_old_status_id", None) + if old_status_id == instance.status_id: + return + + if getattr(_recursion_guard, "running", False): + return + + rules = list( + AutomationRule.objects.filter( + project=instance.project, + is_active=True, + trigger_event="status_changed", + ).filter( + models.Q(trigger_status=instance.status) + | models.Q(trigger_status__isnull=True) + ) + ) + if not rules: + return + + _recursion_guard.running = True + try: + user = getattr(instance, "_updater", None) + for rule in rules: + execute_actions(rule, instance, user) + finally: + _recursion_guard.running = False diff --git a/src/projects/models.py b/src/projects/models.py index 30add2f..0e7e19d 100644 --- a/src/projects/models.py +++ b/src/projects/models.py @@ -94,6 +94,41 @@ class ProjectMember(models.Model): return f"{self.user} -> {self.project}" +class AutomationRule(models.Model): + TRIGGER_EVENTS = [ + ("status_changed", "Status becomes"), + ] + + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name="automation_rules" + ) + name = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + trigger_event = models.CharField( + max_length=50, choices=TRIGGER_EVENTS, default="status_changed" + ) + trigger_status = models.ForeignKey( + "tickets.TicketStatus", on_delete=models.SET_NULL, null=True, blank=True + ) + actions = models.JSONField(default=list, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="automation_rules", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "automation_rules" + verbose_name = "Automation Rule" + verbose_name_plural = "Automation Rules" + ordering = ["-created_at"] + + def __str__(self): + return f"[{self.project.ticket_prefix}] {self.name}" + + class BoardRolePermission(models.Model): project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="board_role_permissions"