Skip to content

Commit f1efad5

Browse files
authored
fix(aci): Try our best in Project.transfer_to (#103704)
Detectors are project-associated, so they work automatically, but DataSources, DataConditionGroups, and Workflows need to be updated. Note that some of these (Workflows in particular) are org scoped, so transferring may not make sense, so we handle the simple case and hope for the best.
1 parent 075a96b commit f1efad5

File tree

2 files changed

+254
-3
lines changed

2 files changed

+254
-3
lines changed

src/sentry/models/project.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import sentry_sdk
1010
from django.conf import settings
1111
from django.db import IntegrityError, models, router, transaction
12-
from django.db.models import Q, QuerySet, Subquery
12+
from django.db.models import Count, Q, QuerySet, Subquery
1313
from django.db.models.signals import pre_delete
1414
from django.utils import timezone
1515
from django.utils.http import urlencode
@@ -55,6 +55,7 @@
5555
if TYPE_CHECKING:
5656
from sentry.models.options.project_option import ProjectOptionManager
5757
from sentry.models.options.project_template_option import ProjectTemplateOptionManager
58+
from sentry.models.organization import Organization
5859
from sentry.users.models.user import User
5960

6061
# NOTE:
@@ -499,7 +500,7 @@ def get_audit_log_data(self):
499500
def get_full_name(self):
500501
return self.slug
501502

502-
def transfer_to(self, organization):
503+
def transfer_to(self, organization: Organization) -> None:
503504
from sentry.deletions.models.scheduleddeletion import RegionScheduledDeletion
504505
from sentry.incidents.models.alert_rule import AlertRule
505506
from sentry.integrations.models.external_issue import ExternalIssue
@@ -514,6 +515,7 @@ def transfer_to(self, organization):
514515
from sentry.models.rule import Rule
515516
from sentry.monitors.models import Monitor, MonitorEnvironment, MonitorStatus
516517
from sentry.snuba.models import SnubaQuery
518+
from sentry.workflow_engine.models import DataConditionGroup, DataSource, Detector, Workflow
517519

518520
old_org_id = self.organization_id
519521
org_changed = old_org_id != organization.id
@@ -637,6 +639,97 @@ def transfer_to(self, organization):
637639

638640
AlertRule.objects.fetch_for_project(self).update(organization=organization)
639641

642+
# Transfer DataSource, Workflow, and DataConditionGroup objects for Detectors attached to this project.
643+
# * DataSources link detectors to their data sources (QuerySubscriptions, Monitors, etc.).
644+
# * Workflows are connected to detectors and define what actions to take.
645+
# * DataConditionGroups are connected to workflows (unique 1:1 via WorkflowDataConditionGroup).
646+
# Since Detectors are project-scoped and their DataSources are project-specific,
647+
# we need to update all related organization-scoped workflow_engine models.
648+
#
649+
# IMPORTANT: Workflows and DataConditionGroups can be shared across multiple projects
650+
# in the same organization. We only transfer them if they're exclusively used by
651+
# detectors in this project. Shared workflows remain in the original organization.
652+
# There are certainly more correct ways to do this, but this should cover most cases.
653+
654+
detector_ids = Detector.objects.filter(project_id=self.id).values_list("id", flat=True)
655+
if detector_ids:
656+
# Update DataSources
657+
# DataSources are 1:1 with their source (e.g., QuerySubscription) so they always transfer
658+
data_source_ids = (
659+
DataSource.objects.filter(detectors__id__in=detector_ids)
660+
.distinct()
661+
.values_list("id", flat=True)
662+
)
663+
DataSource.objects.filter(id__in=data_source_ids).update(
664+
organization_id=organization.id
665+
)
666+
667+
# Update Workflows connected to these detectors
668+
# Only transfer workflows that are exclusively used by detectors in this project
669+
all_workflow_ids = (
670+
Workflow.objects.filter(detectorworkflow__detector_id__in=detector_ids)
671+
.distinct()
672+
.values_list("id", flat=True)
673+
)
674+
675+
# Find workflows that are ONLY connected to detectors in this project
676+
exclusive_workflow_ids = (
677+
Workflow.objects.filter(id__in=all_workflow_ids)
678+
.annotate(
679+
detector_count=Count("detectorworkflow__detector"),
680+
project_detector_count=Count(
681+
"detectorworkflow__detector",
682+
filter=Q(detectorworkflow__detector_id__in=detector_ids),
683+
),
684+
)
685+
.filter(detector_count=models.F("project_detector_count"))
686+
.values_list("id", flat=True)
687+
)
688+
689+
Workflow.objects.filter(id__in=exclusive_workflow_ids).update(
690+
organization_id=organization.id
691+
)
692+
693+
# Update DataConditionGroups connected to the transferred workflows
694+
# These are linked via WorkflowDataConditionGroup with a unique constraint on condition_group
695+
workflow_condition_group_ids = (
696+
DataConditionGroup.objects.filter(
697+
workflowdataconditiongroup__workflow_id__in=exclusive_workflow_ids
698+
)
699+
.distinct()
700+
.values_list("id", flat=True)
701+
)
702+
DataConditionGroup.objects.filter(id__in=workflow_condition_group_ids).update(
703+
organization_id=organization.id
704+
)
705+
706+
# Update DataConditionGroups that are directly owned by detectors
707+
# These are linked via Detector.workflow_condition_group (unique FK)
708+
# and are exclusively owned by the detector, so they always transfer
709+
detector_condition_group_ids = (
710+
Detector.objects.filter(
711+
id__in=detector_ids, workflow_condition_group_id__isnull=False
712+
)
713+
.values_list("workflow_condition_group_id", flat=True)
714+
.distinct()
715+
)
716+
DataConditionGroup.objects.filter(id__in=detector_condition_group_ids).update(
717+
organization_id=organization.id
718+
)
719+
720+
# Update DataConditionGroups used as when_condition_group in transferred workflows
721+
# DataConditionGroups are never shared, so transfer all when_condition_groups
722+
when_condition_group_ids = (
723+
Workflow.objects.filter(
724+
id__in=exclusive_workflow_ids, when_condition_group_id__isnull=False
725+
)
726+
.values_list("when_condition_group_id", flat=True)
727+
.distinct()
728+
)
729+
DataConditionGroup.objects.filter(id__in=when_condition_group_ids).update(
730+
organization_id=organization.id
731+
)
732+
640733
# Manually move over external issues to the new org
641734
linked_groups = GroupLink.objects.filter(project_id=self.id).values_list(
642735
"linked_id", flat=True

tests/sentry/models/test_project.py

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from sentry.types.actor import Actor
3737
from sentry.users.models.user import User
3838
from sentry.users.models.user_option import UserOption
39-
from sentry.workflow_engine.models import Detector
39+
from sentry.workflow_engine.models import Detector, DetectorWorkflow
4040
from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType
4141

4242

@@ -482,6 +482,164 @@ def test_project_detectors(self) -> None:
482482
assert Detector.objects.filter(project=project, type=ErrorGroupType.slug).count() == 1
483483
assert Detector.objects.filter(project=project, type=IssueStreamGroupType.slug).count() == 1
484484

485+
def test_transfer_to_organization_with_metric_issue_detector_and_workflow(self) -> None:
486+
from_org = self.create_organization()
487+
team = self.create_team(organization=from_org)
488+
to_org = self.create_organization()
489+
project = self.create_project(teams=[team])
490+
491+
detector = self.create_detector(project=project)
492+
data_source = self.create_data_source(organization=from_org)
493+
data_source.detectors.add(detector)
494+
workflow = self.create_workflow(organization=from_org)
495+
self.create_detector_workflow(detector=detector, workflow=workflow)
496+
497+
project.transfer_to(organization=to_org)
498+
499+
project.refresh_from_db()
500+
detector.refresh_from_db()
501+
data_source.refresh_from_db()
502+
workflow.refresh_from_db()
503+
504+
assert project.organization_id == to_org.id
505+
assert detector.project_id == project.id
506+
assert data_source.organization_id == to_org.id
507+
assert workflow.organization_id == to_org.id
508+
assert DetectorWorkflow.objects.filter(detector=detector, workflow=workflow).exists()
509+
510+
def test_transfer_to_organization_with_workflow_data_condition_groups(self) -> None:
511+
from_org = self.create_organization()
512+
team = self.create_team(organization=from_org)
513+
to_org = self.create_organization()
514+
project = self.create_project(teams=[team])
515+
516+
detector = self.create_detector(project=project)
517+
workflow = self.create_workflow(organization=from_org)
518+
self.create_detector_workflow(detector=detector, workflow=workflow)
519+
condition_group = self.create_data_condition_group(organization=from_org)
520+
self.create_workflow_data_condition_group(
521+
workflow=workflow, condition_group=condition_group
522+
)
523+
524+
project.transfer_to(organization=to_org)
525+
526+
project.refresh_from_db()
527+
detector.refresh_from_db()
528+
workflow.refresh_from_db()
529+
condition_group.refresh_from_db()
530+
531+
assert project.organization_id == to_org.id
532+
assert detector.project_id == project.id
533+
assert workflow.organization_id == to_org.id
534+
assert condition_group.organization_id == to_org.id
535+
wdcg = condition_group.workflowdataconditiongroup_set.first()
536+
assert wdcg is not None
537+
assert wdcg.workflow_id == workflow.id
538+
539+
def test_transfer_to_organization_does_not_transfer_shared_workflows(self) -> None:
540+
from_org = self.create_organization()
541+
team = self.create_team(organization=from_org)
542+
to_org = self.create_organization()
543+
544+
project_a = self.create_project(teams=[team], name="Project A")
545+
project_b = self.create_project(teams=[team], organization=from_org, name="Project B")
546+
547+
detector_a = self.create_detector(project=project_a)
548+
detector_b = self.create_detector(project=project_b)
549+
550+
shared_workflow = self.create_workflow(organization=from_org, name="Shared Workflow")
551+
self.create_detector_workflow(detector=detector_a, workflow=shared_workflow)
552+
self.create_detector_workflow(detector=detector_b, workflow=shared_workflow)
553+
554+
exclusive_workflow = self.create_workflow(organization=from_org, name="Exclusive Workflow")
555+
self.create_detector_workflow(detector=detector_a, workflow=exclusive_workflow)
556+
557+
shared_dcg = self.create_data_condition_group(organization=from_org)
558+
self.create_workflow_data_condition_group(
559+
workflow=shared_workflow, condition_group=shared_dcg
560+
)
561+
562+
exclusive_dcg = self.create_data_condition_group(organization=from_org)
563+
self.create_workflow_data_condition_group(
564+
workflow=exclusive_workflow, condition_group=exclusive_dcg
565+
)
566+
567+
project_a.transfer_to(organization=to_org)
568+
569+
project_a.refresh_from_db()
570+
project_b.refresh_from_db()
571+
detector_a.refresh_from_db()
572+
detector_b.refresh_from_db()
573+
shared_workflow.refresh_from_db()
574+
exclusive_workflow.refresh_from_db()
575+
shared_dcg.refresh_from_db()
576+
exclusive_dcg.refresh_from_db()
577+
578+
assert project_a.organization_id == to_org.id
579+
assert project_b.organization_id == from_org.id
580+
assert detector_a.project_id == project_a.id
581+
assert detector_b.project_id == project_b.id
582+
assert shared_workflow.organization_id == from_org.id
583+
assert exclusive_workflow.organization_id == to_org.id
584+
assert shared_dcg.organization_id == from_org.id
585+
assert exclusive_dcg.organization_id == to_org.id
586+
assert DetectorWorkflow.objects.filter(
587+
detector=detector_a, workflow=shared_workflow
588+
).exists()
589+
assert DetectorWorkflow.objects.filter(
590+
detector=detector_b, workflow=shared_workflow
591+
).exists()
592+
assert DetectorWorkflow.objects.filter(
593+
detector=detector_a, workflow=exclusive_workflow
594+
).exists()
595+
596+
def test_transfer_to_organization_with_detector_workflow_condition_group(self) -> None:
597+
from_org = self.create_organization()
598+
team = self.create_team(organization=from_org)
599+
to_org = self.create_organization()
600+
project = self.create_project(teams=[team])
601+
602+
detector = self.create_detector(project=project)
603+
workflow_condition_group = self.create_data_condition_group(organization=from_org)
604+
detector.workflow_condition_group = workflow_condition_group
605+
detector.save()
606+
607+
project.transfer_to(organization=to_org)
608+
609+
project.refresh_from_db()
610+
detector.refresh_from_db()
611+
workflow_condition_group.refresh_from_db()
612+
613+
assert project.organization_id == to_org.id
614+
assert detector.project_id == project.id
615+
assert workflow_condition_group.organization_id == to_org.id
616+
assert detector.workflow_condition_group_id == workflow_condition_group.id
617+
618+
def test_transfer_to_organization_with_workflow_when_condition_groups(self) -> None:
619+
from_org = self.create_organization()
620+
to_org = self.create_organization()
621+
team = self.create_team(organization=from_org)
622+
project = self.create_project(teams=[team])
623+
624+
detector = self.create_detector(project=project)
625+
when_condition_group = self.create_data_condition_group(organization=from_org)
626+
workflow = self.create_workflow(
627+
organization=from_org, when_condition_group=when_condition_group
628+
)
629+
self.create_detector_workflow(detector=detector, workflow=workflow)
630+
631+
project.transfer_to(organization=to_org)
632+
633+
project.refresh_from_db()
634+
detector.refresh_from_db()
635+
workflow.refresh_from_db()
636+
when_condition_group.refresh_from_db()
637+
638+
assert project.organization_id == to_org.id
639+
assert detector.project_id == project.id
640+
assert workflow.organization_id == to_org.id
641+
assert when_condition_group.organization_id == to_org.id
642+
485643

486644
class ProjectOptionsTests(TestCase):
487645
"""

0 commit comments

Comments
 (0)