Repository navigation
feat(inbox): Add personal inbox read APIs #129437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| 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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this necessary with |
||
| raise UserRequired | ||
| return args, kwargs | ||
| 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()), | ||
| ) |
| 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}) |
| 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()), | ||
| ) |
| 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.", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| 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: | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}" | ||
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?