issues.py 11 KB

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