Skip to content

Commit c1a7c10

Browse files
authored
feat(feature-health): Backend for health event dismissal (#5845)
1 parent 9f815cd commit c1a7c10

8 files changed

Lines changed: 237 additions & 23 deletions

File tree

api/features/feature_health/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@
88
FEATURE_HEALTH_EVENT_CREATED_PROVIDER_MESSAGE = "\n\nProvided by %s"
99
FEATURE_HEALTH_EVENT_CREATED_REASON_MESSAGE = "\n\nReason:\n%s"
1010

11+
FEATURE_HEALTH_EVENT_MANUALLY_DISMISSED_MESSAGE = "Manually dismissed by %s"
12+
1113
UNHEALTHY_TAG_LABEL = "Unhealthy"
1214
UNHEALTHY_TAG_COLOUR = "#FFC0CB"

api/features/feature_health/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class FeatureHealthEventSerializer(serializers.ModelSerializer[FeatureHealthEven
5656
class Meta:
5757
model = FeatureHealthEvent
5858
fields = read_only_fields = (
59+
"id",
5960
"created_at",
6061
"environment",
6162
"feature",

api/features/feature_health/services.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import json
12
import typing
23

34
import structlog
45

6+
from api_keys.user import APIKeyUser
57
from environments.models import Environment
68
from features.feature_health.constants import (
9+
FEATURE_HEALTH_EVENT_MANUALLY_DISMISSED_MESSAGE,
710
UNHEALTHY_TAG_COLOUR,
811
UNHEALTHY_TAG_LABEL,
912
)
@@ -17,8 +20,10 @@
1720
FeatureHealthProviderName,
1821
)
1922
from features.feature_health.providers import grafana, sample
23+
from features.feature_health.types import FeatureHealthEventReason
2024
from features.models import Feature
2125
from projects.tags.models import Tag, TagType
26+
from users.models import FFAdminUser
2227

2328
if typing.TYPE_CHECKING:
2429
from features.feature_health.types import FeatureHealthProviderResponse
@@ -107,3 +112,45 @@ def update_feature_unhealthy_tag(feature: "Feature") -> None:
107112
else:
108113
feature.tags.remove(unhealthy_tag)
109114
feature.save()
115+
116+
117+
def dismiss_feature_health_event(
118+
feature_health_event: FeatureHealthEvent,
119+
dismissed_by: FFAdminUser | APIKeyUser,
120+
) -> None:
121+
from features.feature_health import tasks
122+
123+
if (
124+
FeatureHealthEventType(feature_health_event.type)
125+
!= FeatureHealthEventType.UNHEALTHY
126+
):
127+
logger.warning(
128+
"feature-health-event-dismissal-not-supported",
129+
feature_health_event_id=feature_health_event.id,
130+
feature_health_event_external_id=feature_health_event.external_id,
131+
feature_health_event_type=feature_health_event.type,
132+
provider_name=feature_health_event.provider_name,
133+
)
134+
return
135+
136+
reason: FeatureHealthEventReason = {
137+
"text_blocks": [
138+
{
139+
"text": FEATURE_HEALTH_EVENT_MANUALLY_DISMISSED_MESSAGE % dismissed_by,
140+
}
141+
],
142+
"url_blocks": [],
143+
}
144+
145+
FeatureHealthEvent.objects.create(
146+
feature=feature_health_event.feature,
147+
environment=feature_health_event.environment,
148+
type=FeatureHealthEventType.HEALTHY,
149+
reason=json.dumps(reason),
150+
provider_name=feature_health_event.provider_name,
151+
external_id=feature_health_event.external_id,
152+
)
153+
154+
tasks.update_feature_unhealthy_tag.delay(
155+
args=(feature_health_event.feature.id,),
156+
)

api/features/feature_health/views.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import typing
22

3+
from common.projects.permissions import (
4+
EDIT_FEATURE,
5+
VIEW_PROJECT,
6+
)
37
from django.db.models import QuerySet
48
from django.shortcuts import get_object_or_404
59
from drf_yasg.utils import swagger_auto_schema # type: ignore[import-untyped]
610
from rest_framework import mixins, status, viewsets
7-
from rest_framework.decorators import api_view, permission_classes
11+
from rest_framework.decorators import action, api_view, permission_classes
812
from rest_framework.permissions import AllowAny, BasePermission
913
from rest_framework.request import Request
1014
from rest_framework.response import Response
@@ -23,6 +27,7 @@
2327
)
2428
from features.feature_health.services import (
2529
create_feature_health_events_from_provider,
30+
dismiss_feature_health_event,
2631
)
2732
from projects.models import Project
2833
from projects.permissions import NestedProjectPermissions
@@ -31,28 +36,44 @@
3136

3237
class FeatureHealthEventViewSet(
3338
mixins.ListModelMixin,
34-
viewsets.GenericViewSet, # type: ignore[type-arg]
39+
viewsets.GenericViewSet[FeatureHealthEvent],
3540
):
3641
serializer_class = FeatureHealthEventSerializer
3742
pagination_class = None # set here to ensure documentation is correct
3843
model_class = FeatureHealthEvent
3944

4045
def get_permissions(self) -> list[BasePermission]:
41-
return [NestedProjectPermissions()]
42-
43-
def get_queryset(self) -> QuerySet[FeatureHealthProvider]:
46+
return [
47+
NestedProjectPermissions(
48+
action_permission_map={
49+
"list": VIEW_PROJECT,
50+
"dismiss": EDIT_FEATURE,
51+
},
52+
get_project_from_object_callable=FeatureHealthEvent._get_project, # type: ignore[arg-type]
53+
),
54+
]
55+
56+
def get_queryset(self) -> QuerySet[FeatureHealthEvent]:
4457
if getattr(self, "swagger_fake_view", False):
4558
return self.model_class.objects.none()
4659

4760
project = get_object_or_404(Project, pk=self.kwargs["project_pk"])
4861
return self.model_class.objects.get_latest_by_project(project)
4962

63+
@action(detail=True, methods=["POST"], url_path="dismiss")
64+
def dismiss(self, *args: typing.Any, **kwargs: typing.Any) -> Response:
65+
dismiss_feature_health_event(
66+
feature_health_event=self.get_object(),
67+
dismissed_by=self.request.user, # type: ignore[arg-type]
68+
)
69+
return Response(status=status.HTTP_204_NO_CONTENT)
70+
5071

5172
class FeatureHealthProviderViewSet(
5273
mixins.DestroyModelMixin,
5374
mixins.CreateModelMixin,
5475
mixins.ListModelMixin,
55-
viewsets.GenericViewSet, # type: ignore[type-arg]
76+
viewsets.GenericViewSet[FeatureHealthProvider],
5677
):
5778
serializer_class = FeatureHealthProviderSerializer
5879
pagination_class = None # set here to ensure documentation is correct
@@ -80,7 +101,12 @@ def get_object(self) -> FeatureHealthProvider:
80101
request_body=CreateFeatureHealthProviderSerializer,
81102
responses={status.HTTP_201_CREATED: FeatureHealthProviderSerializer()},
82103
)
83-
def create(self, request: Request, *args, **kwargs) -> Response: # type: ignore[no-untyped-def]
104+
def create(
105+
self,
106+
request: Request,
107+
*args: typing.Any,
108+
**kwargs: typing.Any,
109+
) -> Response:
84110
request_serializer = CreateFeatureHealthProviderSerializer(data=request.data)
85111
request_serializer.is_valid(raise_exception=True)
86112

@@ -108,9 +134,9 @@ def create(self, request: Request, *args, **kwargs) -> Response: # type: ignore
108134
)
109135

