diff --git a/src/sentry/api/serializers/models/groupsearchview.py b/src/sentry/api/serializers/models/groupsearchview.py index bb09aa726ac7..4c1338f9e10c 100644 --- a/src/sentry/api/serializers/models/groupsearchview.py +++ b/src/sentry/api/serializers/models/groupsearchview.py @@ -12,6 +12,13 @@ from sentry.users.services.user.service import user_service +class GroupSearchViewTimeFilters(TypedDict, total=False): + start: str | None + end: str | None + period: str | None + utc: bool | None + + class GroupSearchViewSerializerResponse(TypedDict): id: str createdBy: UserSerializerResponse | None @@ -20,7 +27,7 @@ class GroupSearchViewSerializerResponse(TypedDict): querySort: SORT_LITERALS projects: list[int] environments: list[str] - timeFilters: dict + timeFilters: GroupSearchViewTimeFilters lastVisited: str | None dateCreated: str dateUpdated: str diff --git a/src/sentry/api/serializers/rest_framework/groupsearchview.py b/src/sentry/api/serializers/rest_framework/groupsearchview.py index 9bf07b9a5dc9..1991408da1ef 100644 --- a/src/sentry/api/serializers/rest_framework/groupsearchview.py +++ b/src/sentry/api/serializers/rest_framework/groupsearchview.py @@ -1,7 +1,9 @@ -from typing import Any, NotRequired, TypedDict +from typing import NotRequired, TypedDict +from drf_spectacular.utils import extend_schema_field, extend_schema_serializer from rest_framework import serializers +from sentry.api.serializers.models.groupsearchview import GroupSearchViewTimeFilters from sentry.api.serializers.rest_framework import ValidationError from sentry.models.project import Project from sentry.models.savedsearch import SORT_LITERALS, SortOptions @@ -9,6 +11,34 @@ MAX_VIEWS = 50 +class GroupSearchViewTimeFiltersSerializer(serializers.Serializer): + start = serializers.CharField( + required=False, + allow_null=True, + help_text="The start of the time range in ISO-8601 format.", + ) + end = serializers.CharField( + required=False, + allow_null=True, + help_text="The end of the time range in ISO-8601 format.", + ) + period = serializers.CharField( + required=False, + allow_null=True, + help_text="The relative time period, such as `14d`.", + ) + utc = serializers.BooleanField( + required=False, + allow_null=True, + help_text="Whether to interpret the time range as UTC.", + ) + + +@extend_schema_field(GroupSearchViewTimeFiltersSerializer) +class GroupSearchViewTimeFiltersField(serializers.DictField): + pass + + class GroupSearchViewValidatorResponse(TypedDict): id: NotRequired[str] name: str @@ -18,22 +48,43 @@ class GroupSearchViewValidatorResponse(TypedDict): projects: list[int] isAllProjects: NotRequired[bool] environments: list[str] - timeFilters: dict[str, Any] + timeFilters: GroupSearchViewTimeFilters dateCreated: str | None dateUpdated: str | None class ViewValidator(serializers.Serializer): - id = serializers.CharField(required=False) - name = serializers.CharField(required=True) - query = serializers.CharField(required=True, allow_blank=True) + id = serializers.CharField(required=False, help_text="The ID of the issue view.") + name = serializers.CharField(required=True, help_text="The name of the issue view.") + query = serializers.CharField( + required=True, allow_blank=True, help_text="The issue search query." + ) querySort = serializers.ChoiceField( - required=False, choices=SortOptions.as_choices(), default=SortOptions.DATE + required=False, + choices=SortOptions.as_choices(), + default=SortOptions.DATE, + help_text="How to sort issues in the view.", ) - projects = serializers.ListField(required=True, allow_empty=True) - environments = serializers.ListField(required=True, allow_empty=True) - timeFilters = serializers.DictField(required=True, allow_empty=False) + projects = serializers.ListField( + child=serializers.IntegerField(), + required=True, + allow_empty=True, + help_text="The project IDs included in the view. Use `-1` to include all projects.", + ) + environments = serializers.ListField( + child=serializers.CharField(), + required=True, + allow_empty=True, + help_text=( + "The environment names included in the view. An empty list includes all environments." + ), + ) + timeFilters = GroupSearchViewTimeFiltersField( + required=True, + allow_empty=False, + help_text="The time range for the view.", + ) def validate_projects(self, value): if value != [-1]: @@ -59,8 +110,11 @@ def validate(self, data) -> GroupSearchViewValidatorResponse: return data +@extend_schema_serializer(exclude_fields=["id"]) class GroupSearchViewPostValidator(ViewValidator): - starred = serializers.BooleanField(required=False) + starred = serializers.BooleanField( + required=False, help_text="Whether to star the issue view for the current user." + ) def validate(self, data): return super().validate(data) diff --git a/src/sentry/issues/endpoints/organization_group_search_views.py b/src/sentry/issues/endpoints/organization_group_search_views.py index 69ce98ce8817..ab0313bae1d5 100644 --- a/src/sentry/issues/endpoints/organization_group_search_views.py +++ b/src/sentry/issues/endpoints/organization_group_search_views.py @@ -2,6 +2,7 @@ from django.db.models import Count, F, OuterRef, Q, Subquery from django.db.models.expressions import Combinable +from drf_spectacular.utils import extend_schema from rest_framework import serializers, status from rest_framework.request import Request from rest_framework.response import Response @@ -14,8 +15,20 @@ from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.paginator import ChainPaginator from sentry.api.serializers import serialize -from sentry.api.serializers.models.groupsearchview import GroupSearchViewSerializer +from sentry.api.serializers.models.groupsearchview import ( + GroupSearchViewSerializer, + GroupSearchViewSerializerResponse, +) from sentry.api.serializers.rest_framework.groupsearchview import GroupSearchViewPostValidator +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.groupsearchview import GroupSearchView, GroupSearchViewVisibility from sentry.models.groupsearchviewlastvisited import GroupSearchViewLastVisited from sentry.models.groupsearchviewstarred import GroupSearchViewStarred @@ -72,11 +85,12 @@ def validate_query(self, value: str | None) -> str | None: return value.strip() if value else None +@extend_schema(tags=["Events"]) @cell_silo_endpoint class OrganizationGroupSearchViewsEndpoint(OrganizationEndpoint): publish_status = { "GET": ApiPublishStatus.EXPERIMENTAL, - "POST": ApiPublishStatus.EXPERIMENTAL, + "POST": ApiPublishStatus.PUBLIC, } owner = ApiOwner.ISSUES permission_classes = (MemberPermission,) @@ -176,7 +190,28 @@ def get(self, request: Request, organization: Organization) -> Response: ), ) - def post(self, request: Request, organization: Organization) -> Response: + @extend_schema( + operation_id="createOrganizationIssueView", + summary="Create an Issue View", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=GroupSearchViewPostValidator, + responses={ + 201: inline_sentry_response_serializer( + "OrganizationIssueView", GroupSearchViewSerializerResponse + ), + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + ) + def post( + self, request: Request, organization: Organization + ) -> ( + Response[GroupSearchViewSerializerResponse] + | Response[ValidationErrorResponse] + | Response[None] + ): """ Create a new custom view for the current organization member. """ @@ -191,7 +226,7 @@ def post(self, request: Request, organization: Organization) -> Response: ) if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST) validated_data = serializer.validated_data @@ -223,13 +258,11 @@ def post(self, request: Request, organization: Organization) -> Response: view=view, ) - return Response( - serialize( - view, - request.user, - serializer=GroupSearchViewSerializer( - organization=organization, - ), + serialized_view: GroupSearchViewSerializerResponse = serialize( + view, + request.user, + serializer=GroupSearchViewSerializer( + organization=organization, ), - status=status.HTTP_201_CREATED, ) + return Response(serialized_view, status=status.HTTP_201_CREATED) diff --git a/tests/apidocs/endpoints/events/test_organization_issue_views.py b/tests/apidocs/endpoints/events/test_organization_issue_views.py new file mode 100644 index 000000000000..e81673e4bbf8 --- /dev/null +++ b/tests/apidocs/endpoints/events/test_organization_issue_views.py @@ -0,0 +1,26 @@ +from django.test.client import RequestFactory + +from fixtures.apidocs_test_case import APIDocsTestCase +from sentry.testutils.helpers.features import with_feature + + +class OrganizationIssueViewsDocs(APIDocsTestCase): + def setUp(self) -> None: + self.login_as(user=self.user) + self.url = f"/api/0/organizations/{self.organization.slug}/group-search-views/" + + @with_feature({"organizations:issue-views": True}) + def test_post(self) -> None: + data = { + "name": "My Issues", + "query": "is:unresolved", + "querySort": "date", + "projects": [], + "environments": [], + "timeFilters": {"period": "14d"}, + } + + response = self.client.post(self.url, data) + request = RequestFactory().post(self.url, data) + + self.validate_schema(request, response) diff --git a/tests/sentry/seer/endpoints/test_organization_agent_token.py b/tests/sentry/seer/endpoints/test_organization_agent_token.py index 3f660430c3f1..aacd3e86dab9 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_token.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -1275,6 +1275,7 @@ def _feature_flags( "GroupAutofixEndpoint": "organizations:gen-ai-features", "GroupIntegrationDetailsEndpoint": "organizations:integrations-issue-basic", "OrganizationEventsEndpoint": "organizations:discover-basic", + "OrganizationGroupSearchViewsEndpoint": "organizations:issue-views", "OrganizationProfilingChunksEndpoint": "organizations:continuous-profiling", "OrganizationProfilingFlamegraphEndpoint": "organizations:profiling", "OrganizationTraceItemAttributesEndpoint": "organizations:visibility-explore-view", @@ -1561,6 +1562,14 @@ def _mutation_payload(self, endpoint: PublicMutationEndpoint) -> dict[str, Any]: }, ("OrganizationDetailsEndpoint", "PUT"): {"name": self.org.name}, ("OrganizationDetectorIndexEndpoint", "PUT"): {"enabled": False}, + ("OrganizationGroupSearchViewsEndpoint", "POST"): { + "name": "Permission Matrix Issue View", + "query": "is:unresolved", + "querySort": "date", + "projects": [self.project.id], + "environments": [], + "timeFilters": {"period": "14d"}, + }, ("OrganizationReleaseFileDetailsEndpoint", "PUT"): {"name": "updated-matrix.js"}, ("ProjectReleaseFileDetailsEndpoint", "PUT"): {"name": "updated-matrix.js"}, ("ProjectReleaseFilesEndpoint", "POST"): {