361 lines
11 KiB
Python
361 lines
11 KiB
Python
import re
|
|
import shlex
|
|
from datetime import datetime, timedelta
|
|
from enum import StrEnum
|
|
from typing import Any, Literal, Optional
|
|
from uuid import UUID
|
|
|
|
from django.db.models import Count, Sum
|
|
from django.db.models.expressions import RawSQL
|
|
from django.db.models.query import QuerySet
|
|
from django.http import Http404, HttpResponse
|
|
from django.utils import timezone
|
|
from ninja import Field, Query, Schema
|
|
from pydantic.functional_validators import BeforeValidator
|
|
from typing_extensions import Annotated
|
|
|
|
from apps.organizations_ext.models import Organization
|
|
from glitchtip.api.authentication import AuthHttpRequest
|
|
from glitchtip.api.pagination import paginate
|
|
from glitchtip.api.permissions import has_permission
|
|
from glitchtip.utils import async_call_celery_task
|
|
|
|
from ..constants import EventStatus, LogLevel
|
|
from ..models import Issue
|
|
from ..schema import IssueDetailSchema, IssueSchema, IssueTagSchema
|
|
from ..tasks import delete_issue_task
|
|
from . import router
|
|
|
|
|
|
async def get_queryset(
|
|
request: AuthHttpRequest,
|
|
organization_slug: Optional[str] = None,
|
|
project_slug: Optional[str] = None,
|
|
):
|
|
user_id = request.auth.user_id
|
|
qs = Issue.objects.all()
|
|
|
|
if organization_slug:
|
|
organization = await Organization.objects.filter(
|
|
users=user_id, slug=organization_slug
|
|
).afirst()
|
|
qs = qs.filter(project__organization_id=organization.id)
|
|
else:
|
|
qs = qs.filter(project__organization__users=user_id)
|
|
|
|
if project_slug:
|
|
qs = qs.filter(project__slug=project_slug)
|
|
qs = qs.annotate(
|
|
num_comments=Count("comments", distinct=True),
|
|
)
|
|
return qs.select_related("project")
|
|
|
|
|
|
@router.get(
|
|
"/issues/{int:issue_id}/",
|
|
response=IssueDetailSchema,
|
|
by_alias=True,
|
|
)
|
|
@has_permission(["event:read", "event:write", "event:admin"])
|
|
async def get_issue(request: AuthHttpRequest, issue_id: int):
|
|
qs = await get_queryset(request)
|
|
qs = qs.annotate(
|
|
user_report_count=Count("userreport", distinct=True),
|
|
)
|
|
try:
|
|
return await qs.filter(id=issue_id).aget()
|
|
except Issue.DoesNotExist:
|
|
raise Http404()
|
|
|
|
|
|
@router.delete("/issues/{int:issue_id}/", response={204: None})
|
|
@has_permission(["event:write", "event:admin"])
|
|
async def delete_issue(request: AuthHttpRequest, issue_id: int):
|
|
qs = await get_queryset(request)
|
|
result = await qs.filter(id=issue_id).aupdate(is_deleted=True)
|
|
if not result:
|
|
raise Http404()
|
|
await async_call_celery_task(delete_issue_task, [issue_id])
|
|
return 204, None
|
|
|
|
|
|
EventStatusEnum = StrEnum("EventStatusEnum", EventStatus.labels)
|
|
|
|
|
|
class UpdateIssueSchema(Schema):
|
|
status: EventStatusEnum
|
|
|
|
|
|
@router.put(
|
|
"organizations/{slug:organization_slug}/issues/{int:issue_id}/",
|
|
response=IssueDetailSchema,
|
|
)
|
|
@has_permission(["event:write", "event:admin"])
|
|
async def update_organization_issue(
|
|
request: AuthHttpRequest,
|
|
organization_slug: str,
|
|
issue_id: int,
|
|
payload: UpdateIssueSchema,
|
|
):
|
|
qs = await get_queryset(request, organization_slug=organization_slug)
|
|
qs = qs.annotate(
|
|
user_report_count=Count("userreport", distinct=True),
|
|
)
|
|
try:
|
|
obj = await qs.filter(id=issue_id).aget()
|
|
except Issue.DoesNotExist:
|
|
raise Http404()
|
|
obj.status = EventStatus.from_string(payload.status)
|
|
await obj.asave()
|
|
return obj
|
|
|
|
|
|
RELATIVE_TIME_REGEX = re.compile(r"now\s*\-\s*\d+\s*(m|h|d)\s*$")
|
|
|
|
|
|
def relative_to_datetime(v: Any) -> datetime:
|
|
"""
|
|
Allow relative terms like now or now-1h. Only 0 or 1 subtraction operation is permitted.
|
|
|
|
Accepts
|
|
- now
|
|
- - (subtraction)
|
|
- m (minutes)
|
|
- h (hours)
|
|
- d (days)
|
|
"""
|
|
result = timezone.now()
|
|
if v == "now":
|
|
return result
|
|
if RELATIVE_TIME_REGEX.match(v):
|
|
spaces_stripped = v.replace(" ", "")
|
|
numbers = int(re.findall(r"\d+", spaces_stripped)[0])
|
|
if spaces_stripped[-1] == "m":
|
|
result -= timedelta(minutes=numbers)
|
|
if spaces_stripped[-1] == "h":
|
|
result -= timedelta(hours=numbers)
|
|
if spaces_stripped[-1] == "d":
|
|
result -= timedelta(days=numbers)
|
|
return result
|
|
return v
|
|
|
|
|
|
RelativeDateTime = Annotated[datetime, BeforeValidator(relative_to_datetime)]
|
|
|
|
|
|
class IssueFilters(Schema):
|
|
id__in: list[int] = Field(None, alias="id")
|
|
first_seen__gte: RelativeDateTime = Field(None, alias="start")
|
|
first_seen__lte: RelativeDateTime = Field(None, alias="end")
|
|
project__in: list[str] = Field(None, alias="project")
|
|
environment: Optional[list[str]] = None
|
|
query: Optional[str] = None
|
|
|
|
|
|
sort_options = Literal[
|
|
"last_seen",
|
|
"first_seen",
|
|
"count",
|
|
"priority",
|
|
"-last_seen",
|
|
"-first_seen",
|
|
"-count",
|
|
"-priority",
|
|
]
|
|
|
|
|
|
def filter_issue_list(
|
|
qs: QuerySet,
|
|
filters: Query[IssueFilters],
|
|
sort: Optional[sort_options] = None,
|
|
event_id: Optional[UUID] = None,
|
|
):
|
|
qs_filters = filters.dict(exclude_none=True)
|
|
query = qs_filters.pop("query", None)
|
|
if filters.environment:
|
|
qs_filters["issuetag__tag_key__key"] = "environment"
|
|
qs_filters["issuetag__tag_value__value__in"] = qs_filters.pop("environment")
|
|
if qs_filters:
|
|
qs = qs.filter(**qs_filters)
|
|
|
|
if event_id:
|
|
qs = qs.filter(issueevent__id=event_id)
|
|
elif query:
|
|
queries = shlex.split(query)
|
|
# First look for structured queries
|
|
for i, query in enumerate(queries):
|
|
query_part = query.split(":", 1)
|
|
if len(query_part) == 2:
|
|
query_name, query_value = query_part
|
|
query_value = query_value.strip('"')
|
|
|
|
if query_name == "is":
|
|
qs = qs.filter(status=EventStatus.from_string(query_value))
|
|
elif query_name == "has":
|
|
# Does not require distinct as we already have a group by from annotations
|
|
qs = qs.filter(
|
|
issuetag__tag_key__key=query_value,
|
|
)
|
|
elif query_name == "level":
|
|
qs = qs.filter(level=LogLevel.from_string(query_value))
|
|
else:
|
|
qs = qs.filter(
|
|
issuetag__tag_key__key=query_name,
|
|
issuetag__tag_value__value=query_value,
|
|
)
|
|
if len(query_part) == 1:
|
|
search_query = " ".join(queries[i:])
|
|
qs = qs.filter(search_vector=search_query)
|
|
# Search queries must be at end of query string, finished when parsing
|
|
break
|
|
|
|
if sort:
|
|
if sort.endswith("priority"):
|
|
# Raw SQL must be added when sorting by priority
|
|
# Inspired by https://stackoverflow.com/a/43788975/443457
|
|
qs = qs.annotate(
|
|
priority=RawSQL(
|
|
"LOG10(count) + EXTRACT(EPOCH FROM last_seen)/300000", ()
|
|
)
|
|
)
|
|
qs = qs.order_by(sort)
|
|
return qs
|
|
|
|
|
|
@router.get(
|
|
"organizations/{slug:organization_slug}/issues/",
|
|
response=list[IssueSchema],
|
|
by_alias=True,
|
|
)
|
|
@has_permission(["event:read", "event:write", "event:admin"])
|
|
@paginate
|
|
async def list_issues(
|
|
request: AuthHttpRequest,
|
|
response: HttpResponse,
|
|
organization_slug: str,
|
|
filters: Query[IssueFilters],
|
|
sort: sort_options = "-last_seen",
|
|
):
|
|
qs = await get_queryset(request, organization_slug=organization_slug)
|
|
event_id: Optional[UUID] = None
|
|
if filters.query:
|
|
try:
|
|
event_id = UUID(filters.query)
|
|
request.matching_event_id = event_id
|
|
response["X-Sentry-Direct-Hit"] = "1"
|
|
except ValueError:
|
|
pass
|
|
return filter_issue_list(qs, filters, sort, event_id)
|
|
|
|
|
|
@router.delete(
|
|
"organizations/{slug:organization_slug}/issues/", response=UpdateIssueSchema
|
|
)
|
|
@has_permission(["event:write", "event:admin"])
|
|
async def delete_issues(
|
|
request: AuthHttpRequest,
|
|
organization_slug: str,
|
|
filters: Query[IssueFilters],
|
|
):
|
|
qs = await get_queryset(request, organization_slug=organization_slug)
|
|
qs = filter_issue_list(qs, filters)
|
|
await qs.aupdate(is_deleted=True)
|
|
issue_ids = [
|
|
issue_id
|
|
async for issue_id in qs.filter(is_deleted=True).values_list("id", flat=True)
|
|
]
|
|
await async_call_celery_task(delete_issue_task, issue_ids)
|
|
return {"status": "resolved"}
|
|
|
|
|
|
@router.put(
|
|
"organizations/{slug:organization_slug}/issues/", response=UpdateIssueSchema
|
|
)
|
|
@has_permission(["event:write", "event:admin"])
|
|
async def update_issues(
|
|
request: AuthHttpRequest,
|
|
organization_slug: str,
|
|
filters: Query[IssueFilters],
|
|
payload: UpdateIssueSchema,
|
|
):
|
|
qs = await get_queryset(request, organization_slug=organization_slug)
|
|
qs = filter_issue_list(qs, filters)
|
|
await qs.aupdate(status=EventStatus.from_string(payload.status))
|
|
return payload
|
|
|
|
|
|
@router.get(
|
|
"projects/{slug:organization_slug}/{slug:project_slug}/issues/",
|
|
response=list[IssueSchema],
|
|
by_alias=True,
|
|
)
|
|
@has_permission(["event:read", "event:write", "event:admin"])
|
|
@paginate
|
|
async def list_project_issues(
|
|
request: AuthHttpRequest,
|
|
response: HttpResponse,
|
|
organization_slug: str,
|
|
project_slug: str,
|
|
filters: Query[IssueFilters],
|
|
sort: sort_options = "-last_seen",
|
|
):
|
|
qs = await get_queryset(
|
|
request, organization_slug=organization_slug, project_slug=project_slug
|
|
)
|
|
event_id: Optional[UUID] = None
|
|
if filters.query:
|
|
try:
|
|
event_id = UUID(filters.query)
|
|
request.matching_event_id = event_id
|
|
response["X-Sentry-Direct-Hit"] = "1"
|
|
except ValueError:
|
|
pass
|
|
return filter_issue_list(qs, filters, sort, event_id)
|
|
|
|
|
|
@router.get(
|
|
"/issues/{int:issue_id}/tags/", response=list[IssueTagSchema], by_alias=True
|
|
)
|
|
@has_permission(["event:read", "event:write", "event:admin"])
|
|
async def list_issue_tags(
|
|
request: AuthHttpRequest, issue_id: int, key: Optional[str] = None
|
|
):
|
|
qs = await get_queryset(request)
|
|
try:
|
|
issue = await qs.filter(id=issue_id).aget()
|
|
except Issue.DoesNotExist:
|
|
raise Http404()
|
|
|
|
qs = issue.issuetag_set
|
|
if key:
|
|
qs = qs.filter(tag_key__key=key)
|
|
qs = (
|
|
qs.values("tag_key__key", "tag_value__value")
|
|
.annotate(total_count=Sum("count"))
|
|
.order_by("-total_count")[:100000]
|
|
)
|
|
keys = {row["tag_key__key"] async for row in qs}
|
|
return [
|
|
{
|
|
"topValues": [
|
|
{
|
|
"name": group["tag_value__value"],
|
|
"value": group["tag_value__value"],
|
|
"count": group["total_count"],
|
|
"key": group["tag_key__key"],
|
|
}
|
|
for group in qs
|
|
if group["tag_key__key"] == key
|
|
],
|
|
"uniqueValues": len(
|
|
[group for group in qs if group["tag_key__key"] == key]
|
|
),
|
|
"key": key,
|
|
"name": key,
|
|
"totalValues": sum(
|
|
[group["total_count"] for group in qs if group["tag_key__key"] == key]
|
|
),
|
|
}
|
|
for key in keys
|
|
]
|