feat(automation): add automation rule handling for ticket status changes

Prevents brute‑force attacks by enabling server‑side automation on ticket status changes.
This commit is contained in:
sankalp committed 2026-06-29 07:49:34 +05:45
1 parent 5e77c3be61
commit a6e482691c
3 files changed
+216

No files matched your search

+3
View File
@@ -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
+178
View File
@@ -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
+35
View File
@@ -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"