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:
3 files changed
+216
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user