test_organization_stats_v2.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. import functools
  2. from datetime import datetime, timedelta
  3. import pytz
  4. from django.urls import reverse
  5. from freezegun import freeze_time
  6. from sentry.constants import DataCategory
  7. from sentry.testutils import APITestCase
  8. from sentry.testutils.cases import OutcomesSnubaTest
  9. from sentry.testutils.silo import region_silo_test
  10. from sentry.utils.outcomes import Outcome
  11. @region_silo_test
  12. class OrganizationStatsTestV2(APITestCase, OutcomesSnubaTest):
  13. def setUp(self):
  14. super().setUp()
  15. self.now = datetime(2021, 3, 14, 12, 27, 28, tzinfo=pytz.utc)
  16. self.login_as(user=self.user)
  17. self.org = self.organization
  18. self.org.flags.allow_joinleave = False
  19. self.org.save()
  20. self.org2 = self.create_organization()
  21. self.org3 = self.create_organization()
  22. self.project = self.create_project(
  23. name="bar", teams=[self.create_team(organization=self.org, members=[self.user])]
  24. )
  25. self.project2 = self.create_project(
  26. name="foo", teams=[self.create_team(organization=self.org, members=[self.user])]
  27. )
  28. self.project3 = self.create_project(organization=self.org2)
  29. self.user2 = self.create_user(is_superuser=False)
  30. self.create_member(user=self.user2, organization=self.organization, role="member", teams=[])
  31. self.create_member(user=self.user2, organization=self.org3, role="member", teams=[])
  32. self.project4 = self.create_project(
  33. name="users2sproj",
  34. teams=[self.create_team(organization=self.org, members=[self.user2])],
  35. )
  36. self.store_outcomes(
  37. {
  38. "org_id": self.org.id,
  39. "timestamp": self.now - timedelta(hours=1),
  40. "project_id": self.project.id,
  41. "outcome": Outcome.ACCEPTED,
  42. "reason": "none",
  43. "category": DataCategory.ERROR,
  44. "quantity": 1,
  45. },
  46. 5,
  47. )
  48. self.store_outcomes(
  49. {
  50. "org_id": self.org.id,
  51. "timestamp": self.now - timedelta(hours=1),
  52. "project_id": self.project.id,
  53. "outcome": Outcome.ACCEPTED,
  54. "reason": "none",
  55. "category": DataCategory.DEFAULT, # test that this shows up under error
  56. "quantity": 1,
  57. }
  58. )
  59. self.store_outcomes(
  60. {
  61. "org_id": self.org.id,
  62. "timestamp": self.now - timedelta(hours=1),
  63. "project_id": self.project.id,
  64. "outcome": Outcome.RATE_LIMITED,
  65. "reason": "smart_rate_limit",
  66. "category": DataCategory.ATTACHMENT,
  67. "quantity": 1024,
  68. }
  69. )
  70. self.store_outcomes(
  71. {
  72. "org_id": self.org.id,
  73. "timestamp": self.now - timedelta(hours=1),
  74. "project_id": self.project2.id,
  75. "outcome": Outcome.RATE_LIMITED,
  76. "reason": "smart_rate_limit",
  77. "category": DataCategory.TRANSACTION,
  78. "quantity": 1,
  79. }
  80. )
  81. def do_request(self, query, user=None, org=None):
  82. self.login_as(user=user or self.user)
  83. url = reverse(
  84. "sentry-api-0-organization-stats-v2",
  85. kwargs={"organization_slug": (org or self.organization).slug},
  86. )
  87. return self.client.get(url, query, format="json")
  88. def test_empty_request(self):
  89. response = self.do_request({})
  90. assert response.status_code == 400, response.content
  91. assert result_sorted(response.data) == {"detail": 'At least one "field" is required.'}
  92. def test_inaccessible_project(self):
  93. response = self.do_request({"project": [self.project3.id]})
  94. assert response.status_code == 403, response.content
  95. assert result_sorted(response.data) == {
  96. "detail": "You do not have permission to perform this action."
  97. }
  98. def test_no_projects_available(self):
  99. response = self.do_request(
  100. {
  101. "groupBy": ["project"],
  102. "statsPeriod": "1d",
  103. "interval": "1d",
  104. "field": ["sum(quantity)"],
  105. "category": ["error", "transaction"],
  106. },
  107. user=self.user2,
  108. org=self.org3,
  109. )
  110. assert response.status_code == 400, response.content
  111. assert result_sorted(response.data) == {
  112. "detail": "No projects available",
  113. }
  114. def test_unknown_field(self):
  115. response = self.do_request(
  116. {
  117. "field": ["summ(qarntenty)"],
  118. "statsPeriod": "1d",
  119. "interval": "1d",
  120. }
  121. )
  122. assert response.status_code == 400, response.content
  123. assert result_sorted(response.data) == {
  124. "detail": 'Invalid field: "summ(qarntenty)"',
  125. }
  126. def test_no_end_param(self):
  127. response = self.do_request(
  128. {"field": ["sum(quantity)"], "interval": "1d", "start": "2021-03-14T00:00:00Z"}
  129. )
  130. assert response.status_code == 400, response.content
  131. assert result_sorted(response.data) == {"detail": "start and end are both required"}
  132. def test_unknown_category(self):
  133. response = self.do_request(
  134. {
  135. "field": ["sum(quantity)"],
  136. "statsPeriod": "1d",
  137. "interval": "1d",
  138. "category": "scoobydoo",
  139. }
  140. )
  141. assert response.status_code == 400, response.content
  142. assert result_sorted(response.data) == {
  143. "detail": 'Invalid category: "scoobydoo"',
  144. }
  145. def test_unknown_outcome(self):
  146. response = self.do_request(
  147. {
  148. "field": ["sum(quantity)"],
  149. "statsPeriod": "1d",
  150. "interval": "1d",
  151. "category": "error",
  152. "outcome": "scoobydoo",
  153. }
  154. )
  155. assert response.status_code == 400, response.content
  156. assert result_sorted(response.data) == {
  157. "detail": 'Invalid outcome: "scoobydoo"',
  158. }
  159. def test_unknown_groupby(self):
  160. response = self.do_request(
  161. {
  162. "field": ["sum(quantity)"],
  163. "groupBy": ["category_"],
  164. "statsPeriod": "1d",
  165. "interval": "1d",
  166. }
  167. )
  168. assert response.status_code == 400, response.content
  169. assert result_sorted(response.data) == {"detail": 'Invalid groupBy: "category_"'}
  170. def test_resolution_invalid(self):
  171. self.login_as(user=self.user)
  172. make_request = functools.partial(
  173. self.client.get,
  174. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  175. )
  176. response = make_request(
  177. {
  178. "statsPeriod": "1d",
  179. "interval": "bad_interval",
  180. }
  181. )
  182. assert response.status_code == 400, response.content
  183. @freeze_time("2021-03-14T12:27:28.303Z")
  184. def test_attachment_filter_only(self):
  185. response = self.do_request(
  186. {
  187. "project": [-1],
  188. "statsPeriod": "1d",
  189. "interval": "1d",
  190. "field": ["sum(quantity)"],
  191. "category": ["error", "attachment"],
  192. }
  193. )
  194. assert response.status_code == 400, response.content
  195. assert result_sorted(response.data) == {
  196. "detail": "if filtering by attachment no other category may be present"
  197. }
  198. @freeze_time("2021-03-14T12:27:28.303Z")
  199. def test_timeseries_interval(self):
  200. response = self.do_request(
  201. {
  202. "project": [-1],
  203. "category": ["error"],
  204. "statsPeriod": "1d",
  205. "interval": "1d",
  206. "field": ["sum(quantity)"],
  207. }
  208. )
  209. assert response.status_code == 200, response.content
  210. assert result_sorted(response.data) == {
  211. "intervals": ["2021-03-14T00:00:00Z"],
  212. "groups": [
  213. {"by": {}, "series": {"sum(quantity)": [6]}, "totals": {"sum(quantity)": 6}}
  214. ],
  215. "start": "2021-03-14T00:00:00Z",
  216. "end": "2021-03-14T12:28:00Z",
  217. }
  218. response = self.do_request(
  219. {
  220. "project": [-1],
  221. "statsPeriod": "1d",
  222. "interval": "6h",
  223. "field": ["sum(quantity)"],
  224. "category": ["error"],
  225. }
  226. )
  227. assert response.status_code == 200, response.content
  228. assert result_sorted(response.data) == {
  229. "intervals": [
  230. "2021-03-13T18:00:00Z",
  231. "2021-03-14T00:00:00Z",
  232. "2021-03-14T06:00:00Z",
  233. "2021-03-14T12:00:00Z",
  234. ],
  235. "groups": [
  236. {
  237. "by": {},
  238. "series": {"sum(quantity)": [0, 0, 6, 0]},
  239. "totals": {"sum(quantity)": 6},
  240. }
  241. ],
  242. "start": "2021-03-13T18:00:00Z",
  243. "end": "2021-03-14T12:28:00Z",
  244. }
  245. @freeze_time("2021-03-14T12:27:28.303Z")
  246. def test_user_org_total_all_accessible(self):
  247. response = self.do_request(
  248. {
  249. "project": [-1],
  250. "statsPeriod": "1d",
  251. "interval": "1d",
  252. "field": ["sum(quantity)"],
  253. "category": ["error", "transaction"],
  254. },
  255. user=self.user2,
  256. )
  257. assert response.status_code == 200, response.content
  258. assert result_sorted(response.data) == {
  259. "start": "2021-03-14T00:00:00Z",
  260. "end": "2021-03-14T12:28:00Z",
  261. "intervals": ["2021-03-14T00:00:00Z"],
  262. "groups": [
  263. {"by": {}, "series": {"sum(quantity)": [7]}, "totals": {"sum(quantity)": 7}}
  264. ],
  265. }
  266. @freeze_time("2021-03-14T12:27:28.303Z")
  267. def test_user_no_proj_specific_access(self):
  268. response = self.do_request(
  269. {
  270. "project": self.project.id,
  271. "statsPeriod": "1d",
  272. "interval": "1d",
  273. "field": ["sum(quantity)"],
  274. "category": ["error", "transaction"],
  275. },
  276. user=self.user2,
  277. )
  278. assert response.status_code == 403
  279. response = self.do_request(
  280. {
  281. "project": [-1],
  282. "statsPeriod": "1d",
  283. "interval": "1d",
  284. "field": ["sum(quantity)"],
  285. "category": ["error", "transaction"],
  286. "groupBy": ["project"],
  287. },
  288. user=self.user2,
  289. )
  290. assert response.status_code == 200
  291. assert result_sorted(response.data) == {
  292. "start": "2021-03-14T00:00:00Z",
  293. "end": "2021-03-14T12:28:00Z",
  294. "groups": [],
  295. }
  296. @freeze_time("2021-03-14T12:27:28.303Z")
  297. def test_no_project_access(self):
  298. user = self.create_user(is_superuser=False)
  299. self.create_member(user=user, organization=self.organization, role="member", teams=[])
  300. response = self.do_request(
  301. {
  302. "project": [self.project.id],
  303. "statsPeriod": "1d",
  304. "interval": "1d",
  305. "category": ["error", "transaction"],
  306. "field": ["sum(quantity)"],
  307. },
  308. org=self.organization,
  309. user=user,
  310. )
  311. assert response.status_code == 403, response.content
  312. assert result_sorted(response.data) == {
  313. "detail": "You do not have permission to perform this action."
  314. }
  315. response = self.do_request(
  316. {
  317. "project": [self.project.id],
  318. "groupBy": ["project"],
  319. "statsPeriod": "1d",
  320. "interval": "1d",
  321. "category": ["error", "transaction"],
  322. "field": ["sum(quantity)"],
  323. },
  324. org=self.organization,
  325. user=user,
  326. )
  327. assert response.status_code == 403, response.content
  328. assert result_sorted(response.data) == {
  329. "detail": "You do not have permission to perform this action."
  330. }
  331. @freeze_time("2021-03-14T12:27:28.303Z")
  332. def test_open_membership_semantics(self):
  333. self.org.flags.allow_joinleave = True
  334. self.org.save()
  335. response = self.do_request(
  336. {
  337. "project": [-1],
  338. "statsPeriod": "1d",
  339. "interval": "1d",
  340. "field": ["sum(quantity)"],
  341. "category": ["error", "transaction"],
  342. "groupBy": ["project"],
  343. },
  344. user=self.user2,
  345. )
  346. assert response.status_code == 200
  347. assert result_sorted(response.data) == {
  348. "start": "2021-03-14T00:00:00Z",
  349. "end": "2021-03-14T12:28:00Z",
  350. "groups": [
  351. {
  352. "by": {"project": self.project.id},
  353. "totals": {"sum(quantity)": 6},
  354. },
  355. {
  356. "by": {"project": self.project2.id},
  357. "totals": {"sum(quantity)": 1},
  358. },
  359. ],
  360. }
  361. @freeze_time("2021-03-14T12:27:28.303Z")
  362. def test_org_simple(self):
  363. make_request = functools.partial(
  364. self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
  365. )
  366. response = make_request(
  367. {
  368. "statsPeriod": "2d",
  369. "interval": "1d",
  370. "field": ["sum(quantity)"],
  371. "groupBy": ["category", "outcome", "reason"],
  372. }
  373. )
  374. assert response.status_code == 200, response.content
  375. assert result_sorted(response.data) == {
  376. "start": "2021-03-13T00:00:00Z",
  377. "end": "2021-03-14T12:28:00Z",
  378. "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
  379. "groups": [
  380. {
  381. "by": {
  382. "outcome": "rate_limited",
  383. "reason": "spike_protection",
  384. "category": "attachment",
  385. },
  386. "totals": {"sum(quantity)": 1024},
  387. "series": {"sum(quantity)": [0, 1024]},
  388. },
  389. {
  390. "by": {"outcome": "accepted", "reason": "none", "category": "error"},
  391. "totals": {"sum(quantity)": 6},
  392. "series": {"sum(quantity)": [0, 6]},
  393. },
  394. {
  395. "by": {
  396. "category": "transaction",
  397. "reason": "spike_protection",
  398. "outcome": "rate_limited",
  399. },
  400. "totals": {"sum(quantity)": 1},
  401. "series": {"sum(quantity)": [0, 1]},
  402. },
  403. ],
  404. }
  405. @freeze_time("2021-03-14T12:27:28.303Z")
  406. def test_org_multiple_fields(self):
  407. make_request = functools.partial(
  408. self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
  409. )
  410. response = make_request(
  411. {
  412. "statsPeriod": "2d",
  413. "interval": "1d",
  414. "field": ["sum(quantity)", "sum(times_seen)"],
  415. "groupBy": ["category", "outcome", "reason"],
  416. }
  417. )
  418. assert response.status_code == 200, response.content
  419. assert result_sorted(response.data) == {
  420. "start": "2021-03-13T00:00:00Z",
  421. "end": "2021-03-14T12:28:00Z",
  422. "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
  423. "groups": [
  424. {
  425. "by": {
  426. "outcome": "rate_limited",
  427. "category": "attachment",
  428. "reason": "spike_protection",
  429. },
  430. "totals": {"sum(quantity)": 1024, "sum(times_seen)": 1},
  431. "series": {"sum(quantity)": [0, 1024], "sum(times_seen)": [0, 1]},
  432. },
  433. {
  434. "by": {"outcome": "accepted", "reason": "none", "category": "error"},
  435. "totals": {"sum(quantity)": 6, "sum(times_seen)": 6},
  436. "series": {"sum(quantity)": [0, 6], "sum(times_seen)": [0, 6]},
  437. },
  438. {
  439. "by": {
  440. "category": "transaction",
  441. "reason": "spike_protection",
  442. "outcome": "rate_limited",
  443. },
  444. "totals": {"sum(quantity)": 1, "sum(times_seen)": 1},
  445. "series": {"sum(quantity)": [0, 1], "sum(times_seen)": [0, 1]},
  446. },
  447. ],
  448. }
  449. @freeze_time("2021-03-14T12:27:28.303Z")
  450. def test_org_group_by_project(self):
  451. make_request = functools.partial(
  452. self.client.get,
  453. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  454. )
  455. response = make_request(
  456. {
  457. "statsPeriod": "1d",
  458. "interval": "1d",
  459. "field": ["sum(times_seen)"],
  460. "groupBy": ["project"],
  461. "category": ["error", "transaction"],
  462. }
  463. )
  464. assert response.status_code == 200, response.content
  465. assert result_sorted(response.data) == {
  466. "start": "2021-03-14T00:00:00Z",
  467. "end": "2021-03-14T12:28:00Z",
  468. "groups": [
  469. {
  470. "by": {"project": self.project.id},
  471. "totals": {"sum(times_seen)": 6},
  472. },
  473. {
  474. "by": {"project": self.project2.id},
  475. "totals": {"sum(times_seen)": 1},
  476. },
  477. ],
  478. }
  479. @freeze_time("2021-03-14T12:27:28.303Z")
  480. def test_org_project_totals_per_project(self):
  481. make_request = functools.partial(
  482. self.client.get,
  483. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  484. )
  485. response_per_group = make_request(
  486. {
  487. "statsPeriod": "1d",
  488. "interval": "1h",
  489. "field": ["sum(times_seen)"],
  490. "groupBy": ["project"],
  491. "category": ["error", "transaction"],
  492. }
  493. )
  494. response_total = make_request(
  495. {
  496. "statsPeriod": "1d",
  497. "interval": "1h",
  498. "field": ["sum(times_seen)"],
  499. "category": ["error", "transaction"],
  500. }
  501. )
  502. per_group_total = 0
  503. for total in response_per_group.data["groups"]:
  504. per_group_total += total["totals"]["sum(times_seen)"]
  505. assert response_per_group.status_code == 200, response_per_group.content
  506. assert response_total.status_code == 200, response_total.content
  507. assert response_total.data["groups"][0]["totals"]["sum(times_seen)"] == per_group_total
  508. @freeze_time("2021-03-14T12:27:28.303Z")
  509. def test_project_filter(self):
  510. make_request = functools.partial(
  511. self.client.get,
  512. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  513. )
  514. response = make_request(
  515. {
  516. "project": self.project.id,
  517. "statsPeriod": "1d",
  518. "interval": "1d",
  519. "field": ["sum(quantity)"],
  520. "category": ["error", "transaction"],
  521. }
  522. )
  523. assert response.status_code == 200, response.content
  524. assert result_sorted(response.data) == {
  525. "start": "2021-03-14T00:00:00Z",
  526. "end": "2021-03-14T12:28:00Z",
  527. "intervals": ["2021-03-14T00:00:00Z"],
  528. "groups": [
  529. {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
  530. ],
  531. }
  532. @freeze_time("2021-03-14T12:27:28.303Z")
  533. def test_reason_filter(self):
  534. make_request = functools.partial(
  535. self.client.get,
  536. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  537. )
  538. response = make_request(
  539. {
  540. "statsPeriod": "1d",
  541. "interval": "1d",
  542. "field": ["sum(times_seen)"],
  543. "reason": ["spike_protection"],
  544. "groupBy": ["category"],
  545. }
  546. )
  547. assert response.status_code == 200, response.content
  548. assert result_sorted(response.data) == {
  549. "start": "2021-03-14T00:00:00Z",
  550. "end": "2021-03-14T12:28:00Z",
  551. "intervals": ["2021-03-14T00:00:00Z"],
  552. "groups": [
  553. {
  554. "by": {"category": "attachment"},
  555. "totals": {"sum(times_seen)": 1},
  556. "series": {"sum(times_seen)": [1]},
  557. },
  558. {
  559. "by": {"category": "transaction"},
  560. "totals": {"sum(times_seen)": 1},
  561. "series": {"sum(times_seen)": [1]},
  562. },
  563. ],
  564. }
  565. @freeze_time("2021-03-14T12:27:28.303Z")
  566. def test_outcome_filter(self):
  567. make_request = functools.partial(
  568. self.client.get,
  569. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  570. )
  571. response = make_request(
  572. {
  573. "statsPeriod": "1d",
  574. "interval": "1d",
  575. "field": ["sum(quantity)"],
  576. "outcome": "accepted",
  577. "category": ["error", "transaction"],
  578. }
  579. )
  580. assert response.status_code == 200, response.content
  581. assert result_sorted(response.data) == {
  582. "start": "2021-03-14T00:00:00Z",
  583. "end": "2021-03-14T12:28:00Z",
  584. "intervals": ["2021-03-14T00:00:00Z"],
  585. "groups": [
  586. {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
  587. ],
  588. }
  589. @freeze_time("2021-03-14T12:27:28.303Z")
  590. def test_category_filter(self):
  591. make_request = functools.partial(
  592. self.client.get,
  593. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  594. )
  595. response = make_request(
  596. {
  597. "statsPeriod": "1d",
  598. "interval": "1d",
  599. "field": ["sum(quantity)"],
  600. "category": "error",
  601. }
  602. )
  603. assert response.status_code == 200, response.content
  604. assert result_sorted(response.data) == {
  605. "start": "2021-03-14T00:00:00Z",
  606. "end": "2021-03-14T12:28:00Z",
  607. "intervals": ["2021-03-14T00:00:00Z"],
  608. "groups": [
  609. {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
  610. ],
  611. }
  612. @freeze_time("2021-03-14T12:27:28.303Z")
  613. def test_minute_interval(self):
  614. make_request = functools.partial(
  615. self.client.get,
  616. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  617. )
  618. response = make_request(
  619. {
  620. "statsPeriod": "1h",
  621. "interval": "15m",
  622. "field": ["sum(quantity)"],
  623. "category": "error",
  624. }
  625. )
  626. assert response.status_code == 200, response.content
  627. assert result_sorted(response.data) == {
  628. "start": "2021-03-14T11:00:00Z",
  629. "end": "2021-03-14T12:28:00Z",
  630. "intervals": [
  631. "2021-03-14T11:00:00Z",
  632. "2021-03-14T11:15:00Z",
  633. "2021-03-14T11:30:00Z",
  634. "2021-03-14T11:45:00Z",
  635. "2021-03-14T12:00:00Z",
  636. "2021-03-14T12:15:00Z",
  637. ],
  638. "groups": [
  639. {
  640. "by": {},
  641. "totals": {"sum(quantity)": 6},
  642. "series": {"sum(quantity)": [0, 6, 0, 0, 0, 0]},
  643. }
  644. ],
  645. }
  646. def result_sorted(result):
  647. """sort the groups of the results array by the `by` object, ensuring a stable order"""
  648. def stable_dict(d):
  649. return tuple(sorted(d.items(), key=lambda t: t[0]))
  650. if "groups" in result:
  651. result["groups"].sort(key=lambda group: stable_dict(group["by"]))
  652. return result
  653. # TEST invalid parameter