issues.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import re
  2. import shlex
  3. from datetime import datetime, timedelta
  4. from enum import StrEnum
  5. from typing import Any, Literal, Optional
  6. from uuid import UUID
  7. from django.db.models import Count, Sum
  8. from django.db.models.expressions import RawSQL
  9. from django.db.models.query import QuerySet
  10. from django.http import Http404, HttpResponse
  11. from django.utils import timezone
  12. from ninja import Field, Query, Schema
  13. from ninja.pagination import paginate
  14. from pydantic.functional_validators import BeforeValidator
  15. from typing_extensions import Annotated
  16. from apps.organizations_ext.models import Organization
  17. from glitchtip.api.authentication import AuthHttpRequest
  18. from glitchtip.api.permissions import has_permission
  19. from glitchtip.utils import async_call_celery_task
  20. from ..constants import EventStatus, LogLevel
  21. from ..models import Issue
  22. from ..schema import IssueDetailSchema, IssueSchema, IssueTagSchema
  23. from ..tasks import delete_issue_task
  24. from . import router
  25. async def get_queryset(
  26. request: AuthHttpRequest,
  27. organization_slug: Optional[str] = None,
  28. project_slug: Optional[str] = None,
  29. ):
  30. user_id = request.auth.user_id
  31. qs = Issue.objects.all()
  32. if organization_slug:
  33. organization = await Organization.objects.filter(
  34. users=user_id, slug=organization_slug
  35. ).afirst()
  36. qs = qs.filter(project__organization_id=organization.id)
  37. else:
  38. qs = qs.filter(project__organization__users=user_id)
  39. if project_slug:
  40. qs = qs.filter(project__slug=project_slug)
  41. qs = qs.annotate(
  42. num_comments=Count("comments", distinct=True),
  43. )
  44. return qs.select_related("project")
  45. @router.get(
  46. "/issues/{int:issue_id}/",
  47. response=IssueDetailSchema,
  48. by_alias=True,
  49. )
  50. @has_permission(["event:read", "event:write", "event:admin"])
  51. async def get_issue(request: AuthHttpRequest, issue_id: int):
  52. qs = await get_queryset(request)
  53. qs = qs.annotate(
  54. user_report_count=Count("userreport", distinct=True),
  55. )
  56. try:
  57. return await qs.filter(id=issue_id).aget()
  58. except Issue.DoesNotExist:
  59. raise Http404()
  60. @router.delete("/issues/{int:issue_id}/", response={204: None})
  61. @has_permission(["event:write", "event:admin"])
  62. async def delete_issue(request: AuthHttpRequest, issue_id: int):
  63. qs = await get_queryset(request)
  64. result = await qs.filter(id=issue_id).aupdate(is_deleted=True)
  65. if not result:
  66. raise Http404()
  67. await async_call_celery_task(delete_issue_task, [issue_id])
  68. return 204, None
  69. EventStatusEnum = StrEnum("EventStatusEnum", EventStatus.labels)
  70. class UpdateIssueSchema(Schema):
  71. status: EventStatusEnum
  72. @router.put(
  73. "organizations/{slug:organization_slug}/issues/{int:issue_id}/",
  74. response=IssueDetailSchema,
  75. )
  76. @has_permission(["event:write", "event:admin"])
  77. async def update_organization_issue(
  78. request: AuthHttpRequest,
  79. organization_slug: str,
  80. issue_id: int,
  81. payload: UpdateIssueSchema,
  82. ):
  83. qs = await get_queryset(request, organization_slug=organization_slug)
  84. qs = qs.annotate(
  85. user_report_count=Count("userreport", distinct=True),
  86. )
  87. try:
  88. obj = await qs.filter(id=issue_id).aget()
  89. except Issue.DoesNotExist:
  90. raise Http404()
  91. obj.status = EventStatus.from_string(payload.status)
  92. await obj.asave()
  93. return obj
  94. RELATIVE_TIME_REGEX = re.compile(r"now\s*\-\s*\d+\s*(m|h|d)\s*$")
  95. def relative_to_datetime(v: Any) -> datetime:
  96. """
  97. Allow relative terms like now or now-1h. Only 0 or 1 subtraction operation is permitted.
  98. Accepts
  99. - now
  100. - - (subtraction)
  101. - m (minutes)
  102. - h (hours)
  103. - d (days)
  104. """
  105. result = timezone.now()
  106. if v == "now":
  107. return result
  108. if RELATIVE_TIME_REGEX.match(v):
  109. spaces_stripped = v.replace(" ", "")
  110. numbers = int(re.findall(r"\d+", spaces_stripped)[0])
  111. if spaces_stripped[-1] == "m":
  112. result -= timedelta(minutes=numbers)
  113. if spaces_stripped[-1] == "h":
  114. result -= timedelta(hours=numbers)
  115. if spaces_stripped[-1] == "d":
  116. result -= timedelta(days=numbers)
  117. return result
  118. return v
  119. RelativeDateTime = Annotated[datetime, BeforeValidator(relative_to_datetime)]
  120. class IssueFilters(Schema):
  121. id__in: list[int] = Field(None, alias="id")
  122. first_seen__gte: RelativeDateTime = Field(None, alias="start")
  123. first_seen__lte: RelativeDateTime = Field(None, alias="end")
  124. project__in: list[str] = Field(None, alias="project")
  125. environment: Optional[list[str]] = None
  126. query: Optional[str] = None
  127. sort_options = Literal[
  128. "last_seen",
  129. "first_seen",
  130. "count",
  131. "priority",
  132. "-last_seen",
  133. "-first_seen",
  134. "-count",
  135. "-priority",
  136. ]
  137. def filter_issue_list(
  138. qs: QuerySet,
  139. filters: Query[IssueFilters],
  140. sort: Optional[sort_options] = None,
  141. event_id: Optional[UUID] = None,
  142. ):
  143. qs_filters = filters.dict(exclude_none=True)
  144. query = qs_filters.pop("query", None)
  145. if filters.environment:
  146. qs_filters["issuetag__tag_key__key"] = "environment"
  147. qs_filters["issuetag__tag_value__value__in"] = qs_filters.pop("environment")
  148. if qs_filters:
  149. qs = qs.filter(**qs_filters)
  150. if event_id:
  151. qs = qs.filter(issueevent__id=event_id)
  152. elif query:
  153. queries = shlex.split(query)
  154. # First look for structured queries
  155. for i, query in enumerate(queries):
  156. query_part = query.split(":", 1)
  157. if len(query_part) == 2:
  158. query_name, query_value = query_part
  159. query_value = query_value.strip('"')
  160. if query_name == "is":
  161. qs = qs.filter(status=EventStatus.from_string(query_value))
  162. elif query_name == "has":
  163. # Does not require distinct as we already have a group by from annotations
  164. qs = qs.filter(
  165. issuetag__tag_key__key=query_value,
  166. )
  167. elif query_name == "level":
  168. qs = qs.filter(level=LogLevel.from_string(query_value))
  169. else:
  170. qs = qs.filter(
  171. issuetag__tag_key__key=query_name,
  172. issuetag__tag_value__value=query_value,
  173. )
  174. if len(query_part) == 1:
  175. search_query = " ".join(queries[i:])
  176. qs = qs.filter(search_vector=search_query)
  177. # Search queries must be at end of query string, finished when parsing
  178. break
  179. if sort:
  180. if sort.endswith("priority"):
  181. # Raw SQL must be added when sorting by priority
  182. # Inspired by https://stackoverflow.com/a/43788975/443457
  183. qs = qs.annotate(
  184. priority=RawSQL(
  185. "LOG10(count) + EXTRACT(EPOCH FROM last_seen)/300000", ()
  186. )
  187. )
  188. qs = qs.order_by(sort)
  189. return qs
  190. @router.get(
  191. "organizations/{slug:organization_slug}/issues/",
  192. response=list[IssueSchema],
  193. by_alias=True,
  194. )
  195. @has_permission(["event:read", "event:write", "event:admin"])
  196. @paginate
  197. async def list_issues(
  198. request: AuthHttpRequest,
  199. response: HttpResponse,
  200. organization_slug: str,
  201. filters: Query[IssueFilters],
  202. sort: sort_options = "-last_seen",
  203. ):
  204. qs = await get_queryset(request, organization_slug=organization_slug)
  205. event_id: Optional[UUID] = None
  206. if filters.query:
  207. try:
  208. event_id = UUID(filters.query)
  209. request.matching_event_id = event_id
  210. response["X-Sentry-Direct-Hit"] = "1"
  211. except ValueError:
  212. pass
  213. return filter_issue_list(qs, filters, sort, event_id)
  214. @router.delete(
  215. "organizations/{slug:organization_slug}/issues/", response=UpdateIssueSchema
  216. )
  217. @has_permission(["event:write", "event:admin"])
  218. async def delete_issues(
  219. request: AuthHttpRequest,
  220. organization_slug: str,
  221. filters: Query[IssueFilters],
  222. ):
  223. qs = await get_queryset(request, organization_slug=organization_slug)
  224. qs = filter_issue_list(qs, filters)
  225. await qs.aupdate(is_deleted=True)
  226. issue_ids = [
  227. issue_id
  228. async for issue_id in qs.filter(is_deleted=True).values_list("id", flat=True)
  229. ]
  230. await async_call_celery_task(delete_issue_task, issue_ids)
  231. return {"status": "resolved"}
  232. @router.put(
  233. "organizations/{slug:organization_slug}/issues/", response=UpdateIssueSchema
  234. )
  235. @has_permission(["event:write", "event:admin"])
  236. async def update_issues(
  237. request: AuthHttpRequest,
  238. organization_slug: str,
  239. filters: Query[IssueFilters],
  240. payload: UpdateIssueSchema,
  241. ):
  242. qs = await get_queryset(request, organization_slug=organization_slug)
  243. qs = filter_issue_list(qs, filters)
  244. await qs.aupdate(status=EventStatus.from_string(payload.status))
  245. return payload
  246. @router.get(
  247. "projects/{slug:organization_slug}/{slug:project_slug}/issues/",
  248. response=list[IssueSchema],
  249. by_alias=True,
  250. )
  251. @has_permission(["event:read", "event:write", "event:admin"])
  252. @paginate
  253. async def list_project_issues(
  254. request: AuthHttpRequest,
  255. response: HttpResponse,
  256. organization_slug: str,
  257. project_slug: str,
  258. filters: Query[IssueFilters],
  259. sort: sort_options = "-last_seen",
  260. ):
  261. qs = await get_queryset(
  262. request, organization_slug=organization_slug, project_slug=project_slug
  263. )
  264. event_id: Optional[UUID] = None
  265. if filters.query:
  266. try:
  267. event_id = UUID(filters.query)
  268. request.matching_event_id = event_id
  269. response["X-Sentry-Direct-Hit"] = "1"
  270. except ValueError:
  271. pass
  272. return filter_issue_list(qs, filters, sort, event_id)
  273. @router.get(
  274. "/issues/{int:issue_id}/tags/", response=list[IssueTagSchema], by_alias=True
  275. )
  276. @has_permission(["event:read", "event:write", "event:admin"])
  277. async def list_issue_tags(
  278. request: AuthHttpRequest, issue_id: int, key: Optional[str] = None
  279. ):
  280. qs = await get_queryset(request)
  281. try:
  282. issue = await qs.filter(id=issue_id).aget()
  283. except Issue.DoesNotExist:
  284. raise Http404()
  285. qs = issue.issuetag_set
  286. if key:
  287. qs = qs.filter(tag_key__key=key)
  288. qs = (
  289. qs.values("tag_key__key", "tag_value__value")
  290. .annotate(total_count=Sum("count"))
  291. .order_by("-total_count")[:100000]
  292. )
  293. keys = {row["tag_key__key"] async for row in qs}
  294. return [
  295. {
  296. "topValues": [
  297. {
  298. "name": group["tag_value__value"],
  299. "value": group["tag_value__value"],
  300. "count": group["total_count"],
  301. "key": group["tag_key__key"],
  302. }
  303. for group in qs
  304. if group["tag_key__key"] == key
  305. ],
  306. "uniqueValues": len(
  307. [group for group in qs if group["tag_key__key"] == key]
  308. ),
  309. "key": key,
  310. "name": key,
  311. "totalValues": sum(
  312. [group["total_count"] for group in qs if group["tag_key__key"] == key]
  313. ),
  314. }
  315. for key in keys
  316. ]