Skip to content
Open
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
22 changes: 22 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@
OrganizationFlagsWebHookSigningSecretEndpoint,
OrganizationFlagsWebHookSigningSecretsEndpoint,
)
from sentry.inbox.endpoints.organization_inbox import OrganizationInboxIndexEndpoint
from sentry.inbox.endpoints.organization_inbox_count import (
OrganizationInboxCountEndpoint,
)
from sentry.inbox.endpoints.organization_inbox_item_events import (
OrganizationInboxItemEventsEndpoint,
)
from sentry.insights.endpoints.starred_segments import OrganizationStarredServiceSpansEndpoint
from sentry.integrations.api.endpoints.data_forwarding_details import DataForwardingDetailsEndpoint
from sentry.integrations.api.endpoints.data_forwarding_index import DataForwardingIndexEndpoint
Expand Down Expand Up @@ -2379,6 +2386,21 @@ def create_group_urls(
OrganizationSentryAppsEndpoint.as_view(),
name="sentry-api-0-organization-sentry-apps",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/inbox/$",
OrganizationInboxIndexEndpoint.as_view(),
name="sentry-api-0-organization-inbox",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/inbox/count/$",
OrganizationInboxCountEndpoint.as_view(),
name="sentry-api-0-organization-inbox-count",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/inbox/(?P<item_id>\d+)/events/$",
OrganizationInboxItemEventsEndpoint.as_view(),
name="sentry-api-0-organization-inbox-item-events",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/investigations/candidates/$",
OrganizationInvestigationCandidatesEndpoint.as_view(),
Expand Down
Empty file.
35 changes: 35 additions & 0 deletions src/sentry/inbox/endpoints/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from typing import Any

from drf_spectacular.utils import OpenApiParameter
from rest_framework.request import Request

from sentry import features
from sentry.api.api_owners import ApiOwner
from sentry.api.bases.organization import OrganizationEndpoint
from sentry.api.exceptions import ResourceDoesNotExist, UserRequired
from sentry.apidocs.parameters import CursorQueryParam
from sentry.auth.principal import has_user

PAGINATION_PARAMETERS = [
CursorQueryParam,
OpenApiParameter(
"per_page", int, description="Results per page, from 1 to 100. Defaults to 25."
),
]


class OrganizationInboxEndpoint(OrganizationEndpoint):
owner = ApiOwner.ISSUES
requires_user = True

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i'm not completely sure this is correct. a lot of inbox functionality assumes we have a user, since it's going to be a personal inbox. i don't know how service accounts / agents / whatever else are going to access endpoints, but they will need to access the API on behalf of a user at some point, so i'm going to go with this for now and figure out if we need to change this assumption later

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This makes sense to me, I imagine that other endpoints like the one that fetches issue views should also be require_user=True?


def convert_args(
self, request: Request, *args: Any, **kwargs: Any
) -> tuple[tuple[Any, ...], dict[str, Any]]:
args, kwargs = super().convert_args(request, *args, **kwargs)
if not features.has(
"organizations:universal-inbox", kwargs["organization"], actor=request.user
):
raise ResourceDoesNotExist
if not has_user(request.user):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this necessary with require_user=True?

raise UserRequired
return args, kwargs
65 changes: 65 additions & 0 deletions src/sentry/inbox/endpoints/organization_inbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from django.db.models import QuerySet
from drf_spectacular.utils import extend_schema
from rest_framework.exceptions import ParseError
from rest_framework.response import Response

from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.serializers import serialize
from sentry.apidocs import constants
from sentry.apidocs.parameters import GlobalParams
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.constants import ObjectStatus
from sentry.inbox.endpoints.base import PAGINATION_PARAMETERS, OrganizationInboxEndpoint
from sentry.inbox.filters import InboxFilterSerializer, filter_inbox_items
from sentry.inbox.paginator import InboxPaginator
from sentry.inbox.response_types import InboxItemResponse
from sentry.inbox.serializers import InboxItemSerializer
from sentry.models.organization import Organization
from sentry.notifications.models.inboxitem import InboxItem
from sentry.utils.auth import AuthenticatedHttpRequest
from sentry.utils.cursors import StringCursor


@cell_silo_endpoint
@extend_schema(tags=["Organizations"])
class OrganizationInboxIndexEndpoint(OrganizationInboxEndpoint):
publish_status = {"GET": ApiPublishStatus.EXPERIMENTAL}

@extend_schema(
operation_id="List Personal Inbox Items",
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
*PAGINATION_PARAMETERS,
InboxFilterSerializer,
],
responses={
200: inline_sentry_response_serializer("InboxItems", list[InboxItemResponse]),
400: constants.RESPONSE_BAD_REQUEST,
401: constants.RESPONSE_UNAUTHORIZED,
403: constants.RESPONSE_FORBIDDEN,
404: constants.RESPONSE_NOT_FOUND,
},
)
def get(
self, request: AuthenticatedHttpRequest, organization: Organization
) -> Response[list[InboxItemResponse]]:
"""List your active inbox items, newest activity first."""
filters = InboxFilterSerializer(data=request.GET)
if not filters.is_valid():
raise ParseError({"detail": filters.errors})
user = request.user
items: QuerySet[InboxItem] = InboxItem.objects.filter(
organization=organization, user_id=user.id, status=ObjectStatus.ACTIVE
)
items = filter_inbox_items(items, filters.validated_data).select_related("organization")
return self.paginate(
request,
queryset=items,
order_by="-last_activity_at",
paginator_cls=InboxPaginator,
cursor_cls=StringCursor,
default_per_page=25,
max_per_page=100,
on_results=lambda page: serialize(page, user, InboxItemSerializer()),
)
49 changes: 49 additions & 0 deletions src/sentry/inbox/endpoints/organization_inbox_count.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from drf_spectacular.utils import extend_schema
from rest_framework.exceptions import ParseError
from rest_framework.response import Response

