test_organization_stats_v2.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  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_unknown_category(self):
  125. response = self.do_request(
  126. {
  127. "field": ["sum(quantity)"],
  128. "statsPeriod": "1d",
  129. "interval": "1d",
  130. "category": "scoobydoo",
  131. }
  132. )
  133. assert response.status_code == 400, response.content
  134. assert result_sorted(response.data) == {
  135. "detail": 'Invalid category: "scoobydoo"',
  136. }
  137. def test_unknown_outcome(self):
  138. response = self.do_request(
  139. {
  140. "field": ["sum(quantity)"],
  141. "statsPeriod": "1d",
  142. "interval": "1d",
  143. "category": "error",
  144. "outcome": "scoobydoo",
  145. }
  146. )
  147. assert response.status_code == 400, response.content
  148. assert result_sorted(response.data) == {
  149. "detail": 'Invalid outcome: "scoobydoo"',
  150. }
  151. def test_unknown_groupby(self):
  152. response = self.do_request(
  153. {
  154. "field": ["sum(quantity)"],
  155. "groupBy": ["cattygory"],
  156. "statsPeriod": "1d",
  157. "interval": "1d",
  158. }
  159. )
  160. assert response.status_code == 400, response.content
  161. assert result_sorted(response.data) == {"detail": 'Invalid groupBy: "cattygory"'}
  162. def test_resolution_invalid(self):
  163. self.login_as(user=self.user)
  164. make_request = functools.partial(
  165. self.client.get,
  166. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  167. )
  168. response = make_request(
  169. {
  170. "statsPeriod": "1d",
  171. "interval": "bad_interval",
  172. }
  173. )
  174. assert response.status_code == 400, response.content
  175. @freeze_time("2021-03-14T12:27:28.303Z")
  176. def test_attachment_filter_only(self):
  177. response = self.do_request(
  178. {
  179. "project": [-1],
  180. "statsPeriod": "1d",
  181. "interval": "1d",
  182. "field": ["sum(quantity)"],
  183. "category": ["error", "attachment"],
  184. }
  185. )
  186. assert response.status_code == 400, response.content
  187. assert result_sorted(response.data) == {
  188. "detail": "if filtering by attachment no other category may be present"
  189. }
  190. @freeze_time("2021-03-14T12:27:28.303Z")
  191. def test_timeseries_interval(self):
  192. response = self.do_request(
  193. {
  194. "project": [-1],
  195. "category": ["error"],
  196. "statsPeriod": "1d",
  197. "interval": "1d",
  198. "field": ["sum(quantity)"],
  199. }
  200. )
  201. assert response.status_code == 200, response.content
  202. assert result_sorted(response.data) == {
  203. "intervals": ["2021-03-14T00:00:00Z"],
  204. "groups": [
  205. {"by": {}, "series": {"sum(quantity)": [6]}, "totals": {"sum(quantity)": 6}}
  206. ],
  207. "start": "2021-03-14T00:00:00Z",
  208. "end": "2021-03-14T12:28:00Z",
  209. }
  210. response = self.do_request(
  211. {
  212. "project": [-1],
  213. "statsPeriod": "1d",
  214. "interval": "6h",
  215. "field": ["sum(quantity)"],
  216. "category": ["error"],
  217. }
  218. )
  219. assert response.status_code == 200, response.content
  220. assert result_sorted(response.data) == {
  221. "intervals": [
  222. "2021-03-13T18:00:00Z",
  223. "2021-03-14T00:00:00Z",
  224. "2021-03-14T06:00:00Z",
  225. "2021-03-14T12:00:00Z",
  226. ],
  227. "groups": [
  228. {
  229. "by": {},
  230. "series": {"sum(quantity)": [0, 0, 6, 0]},
  231. "totals": {"sum(quantity)": 6},
  232. }
  233. ],
  234. "start": "2021-03-13T18:00:00Z",
  235. "end": "2021-03-14T12:28:00Z",
  236. }
  237. @freeze_time("2021-03-14T12:27:28.303Z")
  238. def test_user_org_total_all_accessible(self):
  239. response = self.do_request(
  240. {
  241. "project": [-1],
  242. "statsPeriod": "1d",
  243. "interval": "1d",
  244. "field": ["sum(quantity)"],
  245. "category": ["error", "transaction"],
  246. },
  247. user=self.user2,
  248. )
  249. assert response.status_code == 200, response.content
  250. assert result_sorted(response.data) == {
  251. "start": "2021-03-14T00:00:00Z",
  252. "end": "2021-03-14T12:28:00Z",
  253. "intervals": ["2021-03-14T00:00:00Z"],
  254. "groups": [
  255. {"by": {}, "series": {"sum(quantity)": [7]}, "totals": {"sum(quantity)": 7}}
  256. ],
  257. }
  258. @freeze_time("2021-03-14T12:27:28.303Z")
  259. def test_user_no_proj_specific_access(self):
  260. response = self.do_request(
  261. {
  262. "project": self.project.id,
  263. "statsPeriod": "1d",
  264. "interval": "1d",
  265. "field": ["sum(quantity)"],
  266. "category": ["error", "transaction"],
  267. },
  268. user=self.user2,
  269. )
  270. assert response.status_code == 403
  271. response = self.do_request(
  272. {
  273. "project": [-1],
  274. "statsPeriod": "1d",
  275. "interval": "1d",
  276. "field": ["sum(quantity)"],
  277. "category": ["error", "transaction"],
  278. "groupBy": ["project"],
  279. },
  280. user=self.user2,
  281. )
  282. assert response.status_code == 200
  283. assert result_sorted(response.data) == {
  284. "start": "2021-03-14T00:00:00Z",
  285. "end": "2021-03-14T12:28:00Z",
  286. "groups": [],
  287. }
  288. @freeze_time("2021-03-14T12:27:28.303Z")
  289. def test_no_project_access(self):
  290. user = self.create_user(is_superuser=False)
  291. self.create_member(user=user, organization=self.organization, role="member", teams=[])
  292. response = self.do_request(
  293. {
  294. "project": [self.project.id],
  295. "statsPeriod": "1d",
  296. "interval": "1d",
  297. "category": ["error", "transaction"],
  298. "field": ["sum(quantity)"],
  299. },
  300. org=self.organization,
  301. user=user,
  302. )
  303. assert response.status_code == 403, response.content
  304. assert result_sorted(response.data) == {
  305. "detail": "You do not have permission to perform this action."
  306. }
  307. response = self.do_request(
  308. {
  309. "project": [self.project.id],
  310. "groupBy": ["project"],
  311. "statsPeriod": "1d",
  312. "interval": "1d",
  313. "category": ["error", "transaction"],
  314. "field": ["sum(quantity)"],
  315. },
  316. org=self.organization,
  317. user=user,
  318. )
  319. assert response.status_code == 403, response.content
  320. assert result_sorted(response.data) == {
  321. "detail": "You do not have permission to perform this action."
  322. }
  323. @freeze_time("2021-03-14T12:27:28.303Z")
  324. def test_open_membership_semantics(self):
  325. self.org.flags.allow_joinleave = True
  326. self.org.save()
  327. response = self.do_request(
  328. {
  329. "project": [-1],
  330. "statsPeriod": "1d",
  331. "interval": "1d",
  332. "field": ["sum(quantity)"],
  333. "category": ["error", "transaction"],
  334. "groupBy": ["project"],
  335. },
  336. user=self.user2,
  337. )
  338. assert response.status_code == 200
  339. assert result_sorted(response.data) == {
  340. "start": "2021-03-14T00:00:00Z",
  341. "end": "2021-03-14T12:28:00Z",
  342. "groups": [
  343. {
  344. "by": {"project": self.project.id},
  345. "totals": {"sum(quantity)": 6},
  346. },
  347. {
  348. "by": {"project": self.project2.id},
  349. "totals": {"sum(quantity)": 1},
  350. },
  351. ],
  352. }
  353. @freeze_time("2021-03-14T12:27:28.303Z")
  354. def test_org_simple(self):
  355. make_request = functools.partial(
  356. self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
  357. )
  358. response = make_request(
  359. {
  360. "statsPeriod": "2d",
  361. "interval": "1d",
  362. "field": ["sum(quantity)"],
  363. "groupBy": ["category", "outcome", "reason"],
  364. }
  365. )
  366. assert response.status_code == 200, response.content
  367. assert result_sorted(response.data) == {
  368. "start": "2021-03-13T00:00:00Z",
  369. "end": "2021-03-14T12:28:00Z",
  370. "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
  371. "groups": [
  372. {
  373. "by": {
  374. "outcome": "rate_limited",
  375. "reason": "spike_protection",
  376. "category": "attachment",
  377. },
  378. "totals": {"sum(quantity)": 1024},
  379. "series": {"sum(quantity)": [0, 1024]},
  380. },
  381. {
  382. "by": {"outcome": "accepted", "reason": "none", "category": "error"},
  383. "totals": {"sum(quantity)": 6},
  384. "series": {"sum(quantity)": [0, 6]},
  385. },
  386. {
  387. "by": {
  388. "category": "transaction",
  389. "reason": "spike_protection",
  390. "outcome": "rate_limited",
  391. },
  392. "totals": {"sum(quantity)": 1},
  393. "series": {"sum(quantity)": [0, 1]},
  394. },
  395. ],
  396. }
  397. @freeze_time("2021-03-14T12:27:28.303Z")
  398. def test_org_multiple_fields(self):
  399. make_request = functools.partial(
  400. self.client.get, reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug])
  401. )
  402. response = make_request(
  403. {
  404. "statsPeriod": "2d",
  405. "interval": "1d",
  406. "field": ["sum(quantity)", "sum(times_seen)"],
  407. "groupBy": ["category", "outcome", "reason"],
  408. }
  409. )
  410. assert response.status_code == 200, response.content
  411. assert result_sorted(response.data) == {
  412. "start": "2021-03-13T00:00:00Z",
  413. "end": "2021-03-14T12:28:00Z",
  414. "intervals": ["2021-03-13T00:00:00Z", "2021-03-14T00:00:00Z"],
  415. "groups": [
  416. {
  417. "by": {
  418. "outcome": "rate_limited",
  419. "category": "attachment",
  420. "reason": "spike_protection",
  421. },
  422. "totals": {"sum(quantity)": 1024, "sum(times_seen)": 1},
  423. "series": {"sum(quantity)": [0, 1024], "sum(times_seen)": [0, 1]},
  424. },
  425. {
  426. "by": {"outcome": "accepted", "reason": "none", "category": "error"},
  427. "totals": {"sum(quantity)": 6, "sum(times_seen)": 6},
  428. "series": {"sum(quantity)": [0, 6], "sum(times_seen)": [0, 6]},
  429. },
  430. {
  431. "by": {
  432. "category": "transaction",
  433. "reason": "spike_protection",
  434. "outcome": "rate_limited",
  435. },
  436. "totals": {"sum(quantity)": 1, "sum(times_seen)": 1},
  437. "series": {"sum(quantity)": [0, 1], "sum(times_seen)": [0, 1]},
  438. },
  439. ],
  440. }
  441. @freeze_time("2021-03-14T12:27:28.303Z")
  442. def test_org_group_by_project(self):
  443. make_request = functools.partial(
  444. self.client.get,
  445. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  446. )
  447. response = make_request(
  448. {
  449. "statsPeriod": "1d",
  450. "interval": "1d",
  451. "field": ["sum(times_seen)"],
  452. "groupBy": ["project"],
  453. "category": ["error", "transaction"],
  454. }
  455. )
  456. assert response.status_code == 200, response.content
  457. assert result_sorted(response.data) == {
  458. "start": "2021-03-14T00:00:00Z",
  459. "end": "2021-03-14T12:28:00Z",
  460. "groups": [
  461. {
  462. "by": {"project": self.project.id},
  463. "totals": {"sum(times_seen)": 6},
  464. },
  465. {
  466. "by": {"project": self.project2.id},
  467. "totals": {"sum(times_seen)": 1},
  468. },
  469. ],
  470. }
  471. @freeze_time("2021-03-14T12:27:28.303Z")
  472. def test_org_project_totals_per_project(self):
  473. make_request = functools.partial(
  474. self.client.get,
  475. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  476. )
  477. response_per_group = make_request(
  478. {
  479. "statsPeriod": "1d",
  480. "interval": "1h",
  481. "field": ["sum(times_seen)"],
  482. "groupBy": ["project"],
  483. "category": ["error", "transaction"],
  484. }
  485. )
  486. response_total = make_request(
  487. {
  488. "statsPeriod": "1d",
  489. "interval": "1h",
  490. "field": ["sum(times_seen)"],
  491. "category": ["error", "transaction"],
  492. }
  493. )
  494. per_group_total = 0
  495. for total in response_per_group.data["groups"]:
  496. per_group_total += total["totals"]["sum(times_seen)"]
  497. assert response_per_group.status_code == 200, response_per_group.content
  498. assert response_total.status_code == 200, response_total.content
  499. assert response_total.data["groups"][0]["totals"]["sum(times_seen)"] == per_group_total
  500. @freeze_time("2021-03-14T12:27:28.303Z")
  501. def test_project_filter(self):
  502. make_request = functools.partial(
  503. self.client.get,
  504. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  505. )
  506. response = make_request(
  507. {
  508. "project": self.project.id,
  509. "statsPeriod": "1d",
  510. "interval": "1d",
  511. "field": ["sum(quantity)"],
  512. "category": ["error", "transaction"],
  513. }
  514. )
  515. assert response.status_code == 200, response.content
  516. assert result_sorted(response.data) == {
  517. "start": "2021-03-14T00:00:00Z",
  518. "end": "2021-03-14T12:28:00Z",
  519. "intervals": ["2021-03-14T00:00:00Z"],
  520. "groups": [
  521. {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
  522. ],
  523. }
  524. @freeze_time("2021-03-14T12:27:28.303Z")
  525. def test_reason_filter(self):
  526. make_request = functools.partial(
  527. self.client.get,
  528. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  529. )
  530. response = make_request(
  531. {
  532. "statsPeriod": "1d",
  533. "interval": "1d",
  534. "field": ["sum(times_seen)"],
  535. "reason": ["spike_protection"],
  536. "groupBy": ["category"],
  537. }
  538. )
  539. assert response.status_code == 200, response.content
  540. assert result_sorted(response.data) == {
  541. "start": "2021-03-14T00:00:00Z",
  542. "end": "2021-03-14T12:28:00Z",
  543. "intervals": ["2021-03-14T00:00:00Z"],
  544. "groups": [
  545. {
  546. "by": {"category": "attachment"},
  547. "totals": {"sum(times_seen)": 1},
  548. "series": {"sum(times_seen)": [1]},
  549. },
  550. {
  551. "by": {"category": "transaction"},
  552. "totals": {"sum(times_seen)": 1},
  553. "series": {"sum(times_seen)": [1]},
  554. },
  555. ],
  556. }
  557. @freeze_time("2021-03-14T12:27:28.303Z")
  558. def test_outcome_filter(self):
  559. make_request = functools.partial(
  560. self.client.get,
  561. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  562. )
  563. response = make_request(
  564. {
  565. "statsPeriod": "1d",
  566. "interval": "1d",
  567. "field": ["sum(quantity)"],
  568. "outcome": "accepted",
  569. "category": ["error", "transaction"],
  570. }
  571. )
  572. assert response.status_code == 200, response.content
  573. assert result_sorted(response.data) == {
  574. "start": "2021-03-14T00:00:00Z",
  575. "end": "2021-03-14T12:28:00Z",
  576. "intervals": ["2021-03-14T00:00:00Z"],
  577. "groups": [
  578. {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
  579. ],
  580. }
  581. @freeze_time("2021-03-14T12:27:28.303Z")
  582. def test_category_filter(self):
  583. make_request = functools.partial(
  584. self.client.get,
  585. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  586. )
  587. response = make_request(
  588. {
  589. "statsPeriod": "1d",
  590. "interval": "1d",
  591. "field": ["sum(quantity)"],
  592. "category": "error",
  593. }
  594. )
  595. assert response.status_code == 200, response.content
  596. assert result_sorted(response.data) == {
  597. "start": "2021-03-14T00:00:00Z",
  598. "end": "2021-03-14T12:28:00Z",
  599. "intervals": ["2021-03-14T00:00:00Z"],
  600. "groups": [
  601. {"by": {}, "totals": {"sum(quantity)": 6}, "series": {"sum(quantity)": [6]}}
  602. ],
  603. }
  604. @freeze_time("2021-03-14T12:27:28.303Z")
  605. def test_minute_interval(self):
  606. make_request = functools.partial(
  607. self.client.get,
  608. reverse("sentry-api-0-organization-stats-v2", args=[self.org.slug]),
  609. )
  610. response = make_request(
  611. {
  612. "statsPeriod": "1h",
  613. "interval": "15m",
  614. "field": ["sum(quantity)"],
  615. "category": "error",
  616. }
  617. )
  618. assert response.status_code == 200, response.content
  619. assert result_sorted(response.data) == {
  620. "start": "2021-03-14T11:00:00Z",
  621. "end": "2021-03-14T12:28:00Z",
  622. "intervals": [
  623. "2021-03-14T11:00:00Z",
  624. "2021-03-14T11:15:00Z",
  625. "2021-03-14T11:30:00Z",
  626. "2021-03-14T11:45:00Z",
  627. "2021-03-14T12:00:00Z",
  628. "2021-03-14T12:15:00Z",
  629. ],
  630. "groups": [
  631. {
  632. "by": {},
  633. "totals": {"sum(quantity)": 6},
  634. "series": {"sum(quantity)": [0, 6, 0, 0, 0, 0]},
  635. }
  636. ],
  637. }
  638. def result_sorted(result):
  639. """sort the groups of the results array by the `by` object, ensuring a stable order"""
  640. def stable_dict(d):
  641. return tuple(sorted(d.items(), key=lambda t: t[0]))
  642. if "groups" in result:
  643. result["groups"].sort(key=lambda group: stable_dict(group["by"]))
  644. return result
  645. # TEST invalid parameter