Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/sentry/api/serializers/models/groupsearchview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
74 changes: 64 additions & 10 deletions src/sentry/api/serializers/rest_framework/groupsearchview.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,44 @@
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

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
Comment thread
sentry[bot] marked this conversation as resolved.


class GroupSearchViewValidatorResponse(TypedDict):
id: NotRequired[str]
name: str
Expand All @@ -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]:
Expand All @@ -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)
57 changes: 45 additions & 12 deletions src/sentry/issues/endpoints/organization_group_search_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,)
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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

Expand Down Expand Up @@ -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)
26 changes: 26 additions & 0 deletions tests/apidocs/endpoints/events/test_organization_issue_views.py
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The POST request in test_post() is missing content_type="application/json", causing the test client to send data as multipart/form-data, which fails to serialize nested objects and empty lists.
Severity: LOW

Suggested Fix

Add content_type="application/json" to the self.client.post() call to ensure the payload is correctly serialized as JSON. For example: self.client.post(self.url, data, content_type="application/json").

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: tests/apidocs/endpoints/events/test_organization_issue_views.py#L23

Potential issue: The test `test_post()` sends a POST request with a payload containing a
nested dictionary (`timeFilters`) and empty lists. Because
`content_type="application/json"` is not specified, the Django test client defaults to
`multipart/form-data`. This encoding does not correctly serialize the nested data
structures. The backend's `GroupSearchViewPostValidator` serializer will fail to
validate the malformed data, returning a 400 status code. This causes the test to fail
on the `self.validate_schema` call, which asserts that the response status code must be
in the 2xx range.

request = RequestFactory().post(self.url, data)

self.validate_schema(request, response)
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"): {
Expand Down
Loading