from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.apidocs import constants
from sentry.apidocs.parameters import GlobalParams
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.constants import ObjectStatus
from sentry.inbox.endpoints.base import OrganizationInboxEndpoint
from sentry.inbox.filters import InboxFilterSerializer, filter_inbox_items
from sentry.inbox.response_types import InboxCountResponse
from sentry.models.organization import Organization
from sentry.notifications.models.inboxitem import InboxItem
from sentry.utils.auth import AuthenticatedHttpRequest


@cell_silo_endpoint
@extend_schema(tags=["Organizations"])
class OrganizationInboxCountEndpoint(OrganizationInboxEndpoint):
publish_status = {"GET": ApiPublishStatus.EXPERIMENTAL}

@extend_schema(
operation_id="Count Personal Inbox Items",
parameters=[GlobalParams.ORG_ID_OR_SLUG, InboxFilterSerializer],
responses={
200: inline_sentry_response_serializer("InboxCount", InboxCountResponse),
400: constants.RESPONSE_BAD_REQUEST,
401: constants.RESPONSE_UNAUTHORIZED,
403: constants.RESPONSE_FORBIDDEN,
404: constants.RESPONSE_NOT_FOUND,
},
)
def get(
self, request: AuthenticatedHttpRequest, organization: Organization
) -> Response[InboxCountResponse]:
"""Count your active inbox items matching the filters."""
filters = InboxFilterSerializer(data=request.GET)
if not filters.is_valid():
raise ParseError({"detail": filters.errors})
user = request.user
items = InboxItem.objects.filter(
organization=organization,
user_id=user.id,
status=ObjectStatus.ACTIVE,
)
count = filter_inbox_items(items, filters.validated_data).count()
return Response({"count": count})
63 changes: 63 additions & 0 deletions src/sentry/inbox/endpoints/organization_inbox_item_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.response import Response

