test_organization_stats_v2.py 24 KB

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