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
101 changes: 101 additions & 0 deletions src/sentry/api/serializers/models/exploresavedquery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from collections import defaultdict
from typing import DefaultDict, TypedDict

from sentry.api.serializers import Serializer, register
from sentry.constants import ALL_ACCESS_PROJECTS
from sentry.explore.models import ExploreSavedQuery, ExploreSavedQueryDataset
from sentry.users.api.serializers.user import UserSerializerResponse
from sentry.users.services.user.service import user_service
from sentry.utils.dates import outside_retention_with_modified_start, parse_timestamp


class ExploreSavedQueryResponseOptional(TypedDict, total=False):
environment: list[str]
query: str
fields: list[str]
range: str
start: str
end: str
orderby: str
visualize: list[dict]
interval: str
mode: str


class ExploreSavedQueryResponse(ExploreSavedQueryResponseOptional):
id: str
name: str
projects: list[int]
dataset: str
expired: bool
dateAdded: str
dateUpdated: str
createdBy: UserSerializerResponse


@register(ExploreSavedQuery)
class ExploreSavedQueryModelSerializer(Serializer):
def get_attrs(self, item_list, user, **kwargs):
result: DefaultDict[str, dict] = defaultdict(lambda: {"created_by": {}})

service_serialized = user_service.serialize_many(
filter={
"user_ids": [
explore_saved_query.created_by_id
for explore_saved_query in item_list
if explore_saved_query.created_by_id
]
},
as_user=user if user.id else None,
)
serialized_users = {user["id"]: user for user in service_serialized}

for explore_saved_query in item_list:
result[explore_saved_query]["created_by"] = serialized_users.get(
str(explore_saved_query.created_by_id)
)

return result

def serialize(self, obj, attrs, user, **kwargs) -> ExploreSavedQueryResponse:
query_keys = [
"environment",
"query",
"fields",
"range",
"start",
"end",
"orderby",
"visualize",
"interval",
"mode",
]
data: ExploreSavedQueryResponse = {
"id": str(obj.id),
"name": obj.name,
"projects": [project.id for project in obj.projects.all()],
"dataset": ExploreSavedQueryDataset.get_type_name(obj.dataset),
"expired": False,
"dateAdded": obj.date_added,
"dateUpdated": obj.date_updated,
"createdBy": attrs.get("created_by"),
}

for key in query_keys:
if obj.query.get(key) is not None:
data[key] = obj.query[key] # type: ignore[literal-required]

# expire queries that are beyond the retention period
if "start" in obj.query:
start, end = parse_timestamp(obj.query["start"]), parse_timestamp(obj.query["end"])
if start and end:
expired, modified_start = outside_retention_with_modified_start(
start, end, obj.organization
)
data["expired"] = expired
data["start"] = modified_start.strftime("%Y-%m-%dT%H:%M:%S.%fZ")

if obj.query.get("all_projects"):
data["projects"] = list(ALL_ACCESS_PROJECTS)