from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.serializers import serialize
from sentry.apidocs import constants
from sentry.apidocs.parameters import GlobalParams
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.constants import ObjectStatus
from sentry.inbox.endpoints.base import PAGINATION_PARAMETERS, OrganizationInboxEndpoint
from sentry.inbox.paginator import InboxPaginator
from sentry.inbox.response_types import InboxItemEventResponse
from sentry.inbox.serializers import InboxItemEventSerializer
from sentry.models.organization import Organization
from sentry.notifications.models.inboxitem import InboxItem
from sentry.utils.auth import AuthenticatedHttpRequest
from sentry.utils.cursors import StringCursor


@cell_silo_endpoint
@extend_schema(tags=["Organizations"])
class OrganizationInboxItemEventsEndpoint(OrganizationInboxEndpoint):
publish_status = {"GET": ApiPublishStatus.EXPERIMENTAL}

@extend_schema(
operation_id="List Personal Inbox Item Events",
parameters=[
GlobalParams.ORG_ID_OR_SLUG,
OpenApiParameter("item_id", str, location="path", required=True),
*PAGINATION_PARAMETERS,
],
responses={
200: inline_sentry_response_serializer("InboxItemEvents", list[InboxItemEventResponse]),
400: constants.RESPONSE_BAD_REQUEST,
401: constants.RESPONSE_UNAUTHORIZED,
403: constants.RESPONSE_FORBIDDEN,
404: constants.RESPONSE_NOT_FOUND,
},
)
def get(
self, request: AuthenticatedHttpRequest, organization: Organization, item_id: str
) -> Response[list[InboxItemEventResponse]]:
"""List an inbox item's events, newest first. Reading does not change its read state."""
user = request.user
item = get_object_or_404(
InboxItem,
id=item_id,
organization=organization,
user_id=user.id,
status=ObjectStatus.ACTIVE,
)
return self.paginate(
request,
queryset=item.events.select_related("inbox_item"),
order_by="-date_added",
paginator_cls=InboxPaginator,
cursor_cls=StringCursor,
default_per_page=25,
max_per_page=100,
on_results=lambda page: serialize(page, user, InboxItemEventSerializer()),
)
80 changes: 80 additions & 0 deletions src/sentry/inbox/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from typing import Literal, TypedDict

from django.db.models import Exists, F, OuterRef, Q, QuerySet
from rest_framework import serializers

from sentry.inbox.registry import inbox_item_event_type_registry, inbox_item_type_registry
from sentry.notifications.models.inboxitem import InboxItem
from sentry.notifications.models.inboxitemevent import InboxItemEvent

UNREAD_ITEMS = Q(last_read_at__isnull=True) | Q(last_read_at__lt=F("last_activity_at"))


class InboxFilters(TypedDict, total=False):
subject_type: list[str]
subject_id: str
event_type: list[str]
id: list[int]
is_read: Literal["true", "false"]


class InboxFilterSerializer(serializers.Serializer[InboxFilters]):
subject_type = serializers.ListField(
child=serializers.CharField(trim_whitespace=False),
required=False,
help_text="Match any of these registered subject types.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does the typing/documentation help users/agents understand which subject types are available?

)
subject_id = serializers.CharField(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need to allow filtering by subject ID?

required=False,
allow_blank=True,
trim_whitespace=False,
help_text="Subject ID; requires exactly one subject_type.",
)
event_type = serializers.ListField(
child=serializers.CharField(trim_whitespace=False),
required=False,
help_text="Match items containing any of these event types.",
)
id = serializers.ListField(
child=serializers.IntegerField(),
required=False,
help_text="Select personal inbox item IDs.",
)
is_read = serializers.ChoiceField(
choices=("true", "false"),
required=False,
help_text="Filter by read state. Omit for both.",
)

