from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.pagination import CursorPagination from rest_framework.exceptions import ValidationError from rest_framework.generics import get_object_or_404 from rest_framework.response import Response from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes from bugsink.api_mixins import AtomicRequestMixin from bugsink.api_capabilities import lookup, token_guard from bugsink.api_pagination import AscDescCursorPagination from bugsink.api_authorization import enforce_project_boundness, enforce_user_boundness_for_project from projects.models import Project, get_issue_accessible_project_ids from tags.search import search_issues from events.api_counts import EventCountRangeSerializer, EventCountsSerializer, event_counts_response from events.models import IssueEventCountsPerHour from .models import Issue, IssueStateManager, TurningPoint, apply_issue_action from .serializers import ( GroupingSerializer, IssueCommentSerializer, IssueMuteForSerializer, IssueMuteUntilSerializer, IssueSerializer, IssueTagSerializer, TurningPointSerializer, ) ISSUE_ID_PARAMETER = OpenApiParameter( name="id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH, required=True, description="Issue UUID or friendly ID (e.g. PROJECT-123).", ) class IssueRelatedPagination(CursorPagination): ordering = "id" page_size = 250 class IssuesCursorPagination(CursorPagination): """ Cursor paginator for /issues supporting ?sort=… and ?order=asc|desc. Sort modes are named after the *primary* column: - sort=digest_order → unique per project → no tie-breakers needed - sort=last_seen → timestamp → tie-breaker on id - sort=digested_event_count → lifetime count → tie-breakers on last_seen and id Direction applies to primary *and beyond* (i.e. all fields in the list). digest_order requires a single project; global lists default to last_seen. """ # With a single project, digest_order uses the (project_id, digest_order) index and is cursor-stable. # We also offer a "recent" mode: sort=last_seen. This is not stable, as new events can come in mid-cursor, and # reshuffle things causing misses or duplicates. However, this is the desired UX for a "recent activity" view. # i.e. the typical usage would in fact just be to get the "first page" of recent activity. # Event-count sorting has the same instability when new events arrive. page_size = 250 default_direction = "asc" default_sort = "digest_order" VALID_SORTS = ("digest_order", "last_seen", "digested_event_count") VALID_ORDERS = ("asc", "desc") def get_ordering(self, request, queryset, view): single_project = view.project is not None sort = request.query_params.get("sort", self.default_sort if single_project else "last_seen") if sort not in self.VALID_SORTS: raise ValidationError({ "sort": ["Must be 'digest_order', 'last_seen', or 'digested_event_count'."], }) order = request.query_params.get("order", self.default_direction if single_project else "desc") if order not in self.VALID_ORDERS: raise ValidationError({"order": ["Must be 'asc' or 'desc'."]}) desc = (order == "desc") if sort == "digest_order": if not single_project: raise ValidationError({"sort": ["digest_order requires a single project."]}) # Unique per project; stable cursor once filtered by project. return ["-digest_order" if desc else "digest_order"] fields = ["last_seen", "id"] if sort == "last_seen" else ["digested_event_count", "last_seen", "id"] return [f"-{field}" for field in fields] if desc else fields class IssueViewSet(AtomicRequestMixin, viewsets.ReadOnlyModelViewSet): queryset = Issue.objects.filter(is_deleted=False).select_related("project") # hide soft-deleted; router basename serializer_class = IssueSerializer pagination_class = IssuesCursorPagination http_method_names = ["get", "post", "delete", "head", "options"] def get_queryset(self): return self.queryset @token_guard("issues:read", scopable_by_bound_project=True) @extend_schema( summary="List issues", description=( "List issues across accessible projects, optionally limited to one project. " "Project-bound tokens stay within their project; personal tokens require accepted project membership " "(superusers can access all projects). Deleted issues and projects are excluded." ), parameters=[ OpenApiParameter( name="project", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Filter issues by project ID. Omit to list all projects accessible to the token.", ), OpenApiParameter( name="q", type=OpenApiTypes.STR, description=( "Search titles as plain text and filter tags with `key:value`; quote values containing spaces. " "Results must match the text and every tag filter." ), ), OpenApiParameter( name="state", type=OpenApiTypes.STR, enum=["all", "open", "unresolved", "resolved", "muted"], description="Filter by issue state (default: all). Open means neither resolved nor muted.", ), OpenApiParameter( name="sort", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, enum=["digest_order", "last_seen", "digested_event_count"], description="Default: digest_order for a single project, last_seen across projects. " "digest_order requires a single project.", ), OpenApiParameter( name="order", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, enum=["asc", "desc"], description="Default: asc for a single project, desc across projects.", ), ] ) def list(self, request, bound_project, *args, **kwargs): self.project = bound_project if "project" in request.query_params: self.project = get_object_or_404( Project.objects.filter(is_deleted=False), pk=request.query_params["project"]) if self.project is not None: self.check_object_permissions(request, self.project) enforce_project_boundness(request.auth, self.project) enforce_user_boundness_for_project(request.auth, "issues:read", self.project) return super().list(request, *args, **kwargs) @token_guard("issues:read", guarding_issue=lookup(url="pk")) @extend_schema( summary="Retrieve an issue", description="Retrieve an issue by issue UUID or friendly ID.", parameters=[ISSUE_ID_PARAMETER], responses=IssueSerializer, ) def retrieve(self, request, issue, *args, **kwargs): return Response(self.get_serializer(issue).data) @token_guard("events:read", guarding_issue=lookup(url="pk")) @extend_schema( summary="List issue tag values and counts", description=( "List stored tag values and their occurrence counts, ordered by ID. " "Counts are not reduced when event payloads are evicted. " "Includes values the UI groups under Other or hides as mostly unique." ), parameters=[ ISSUE_ID_PARAMETER, OpenApiParameter(name="key", type=OpenApiTypes.STR, description="Filter by exact tag key."), ], responses=IssueTagSerializer(many=True), ) @action(detail=True, methods=["get"], serializer_class=IssueTagSerializer, pagination_class=IssueRelatedPagination) def tags(self, request, issue, pk=None): queryset = issue.tags.select_related("key", "value") if "key" in request.query_params: queryset = queryset.filter(key__key=request.query_params["key"]) page = self.paginate_queryset(queryset) return self.get_paginated_response(self.get_serializer(page, many=True).data) @token_guard("events:read", guarding_issue=lookup(url="pk")) @extend_schema( summary="List issue grouping keys", description="List the grouping keys and mechanisms associated with this issue, ordered by ID.", parameters=[ISSUE_ID_PARAMETER], responses=GroupingSerializer(many=True), ) @action(detail=True, methods=["get"], serializer_class=GroupingSerializer, pagination_class=IssueRelatedPagination) def grouping(self, request, issue, pk=None): page = self.paginate_queryset(issue.grouping_set.all()) return self.get_paginated_response(self.get_serializer(page, many=True).data) @token_guard("issues:read", guarding_issue=lookup(url="pk")) @extend_schema( summary="Retrieve issue event counts", description=( "Hourly counts by digest time, including evicted events. Empty hours have count zero. " "Defaults to 24 hours, including the incomplete current hour. " "Ranges must use UTC hour boundaries and fall within the last 90 days." ), parameters=[ISSUE_ID_PARAMETER, EventCountRangeSerializer], responses=EventCountsSerializer, ) @action(detail=True, methods=["get"], url_path="event-counts", pagination_class=None) def event_counts(self, request, issue, pk=None): return event_counts_response(IssueEventCountsPerHour.objects.filter(issue=issue), request.query_params) @token_guard("issues:delete", guarding_issue=lookup(url="pk")) @extend_schema( summary="Delete an issue", description="Delete an issue.", parameters=[ISSUE_ID_PARAMETER], ) def destroy(self, request, issue, *args, **kwargs): issue.delete_deferred() return Response(status=status.HTTP_204_NO_CONTENT) def filter_queryset(self, queryset): queryset = super().filter_queryset(queryset) if self.action != "list": return queryset state = self.request.query_params.get("state", "all") state_filters = { "all": {}, "open": {"is_resolved": False, "is_muted": False}, "unresolved": {"is_resolved": False}, "resolved": {"is_resolved": True, "is_muted": False}, "muted": {"is_resolved": False, "is_muted": True}, } if state not in state_filters: raise ValidationError({"state": ["Must be 'all', 'open', 'unresolved', 'resolved', or 'muted'."]}) queryset = queryset.filter(project__is_deleted=False, **state_filters[state]) if self.project is not None: queryset = queryset.filter(project=self.project) elif self.request.auth.is_user_bound: queryset = queryset.filter(project_id__in=get_issue_accessible_project_ids(self.request.auth.user)) return search_issues(self.project, queryset, self.request.query_params.get("q", "").strip()) def _action_response(self, issue): issue.save() return Response(self.get_serializer(issue).data) def _assert_unresolved(self, issue): if issue.is_resolved: raise ValidationError({"detail": "Issue is already resolved."}) def _assert_resolved(self, issue): if not issue.is_resolved: raise ValidationError({"detail": "Issue is not resolved."}) def _assert_unmuted(self, issue): if issue.is_muted: raise ValidationError({"detail": "Issue is already muted."}) def _apply_issue_action(self, issue, action): apply_issue_action(IssueStateManager, issue, action, user=None) return self._action_response(issue) @token_guard("issues:triage", guarding_issue=lookup(url="pk")) @extend_schema( summary="Resolve an issue", description="Mark this issue as resolved.", parameters=[ISSUE_ID_PARAMETER], request=OpenApiTypes.NONE, responses=IssueSerializer, ) @action(detail=True, methods=["post"]) def resolve(self, request, issue, pk=None): self._assert_unresolved(issue) return self._apply_issue_action(issue, "resolve") @token_guard("issues:triage", guarding_issue=lookup(url="pk")) @extend_schema( summary="Resolve an issue in the next release", description="Mark this issue as resolved by the next release.", parameters=[ISSUE_ID_PARAMETER], request=OpenApiTypes.NONE, responses=IssueSerializer, ) @action(detail=True, methods=["post"], url_path="resolve-next") def resolve_next(self, request, issue, pk=None): self._assert_unresolved(issue) return self._apply_issue_action(issue, "resolved_next") @token_guard("issues:triage", guarding_issue=lookup(url="pk")) @extend_schema( summary="Resolve an issue in the latest release", description="Mark this issue as resolved in the latest release.", parameters=[ISSUE_ID_PARAMETER], request=OpenApiTypes.NONE, responses=IssueSerializer, ) @action(detail=True, methods=["post"], url_path="resolve-latest") def resolve_latest(self, request, issue, pk=None): self._assert_unresolved(issue) if not issue.project.has_releases: raise ValidationError({"detail": "Project has no releases."}) latest_release = issue.project.get_latest_release() return self._apply_issue_action(issue, "resolved_release:" + latest_release.version) @token_guard("issues:triage", guarding_issue=lookup(url="pk")) @extend_schema( summary="Reopen an issue", description="Mark this resolved issue as unresolved again.", parameters=[ISSUE_ID_PARAMETER], request=OpenApiTypes.NONE, responses=IssueSerializer, ) @action(detail=True, methods=["post"]) def reopen(self, request, issue, pk=None): self._assert_resolved(issue) return self._apply_issue_action(issue, "reopen") @token_guard("issues:triage", guarding_issue=lookup(url="pk")) @extend_schema( summary="Mute an issue", description="Mute this issue.", parameters=[ISSUE_ID_PARAMETER], request=OpenApiTypes.NONE, responses=IssueSerializer, ) @action(detail=True, methods=["post"]) def mute(self, request, issue, pk=None): self._assert_unresolved(issue) self._assert_unmuted(issue) return self._apply_issue_action(issue, "mute") @token_guard("issues:triage", guarding_issue=lookup(url="pk")) @extend_schema( summary="Mute an issue for a period", description="Mute this issue for a relative period, e.g. for 3 days.", parameters=[ISSUE_ID_PARAMETER], request=IssueMuteForSerializer, responses=IssueSerializer, ) @action(detail=True, methods=["post"], url_path="mute-for") def mute_for(self, request, issue, pk=None): serializer = IssueMuteForSerializer(data=request.data) serializer.is_valid(raise_exception=True) period_name = serializer.validated_data["period_name"] nr_of_periods = serializer.validated_data["nr_of_periods"] self._assert_unresolved(issue) self._assert_unmuted(issue) return self._apply_issue_action(issue, f"mute_for:{period_name},{nr_of_periods},") @token_guard("issues:triage", guarding_issue=lookup(url="pk")) @extend_schema( summary="Mute an issue until a threshold is reached", description="Mute this issue until a threshold is reached, e.g. more than 10 events in 1 hour.", parameters=[ISSUE_ID_PARAMETER], request=IssueMuteUntilSerializer, responses=IssueSerializer, ) @action(detail=True, methods=["post"], url_path="mute-until") def mute_until(self, request, issue, pk=None): serializer = IssueMuteUntilSerializer(data=request.data) serializer.is_valid(raise_exception=True) period_name = serializer.validated_data["period_name"] nr_of_periods = serializer.validated_data["nr_of_periods"] gte_threshold = serializer.validated_data["gte_threshold"] self._assert_unresolved(issue) self._assert_unmuted(issue) return self._apply_issue_action(issue, f"mute_until:{period_name},{nr_of_periods},{gte_threshold}") @token_guard("issues:triage", guarding_issue=lookup(url="pk")) @extend_schema( summary="Unmute an issue", description="Unmute this issue.", parameters=[ISSUE_ID_PARAMETER], request=OpenApiTypes.NONE, responses=IssueSerializer, ) @action(detail=True, methods=["post"]) def unmute(self, request, issue, pk=None): self._assert_unresolved(issue) if not issue.is_muted: raise ValidationError({"detail": "Issue is not muted."}) return self._apply_issue_action(issue, "unmute") class IssueCommentViewSet(AtomicRequestMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): queryset = TurningPoint.objects.none() # router basename only serializer_class = IssueCommentSerializer http_method_names = ["post", "head", "options"] @token_guard("issues:comment", guarding_issue=lookup(serializer="issue")) @extend_schema( summary="Create an issue comment", description="Add a comment to an issue. `issue` accepts an issue UUID or friendly ID.", request=IssueCommentSerializer, responses=IssueCommentSerializer, ) def create(self, request, serializer, issue, *args, **kwargs): self.perform_create(serializer) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) class IssueHistoryPagination(AscDescCursorPagination): # Timestamps are not unique (one digest can produce several turning points), so id breaks ties. base_ordering = ("timestamp", "id") page_size = 250 default_direction = "desc" # newest first, aligned with the UI's history tab class IssueHistoryViewSet(AtomicRequestMixin, mixins.ListModelMixin, viewsets.GenericViewSet): queryset = TurningPoint.objects.select_related("user") serializer_class = TurningPointSerializer pagination_class = IssueHistoryPagination def filter_queryset(self, queryset): return queryset.filter(issue=self.issue) @token_guard("issues:read", guarding_issue=lookup(query="issue")) @extend_schema( summary="List issue history", description="List the turning points (state changes and comments) of an issue.", parameters=[ OpenApiParameter( name="issue", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True, description="Filter history by issue UUID or friendly ID (required).", ), OpenApiParameter( name="order", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, enum=["asc", "desc"], description="Sort order of timestamp (default: desc).", ), ] ) def list(self, request, issue, *args, **kwargs): self.issue = issue return super().list(request, *args, **kwargs)