return data
7 changes: 7 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
DiscoverSavedQueryDetailEndpoint,
DiscoverSavedQueryVisitEndpoint,
)
from sentry.explore.endpoints.explore_saved_queries import ExploreSavedQueriesEndpoint
from sentry.flags.endpoints.hooks import OrganizationFlagsHooksEndpoint
from sentry.flags.endpoints.logs import (
OrganizationFlagLogDetailsEndpoint,
Expand Down Expand Up @@ -1281,6 +1282,12 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
ProjectTransactionThresholdOverrideEndpoint.as_view(),
name="sentry-api-0-organization-project-transaction-threshold-override",
),
# Explore
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/explore/saved/$",
ExploreSavedQueriesEndpoint.as_view(),
name="sentry-api-0-explore-saved-queries",
),
# Dashboards
re_path(
r"^(?P<organization_id_or_slug>[^\/]+)/dashboards/$",
Expand Down
161 changes: 161 additions & 0 deletions src/sentry/apidocs/examples/explore_saved_query_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
from drf_spectacular.utils import OpenApiExample

EXPLORE_SAVED_QUERY_OBJ = {
"id": "1",
"name": "Pageloads",
"projects": [],
"dateAdded": "2024-07-25T19:35:38.422859Z",
"dateUpdated": "2024-07-25T19:35:38.422874Z",
"environment": [],
"query": "span.op:pageload",
"fields": [
"span.op",
"project",
"count(span.duration)",
"avg(span.duration)",
"p75(span.duration)",
"p95(span.duration)",
],
"range": "24h",
"orderby": "-count(span.duration)",
"mode": "samples",
"dataset": "spans",
"expired": False,
"createdBy": {
"id": "1",
"name": "Admin",
"username": "admin",
"email": "admin@sentry.io",
"avatarUrl": "www.example.com",
"isActive": True,
"hasPasswordAuth": True,
"isManaged": False,
"dateJoined": "2021-10-25T17:07:33.190596Z",
"lastLogin": "2024-07-16T15:28:39.261659Z",
"has2fa": True,
"lastActive": "2024-07-16T20:45:49.364197Z",
"isSuperuser": False,
"isStaff": False,
"experiments": {},
"emails": [{"id": "1", "email": "admin@sentry.io", "is_verified": True}],
"avatar": {
"avatarType": "letter_avatar",
"avatarUuid": None,
"avatarUrl": "www.example.com",
},
},
}

SAVED_QUERIES = [
{
"id": "1",
"name": "Pageloads",
"projects": [],
"dateAdded": "2024-07-25T19:35:38.422859Z",
"dateUpdated": "2024-07-25T19:35:38.422874Z",
"environment": [],
"query": "span.op:pageload",
"fields": [
"span.op",
"timestamp",
],
"range": "24h",
"orderby": "-timestamp",
"mode": "samples",
"dataset": "spans",
"expired": False,
"createdBy": {
"id": "1",
"name": "Admin",
"username": "admin",
"email": "admin@sentry.io",
"avatarUrl": "www.example.com",
"isActive": True,
"hasPasswordAuth": True,
"isManaged": False,
"dateJoined": "2021-10-25T17:07:33.190596Z",
"lastLogin": "2024-07-16T15:28:39.261659Z",
"has2fa": True,
"lastActive": "2024-07-16T20:45:49.364197Z",
"isSuperuser": False,
"isStaff": False,
"experiments": {},
"emails": [{"id": "1", "email": "admin@sentry.io", "is_verified": True}],
"avatar": {
"avatarType": "letter_avatar",
"avatarUuid": None,
"avatarUrl": "www.example.com",
},
},
},
{
"id": "2",
"name": "Cache Gets",
"projects": [],
"dateAdded": "2024-07-25T19:35:38.422859Z",
"dateUpdated": "2024-07-25T19:35:38.422874Z",
"environment": [],
"query": "span.op:cache.get",
"fields": [
"span.op",
"span.duration" "timestamp",
],
"range": "24h",
"orderby": "-timestamp",
"mode": "samples",
"dataset": "spans",
"expired": False,
"createdBy": {
"id": "1",
"name": "Admin",
"username": "admin",
"email": "admin@sentry.io",
"avatarUrl": "www.example.com",
"isActive": True,
"hasPasswordAuth": True,
"isManaged": False,
"dateJoined": "2021-10-25T17:07:33.190596Z",
"lastLogin": "2024-07-16T15:28:39.261659Z",
"has2fa": True,
"lastActive": "2024-07-16T20:45:49.364197Z",
"isSuperuser": False,
"isStaff": False,
"experiments": {},
"emails": [{"id": "1", "email": "admin@sentry.io", "is_verified": True}],
"avatar": {
"avatarType": "letter_avatar",
"avatarUuid": None,
"avatarUrl": "www.example.com",
},
},
},
]


class ExploreExamples:
EXPLORE_SAVED_QUERY_GET_RESPONSE = [
OpenApiExample(
"Explore Saved Query GET response",
value=EXPLORE_SAVED_QUERY_OBJ,
status_codes=["200"],
response_only=True,
)
]

EXPLORE_SAVED_QUERY_POST_RESPONSE = [
OpenApiExample(
"Create Explore Saved Query",
value=EXPLORE_SAVED_QUERY_OBJ,
status_codes=["201"],
response_only=True,
)
]

EXPLORE_SAVED_QUERIES_QUERY_RESPONSE = [
OpenApiExample(
"Get Explore Saved Queries",
value=SAVED_QUERIES,
status_codes=["200"],
response_only=True,
)
]
27 changes: 27 additions & 0 deletions src/sentry/apidocs/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -817,3 +817,30 @@ class DiscoverSavedQueriesParams:
- `myqueries`
""",
)


class ExploreSavedQueriesParams:
QUERY = OpenApiParameter(
name="query",
location="query",
required=False,
type=str,
description="""The name of the Explore query you'd like to filter by.""",
)

SORT = OpenApiParameter(
name="sortBy",
location="query",
required=False,
type=str,
description="""The property to sort results by. If not specified, the results are sorted by query name.

Available fields are:
- `name`
- `dateCreated`
- `dateUpdated`
- `mostPopular`
- `recentlyViewed`
- `myqueries`
""",
)
Empty file.
39 changes: 39 additions & 0 deletions src/sentry/explore/endpoints/bases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from sentry.api.bases.organization import OrganizationPermission
from sentry.explore.models import ExploreSavedQuery
from sentry.models.organization import Organization


class ExploreSavedQueryPermission(OrganizationPermission):
# Relaxed permissions for saved queries in Explore
scope_map = {
"GET": ["org:read", "org:write", "org:admin"],
"POST": ["org:read", "org:write", "org:admin"],
"PUT": ["org:read", "org:write", "org:admin"],
"DELETE": ["org:read", "org:write", "org:admin"],
}

def has_object_permission(self, request, view, obj):
if isinstance(obj, Organization):
return super().has_object_permission(request, view, obj)

if isinstance(obj, ExploreSavedQuery):
# 1. Saved Query contains certain projects
if obj.projects.exists():
return request.access.has_projects_access(obj.projects.all())

# 2. Saved Query covers all projects or all my projects

# allow when Open Membership
if obj.organization.flags.allow_joinleave:
return True

# allow for Managers and Owners
if request.access.has_scope("org:write"):
return True

# allow for creator
if request.user.id == obj.created_by_id:
return True

return False
return True
Loading