diff --git a/src/sentry/models/groupopenperiodactivity.py b/src/sentry/models/groupopenperiodactivity.py index d8fd72cb11e011..5b6cfa72aff9be 100644 --- a/src/sentry/models/groupopenperiodactivity.py +++ b/src/sentry/models/groupopenperiodactivity.py @@ -13,6 +13,12 @@ class OpenPeriodActivityType(IntEnum): STATUS_CHANGE = 2 CLOSED = 3 + def to_str(self) -> str: + """ + Return the string representation of the activity type. + """ + return self.name.lower() + def generate_random_uuid() -> UUID: return uuid4() diff --git a/src/sentry/workflow_engine/endpoints/serializers/group_open_period_serializer.py b/src/sentry/workflow_engine/endpoints/serializers/group_open_period_serializer.py index 83c236a9ebc09e..6d4f049481c935 100644 --- a/src/sentry/workflow_engine/endpoints/serializers/group_open_period_serializer.py +++ b/src/sentry/workflow_engine/endpoints/serializers/group_open_period_serializer.py @@ -1,9 +1,18 @@ +from collections import defaultdict from collections.abc import Mapping from datetime import datetime, timedelta from typing import Any, TypedDict -from sentry.api.serializers import Serializer, register +from sentry.api.serializers import Serializer, register, serialize from sentry.models.groupopenperiod import GroupOpenPeriod, get_last_checked_for_open_period +from sentry.models.groupopenperiodactivity import GroupOpenPeriodActivity, OpenPeriodActivityType +from sentry.types.group import PriorityLevel + + +class GroupOpenPeriodActivityResponse(TypedDict): + id: str + type: str + value: str | None class GroupOpenPeriodResponse(TypedDict): @@ -13,18 +22,50 @@ class GroupOpenPeriodResponse(TypedDict): duration: timedelta | None isOpen: bool lastChecked: datetime + activities: list[GroupOpenPeriodActivityResponse] | None + + +@register(GroupOpenPeriodActivity) +class GroupOpenPeriodActivitySerializer(Serializer): + def serialize( + self, obj: GroupOpenPeriodActivity, attrs: Mapping[str, Any], user, **kwargs + ) -> GroupOpenPeriodActivityResponse: + return GroupOpenPeriodActivityResponse( + id=str(obj.id), + type=OpenPeriodActivityType(obj.type).to_str(), + value=PriorityLevel(obj.value).to_str() if obj.value else None, + ) @register(GroupOpenPeriod) class GroupOpenPeriodSerializer(Serializer): + def get_attrs(self, item_list, user, **kwargs): + result: defaultdict[GroupOpenPeriod, dict[str, list[GroupOpenPeriodActivityResponse]]] = ( + defaultdict(dict) + ) + activities = GroupOpenPeriodActivity.objects.filter( + group_open_period__in=item_list + ).order_by("id") + + gopas = defaultdict(list) + for activity, serialized_activity in zip( + activities, serialize(list(activities), user=user, **kwargs) + ): + gopas[activity.group_open_period].append(serialized_activity) + for item in item_list: + result[item]["activities"] = gopas[item][:100] + + return result + def serialize( self, obj: GroupOpenPeriod, attrs: Mapping[str, Any], user, **kwargs ) -> GroupOpenPeriodResponse: - return { - "id": str(obj.id), - "start": obj.date_started, - "end": obj.date_ended, - "duration": obj.date_ended - obj.date_started if obj.date_ended else None, - "isOpen": obj.date_ended is None, - "lastChecked": get_last_checked_for_open_period(obj.group), - } + return GroupOpenPeriodResponse( + id=str(obj.id), + start=obj.date_started, + end=obj.date_ended, + duration=obj.date_ended - obj.date_started if obj.date_ended else None, + isOpen=obj.date_ended is None, + lastChecked=get_last_checked_for_open_period(obj.group), + activities=attrs.get("activities"), + ) diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_open_periods.py b/tests/sentry/workflow_engine/endpoints/test_organization_open_periods.py index 740d40a9aa42d2..5f270f8c2952c4 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_open_periods.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_open_periods.py @@ -12,8 +12,10 @@ get_open_periods_for_group, update_group_open_period, ) +from sentry.models.groupopenperiodactivity import GroupOpenPeriodActivity, OpenPeriodActivityType from sentry.testutils.cases import APITestCase from sentry.types.activity import ActivityType +from sentry.types.group import PriorityLevel from sentry.workflow_engine.models.detector_group import DetectorGroup @@ -27,7 +29,7 @@ def setUp(self) -> None: self.login_as(user=self.user) self.detector = self.create_detector() - self.group = self.create_group() + self.group = self.create_group(priority=PriorityLevel.LOW) # Metric issue is the only type (currently) that has open periods self.group.type = MetricIssue.type_id self.group.save() @@ -35,6 +37,15 @@ def setUp(self) -> None: # Link detector to group DetectorGroup.objects.create(detector=self.detector, group=self.group) + self.group_open_period = GroupOpenPeriod.objects.get(group=self.group) + + self.opened_gopa = GroupOpenPeriodActivity.objects.create( + date_added=self.group_open_period.date_added, + group_open_period=self.group_open_period, + type=OpenPeriodActivityType.OPENED, + value=self.group.priority, + ) + def get_url_args(self): return [self.organization.slug] @@ -58,6 +69,12 @@ def test_open_period_linked_to_group(self) -> None: assert open_period["end"] is None assert open_period["duration"] is None assert open_period["isOpen"] is True + assert len(open_period["activities"]) == 1 + assert open_period["activities"][0] == { + "id": str(self.opened_gopa.id), + "type": OpenPeriodActivityType.OPENED.to_str(), + "value": PriorityLevel(self.group.priority).to_str(), + } def test_open_periods_group_id(self) -> None: response = self.get_success_response( @@ -82,13 +99,17 @@ def test_open_periods_new_group_with_last_checked(self) -> None: assert response.status_code == 200, response.content assert len(response.data) == 1 resp = response.data[0] - open_period = GroupOpenPeriod.objects.get(group=self.group) - assert resp["id"] == str(open_period.id) + assert resp["id"] == str(self.group_open_period.id) assert resp["start"] == self.group.first_seen assert resp["end"] is None assert resp["duration"] is None assert resp["isOpen"] is True assert resp["lastChecked"] >= last_checked + assert resp["activities"][0] == { + "id": str(self.opened_gopa.id), + "type": OpenPeriodActivityType.OPENED.to_str(), + "value": PriorityLevel(self.group.priority).to_str(), + } def test_open_periods_resolved_group(self) -> None: self.group.status = GroupStatus.RESOLVED @@ -114,6 +135,9 @@ def test_open_periods_resolved_group(self) -> None: assert response.status_code == 200, response.content resp = response.data[0] open_period = GroupOpenPeriod.objects.get(group=self.group, date_ended=resolved_time) + closed_gopa = GroupOpenPeriodActivity.objects.get( + group_open_period=open_period, type=OpenPeriodActivityType.CLOSED + ) assert resp["id"] == str(open_period.id) assert resp["start"] == self.group.first_seen assert resp["end"] == resolved_time @@ -122,6 +146,17 @@ def test_open_periods_resolved_group(self) -> None: assert resp["lastChecked"].replace(second=0, microsecond=0) == activity.datetime.replace( second=0, microsecond=0 ) + assert len(resp["activities"]) == 2 + assert resp["activities"][0] == { + "id": str(self.opened_gopa.id), + "type": OpenPeriodActivityType.OPENED.to_str(), + "value": PriorityLevel(self.group.priority).to_str(), + } + assert resp["activities"][1] == { + "id": str(closed_gopa.id), + "type": OpenPeriodActivityType.CLOSED.to_str(), + "value": None, + } def test_open_periods_unresolved_group(self) -> None: self.group.status = GroupStatus.RESOLVED @@ -140,6 +175,9 @@ def test_open_periods_unresolved_group(self) -> None: resolution_activity=resolve_activity, ) open_period = GroupOpenPeriod.objects.get(group=self.group, date_ended=resolved_time) + closed_gopa = GroupOpenPeriodActivity.objects.get( + group_open_period=open_period, type=OpenPeriodActivityType.CLOSED + ) unresolved_time = timezone.now() self.group.status = GroupStatus.UNRESOLVED @@ -170,6 +208,12 @@ def test_open_periods_unresolved_group(self) -> None: open_period2 = GroupOpenPeriod.objects.get( group=self.group, date_ended=second_resolved_time ) + opened_gopa2 = GroupOpenPeriodActivity.objects.get( + group_open_period=open_period2, type=OpenPeriodActivityType.OPENED + ) + closed_gopa2 = GroupOpenPeriodActivity.objects.get( + group_open_period=open_period2, type=OpenPeriodActivityType.CLOSED + ) response = self.get_success_response( *self.get_url_args(), qs_params={"groupId": self.group.id} @@ -186,6 +230,17 @@ def test_open_periods_unresolved_group(self) -> None: assert resp["lastChecked"].replace(second=0, microsecond=0) == second_resolved_time.replace( second=0, microsecond=0 ) + assert len(resp["activities"]) == 2 + assert resp["activities"][0] == { + "id": str(opened_gopa2.id), + "type": OpenPeriodActivityType.OPENED.to_str(), + "value": PriorityLevel(self.group.priority).to_str(), + } + assert resp["activities"][1] == { + "id": str(closed_gopa2.id), + "type": OpenPeriodActivityType.CLOSED.to_str(), + "value": None, + } assert resp2["id"] == str(open_period.id) assert resp2["start"] == self.group.first_seen @@ -195,6 +250,17 @@ def test_open_periods_unresolved_group(self) -> None: assert resp2["lastChecked"].replace(second=0, microsecond=0) == resolved_time.replace( second=0, microsecond=0 ) + assert len(resp2["activities"]) == 2 + assert resp2["activities"][0] == { + "id": str(self.opened_gopa.id), + "type": OpenPeriodActivityType.OPENED.to_str(), + "value": PriorityLevel(self.group.priority).to_str(), + } + assert resp2["activities"][1] == { + "id": str(closed_gopa.id), + "type": OpenPeriodActivityType.CLOSED.to_str(), + "value": None, + } def test_open_periods_limit(self) -> None: self.group.status = GroupStatus.RESOLVED