110136

111-
@api_view(["POST"]) # type: ignore[arg-type]
137+
@api_view(["POST"])
112138
@permission_classes([AllowAny])
113-
def feature_health_webhook(request: Request, **kwargs: typing.Any) -> Response:
139+
def feature_health_webhook(request: Request, /, **kwargs: typing.Any) -> Response:
114140
path = kwargs["path"]
115141
if not (provider := get_provider_from_webhook_path(path)):
116142
return Response(status=status.HTTP_404_NOT_FOUND)

api/tests/integration/features/feature_health/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ def unhealthy_feature(
4141
return feature
4242

4343

44+
@pytest.fixture
45+
def unhealthy_feature_health_event(
46+
project: int,
47+
unhealthy_feature: int,
48+
admin_client_new: APIClient,
49+
) -> int:
50+
url = reverse(
51+
"api-v1:projects:feature-health-events-list",
52+
args=[project],
53+
)
54+
response = admin_client_new.get(url)
55+
unhealthy_feature_health_event_id: int = response.json()[0]["id"]
56+
return unhealthy_feature_health_event_id
57+
58+
4459
@pytest.fixture
4560
def grafana_feature_health_provider_webhook_url(
4661
project: int, admin_client_new: APIClient

api/tests/integration/features/feature_health/test_views.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ def expected_created_by(
2020
return None
2121

2222

23+
@pytest.fixture
24+
def expected_dismissed_by(
25+
admin_client_auth_type: AdminClientAuthType,
26+
admin_user_email: str,
27+
) -> str:
28+
if admin_client_auth_type == "user":
29+
return admin_user_email
30+
return "test_key"
31+
32+
2333
def test_feature_health_providers__get__expected_response(
2434
project: int,
2535
admin_client_new: APIClient,
@@ -76,6 +86,70 @@ def test_feature_health_providers__delete__expected_response(
7686
assert response.json() == []
7787

7888

89+
def test_feature_health_events__dismiss__unauthorized__expected_response(
90+
project: int,
91+
unhealthy_feature_health_event: int,
92+
test_user_client: APIClient,
93+
) -> None:
94+
# Given
95+
feature_health_events_dismiss_url = reverse(
96+
"api-v1:projects:feature-health-events-dismiss",
97+
args=[project, unhealthy_feature_health_event],
98+
)
99+
100+
# When
101+
response = test_user_client.post(feature_health_events_dismiss_url)
102+
103+
# Then
104+
assert response.status_code == 403
105+
assert response.json() == {
106+
"detail": "You do not have permission to perform this action."
107+
}
108+
109+
110+
@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00")
111+
def test_feature_health_events__dismiss__expected_response(
112+
project: int,
113+
unhealthy_feature: int,
114+
unhealthy_feature_health_event: int,
115+
admin_client_new: APIClient,
116+
expected_dismissed_by: str,
117+
mocker: MockerFixture,
118+
) -> None:
119+
# Given
120+
feature_health_events_url = reverse(
121+
"api-v1:projects:feature-health-events-list", args=[project]
122+
)
123+
feature_health_events_dismiss_url = reverse(
124+
"api-v1:projects:feature-health-events-dismiss",
125+
args=[project, unhealthy_feature_health_event],
126+
)
127+
128+
# When
129+
with freeze_time("2023-01-19T10:09:47.325132+00:00"):
130+
response = admin_client_new.post(feature_health_events_dismiss_url)
131+
132+
# Then
133+
assert response.status_code == 204
134+
response = admin_client_new.get(feature_health_events_url)
135+
assert response.json() == [
136+
{
137+
"created_at": "2023-01-19T10:09:47.325132Z",
138+
"environment": None,
139+
"feature": unhealthy_feature,
140+
"id": mocker.ANY,
141+
"provider_name": "Sample",
142+
"reason": {
143+
"text_blocks": [
144+
{"text": f"Manually dismissed by {expected_dismissed_by}"}
145+
],
146+
"url_blocks": [],
147+
},
148+
"type": "HEALTHY",
149+
},
150+
]
151+
152+
79153
def test_webhook__invalid_path__expected_response(
80154
api_client: APIClient,
81155
) -> None:
@@ -97,6 +171,7 @@ def test_webhook__sample_provider__post__expected_feature_health_event_created__
97171
sample_feature_health_provider_webhook_url: str,
98172
api_client: APIClient,
99173
admin_client_new: APIClient,
174+
mocker: MockerFixture,
100175
) -> None:
101176
# Given
102177
feature_health_events_url = reverse(
@@ -121,6 +196,7 @@ def test_webhook__sample_provider__post__expected_feature_health_event_created__
121196
response = admin_client_new.get(feature_health_events_url)
122197
assert response.json() == [
123198
{
199+
"id": mocker.ANY,
124200
"created_at": "2023-01-19T09:09:47.325132Z",
125201
"environment": None,
126202
"feature": feature,
@@ -156,6 +232,7 @@ def test_webhook__sample_provider__post_with_environment_expected_feature_health
156232
sample_feature_health_provider_webhook_url: str,
157233
api_client: APIClient,
158234
admin_client_new: APIClient,
235+
mocker: MockerFixture,
159236
) -> None:
160237
# Given
161238
feature_health_events_url = reverse(
@@ -179,6 +256,7 @@ def test_webhook__sample_provider__post_with_environment_expected_feature_health
179256
response = admin_client_new.get(feature_health_events_url)
180257
assert response.json() == [
181258
{
259+
"id": mocker.ANY,
182260
"created_at": "2023-01-19T09:09:47.325132Z",
183261
"environment": environment,
184262
"feature": feature,
@@ -197,6 +275,7 @@ def test_webhook__unhealthy_feature__post__expected_feature_health_event_created
197275
sample_feature_health_provider_webhook_url: str,
198276
api_client: APIClient,
199277
admin_client_new: APIClient,
278+
mocker: MockerFixture,
200279
) -> None:
201280
# Given
202281
feature_health_events_url = reverse(
@@ -222,6 +301,7 @@ def test_webhook__unhealthy_feature__post__expected_feature_health_event_created
222301
response = admin_client_new.get(feature_health_events_url)
223302
assert response.json() == [
224303
{
304+
"id": mocker.ANY,
225305
"created_at": "2023-01-19T09:09:48.325132Z",
226306
"environment": None,
227307
"feature": unhealthy_feature,
@@ -273,6 +353,7 @@ def test_webhook__grafana_provider__post__expected_feature_health_event_created(
273353
grafana_feature_health_provider_webhook_url: str,
274354
api_client: APIClient,
275355
admin_client_new: APIClient,
356+
mocker: MockerFixture,
276357
) -> None:
277358
# Given
278359
feature_health_events_url = reverse(
@@ -335,6 +416,7 @@ def test_webhook__grafana_provider__post__expected_feature_health_event_created(
335416
response = admin_client_new.get(feature_health_events_url)
336417
assert response.json() == [
337418
{
419+
"id": mocker.ANY,
338420
"created_at": "2025-02-12T21:06:50Z",
339421
"environment": None,
340422
"feature": feature,
@@ -354,6 +436,7 @@ def test_webhook__grafana_provider__post__multiple__expected_feature_health_even
354436
grafana_feature_health_provider_webhook_url: str,
355437
api_client: APIClient,
356438
admin_client_new: APIClient,
439+
mocker: MockerFixture,
357440
) -> None:
358441
# Given
359442
feature_health_events_url = reverse(
@@ -502,6 +585,7 @@ def test_webhook__grafana_provider__post__multiple__expected_feature_health_even
502585
# second firing alert has not been resolved
503586
# and provided an environment label
504587
{
588+
"id": mocker.ANY,
505589
"created_at": "2025-02-12T21:07:50Z",
506590
"environment": environment,
507591
"feature": feature,
@@ -511,6 +595,7 @@ def test_webhook__grafana_provider__post__multiple__expected_feature_health_even
511595
},
512596
# first firing alert has been resolved
513597
{
598+
"id": mocker.ANY,
514599
"created_at": "2025-02-12T21:12:50Z",
515600
"environment": None,
516601
"feature": feature,

0 commit comments

Comments
 (0)