From 43552743bf5258a59e9fb21aada6e75b1dd5f7bd Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Wed, 21 Oct 2026 10:14:00 -0700 Subject: [PATCH 1/2] feat(inbox): Add personal inbox read APIs Add organization-scoped endpoints for inbox items, filtered item counts, and item events. Require a user and org:read access, and gate reads behind the inbox UI feature. Share subject, event, read-state, and item-ID filters between list and count requests. Paginate by timestamp and ID so equal timestamps work in either direction. Fixes ISWF-3646 Fixes ISWF-3647 Co-authored-by: Codex --- src/sentry/api/urls.py | 22 ++ src/sentry/inbox/endpoints/__init__.py | 0 src/sentry/inbox/endpoints/base.py | 35 +++ .../inbox/endpoints/organization_inbox.py | 65 +++++ .../endpoints/organization_inbox_count.py | 49 ++++ .../organization_inbox_item_events.py | 63 ++++ src/sentry/inbox/filters.py | 80 +++++ src/sentry/inbox/paginator.py | 61 ++++ src/sentry/inbox/response_types.py | 4 +- tests/sentry/inbox/endpoints/__init__.py | 0 .../endpoints/test_organization_inbox.py | 273 ++++++++++++++++++ .../test_organization_inbox_count.py | 78 +++++ .../test_organization_inbox_item_events.py | 74 +++++ tests/sentry/inbox/test_types.py | 4 +- 14 files changed, 804 insertions(+), 4 deletions(-) create mode 100644 src/sentry/inbox/endpoints/__init__.py create mode 100644 src/sentry/inbox/endpoints/base.py create mode 100644 src/sentry/inbox/endpoints/organization_inbox.py create mode 100644 src/sentry/inbox/endpoints/organization_inbox_count.py create mode 100644 src/sentry/inbox/endpoints/organization_inbox_item_events.py create mode 100644 src/sentry/inbox/filters.py create mode 100644 src/sentry/inbox/paginator.py create mode 100644 tests/sentry/inbox/endpoints/__init__.py create mode 100644 tests/sentry/inbox/endpoints/test_organization_inbox.py create mode 100644 tests/sentry/inbox/endpoints/test_organization_inbox_count.py create mode 100644 tests/sentry/inbox/endpoints/test_organization_inbox_item_events.py diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 614205ed82d9..a389170e7e16 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -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 @@ -2379,6 +2386,21 @@ def create_group_urls( OrganizationSentryAppsEndpoint.as_view(), name="sentry-api-0-organization-sentry-apps", ), + re_path( + r"^(?P[^/]+)/inbox/$", + OrganizationInboxIndexEndpoint.as_view(), + name="sentry-api-0-organization-inbox", + ), + re_path( + r"^(?P[^/]+)/inbox/count/$", + OrganizationInboxCountEndpoint.as_view(), + name="sentry-api-0-organization-inbox-count", + ), + re_path( + r"^(?P[^/]+)/inbox/(?P\d+)/events/$", + OrganizationInboxItemEventsEndpoint.as_view(), + name="sentry-api-0-organization-inbox-item-events", + ), re_path( r"^(?P[^/]+)/investigations/candidates/$", OrganizationInvestigationCandidatesEndpoint.as_view(), diff --git a/src/sentry/inbox/endpoints/__init__.py b/src/sentry/inbox/endpoints/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/sentry/inbox/endpoints/base.py b/src/sentry/inbox/endpoints/base.py new file mode 100644 index 000000000000..9914cd97da80 --- /dev/null +++ b/src/sentry/inbox/endpoints/base.py @@ -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 + + 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): + raise UserRequired + return args, kwargs diff --git a/src/sentry/inbox/endpoints/organization_inbox.py b/src/sentry/inbox/endpoints/organization_inbox.py new file mode 100644 index 000000000000..4789253402e6 --- /dev/null +++ b/src/sentry/inbox/endpoints/organization_inbox.py @@ -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()), + ) diff --git a/src/sentry/inbox/endpoints/organization_inbox_count.py b/src/sentry/inbox/endpoints/organization_inbox_count.py new file mode 100644 index 000000000000..27209d95b9fc --- /dev/null +++ b/src/sentry/inbox/endpoints/organization_inbox_count.py @@ -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}) diff --git a/src/sentry/inbox/endpoints/organization_inbox_item_events.py b/src/sentry/inbox/endpoints/organization_inbox_item_events.py new file mode 100644 index 000000000000..c58ddf4522e7 --- /dev/null +++ b/src/sentry/inbox/endpoints/organization_inbox_item_events.py @@ -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()), + ) diff --git a/src/sentry/inbox/filters.py b/src/sentry/inbox/filters.py new file mode 100644 index 000000000000..443cecbde9f7 --- /dev/null +++ b/src/sentry/inbox/filters.py @@ -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.", + ) + subject_id = serializers.CharField( + 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 diff --git a/src/sentry/inbox/paginator.py b/src/sentry/inbox/paginator.py new file mode 100644 index 000000000000..eb0c09cb3552 --- /dev/null +++ b/src/sentry/inbox/paginator.py @@ -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: + 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}" diff --git a/src/sentry/inbox/response_types.py b/src/sentry/inbox/response_types.py index c55e605315cb..600dfe5bff35 100644 --- a/src/sentry/inbox/response_types.py +++ b/src/sentry/inbox/response_types.py @@ -46,7 +46,7 @@ class InboxItemResponse(TypedDict): lastReadAt: datetime | None -class InboxUnreadCountResponse(TypedDict): - """Number of active unread inbox items.""" +class InboxCountResponse(TypedDict): + """Number of active inbox items matching the filters.""" count: int diff --git a/tests/sentry/inbox/endpoints/__init__.py b/tests/sentry/inbox/endpoints/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/sentry/inbox/endpoints/test_organization_inbox.py b/tests/sentry/inbox/endpoints/test_organization_inbox.py new file mode 100644 index 000000000000..66f2b147163b --- /dev/null +++ b/tests/sentry/inbox/endpoints/test_organization_inbox.py @@ -0,0 +1,273 @@ +import pytest + +import sentry.apidocs.extensions # noqa: F401 +from sentry.constants import ObjectStatus +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.inbox.issue import ISSUE_PR_READY_FOR_REVIEW, IssuePRReadyForReviewData +from sentry.inbox.types import SYSTEM_ACTOR +from sentry.silo.base import SiloMode +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.datetime import before_now +from sentry.testutils.helpers.features import with_feature +from sentry.testutils.helpers.link_header import parse_link_header +from sentry.testutils.silo import assume_test_silo_mode +from sentry.utils.security.orgauthtoken_token import generate_token, hash_token +from tests.sentry.apidocs import generate_schema + + +@pytest.mark.parametrize( + "route, endpoint", + [ + ("organizations//inbox/", OrganizationInboxIndexEndpoint), + ( + "organizations//inbox/count/", + OrganizationInboxCountEndpoint, + ), + ( + "organizations//inbox//events/", + OrganizationInboxItemEventsEndpoint, + ), + ], +) +def test_read_api_schema(route: str, endpoint: type) -> None: + schema = generate_schema(route, view=endpoint) + assert schema["paths"] + + +@with_feature("organizations:universal-inbox") +class OrganizationInboxIndexTest(APITestCase): + endpoint = "sentry-api-0-organization-inbox" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + + def test_list_is_personal_and_organization_scoped(self) -> None: + item = self.create_inbox_item(subject_id=str(self.group.id)) + self.create_inbox_item_event(inbox_item=item, data={"summary": "Assigned to you"}) + self.create_inbox_item(user=self.create_user()) + self.create_inbox_item(organization=self.create_organization()) + self.create_inbox_item(subject_id="removed", status=ObjectStatus.PENDING_DELETION) + + response = self.get_success_response(self.organization.slug) + + assert [entry["id"] for entry in response.data] == [str(item.id)] + assert response.data[0]["title"] == self.group.title + assert response.data[0]["subtitle"] == "Assigned to you" + + def test_recording_works_while_ui_is_off(self) -> None: + with self.feature( + { + "organizations:universal-inbox": False, + "organizations:universal-inbox-recording": True, + } + ): + ISSUE_PR_READY_FOR_REVIEW.record( + subject=self.group, + data=IssuePRReadyForReviewData( + summary="Pull request ready for review", + pr_url="https://github.com/example/repo/pull/1", + seer_run_id=123, + ), + recipient_user_ids=[self.user.id], + actor=SYSTEM_ACTOR, + ) + self.get_error_response(self.organization.slug, status_code=404) + + response = self.get_success_response(self.organization.slug) + assert len(response.data) == 1 + + def test_subject_lookup_does_not_return_another_users_item(self) -> None: + self.create_inbox_item(user=self.create_user(), subject_id=str(self.group.id)) + + response = self.get_success_response( + self.organization.slug, subject_type="issue", subject_id=str(self.group.id) + ) + assert response.data == [] + + item = self.create_inbox_item(subject_id=str(self.group.id)) + self.create_inbox_item_event(inbox_item=item, data={"summary": "An assignment"}) + response = self.get_success_response( + self.organization.slug, subject_type="issue", subject_id=str(self.group.id) + ) + assert [entry["id"] for entry in response.data] == [str(item.id)] + + def test_read_and_event_filters(self) -> None: + unread = self.create_inbox_item(subject_id="1") + self.create_inbox_item_event( + inbox_item=unread, + type="issue.pr_ready_for_review", + data={"summary": "Pull request ready for review"}, + ) + self.create_inbox_item_event( + inbox_item=unread, + type="issue.pr_ready_for_review", + data={"summary": "Another pull request ready for review"}, + ) + read = self.create_inbox_item(subject_id="2", last_read_at=before_now(seconds=-10)) + self.create_inbox_item_event(inbox_item=read, data={"summary": "Assigned to you"}) + + response = self.get_success_response( + self.organization.slug, is_read="false", event_type="issue.pr_ready_for_review" + ) + assert [entry["id"] for entry in response.data] == [str(unread.id)] + response = self.get_success_response(self.organization.slug, is_read="true") + assert [entry["id"] for entry in response.data] == [str(read.id)] + response = self.get_success_response(self.organization.slug, id=[str(read.id)]) + assert [entry["id"] for entry in response.data] == [str(read.id)] + response = self.get_success_response( + self.organization.slug, + id=[str(read.id), str(unread.id)], + event_type=["issue.assigned", "issue.pr_ready_for_review"], + subject_type=["issue", "issue"], + ) + assert {entry["id"] for entry in response.data} == {str(read.id), str(unread.id)} + + def test_invalid_filters(self) -> None: + for params in ( + {"subject_type": "unknown"}, + {"event_type": "unknown"}, + {"event_type": ["issue.assigned", "unknown"]}, + {"subject_id": "1"}, + {"subject_type": ["issue", "issue"], "subject_id": "1"}, + {"is_read": "unknown"}, + {"is_read": "1"}, + {"id": "invalid"}, + {"id": ["1", "invalid"]}, + {"cursor": "invalid:0:0"}, + {"cursor": "invalid_1:0:0"}, + {"cursor": "1577836800000000_invalid:0:0"}, + {"cursor": "1577836800000000_1:1:0"}, + {"cursor": "999999999999999999999999_1:0:0"}, + {"per_page": 0}, + {"per_page": 101}, + ): + with self.subTest(params=params): + response = self.get_error_response( + self.organization.slug, status_code=400, **params + ) + assert "detail" in response.data + + def test_member_with_read_scope_and_no_project_membership(self) -> None: + member = self.create_user() + self.create_member(organization=self.organization, user=member, role="member", teams=[]) + item = self.create_inbox_item(user=member, subject_id=str(self.group.id)) + self.create_inbox_item_event( + inbox_item=item, actor=self.user, data={"summary": "Assigned to you"} + ) + token = self.create_user_auth_token(user=member, scope_list=["org:read"]) + with assume_test_silo_mode(SiloMode.CONTROL): + self.client.logout() + + response = self.get_success_response( + self.organization.slug, extra_headers={"HTTP_AUTHORIZATION": f"Bearer {token.token}"} + ) + + assert [entry["id"] for entry in response.data] == [str(item.id)] + assert response.data[0]["title"] == self.group.title + assert response.data[0]["latestEvent"]["actor"]["id"] == str(self.user.id) + + def test_org_token_is_rejected(self) -> None: + token = generate_token(self.organization.slug, "") + self.create_org_auth_token( + organization_id=self.organization.id, + name="Inbox test token", + token_hashed=hash_token(token), + token_last_characters=token[-4:], + scope_list=["org:read"], + ) + with assume_test_silo_mode(SiloMode.CONTROL): + self.client.logout() + + self.get_error_response( + self.organization.slug, + extra_headers={"HTTP_AUTHORIZATION": f"Bearer {token}"}, + status_code=403, + ) + + def test_stable_pagination_with_equal_timestamps(self) -> None: + timestamp = before_now(minutes=1).replace(microsecond=123456) + items = [ + self.create_inbox_item(subject_id=str(index), last_activity_at=timestamp) + for index in range(4) + ] + for item in items: + self.create_inbox_item_event(inbox_item=item, data={"summary": "An assignment"}) + + first = self.get_success_response(self.organization.slug, per_page=2) + assert [entry["id"] for entry in first.data] == [str(items[3].id), str(items[2].id)] + next_url = next( + url for url, attrs in parse_link_header(first["Link"]).items() if attrs["rel"] == "next" + ) + second = self.client.get(next_url) + assert second.status_code == 200, second.data + assert [entry["id"] for entry in second.data] == [str(items[1].id), str(items[0].id)] + previous_url = next( + url + for url, attrs in parse_link_header(second["Link"]).items() + if attrs["rel"] == "previous" + ) + back = self.client.get(previous_url) + assert back.status_code == 200, back.data + assert [entry["id"] for entry in back.data] == [str(items[3].id), str(items[2].id)] + + def test_partial_last_page(self) -> None: + timestamp = before_now(minutes=1) + items = [ + self.create_inbox_item(subject_id=str(index), last_activity_at=timestamp) + for index in range(3) + ] + for item in items: + self.create_inbox_item_event(inbox_item=item, data={"summary": "An assignment"}) + + first = self.get_success_response(self.organization.slug, per_page=2) + first_links = { + attrs["rel"]: (url, attrs) for url, attrs in parse_link_header(first["Link"]).items() + } + assert first_links["previous"][1]["results"] == "false" + assert first_links["next"][1]["results"] == "true" + + last = self.client.get(first_links["next"][0]) + assert last.status_code == 200, last.data + assert [entry["id"] for entry in last.data] == [str(items[0].id)] + last_links = { + attrs["rel"]: (url, attrs) for url, attrs in parse_link_header(last["Link"]).items() + } + assert last_links["previous"][1]["results"] == "true" + assert last_links["next"][1]["results"] == "false" + + back = self.client.get(last_links["previous"][0]) + assert back.status_code == 200, back.data + assert [entry["id"] for entry in back.data] == [str(items[2].id), str(items[1].id)] + + def test_empty_inbox_pagination(self) -> None: + response = self.get_success_response(self.organization.slug) + assert response.data == [] + links = parse_link_header(response["Link"]) + assert [attrs["results"] for attrs in links.values()] == ["false", "false"] + for url in links: + page = self.client.get(url) + assert page.status_code == 200, page.data + assert page.data == [] + + def test_pagination_after_boundary_item_is_deleted(self) -> None: + items = [self.create_inbox_item(subject_id=str(index)) for index in range(3)] + for item in items: + self.create_inbox_item_event(inbox_item=item, data={"summary": "An assignment"}) + + first = self.get_success_response(self.organization.slug, per_page=1) + assert [entry["id"] for entry in first.data] == [str(items[2].id)] + next_url = next( + url for url, attrs in parse_link_header(first["Link"]).items() if attrs["rel"] == "next" + ) + items[2].delete() + + second = self.client.get(next_url) + assert second.status_code == 200, second.data + assert [entry["id"] for entry in second.data] == [str(items[1].id)] diff --git a/tests/sentry/inbox/endpoints/test_organization_inbox_count.py b/tests/sentry/inbox/endpoints/test_organization_inbox_count.py new file mode 100644 index 000000000000..86347c5af706 --- /dev/null +++ b/tests/sentry/inbox/endpoints/test_organization_inbox_count.py @@ -0,0 +1,78 @@ +from sentry.constants import ObjectStatus +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.datetime import before_now +from sentry.testutils.helpers.features import with_feature + + +@with_feature("organizations:universal-inbox") +class OrganizationInboxCountTest(APITestCase): + endpoint = "sentry-api-0-organization-inbox-count" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + + def test_counts_only_the_viewers_active_items(self) -> None: + self.create_inbox_item(subject_id="unread") + self.create_inbox_item(subject_id="newer", last_read_at=before_now(minutes=1)) + read_at = before_now(minutes=1) + self.create_inbox_item(subject_id="read", last_activity_at=read_at, last_read_at=read_at) + self.create_inbox_item(subject_id="removed", status=ObjectStatus.PENDING_DELETION) + self.create_inbox_item(user=self.create_user()) + self.create_inbox_item(organization=self.create_organization()) + + response = self.get_success_response(self.organization.slug) + + assert response.data == {"count": 3} + + response = self.get_success_response(self.organization.slug, is_read="false") + assert response.data == {"count": 2} + + response = self.get_success_response(self.organization.slug, is_read="true") + assert response.data == {"count": 1} + + def test_subject_event_and_id_filters(self) -> None: + first = self.create_inbox_item(subject_id="1") + second = self.create_inbox_item(subject_id="2") + self.create_inbox_item(subject_type="other", subject_id="1") + self.create_inbox_item_event( + inbox_item=first, type="issue.pr_ready_for_review", data={"summary": "PR ready"} + ) + self.create_inbox_item_event( + inbox_item=first, type="issue.pr_ready_for_review", data={"summary": "Another PR ready"} + ) + + response = self.get_success_response(self.organization.slug, subject_type="issue") + assert response.data == {"count": 2} + + response = self.get_success_response( + self.organization.slug, subject_type="issue", subject_id="1" + ) + assert response.data == {"count": 1} + + response = self.get_success_response( + self.organization.slug, event_type="issue.pr_ready_for_review" + ) + assert response.data == {"count": 1} + + response = self.get_success_response( + self.organization.slug, id=[str(first.id), str(second.id)] + ) + assert response.data == {"count": 2} + + def test_invalid_filters(self) -> None: + for params in ( + {"subject_type": "unknown"}, + {"subject_id": "1"}, + {"event_type": "unknown"}, + {"is_read": "invalid"}, + ): + with self.subTest(params=params): + response = self.get_error_response( + self.organization.slug, status_code=400, **params + ) + assert "detail" in response.data + + def test_feature_disabled(self) -> None: + with self.feature({"organizations:universal-inbox": False}): + self.get_error_response(self.organization.slug, status_code=404) diff --git a/tests/sentry/inbox/endpoints/test_organization_inbox_item_events.py b/tests/sentry/inbox/endpoints/test_organization_inbox_item_events.py new file mode 100644 index 000000000000..8920a07dabe5 --- /dev/null +++ b/tests/sentry/inbox/endpoints/test_organization_inbox_item_events.py @@ -0,0 +1,74 @@ +from datetime import timedelta + +from sentry.constants import ObjectStatus +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.datetime import before_now +from sentry.testutils.helpers.features import with_feature +from sentry.testutils.helpers.link_header import parse_link_header + + +@with_feature("organizations:universal-inbox") +class OrganizationInboxItemEventsTest(APITestCase): + endpoint = "sentry-api-0-organization-inbox-item-events" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + + def test_list_events(self) -> None: + item = self.create_inbox_item(last_read_at=before_now(minutes=2)) + older = self.create_inbox_item_event(inbox_item=item, data={"summary": "Older"}) + older.update(date_added=before_now(minutes=5)) + newer = self.create_inbox_item_event(inbox_item=item, data={"summary": "Newer"}) + + response = self.get_success_response(self.organization.slug, item.id) + + assert [event["id"] for event in response.data] == [str(newer.id), str(older.id)] + assert [event["isUnread"] for event in response.data] == [True, False] + + def test_another_users_item(self) -> None: + item = self.create_inbox_item(user=self.create_user()) + self.get_error_response(self.organization.slug, item.id, status_code=404) + + def test_pagination_preserves_timestamp_precision_and_ties(self) -> None: + item = self.create_inbox_item() + timestamp = before_now(minutes=1).replace(microsecond=123456) + newest = self.create_inbox_item_event( + inbox_item=item, + data={"summary": "Newest"}, + ) + newest.update(date_added=timestamp + timedelta(microseconds=1)) + first_tie = self.create_inbox_item_event(inbox_item=item, data={"summary": "First tie"}) + first_tie.update(date_added=timestamp) + second_tie = self.create_inbox_item_event(inbox_item=item, data={"summary": "Second tie"}) + second_tie.update(date_added=timestamp) + oldest = self.create_inbox_item_event( + inbox_item=item, + data={"summary": "Oldest"}, + ) + oldest.update(date_added=timestamp - timedelta(microseconds=1)) + + first = self.get_success_response(self.organization.slug, item.id, per_page=2) + assert [event["id"] for event in first.data] == [str(newest.id), str(second_tie.id)] + next_url = next( + url for url, attrs in parse_link_header(first["Link"]).items() if attrs["rel"] == "next" + ) + second = self.client.get(next_url) + assert second.status_code == 200, second.data + assert [event["id"] for event in second.data] == [str(first_tie.id), str(oldest.id)] + previous_url = next( + url + for url, attrs in parse_link_header(second["Link"]).items() + if attrs["rel"] == "previous" + ) + back = self.client.get(previous_url) + assert back.status_code == 200, back.data + assert [event["id"] for event in back.data] == [str(newest.id), str(second_tie.id)] + + def test_other_organization(self) -> None: + item = self.create_inbox_item(organization=self.create_organization()) + self.get_error_response(self.organization.slug, item.id, status_code=404) + + def test_removed_item(self) -> None: + item = self.create_inbox_item(status=ObjectStatus.PENDING_DELETION) + self.get_error_response(self.organization.slug, item.id, status_code=404) diff --git a/tests/sentry/inbox/test_types.py b/tests/sentry/inbox/test_types.py index 2b1e66854882..5f0d202c4518 100644 --- a/tests/sentry/inbox/test_types.py +++ b/tests/sentry/inbox/test_types.py @@ -13,9 +13,9 @@ from sentry.inbox.issue import ISSUE_USER_MENTIONED, IssueMentionedData from sentry.inbox.registry import inbox_item_event_type_registry, inbox_item_type_registry from sentry.inbox.response_types import ( + InboxCountResponse, InboxItemEventResponse, InboxItemResponse, - InboxUnreadCountResponse, ) from sentry.inbox.types import ( SYSTEM_ACTOR, @@ -73,7 +73,7 @@ class MentionedData(InboxEventData): @pytest.mark.parametrize( - "response_type", [InboxItemResponse, InboxItemEventResponse, InboxUnreadCountResponse] + "response_type", [InboxItemResponse, InboxItemEventResponse, InboxCountResponse] ) def test_response_types_support_openapi(response_type: type) -> None: OAS30Validator.check_schema(resolve_type_hint(response_type)) From 875bad72f6cc04c0a22cda8b82c25127a4d81165 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:54:10 +0000 Subject: [PATCH 2/2] chore(api): Update generated inbox routes Co-authored-by: Codex --- static/app/utils/api/knownSentryApiUrls.generated.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 75d7c2e77947..e416108a45a5 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -177,6 +177,9 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/group-search-views/$viewId/visit/' | '/organizations/$organizationIdOrSlug/group-search-views/starred/' | '/organizations/$organizationIdOrSlug/group-search-views/starred/order/' + | '/organizations/$organizationIdOrSlug/inbox/' + | '/organizations/$organizationIdOrSlug/inbox/$itemId/events/' + | '/organizations/$organizationIdOrSlug/inbox/count/' | '/organizations/$organizationIdOrSlug/incident-groupopenperiod/' | '/organizations/$organizationIdOrSlug/insights/tree/' | '/organizations/$organizationIdOrSlug/integration-requests/'