def validate(self, attrs: InboxFilters) -> InboxFilters:
if set(attrs.get("subject_type", [])) - inbox_item_type_registry.registrations.keys():
raise serializers.ValidationError("Unknown subject type.")
if set(attrs.get("event_type", [])) - inbox_item_event_type_registry.registrations.keys():
raise serializers.ValidationError("Unknown event type.")
if "subject_id" in attrs and len(attrs.get("subject_type", [])) != 1:
raise serializers.ValidationError("subject_id requires exactly one subject_type.")
return attrs


def filter_inbox_items(items: QuerySet[InboxItem], filters: InboxFilters) -> QuerySet[InboxItem]:
subject_types = filters.get("subject_type")
event_types = filters.get("event_type")

if subject_types:
items = items.filter(subject_type__in=subject_types)
if "subject_id" in filters:
items = items.filter(subject_id=filters["subject_id"])
if event_types:
items = items.filter(
Exists(
InboxItemEvent.objects.filter(inbox_item_id=OuterRef("pk"), type__in=event_types)
)
)
if "id" in filters:
items = items.filter(id__in=filters["id"])
is_read = filters.get("is_read")
if is_read == "true":
items = items.exclude(UNREAD_ITEMS)
elif is_read == "false":
items = items.filter(UNREAD_ITEMS)
return items
61 changes: 61 additions & 0 deletions src/sentry/inbox/paginator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from datetime import UTC, datetime, timedelta
from typing import Any

from django.db.models import Q, QuerySet

from sentry.api.paginator import BadPaginationError
from sentry.utils.cursors import Cursor, CursorResult, StringCursor

_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)


class InboxPaginator:
"""Page by timestamp and ID, including when timestamps are equal."""

def __init__(self, queryset: QuerySet[Any], order_by: str) -> None:
self.queryset = queryset
self.key = order_by.removeprefix("-")

def get_result(
self, limit: int = 100, cursor: Cursor | None = None, **kwargs: Any
) -> CursorResult[Any]:
if cursor is None:
cursor = StringCursor("0")
if cursor.offset != 0:
raise BadPaginationError("Invalid inbox cursor.")
queryset = self.queryset
cursor_value = str(cursor.value)
has_cursor = cursor_value != "0"
if has_cursor:
try:
timestamp, item_id = cursor_value.split("_")
microseconds = int(timestamp)
boundary_time = _EPOCH + timedelta(microseconds=microseconds)
boundary_id = int(item_id)
except (ValueError, OverflowError):
raise BadPaginationError("Invalid inbox cursor.")
cursor_value = f"{microseconds}_{boundary_id}"
comparison = "gt" if cursor.is_prev else "lt"
queryset = queryset.filter(
Q(**{f"{self.key}__{comparison}": boundary_time})
| Q(**{self.key: boundary_time, f"id__{comparison}": boundary_id})
)
prefix = "" if cursor.is_prev else "-"
rows = list(queryset.order_by(f"{prefix}{self.key}", f"{prefix}id")[: limit + 1])
has_more = len(rows) > limit
rows = rows[:limit]
if cursor.is_prev:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this cursor isn't consistent with the typical issues cursor because of #129437 (comment). open to thoughts here

@malwilley malwilley Nov 3, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

IMO it's okay to have an inbox paginator class, there are some other instances of endpoints defining their own already. You could create a new reusable DateTimeIDPaginator but I don't know if anything else will need it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

does that concern still hold, given that you're using epoch microseconds now?

rows.reverse()
next_value = self._cursor_value(rows[-1]) if rows else cursor_value
prev_value = self._cursor_value(rows[0]) if rows else cursor_value
return CursorResult(
results=rows,
next=StringCursor(next_value, has_results=has_cursor if cursor.is_prev else has_more),
prev=StringCursor(
prev_value, is_prev=True, has_results=has_more if cursor.is_prev else has_cursor
),
)

def _cursor_value(self, item: Any) -> str:
microseconds = (getattr(item, self.key) - _EPOCH) // timedelta(microseconds=1)
return f"{microseconds}_{item.id}"
Loading
Loading