savant-code 0.0.19 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1591) hide show
  1. package/README.md +381 -550
  2. package/{cli/release/index.js → index.js} +0 -0
  3. package/package.json +44 -71
  4. package/.agents/skills/coding-csharp/SKILL.md +0 -82
  5. package/.agents/skills/coding-go/SKILL.md +0 -76
  6. package/.agents/skills/coding-java/SKILL.md +0 -79
  7. package/.agents/skills/coding-python/SKILL.md +0 -87
  8. package/.agents/skills/coding-rust/SKILL.md +0 -83
  9. package/.agents/skills/coding-typescript/SKILL.md +0 -90
  10. package/.agents/skills/release-workflow/SKILL.md +0 -69
  11. package/.bun-version +0 -1
  12. package/.commandcode/settings.json +0 -11
  13. package/.commandcode/taste/workflow/taste.md +0 -6
  14. package/.env.example +0 -95
  15. package/.gitattributes +0 -43
  16. package/.githooks/pre-push +0 -35
  17. package/.github/workflows/build-release-binaries.yml +0 -163
  18. package/.markdownlint.json +0 -42
  19. package/.markdownlintignore +0 -21
  20. package/.prettierignore +0 -9
  21. package/.prettierrc +0 -23
  22. package/.savantignore +0 -3
  23. package/AGENTS.md +0 -97
  24. package/ARCHITECTURE.md +0 -241
  25. package/CHANGELOG.md +0 -2631
  26. package/CONTRIBUTING.md +0 -88
  27. package/ECHO-freebuff.md +0 -22
  28. package/ECHO.md +0 -722
  29. package/FREEREADME.md +0 -35
  30. package/LEARNINGS.md +0 -345
  31. package/NOTICE +0 -15
  32. package/README.zh-CN.md +0 -479
  33. package/SECURITY.md +0 -8
  34. package/VERSION +0 -1
  35. package/WINDOWS.md +0 -238
  36. package/agents/README.md +0 -41
  37. package/agents/base-chat.ts +0 -44
  38. package/agents/basher.ts +0 -107
  39. package/agents/browser-use/browser-use.test.ts +0 -211
  40. package/agents/browser-use/browser-use.ts +0 -259
  41. package/agents/constants.ts +0 -1
  42. package/agents/context-pruner.ts +0 -1076
  43. package/agents/detective/detective.ts +0 -168
  44. package/agents/editor/best-of-n/best-of-n-selector2.ts +0 -148
  45. package/agents/editor/best-of-n/editor-implementor-gpt-5.ts +0 -7
  46. package/agents/editor/best-of-n/editor-implementor-opus.ts +0 -7
  47. package/agents/editor/best-of-n/editor-implementor.ts +0 -196
  48. package/agents/editor/best-of-n/editor-multi-prompt.ts +0 -265
  49. package/agents/file-explorer/directory-lister.ts +0 -92
  50. package/agents/file-explorer/glob-matcher.ts +0 -96
  51. package/agents/forge/forge.ts +0 -146
  52. package/agents/librarian/librarian.test.ts +0 -289
  53. package/agents/librarian/librarian.ts +0 -158
  54. package/agents/package.json +0 -11
  55. package/agents/recorder/recorder.ts +0 -104
  56. package/agents/researcher/researcher-docs.ts +0 -32
  57. package/agents/researcher/researcher-web.ts +0 -40
  58. package/agents/savant/savant-analyze.ts +0 -11
  59. package/agents/savant/savant-free-deepseek-flash.ts +0 -13
  60. package/agents/savant/savant-free-deepseek.ts +0 -13
  61. package/agents/savant/savant-free-evals.ts +0 -9
  62. package/agents/savant/savant-free-glm.ts +0 -13
  63. package/agents/savant/savant-free-kimi.ts +0 -13
  64. package/agents/savant/savant-free-mimo-pro.ts +0 -13
  65. package/agents/savant/savant-free-mimo.ts +0 -13
  66. package/agents/savant/savant-free-minimax-m3.ts +0 -13
  67. package/agents/savant/savant-free.ts +0 -9
  68. package/agents/savant/savant-scaffold.ts +0 -15
  69. package/agents/savant/savant.ts +0 -706
  70. package/agents/scout/scout.ts +0 -305
  71. package/agents/scribe/scribe.ts +0 -54
  72. package/agents/thinker/thinker-gemini.ts +0 -26
  73. package/agents/thinker/thinker-gpt.ts +0 -21
  74. package/agents/thinker/thinker-with-files-gemini.ts +0 -66
  75. package/agents/thinker/thinker.ts +0 -99
  76. package/agents/tmux-cli.ts +0 -724
  77. package/agents/tsconfig.json +0 -12
  78. package/agents/types/agent-definition.ts +0 -1
  79. package/agents/types/secret-agent-definition.ts +0 -103
  80. package/agents/types/tools.ts +0 -465
  81. package/agents/types/util-types.ts +0 -167
  82. package/agents/verifier/verifier.ts +0 -80
  83. package/art/savant-redesign.md +0 -7
  84. package/art/savant-text-graf.md +0 -5
  85. package/art/savant_ascii.md +0 -88
  86. package/assets/banner.png +0 -0
  87. package/bun.lock +0 -1979
  88. package/bunfig.toml +0 -7
  89. package/cli/README.md +0 -111
  90. package/cli/bunfig.toml +0 -2
  91. package/cli/cli_exit +0 -1
  92. package/cli/package.json +0 -66
  93. package/cli/release/README.md +0 -348
  94. package/cli/release/package.json +0 -56
  95. package/cli/release-core/README.md +0 -20
  96. package/cli/release-core/package.json +0 -4
  97. package/cli/release-core/prepare-package.js +0 -31
  98. package/cli/release-staging/README.md +0 -70
  99. package/cli/release-staging/index.js +0 -42
  100. package/cli/release-staging/package.json +0 -43
  101. package/cli/scripts/build-binary.ts +0 -469
  102. package/cli/scripts/clean.ts +0 -16
  103. package/cli/scripts/prebuild-agents.ts +0 -250
  104. package/cli/scripts/release.ts +0 -103
  105. package/cli/scripts/smoke-binary.ts +0 -280
  106. package/cli/scripts/test-sdk-file-hooks.sh +0 -87
  107. package/cli/scripts/validate-cli-with-tmux.sh +0 -43
  108. package/cli/src/__tests__/README.md +0 -256
  109. package/cli/src/__tests__/bash-mode.test.ts +0 -442
  110. package/cli/src/__tests__/cli-args.test.ts +0 -241
  111. package/cli/src/__tests__/e2e/first-time-login.test.ts +0 -173
  112. package/cli/src/__tests__/e2e/logout-relogin-flow.test.ts +0 -110
  113. package/cli/src/__tests__/e2e/returning-user-auth.test.ts +0 -111
  114. package/cli/src/__tests__/e2e-cli.test.ts +0 -185
  115. package/cli/src/__tests__/helpers/mock-api-client.ts +0 -64
  116. package/cli/src/__tests__/helpers/terminal-watchdog-fixture.ts +0 -50
  117. package/cli/src/__tests__/home-directory-detection.test.ts +0 -213
  118. package/cli/src/__tests__/integration/agent-toolnames-validation.test.ts +0 -63
  119. package/cli/src/__tests__/integration/api-integration.test.ts +0 -289
  120. package/cli/src/__tests__/integration/credentials-storage.test.ts +0 -453
  121. package/cli/src/__tests__/integration/local-agents.test.ts +0 -1187
  122. package/cli/src/__tests__/integration/login-flow-instrumentation.test.ts +0 -146
  123. package/cli/src/__tests__/integration/login-polling-working.test.ts +0 -287
  124. package/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts +0 -177
  125. package/cli/src/__tests__/integration-tmux.test.ts +0 -186
  126. package/cli/src/__tests__/mocks/hover-toggle-controller.ts +0 -71
  127. package/cli/src/__tests__/path-completion.test.ts +0 -243
  128. package/cli/src/__tests__/project-files-chat-id.test.ts +0 -34
  129. package/cli/src/__tests__/release/proxy-http-get.test.ts +0 -670
  130. package/cli/src/__tests__/release/wrapper-safety.test.ts +0 -177
  131. package/cli/src/__tests__/rerender-perf.integration.test.ts +0 -283
  132. package/cli/src/__tests__/terminal-reset-sequences.test.ts +0 -36
  133. package/cli/src/__tests__/terminal-watchdog.test.ts +0 -114
  134. package/cli/src/__tests__/test-utils.ts +0 -266
  135. package/cli/src/__tests__/tmux-poc.ts +0 -150
  136. package/cli/src/__tests__/unit/agent-mode-toggle.test.ts +0 -138
  137. package/cli/src/__tests__/unit/copy-button.test.tsx +0 -188
  138. package/cli/src/__tests__/unit/create-run-config.test.ts +0 -77
  139. package/cli/src/__tests__/unit/publish-confirmation.test.ts +0 -70
  140. package/cli/src/__tests__/unit/segmented-control.test.ts +0 -167
  141. package/cli/src/__tests__/utils/env.test.ts +0 -210
  142. package/cli/src/__tests__/utils/project-picker.test.ts +0 -39
  143. package/cli/src/agents/bundled-agents.generated.d.ts +0 -14
  144. package/cli/src/agents/bundled-agents.generated.ts +0 -1962
  145. package/cli/src/app.tsx +0 -383
  146. package/cli/src/chat.tsx +0 -2008
  147. package/cli/src/cli-args.ts +0 -183
  148. package/cli/src/commands/__tests__/bash-command.test.ts +0 -449
  149. package/cli/src/commands/__tests__/command-args.test.ts +0 -325
  150. package/cli/src/commands/__tests__/copy-conversation.test.ts +0 -240
  151. package/cli/src/commands/__tests__/dev-command.test.ts +0 -128
  152. package/cli/src/commands/__tests__/diagnostics-command.test.ts +0 -35
  153. package/cli/src/commands/__tests__/image.test.ts +0 -99
  154. package/cli/src/commands/__tests__/init.test.ts +0 -479
  155. package/cli/src/commands/__tests__/loop-command.test.ts +0 -89
  156. package/cli/src/commands/__tests__/new-command-rotates-chat.test.ts +0 -87
  157. package/cli/src/commands/__tests__/permissions-command.test.ts +0 -93
  158. package/cli/src/commands/__tests__/process-diagnostics.test.ts +0 -54
  159. package/cli/src/commands/__tests__/prompt-builders.test.ts +0 -69
  160. package/cli/src/commands/__tests__/registry-gating.test.ts +0 -125
  161. package/cli/src/commands/__tests__/rewind-command.test.ts +0 -227
  162. package/cli/src/commands/__tests__/router-connect-chatgpt.test.ts +0 -72
  163. package/cli/src/commands/__tests__/router-input.test.ts +0 -302
  164. package/cli/src/commands/__tests__/router-provider-setup.test.ts +0 -180
  165. package/cli/src/commands/__tests__/savant-free-command-aliases.test.ts +0 -56
  166. package/cli/src/commands/ads.ts +0 -45
  167. package/cli/src/commands/command-registry.ts +0 -1112
  168. package/cli/src/commands/copy-conversation.ts +0 -386
  169. package/cli/src/commands/goal.ts +0 -87
  170. package/cli/src/commands/health-command.ts +0 -61
  171. package/cli/src/commands/help.ts +0 -13
  172. package/cli/src/commands/image.ts +0 -20
  173. package/cli/src/commands/init.ts +0 -121
  174. package/cli/src/commands/loop.ts +0 -182
  175. package/cli/src/commands/process-diagnostics.ts +0 -120
  176. package/cli/src/commands/prompt-builders.ts +0 -140
  177. package/cli/src/commands/publish.ts +0 -261
  178. package/cli/src/commands/rewind.ts +0 -130
  179. package/cli/src/commands/router-utils.ts +0 -80
  180. package/cli/src/commands/router.ts +0 -589
  181. package/cli/src/commands/telemetry.ts +0 -68
  182. package/cli/src/commands/usage.ts +0 -26
  183. package/cli/src/components/__tests__/ad-banner.test.tsx +0 -84
  184. package/cli/src/components/__tests__/block-helpers.test.tsx +0 -139
  185. package/cli/src/components/__tests__/clickable.test.tsx +0 -39
  186. package/cli/src/components/__tests__/grid-layout.integration.test.tsx +0 -365
  187. package/cli/src/components/__tests__/grid-layout.test.tsx +0 -1033
  188. package/cli/src/components/__tests__/markdown-content.test.tsx +0 -163
  189. package/cli/src/components/__tests__/message-block.completion.test.tsx +0 -104
  190. package/cli/src/components/__tests__/message-block.streaming.test.tsx +0 -78
  191. package/cli/src/components/__tests__/message-with-agents.test.tsx +0 -571
  192. package/cli/src/components/__tests__/multiline-input.test.tsx +0 -1129
  193. package/cli/src/components/__tests__/selectable-list.test.ts +0 -232
  194. package/cli/src/components/__tests__/shimmer-text.test.tsx +0 -32
  195. package/cli/src/components/__tests__/status-indicator.test.tsx +0 -184
  196. package/cli/src/components/__tests__/user-error-banner.test.tsx +0 -100
  197. package/cli/src/components/ad-banner.tsx +0 -351
  198. package/cli/src/components/agent-checklist.tsx +0 -385
  199. package/cli/src/components/agent-mode-toggle.tsx +0 -294
  200. package/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts +0 -432
  201. package/cli/src/components/ask-user/__tests__/validation.test.ts +0 -213
  202. package/cli/src/components/ask-user/components/accordion-question.tsx +0 -153
  203. package/cli/src/components/ask-user/components/custom-answer-input.tsx +0 -66
  204. package/cli/src/components/ask-user/components/options-list.tsx +0 -139
  205. package/cli/src/components/ask-user/components/question-header.tsx +0 -80
  206. package/cli/src/components/ask-user/components/question-option.tsx +0 -94
  207. package/cli/src/components/ask-user/constants.ts +0 -35
  208. package/cli/src/components/ask-user/index.tsx +0 -660
  209. package/cli/src/components/ask-user/utils/validation.ts +0 -85
  210. package/cli/src/components/attachment-card.tsx +0 -67
  211. package/cli/src/components/blocks/agent-block-grid.tsx +0 -58
  212. package/cli/src/components/blocks/agent-branch-item.tsx +0 -230
  213. package/cli/src/components/blocks/agent-branch-wrapper.tsx +0 -524
  214. package/cli/src/components/blocks/agent-list-branch.tsx +0 -77
  215. package/cli/src/components/blocks/ask-user-branch.tsx +0 -98
  216. package/cli/src/components/blocks/block-helpers.tsx +0 -257
  217. package/cli/src/components/blocks/blocks-renderer.tsx +0 -296
  218. package/cli/src/components/blocks/content-with-markdown.tsx +0 -34
  219. package/cli/src/components/blocks/copy-button.tsx +0 -106
  220. package/cli/src/components/blocks/copyable-block.tsx +0 -47
  221. package/cli/src/components/blocks/image-block.tsx +0 -128
  222. package/cli/src/components/blocks/implementor-row.tsx +0 -453
  223. package/cli/src/components/blocks/markdown-content.tsx +0 -360
  224. package/cli/src/components/blocks/markdown-renderables.tsx +0 -347
  225. package/cli/src/components/blocks/single-block.tsx +0 -210
  226. package/cli/src/components/blocks/thinking-block.tsx +0 -77
  227. package/cli/src/components/blocks/tool-block-group.tsx +0 -72
  228. package/cli/src/components/blocks/tool-branch.tsx +0 -196
  229. package/cli/src/components/blocks/user-content-copy.tsx +0 -171
  230. package/cli/src/components/bottom-banner.tsx +0 -145
  231. package/cli/src/components/build-mode-buttons.tsx +0 -110
  232. package/cli/src/components/button.tsx +0 -75
  233. package/cli/src/components/chat-header.tsx +0 -11
  234. package/cli/src/components/chat-history-screen.tsx +0 -417
  235. package/cli/src/components/chat-input-bar.tsx +0 -535
  236. package/cli/src/components/chatgpt-connect-banner.tsx +0 -234
  237. package/cli/src/components/clickable.tsx +0 -149
  238. package/cli/src/components/collapse-button.tsx +0 -36
  239. package/cli/src/components/command-palette.tsx +0 -166
  240. package/cli/src/components/copy-button.tsx +0 -219
  241. package/cli/src/components/dialog.tsx +0 -111
  242. package/cli/src/components/elapsed-timer.tsx +0 -56
  243. package/cli/src/components/error-boundary.tsx +0 -63
  244. package/cli/src/components/feedback-container.tsx +0 -206
  245. package/cli/src/components/feedback-icon-button.tsx +0 -89
  246. package/cli/src/components/feedback-input-mode.tsx +0 -352
  247. package/cli/src/components/file-attachment-card.tsx +0 -89
  248. package/cli/src/components/grid-layout.tsx +0 -83
  249. package/cli/src/components/help-banner.tsx +0 -136
  250. package/cli/src/components/highlighted-text.tsx +0 -52
  251. package/cli/src/components/image-card.tsx +0 -167
  252. package/cli/src/components/image-thumbnail.tsx +0 -112
  253. package/cli/src/components/input-cursor.tsx +0 -77
  254. package/cli/src/components/input-mode-banner.tsx +0 -70
  255. package/cli/src/components/load-previous-button.tsx +0 -48
  256. package/cli/src/components/login-modal.tsx +0 -450
  257. package/cli/src/components/message-block.tsx +0 -337
  258. package/cli/src/components/message-footer.tsx +0 -269
  259. package/cli/src/components/message-with-agents.tsx +0 -594
  260. package/cli/src/components/mode-divider.tsx +0 -43
  261. package/cli/src/components/model-picker.tsx +0 -371
  262. package/cli/src/components/multiline-input.tsx +0 -1276
  263. package/cli/src/components/out-of-credits-banner.tsx +0 -155
  264. package/cli/src/components/pending-attachments-banner.tsx +0 -125
  265. package/cli/src/components/pending-bash-message.tsx +0 -57
  266. package/cli/src/components/progress-bar.tsx +0 -81
  267. package/cli/src/components/project-picker-screen.tsx +0 -469
  268. package/cli/src/components/provider-picker.tsx +0 -139
  269. package/cli/src/components/publish-confirmation.tsx +0 -514
  270. package/cli/src/components/publish-container.tsx +0 -737
  271. package/cli/src/components/raised-pill.tsx +0 -89
  272. package/cli/src/components/renderers/plan-box.tsx +0 -70
  273. package/cli/src/components/review-screen.tsx +0 -114
  274. package/cli/src/components/rewind-picker.tsx +0 -276
  275. package/cli/src/components/right-sidebar.tsx +0 -296
  276. package/cli/src/components/savant-free-active-session-summary.tsx +0 -63
  277. package/cli/src/components/savant-free-landing-screen.tsx +0 -756
  278. package/cli/src/components/savant-free-model-selector.tsx +0 -815
  279. package/cli/src/components/savant-free-referral-banner.tsx +0 -423
  280. package/cli/src/components/savant-free-superseded-screen.tsx +0 -62
  281. package/cli/src/components/savant-ui/animation/pulse.tsx +0 -26
  282. package/cli/src/components/savant-ui/animation/typewriter.tsx +0 -40
  283. package/cli/src/components/savant-ui/branding.tsx +0 -59
  284. package/cli/src/components/savant-ui/data-display/badge.tsx +0 -52
  285. package/cli/src/components/savant-ui/data-display/code-block.tsx +0 -55
  286. package/cli/src/components/savant-ui/data-display/key-value.tsx +0 -34
  287. package/cli/src/components/savant-ui/data-display/sparkline.tsx +0 -42
  288. package/cli/src/components/savant-ui/data-display/timeline.tsx +0 -44
  289. package/cli/src/components/savant-ui/data-display/tree-view.tsx +0 -55
  290. package/cli/src/components/savant-ui/echo/__tests__/sidebar-collapse.test.tsx +0 -90
  291. package/cli/src/components/savant-ui/echo/agent-stack.tsx +0 -72
  292. package/cli/src/components/savant-ui/echo/agent-status.tsx +0 -66
  293. package/cli/src/components/savant-ui/echo/fid-card.tsx +0 -89
  294. package/cli/src/components/savant-ui/echo/fid-list.tsx +0 -68
  295. package/cli/src/components/savant-ui/echo/loop-status-panel.tsx +0 -64
  296. package/cli/src/components/savant-ui/echo/perfection-loop.tsx +0 -118
  297. package/cli/src/components/savant-ui/echo/phase-indicator.tsx +0 -68
  298. package/cli/src/components/savant-ui/echo/phase-info.ts +0 -72
  299. package/cli/src/components/savant-ui/echo/token-meter.tsx +0 -39
  300. package/cli/src/components/savant-ui/feedback/alert.tsx +0 -45
  301. package/cli/src/components/savant-ui/feedback/cost-tracker.tsx +0 -54
  302. package/cli/src/components/savant-ui/feedback/progress-bar.tsx +0 -71
  303. package/cli/src/components/savant-ui/feedback/spinner.tsx +0 -40
  304. package/cli/src/components/savant-ui/icon-theme-keys.ts +0 -67
  305. package/cli/src/components/savant-ui/icon.tsx +0 -53
  306. package/cli/src/components/savant-ui/index.ts +0 -75
  307. package/cli/src/components/savant-ui/input/select.tsx +0 -26
  308. package/cli/src/components/savant-ui/input/toggle.tsx +0 -28
  309. package/cli/src/components/savant-ui/layout/grid.tsx +0 -52
  310. package/cli/src/components/savant-ui/layout/header.tsx +0 -28
  311. package/cli/src/components/savant-ui/navigation/stepper.tsx +0 -41
  312. package/cli/src/components/savant-ui/primitives/key-hint.tsx +0 -45
  313. package/cli/src/components/savant-ui/primitives/key-value-row.tsx +0 -41
  314. package/cli/src/components/savant-ui/primitives/panel.tsx +0 -42
  315. package/cli/src/components/savant-ui/primitives/separator.tsx +0 -34
  316. package/cli/src/components/savant-ui/primitives/sidebar-section.tsx +0 -64
  317. package/cli/src/components/savant-ui/primitives/spacer.tsx +0 -13
  318. package/cli/src/components/savant-ui/primitives/stack.tsx +0 -35
  319. package/cli/src/components/savant-ui/theme.ts +0 -45
  320. package/cli/src/components/scroll-to-bottom-button.tsx +0 -34
  321. package/cli/src/components/segmented-control.tsx +0 -199
  322. package/cli/src/components/selectable-list.tsx +0 -249
  323. package/cli/src/components/selected-chips.tsx +0 -86
  324. package/cli/src/components/separator.tsx +0 -40
  325. package/cli/src/components/session-ended-banner.tsx +0 -183
  326. package/cli/src/components/shimmer-text.tsx +0 -254
  327. package/cli/src/components/status-bar.tsx +0 -279
  328. package/cli/src/components/subscription-limit-banner.tsx +0 -269
  329. package/cli/src/components/suggested-prompts.tsx +0 -184
  330. package/cli/src/components/suggestion-menu.tsx +0 -219
  331. package/cli/src/components/terminal-command-display.tsx +0 -152
  332. package/cli/src/components/terminal-link.tsx +0 -97
  333. package/cli/src/components/text-attachment-card.tsx +0 -77
  334. package/cli/src/components/thinking.tsx +0 -108
  335. package/cli/src/components/toast.tsx +0 -109
  336. package/cli/src/components/tools/__tests__/apply-patch.test.tsx +0 -97
  337. package/cli/src/components/tools/__tests__/code-search.test.tsx +0 -45
  338. package/cli/src/components/tools/__tests__/gravity-index.test.ts +0 -76
  339. package/cli/src/components/tools/__tests__/render-ui.test.tsx +0 -556
  340. package/cli/src/components/tools/__tests__/run-terminal-command.test.ts +0 -274
  341. package/cli/src/components/tools/__tests__/tool-call-item.test.tsx +0 -73
  342. package/cli/src/components/tools/apply-patch.tsx +0 -101
  343. package/cli/src/components/tools/code-search.tsx +0 -50
  344. package/cli/src/components/tools/composio.tsx +0 -140
  345. package/cli/src/components/tools/diff-viewer.tsx +0 -21
  346. package/cli/src/components/tools/glob.tsx +0 -66
  347. package/cli/src/components/tools/gravity-index.tsx +0 -80
  348. package/cli/src/components/tools/list-directory.tsx +0 -66
  349. package/cli/src/components/tools/read-docs.tsx +0 -36
  350. package/cli/src/components/tools/read-files.tsx +0 -97
  351. package/cli/src/components/tools/read-subtree.tsx +0 -40
  352. package/cli/src/components/tools/read-url.tsx +0 -32
  353. package/cli/src/components/tools/registry.ts +0 -131
  354. package/cli/src/components/tools/render-ui.tsx +0 -466
  355. package/cli/src/components/tools/run-terminal-command.tsx +0 -86
  356. package/cli/src/components/tools/skill.tsx +0 -26
  357. package/cli/src/components/tools/str-replace.tsx +0 -77
  358. package/cli/src/components/tools/suggest-followups.tsx +0 -373
  359. package/cli/src/components/tools/task-completed.tsx +0 -16
  360. package/cli/src/components/tools/tool-call-item.tsx +0 -163
  361. package/cli/src/components/tools/types.ts +0 -100
  362. package/cli/src/components/tools/web-search.tsx +0 -32
  363. package/cli/src/components/tools/write-file.tsx +0 -20
  364. package/cli/src/components/tools/write-todos.tsx +0 -105
  365. package/cli/src/components/top-banner.tsx +0 -187
  366. package/cli/src/components/usage-banner.tsx +0 -296
  367. package/cli/src/components/user-error-banner.tsx +0 -54
  368. package/cli/src/components/validation-error-popover.tsx +0 -191
  369. package/cli/src/data/slash-commands.ts +0 -306
  370. package/cli/src/hooks/__tests__/holds-live-savant-free-slot.test.ts +0 -49
  371. package/cli/src/hooks/__tests__/run-outcome.test.ts +0 -178
  372. package/cli/src/hooks/__tests__/session-fetch-signal.test.ts +0 -45
  373. package/cli/src/hooks/__tests__/use-activity-query.test.ts +0 -810
  374. package/cli/src/hooks/__tests__/use-ask-user-bridge.test.ts +0 -175
  375. package/cli/src/hooks/__tests__/use-auth-query.test.ts +0 -21
  376. package/cli/src/hooks/__tests__/use-chat-messages-collapse.test.ts +0 -96
  377. package/cli/src/hooks/__tests__/use-connection-status.test.ts +0 -112
  378. package/cli/src/hooks/__tests__/use-directory-browser.test.ts +0 -215
  379. package/cli/src/hooks/__tests__/use-gravity-ad.test.ts +0 -73
  380. package/cli/src/hooks/__tests__/use-grid-layout.test.ts +0 -346
  381. package/cli/src/hooks/__tests__/use-input-history.test.ts +0 -704
  382. package/cli/src/hooks/__tests__/use-loop-scheduler.test.ts +0 -247
  383. package/cli/src/hooks/__tests__/use-path-tab-completion.test.ts +0 -324
  384. package/cli/src/hooks/__tests__/use-queue-controls.test.ts +0 -68
  385. package/cli/src/hooks/__tests__/use-searchable-list.test.ts +0 -364
  386. package/cli/src/hooks/__tests__/use-send-message-timer.test.ts +0 -146
  387. package/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts +0 -364
  388. package/cli/src/hooks/__tests__/use-suggestion-engine.test.ts +0 -485
  389. package/cli/src/hooks/__tests__/use-terminal-focus.test.ts +0 -66
  390. package/cli/src/hooks/__tests__/use-terminal-layout.test.ts +0 -675
  391. package/cli/src/hooks/__tests__/use-timeout.test.ts +0 -330
  392. package/cli/src/hooks/__tests__/use-usage-query.test.ts +0 -526
  393. package/cli/src/hooks/__tests__/use-user-details-query.test.ts +0 -225
  394. package/cli/src/hooks/helpers/__tests__/send-message.test.ts +0 -1851
  395. package/cli/src/hooks/helpers/send-message.ts +0 -553
  396. package/cli/src/hooks/run-outcome.ts +0 -64
  397. package/cli/src/hooks/stream-state.ts +0 -106
  398. package/cli/src/hooks/use-activity-query.ts +0 -696
  399. package/cli/src/hooks/use-agent-validation.ts +0 -80
  400. package/cli/src/hooks/use-ask-user-bridge.ts +0 -102
  401. package/cli/src/hooks/use-auth-query.ts +0 -261
  402. package/cli/src/hooks/use-auth-state.ts +0 -155
  403. package/cli/src/hooks/use-chat-input.ts +0 -87
  404. package/cli/src/hooks/use-chat-keyboard.ts +0 -325
  405. package/cli/src/hooks/use-chat-messages.ts +0 -273
  406. package/cli/src/hooks/use-chat-state.ts +0 -219
  407. package/cli/src/hooks/use-chat-streaming.ts +0 -270
  408. package/cli/src/hooks/use-chat-ui.ts +0 -136
  409. package/cli/src/hooks/use-clipboard.ts +0 -154
  410. package/cli/src/hooks/use-connection-status.ts +0 -156
  411. package/cli/src/hooks/use-directory-browser.ts +0 -88
  412. package/cli/src/hooks/use-elapsed-time.ts +0 -116
  413. package/cli/src/hooks/use-event.ts +0 -36
  414. package/cli/src/hooks/use-exit-handler.ts +0 -108
  415. package/cli/src/hooks/use-fetch-login-url.ts +0 -65
  416. package/cli/src/hooks/use-fids.ts +0 -48
  417. package/cli/src/hooks/use-fingerprint.ts +0 -64
  418. package/cli/src/hooks/use-gravity-ad.ts +0 -688
  419. package/cli/src/hooks/use-grid-layout.ts +0 -78
  420. package/cli/src/hooks/use-input-history.ts +0 -173
  421. package/cli/src/hooks/use-login-keyboard-handlers.ts +0 -74
  422. package/cli/src/hooks/use-login-polling.ts +0 -120
  423. package/cli/src/hooks/use-logo.tsx +0 -178
  424. package/cli/src/hooks/use-loop-scheduler.ts +0 -481
  425. package/cli/src/hooks/use-message-queue.ts +0 -301
  426. package/cli/src/hooks/use-now.ts +0 -20
  427. package/cli/src/hooks/use-path-tab-completion.ts +0 -88
  428. package/cli/src/hooks/use-publish-mutation.ts +0 -60
  429. package/cli/src/hooks/use-queue-controls.ts +0 -70
  430. package/cli/src/hooks/use-queue-ui.ts +0 -55
  431. package/cli/src/hooks/use-savant-free-ctrl-c-exit.ts +0 -23
  432. package/cli/src/hooks/use-savant-free-session-progress.ts +0 -34
  433. package/cli/src/hooks/use-savant-free-session.ts +0 -679
  434. package/cli/src/hooks/use-savant-free-streak-query.ts +0 -72
  435. package/cli/src/hooks/use-scaffold-confirm.ts +0 -62
  436. package/cli/src/hooks/use-scaffold-revert-subscriber.ts +0 -66
  437. package/cli/src/hooks/use-scroll-management.ts +0 -182
  438. package/cli/src/hooks/use-searchable-list.ts +0 -99
  439. package/cli/src/hooks/use-send-message.ts +0 -1038
  440. package/cli/src/hooks/use-sheen-animation.tsx +0 -88
  441. package/cli/src/hooks/use-subscription-query.ts +0 -106
  442. package/cli/src/hooks/use-suggestion-engine.ts +0 -793
  443. package/cli/src/hooks/use-terminal-breakpoints.ts +0 -77
  444. package/cli/src/hooks/use-terminal-dimensions.ts +0 -50
  445. package/cli/src/hooks/use-terminal-focus.ts +0 -146
  446. package/cli/src/hooks/use-terminal-layout.ts +0 -189
  447. package/cli/src/hooks/use-theme.tsx +0 -140
  448. package/cli/src/hooks/use-timeout.ts +0 -88
  449. package/cli/src/hooks/use-toast.ts +0 -114
  450. package/cli/src/hooks/use-update-preference.ts +0 -77
  451. package/cli/src/hooks/use-usage-monitor.ts +0 -59
  452. package/cli/src/hooks/use-usage-query.ts +0 -138
  453. package/cli/src/hooks/use-user-details-query.ts +0 -104
  454. package/cli/src/hooks/use-why-did-you-update.ts +0 -178
  455. package/cli/src/index.tsx +0 -484
  456. package/cli/src/init/__tests__/init-direnv.test.ts +0 -597
  457. package/cli/src/init/init-app.ts +0 -54
  458. package/cli/src/init/init-direnv.ts +0 -133
  459. package/cli/src/login/constants.ts +0 -71
  460. package/cli/src/login/login-flow.ts +0 -224
  461. package/cli/src/login/plain-login.ts +0 -107
  462. package/cli/src/login/utils.ts +0 -183
  463. package/cli/src/native/ripgrep.ts +0 -82
  464. package/cli/src/polyfills/bun-strip-ansi.ts +0 -12
  465. package/cli/src/pre-init/load-dev-env.ts +0 -98
  466. package/cli/src/pre-init/tree-sitter-wasm.ts +0 -98
  467. package/cli/src/project-files.ts +0 -102
  468. package/cli/src/state/__tests__/chat-store-focus.test.ts +0 -22
  469. package/cli/src/state/__tests__/feedback-store.test.ts +0 -260
  470. package/cli/src/state/chat-history-store.ts +0 -27
  471. package/cli/src/state/chat-store.ts +0 -791
  472. package/cli/src/state/feedback-store.ts +0 -162
  473. package/cli/src/state/gateway-catalog-store.ts +0 -45
  474. package/cli/src/state/login-store.ts +0 -150
  475. package/cli/src/state/message-block-store.ts +0 -143
  476. package/cli/src/state/model-picker-store.ts +0 -67
  477. package/cli/src/state/provider-picker-store.ts +0 -53
  478. package/cli/src/state/publish-store.ts +0 -141
  479. package/cli/src/state/review-store.ts +0 -24
  480. package/cli/src/state/rewind-picker-store.ts +0 -74
  481. package/cli/src/state/savant-free-model-store.ts +0 -59
  482. package/cli/src/state/savant-free-session-store.ts +0 -32
  483. package/cli/src/test-setup.ts +0 -20
  484. package/cli/src/testing/env.ts +0 -46
  485. package/cli/src/types/chat-state.ts +0 -15
  486. package/cli/src/types/chat.ts +0 -242
  487. package/cli/src/types/contracts/send-message.ts +0 -16
  488. package/cli/src/types/env.ts +0 -107
  489. package/cli/src/types/function-params.ts +0 -35
  490. package/cli/src/types/opentui-augmentation.d.ts +0 -11
  491. package/cli/src/types/react19-compat.d.ts +0 -19
  492. package/cli/src/types/savant-free-session.ts +0 -17
  493. package/cli/src/types/store.ts +0 -112
  494. package/cli/src/types/theme-system.ts +0 -194
  495. package/cli/src/types/utils.ts +0 -3
  496. package/cli/src/utils/__tests__/activity-tracker.test.ts +0 -440
  497. package/cli/src/utils/__tests__/agent-display.test.ts +0 -139
  498. package/cli/src/utils/__tests__/analytics-client.test.ts +0 -311
  499. package/cli/src/utils/__tests__/anonymous-id.test.ts +0 -74
  500. package/cli/src/utils/__tests__/arrays.test.ts +0 -264
  501. package/cli/src/utils/__tests__/bash-context-processor.test.ts +0 -38
  502. package/cli/src/utils/__tests__/block-processor.test.ts +0 -690
  503. package/cli/src/utils/__tests__/chat-history.test.ts +0 -247
  504. package/cli/src/utils/__tests__/chat-input-key-intercept.test.ts +0 -40
  505. package/cli/src/utils/__tests__/chat-layout.test.ts +0 -32
  506. package/cli/src/utils/__tests__/chat-meta.test.ts +0 -160
  507. package/cli/src/utils/__tests__/chatgpt-oauth.test.ts +0 -50
  508. package/cli/src/utils/__tests__/clipboard.test.ts +0 -998
  509. package/cli/src/utils/__tests__/code-search-summary.test.ts +0 -84
  510. package/cli/src/utils/__tests__/collapse-helpers.test.ts +0 -1272
  511. package/cli/src/utils/__tests__/error-handling.test.ts +0 -611
  512. package/cli/src/utils/__tests__/feedback-helpers.test.ts +0 -452
  513. package/cli/src/utils/__tests__/feedback-submission.test.ts +0 -26
  514. package/cli/src/utils/__tests__/fetch-usage.test.ts +0 -355
  515. package/cli/src/utils/__tests__/fingerprint.test.ts +0 -147
  516. package/cli/src/utils/__tests__/format-elapsed-time.test.ts +0 -103
  517. package/cli/src/utils/__tests__/format-timeout.test.ts +0 -87
  518. package/cli/src/utils/__tests__/image-dimensions.test.ts +0 -243
  519. package/cli/src/utils/__tests__/image-processor.test.ts +0 -250
  520. package/cli/src/utils/__tests__/implementor-helpers.test.ts +0 -1457
  521. package/cli/src/utils/__tests__/keyboard-actions.test.ts +0 -686
  522. package/cli/src/utils/__tests__/layout-helpers.test.ts +0 -36
  523. package/cli/src/utils/__tests__/lazy-response-ads.test.ts +0 -167
  524. package/cli/src/utils/__tests__/logger-sanitize-secrets.test.ts +0 -80
  525. package/cli/src/utils/__tests__/markdown-renderer.test.tsx +0 -518
  526. package/cli/src/utils/__tests__/markdown-streaming.test.ts +0 -64
  527. package/cli/src/utils/__tests__/message-block-helpers.test.ts +0 -1197
  528. package/cli/src/utils/__tests__/message-updater.test.ts +0 -659
  529. package/cli/src/utils/__tests__/ollama-onboarding.test.ts +0 -239
  530. package/cli/src/utils/__tests__/openrouter-models.test.ts +0 -295
  531. package/cli/src/utils/__tests__/osc-timeout-scenarios.test.ts +0 -172
  532. package/cli/src/utils/__tests__/pending-attachments.test.ts +0 -278
  533. package/cli/src/utils/__tests__/provider-setup.test.ts +0 -183
  534. package/cli/src/utils/__tests__/run-state-storage.test.ts +0 -741
  535. package/cli/src/utils/__tests__/savant-code-api.test.ts +0 -634
  536. package/cli/src/utils/__tests__/savant-free-instance-owner.test.ts +0 -69
  537. package/cli/src/utils/__tests__/savant-free-model-navigation.test.ts +0 -104
  538. package/cli/src/utils/__tests__/savant-free-premium-reset.test.ts +0 -81
  539. package/cli/src/utils/__tests__/savant-free-referral-cache.test.ts +0 -67
  540. package/cli/src/utils/__tests__/savant-free-session-display.test.ts +0 -23
  541. package/cli/src/utils/__tests__/savant-free-streak-line.test.ts +0 -76
  542. package/cli/src/utils/__tests__/sdk-event-handlers.test.ts +0 -615
  543. package/cli/src/utils/__tests__/send-message-helpers.test.ts +0 -1875
  544. package/cli/src/utils/__tests__/send-message-timer.test.ts +0 -68
  545. package/cli/src/utils/__tests__/settings.test.ts +0 -138
  546. package/cli/src/utils/__tests__/strings.test.ts +0 -210
  547. package/cli/src/utils/__tests__/syntax-theme.test.ts +0 -91
  548. package/cli/src/utils/__tests__/terminal-color-detection.test.ts +0 -425
  549. package/cli/src/utils/__tests__/terminal-enter-detection.test.ts +0 -77
  550. package/cli/src/utils/__tests__/text-layout.test.ts +0 -82
  551. package/cli/src/utils/__tests__/think-tag-parser.test.ts +0 -159
  552. package/cli/src/utils/__tests__/trace-writer.test.ts +0 -193
  553. package/cli/src/utils/__tests__/trim-chat-logs.test.ts +0 -79
  554. package/cli/src/utils/__tests__/usage-banner-state.test.ts +0 -298
  555. package/cli/src/utils/__tests__/validation-error-formatting.test.ts +0 -126
  556. package/cli/src/utils/__tests__/write-file-atomic.test.ts +0 -130
  557. package/cli/src/utils/active-run.ts +0 -22
  558. package/cli/src/utils/activity-tracker.ts +0 -66
  559. package/cli/src/utils/agent-display.ts +0 -87
  560. package/cli/src/utils/agent-helpers.ts +0 -47
  561. package/cli/src/utils/ai-message-id.ts +0 -12
  562. package/cli/src/utils/analytics.ts +0 -407
  563. package/cli/src/utils/anonymous-id.ts +0 -87
  564. package/cli/src/utils/arrays.ts +0 -117
  565. package/cli/src/utils/auth.ts +0 -258
  566. package/cli/src/utils/bash-context-processor.ts +0 -47
  567. package/cli/src/utils/bash-messages.ts +0 -108
  568. package/cli/src/utils/block-margins.ts +0 -35
  569. package/cli/src/utils/block-operations.ts +0 -548
  570. package/cli/src/utils/block-processor.ts +0 -212
  571. package/cli/src/utils/chat-history.ts +0 -265
  572. package/cli/src/utils/chat-input-key-intercept.ts +0 -52
  573. package/cli/src/utils/chat-layout.ts +0 -44
  574. package/cli/src/utils/chat-meta.ts +0 -101
  575. package/cli/src/utils/chat-scroll-accel.ts +0 -151
  576. package/cli/src/utils/chatgpt-oauth.ts +0 -350
  577. package/cli/src/utils/clipboard-image.ts +0 -610
  578. package/cli/src/utils/clipboard.ts +0 -307
  579. package/cli/src/utils/code-search-summary.ts +0 -69
  580. package/cli/src/utils/collapse-helpers.ts +0 -265
  581. package/cli/src/utils/config-dir.ts +0 -30
  582. package/cli/src/utils/constants.ts +0 -159
  583. package/cli/src/utils/create-event-handler-state.ts +0 -79
  584. package/cli/src/utils/create-run-config.ts +0 -160
  585. package/cli/src/utils/db-storage.ts +0 -254
  586. package/cli/src/utils/detect-shell.ts +0 -112
  587. package/cli/src/utils/directory-browser.ts +0 -74
  588. package/cli/src/utils/engagement.ts +0 -55
  589. package/cli/src/utils/env.ts +0 -109
  590. package/cli/src/utils/error-handling.ts +0 -246
  591. package/cli/src/utils/error-messages.ts +0 -84
  592. package/cli/src/utils/feedback-helpers.ts +0 -115
  593. package/cli/src/utils/feedback-submission.ts +0 -24
  594. package/cli/src/utils/fetch-usage.ts +0 -75
  595. package/cli/src/utils/fid-loader.ts +0 -135
  596. package/cli/src/utils/fingerprint.ts +0 -263
  597. package/cli/src/utils/finish-logic.ts +0 -154
  598. package/cli/src/utils/format-elapsed-time.ts +0 -33
  599. package/cli/src/utils/format-session-units.ts +0 -6
  600. package/cli/src/utils/format-timeout.ts +0 -28
  601. package/cli/src/utils/format-validation-errors-for-message.ts +0 -85
  602. package/cli/src/utils/git.ts +0 -17
  603. package/cli/src/utils/glyphs.ts +0 -188
  604. package/cli/src/utils/helpers.ts +0 -47
  605. package/cli/src/utils/image-display.ts +0 -73
  606. package/cli/src/utils/image-handler.ts +0 -336
  607. package/cli/src/utils/image-processor.ts +0 -133
  608. package/cli/src/utils/image-thumbnail.ts +0 -83
  609. package/cli/src/utils/implementor-helpers.ts +0 -817
  610. package/cli/src/utils/input-modes.ts +0 -196
  611. package/cli/src/utils/keyboard-actions.ts +0 -394
  612. package/cli/src/utils/keypad-keys.ts +0 -47
  613. package/cli/src/utils/layout-helpers.ts +0 -36
  614. package/cli/src/utils/lazy-response-ads.ts +0 -1
  615. package/cli/src/utils/loading-phrases.ts +0 -68
  616. package/cli/src/utils/local-agent-registry.ts +0 -611
  617. package/cli/src/utils/log-shipper.ts +0 -116
  618. package/cli/src/utils/logger.ts +0 -435
  619. package/cli/src/utils/markdown-renderer.tsx +0 -1315
  620. package/cli/src/utils/math.ts +0 -3
  621. package/cli/src/utils/message-block-helpers.ts +0 -667
  622. package/cli/src/utils/message-history.ts +0 -153
  623. package/cli/src/utils/message-tree-utils.ts +0 -67
  624. package/cli/src/utils/message-updater.ts +0 -279
  625. package/cli/src/utils/ollama-onboarding.ts +0 -215
  626. package/cli/src/utils/open-file.ts +0 -144
  627. package/cli/src/utils/open-url.ts +0 -63
  628. package/cli/src/utils/openrouter-models.ts +0 -945
  629. package/cli/src/utils/path-completion.ts +0 -111
  630. package/cli/src/utils/path-helpers.ts +0 -38
  631. package/cli/src/utils/pending-attachments.ts +0 -363
  632. package/cli/src/utils/post-processing.ts +0 -99
  633. package/cli/src/utils/project-picker.ts +0 -12
  634. package/cli/src/utils/provider-setup.ts +0 -259
  635. package/cli/src/utils/recent-projects.ts +0 -156
  636. package/cli/src/utils/renderer-cleanup.ts +0 -142
  637. package/cli/src/utils/response-ad-positions.ts +0 -1
  638. package/cli/src/utils/rewind.ts +0 -175
  639. package/cli/src/utils/run-state-storage.ts +0 -480
  640. package/cli/src/utils/savant-code-api.ts +0 -686
  641. package/cli/src/utils/savant-code-client.ts +0 -217
  642. package/cli/src/utils/savant-free-agent-selection.ts +0 -12
  643. package/cli/src/utils/savant-free-exit.ts +0 -24
  644. package/cli/src/utils/savant-free-instance-owner.ts +0 -66
  645. package/cli/src/utils/savant-free-model-navigation.ts +0 -46
  646. package/cli/src/utils/savant-free-premium-reset.ts +0 -52
  647. package/cli/src/utils/savant-free-referral-cache.ts +0 -35
  648. package/cli/src/utils/savant-free-session-display.ts +0 -21
  649. package/cli/src/utils/savant-free-streak-line.ts +0 -67
  650. package/cli/src/utils/sdk-event-handlers.ts +0 -587
  651. package/cli/src/utils/send-message-helpers.ts +0 -199
  652. package/cli/src/utils/send-message-timer.ts +0 -110
  653. package/cli/src/utils/settings.ts +0 -388
  654. package/cli/src/utils/skill-registry.ts +0 -100
  655. package/cli/src/utils/spawn-agent-matcher.ts +0 -56
  656. package/cli/src/utils/status-indicator-state.ts +0 -100
  657. package/cli/src/utils/stream-chunk-processor.ts +0 -65
  658. package/cli/src/utils/strings.ts +0 -272
  659. package/cli/src/utils/subscription.ts +0 -31
  660. package/cli/src/utils/syntax-highlighter.tsx +0 -19
  661. package/cli/src/utils/syntax-theme.ts +0 -131
  662. package/cli/src/utils/terminal-color-detection.ts +0 -472
  663. package/cli/src/utils/terminal-enter-detection.ts +0 -63
  664. package/cli/src/utils/terminal-images.ts +0 -223
  665. package/cli/src/utils/terminal-reset-sequences.ts +0 -33
  666. package/cli/src/utils/terminal-title.ts +0 -113
  667. package/cli/src/utils/terminal-watchdog.ts +0 -246
  668. package/cli/src/utils/text-layout.ts +0 -153
  669. package/cli/src/utils/theme-config.ts +0 -143
  670. package/cli/src/utils/theme-system.ts +0 -1265
  671. package/cli/src/utils/think-tag-parser.ts +0 -105
  672. package/cli/src/utils/trace-writer.ts +0 -147
  673. package/cli/src/utils/ui-constants.ts +0 -69
  674. package/cli/src/utils/usage-banner-state.ts +0 -146
  675. package/cli/src/utils/validation-error-formatting.ts +0 -92
  676. package/cli/src/utils/validation-error-helpers.ts +0 -19
  677. package/cli/src/utils/version.ts +0 -23
  678. package/cli/src/utils/word-wrap-utils.ts +0 -53
  679. package/cli/src/utils/write-file-atomic.ts +0 -82
  680. package/cli/src/utils/yield-to-event-loop.ts +0 -9
  681. package/cli/tsconfig.json +0 -26
  682. package/coding-standards/csharp.md +0 -77
  683. package/coding-standards/go.md +0 -71
  684. package/coding-standards/java.md +0 -74
  685. package/coding-standards/python.md +0 -82
  686. package/coding-standards/rust.md +0 -78
  687. package/coding-standards/typescript.md +0 -85
  688. package/coding-standards/x402.md +0 -227
  689. package/common/README.md +0 -41
  690. package/common/common_exit +0 -1
  691. package/common/package.json +0 -45
  692. package/common/src/__tests__/agent-validation.test.ts +0 -861
  693. package/common/src/__tests__/agents.test.ts +0 -16
  694. package/common/src/__tests__/dynamic-agent-template-schema.test.ts +0 -420
  695. package/common/src/__tests__/env-ci.test.ts +0 -167
  696. package/common/src/__tests__/env-process.test.ts +0 -145
  697. package/common/src/__tests__/free-agents.test.ts +0 -269
  698. package/common/src/__tests__/handlesteps-parsing.test.ts +0 -247
  699. package/common/src/__tests__/model-config.test.ts +0 -41
  700. package/common/src/__tests__/project-file-tree.test.ts +0 -82
  701. package/common/src/__tests__/response-ad-positions.test.ts +0 -78
  702. package/common/src/__tests__/savant-free-models.test.ts +0 -460
  703. package/common/src/__tests__/savant-free-referral-tiers.test.ts +0 -122
  704. package/common/src/__tests__/user-state.test.ts +0 -30
  705. package/common/src/actions.ts +0 -215
  706. package/common/src/analytics-core.ts +0 -73
  707. package/common/src/analytics.ts +0 -96
  708. package/common/src/api-keys/constants.ts +0 -26
  709. package/common/src/browser-actions.ts +0 -414
  710. package/common/src/constants/agents.ts +0 -250
  711. package/common/src/constants/analytics-events.ts +0 -318
  712. package/common/src/constants/anthropic.ts +0 -72
  713. package/common/src/constants/byok.ts +0 -2
  714. package/common/src/constants/chatgpt-oauth.ts +0 -85
  715. package/common/src/constants/composio.ts +0 -34
  716. package/common/src/constants/feedback.ts +0 -18
  717. package/common/src/constants/free-agents.ts +0 -347
  718. package/common/src/constants/gemini.ts +0 -6
  719. package/common/src/constants/grant-priorities.ts +0 -13
  720. package/common/src/constants/hosts.ts +0 -6
  721. package/common/src/constants/images.ts +0 -53
  722. package/common/src/constants/index.ts +0 -8
  723. package/common/src/constants/knowledge.ts +0 -46
  724. package/common/src/constants/limits.ts +0 -23
  725. package/common/src/constants/model-config.ts +0 -469
  726. package/common/src/constants/paths.ts +0 -69
  727. package/common/src/constants/savant-code-config.ts +0 -12
  728. package/common/src/constants/savant-free-gemini-thinker.ts +0 -30
  729. package/common/src/constants/savant-free-models.ts +0 -855
  730. package/common/src/constants/savant-free-referral-tiers.ts +0 -169
  731. package/common/src/constants/skills.ts +0 -60
  732. package/common/src/constants/subscription-plans.ts +0 -54
  733. package/common/src/constants/ui.ts +0 -25
  734. package/common/src/env-ci.ts +0 -36
  735. package/common/src/env-process.ts +0 -95
  736. package/common/src/env-schema.ts +0 -42
  737. package/common/src/env.ts +0 -69
  738. package/common/src/mcp/client.ts +0 -308
  739. package/common/src/old-constants.ts +0 -10
  740. package/common/src/project-file-tree.ts +0 -361
  741. package/common/src/reddit-capi.ts +0 -175
  742. package/common/src/schemas/feedback.ts +0 -57
  743. package/common/src/schemas/logs.ts +0 -66
  744. package/common/src/templates/agent-validation.ts +0 -436
  745. package/common/src/templates/initial-agents-dir/LICENSE +0 -202
  746. package/common/src/templates/initial-agents-dir/README.md +0 -314
  747. package/common/src/templates/initial-agents-dir/examples/01-basic-diff-reviewer.ts +0 -17
  748. package/common/src/templates/initial-agents-dir/examples/02-intermediate-git-committer.ts +0 -78
  749. package/common/src/templates/initial-agents-dir/examples/03-advanced-file-explorer.ts +0 -76
  750. package/common/src/templates/initial-agents-dir/my-custom-agent.ts +0 -40
  751. package/common/src/templates/initial-agents-dir/package.json +0 -6
  752. package/common/src/templates/initial-agents-dir/skills/README.md +0 -66
  753. package/common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md +0 -29
  754. package/common/src/templates/initial-agents-dir/types/agent-definition.ts +0 -484
  755. package/common/src/templates/initial-agents-dir/types/tools.ts +0 -452
  756. package/common/src/templates/initial-agents-dir/types/util-types.ts +0 -167
  757. package/common/src/testing/TESTING_PATTERNS.md +0 -353
  758. package/common/src/testing/errors.ts +0 -33
  759. package/common/src/testing/fixtures/agent-runtime.ts +0 -325
  760. package/common/src/testing/impl/agent-runtime.ts +0 -6
  761. package/common/src/testing/index.ts +0 -84
  762. package/common/src/testing/mock-modules.ts +0 -54
  763. package/common/src/testing/mock-types.ts +0 -123
  764. package/common/src/testing/mocks/analytics.ts +0 -262
  765. package/common/src/testing/mocks/child-process.ts +0 -93
  766. package/common/src/testing/mocks/crypto.ts +0 -218
  767. package/common/src/testing/mocks/database.ts +0 -337
  768. package/common/src/testing/mocks/filesystem.ts +0 -166
  769. package/common/src/testing/mocks/index.ts +0 -83
  770. package/common/src/testing/mocks/logger.ts +0 -136
  771. package/common/src/testing/mocks/stream.ts +0 -314
  772. package/common/src/testing/mocks/timers.ts +0 -132
  773. package/common/src/testing/mocks/tree-sitter.ts +0 -127
  774. package/common/src/testing/setup.ts +0 -282
  775. package/common/src/testing-env-ci.ts +0 -15
  776. package/common/src/testing-env-process.ts +0 -78
  777. package/common/src/tools/__tests__/compile-tool-definitions.test.ts +0 -34
  778. package/common/src/tools/__tests__/thought-session.test.ts +0 -256
  779. package/common/src/tools/compile-tool-definitions.ts +0 -194
  780. package/common/src/tools/constants.ts +0 -132
  781. package/common/src/tools/list.ts +0 -193
  782. package/common/src/tools/params/__tests__/coerce-to-array.test.ts +0 -237
  783. package/common/src/tools/params/__tests__/sequential-thinking-coercion.test.ts +0 -98
  784. package/common/src/tools/params/tool/add-message.ts +0 -36
  785. package/common/src/tools/params/tool/add-subgoal.ts +0 -57
  786. package/common/src/tools/params/tool/apply-patch.ts +0 -110
  787. package/common/src/tools/params/tool/ask-user.ts +0 -181
  788. package/common/src/tools/params/tool/browser-logs.ts +0 -85
  789. package/common/src/tools/params/tool/code-search.ts +0 -144
  790. package/common/src/tools/params/tool/composio.ts +0 -131
  791. package/common/src/tools/params/tool/create-plan.ts +0 -80
  792. package/common/src/tools/params/tool/end-turn.ts +0 -54
  793. package/common/src/tools/params/tool/find-files.ts +0 -60
  794. package/common/src/tools/params/tool/glob.ts +0 -74
  795. package/common/src/tools/params/tool/gravity-index.ts +0 -92
  796. package/common/src/tools/params/tool/list-directory.ts +0 -58
  797. package/common/src/tools/params/tool/lookup-agent-info.ts +0 -37
  798. package/common/src/tools/params/tool/propose-str-replace.ts +0 -103
  799. package/common/src/tools/params/tool/propose-write-file.ts +0 -71
  800. package/common/src/tools/params/tool/read-docs.ts +0 -85
  801. package/common/src/tools/params/tool/read-files.ts +0 -61
  802. package/common/src/tools/params/tool/read-subtree.ts +0 -83
  803. package/common/src/tools/params/tool/read-url.ts +0 -81
  804. package/common/src/tools/params/tool/render-ui.ts +0 -203
  805. package/common/src/tools/params/tool/run-file-change-hooks.ts +0 -57
  806. package/common/src/tools/params/tool/run-readonly-command.ts +0 -76
  807. package/common/src/tools/params/tool/run-terminal-command.ts +0 -185
  808. package/common/src/tools/params/tool/sequential-thinking.ts +0 -110
  809. package/common/src/tools/params/tool/set-messages.ts +0 -42
  810. package/common/src/tools/params/tool/set-output.ts +0 -62
  811. package/common/src/tools/params/tool/set-scaffold-complete.ts +0 -51
  812. package/common/src/tools/params/tool/skill.ts +0 -56
  813. package/common/src/tools/params/tool/spawn-agent-inline.ts +0 -57
  814. package/common/src/tools/params/tool/spawn-agents.ts +0 -154
  815. package/common/src/tools/params/tool/str-replace.ts +0 -107
  816. package/common/src/tools/params/tool/suggest-followups.ts +0 -94
  817. package/common/src/tools/params/tool/task-completed.ts +0 -58
  818. package/common/src/tools/params/tool/think-deeply.ts +0 -53
  819. package/common/src/tools/params/tool/transition-phase.ts +0 -57
  820. package/common/src/tools/params/tool/update-subgoal.ts +0 -89
  821. package/common/src/tools/params/tool/web-search.ts +0 -73
  822. package/common/src/tools/params/tool/write-file.ts +0 -71
  823. package/common/src/tools/params/tool/write-todos.ts +0 -67
  824. package/common/src/tools/params/utils.ts +0 -152
  825. package/common/src/tools/safety-registry.ts +0 -274
  826. package/common/src/tools/safety.ts +0 -42
  827. package/common/src/tools/sequential-thinking.ts +0 -340
  828. package/common/src/tools/utils.ts +0 -25
  829. package/common/src/types/__tests__/dynamic-agent-template.test.ts +0 -20
  830. package/common/src/types/agent-template.ts +0 -223
  831. package/common/src/types/api/agents/publish.ts +0 -63
  832. package/common/src/types/contracts/agent-runtime.ts +0 -84
  833. package/common/src/types/contracts/analytics.ts +0 -12
  834. package/common/src/types/contracts/bigquery.ts +0 -55
  835. package/common/src/types/contracts/billing.ts +0 -46
  836. package/common/src/types/contracts/client.ts +0 -52
  837. package/common/src/types/contracts/database.ts +0 -104
  838. package/common/src/types/contracts/env.ts +0 -203
  839. package/common/src/types/contracts/llm.ts +0 -183
  840. package/common/src/types/contracts/logger.ts +0 -29
  841. package/common/src/types/contracts/logs.ts +0 -34
  842. package/common/src/types/contracts/trace.ts +0 -21
  843. package/common/src/types/dynamic-agent-template.ts +0 -290
  844. package/common/src/types/filesystem.ts +0 -10
  845. package/common/src/types/function-params.ts +0 -35
  846. package/common/src/types/grant.ts +0 -20
  847. package/common/src/types/gravity-index.ts +0 -170
  848. package/common/src/types/json.ts +0 -26
  849. package/common/src/types/mcp.ts +0 -26
  850. package/common/src/types/messages/content-part.ts +0 -59
  851. package/common/src/types/messages/data-content.ts +0 -14
  852. package/common/src/types/messages/provider-metadata.ts +0 -10
  853. package/common/src/types/messages/savant-code-message.ts +0 -55
  854. package/common/src/types/organization.ts +0 -118
  855. package/common/src/types/print-mode.ts +0 -167
  856. package/common/src/types/publisher.ts +0 -67
  857. package/common/src/types/savant-free-session.ts +0 -295
  858. package/common/src/types/savant-free-streak.ts +0 -6
  859. package/common/src/types/session-state.ts +0 -237
  860. package/common/src/types/skill.ts +0 -56
  861. package/common/src/types/source.ts +0 -11
  862. package/common/src/types/spawn.ts +0 -13
  863. package/common/src/types/subscription.ts +0 -68
  864. package/common/src/types/usage.ts +0 -16
  865. package/common/src/types/util.ts +0 -3
  866. package/common/src/util/__tests__/analytics-dispatcher.test.ts +0 -145
  867. package/common/src/util/__tests__/analytics-log.test.ts +0 -110
  868. package/common/src/util/__tests__/analytics-sampling.test.ts +0 -131
  869. package/common/src/util/__tests__/axiom-only-log.test.ts +0 -64
  870. package/common/src/util/__tests__/engagement-tracker.test.ts +0 -115
  871. package/common/src/util/__tests__/error-abort.test.ts +0 -822
  872. package/common/src/util/__tests__/error-api-details.test.ts +0 -190
  873. package/common/src/util/__tests__/format-code-search.test.ts +0 -60
  874. package/common/src/util/__tests__/log-mirror.test.ts +0 -31
  875. package/common/src/util/__tests__/messages.test.ts +0 -1118
  876. package/common/src/util/__tests__/partial-json-delta.test.ts +0 -505
  877. package/common/src/util/__tests__/paths.test.ts +0 -289
  878. package/common/src/util/__tests__/promise.test.ts +0 -334
  879. package/common/src/util/__tests__/protocol-config.test.ts +0 -119
  880. package/common/src/util/__tests__/rate-limit.test.ts +0 -28
  881. package/common/src/util/__tests__/reddit-savant-free-retention.test.ts +0 -120
  882. package/common/src/util/__tests__/savant-free-streak.test.ts +0 -142
  883. package/common/src/util/__tests__/saxy.test.ts +0 -1008
  884. package/common/src/util/__tests__/string.test.ts +0 -238
  885. package/common/src/util/__tests__/zoned-time.test.ts +0 -88
  886. package/common/src/util/agent-file-utils.ts +0 -110
  887. package/common/src/util/agent-id-parsing.ts +0 -147
  888. package/common/src/util/agent-name-normalization.ts +0 -39
  889. package/common/src/util/analytics-dispatcher.ts +0 -81
  890. package/common/src/util/analytics-log.ts +0 -84
  891. package/common/src/util/analytics-sampling.ts +0 -225
  892. package/common/src/util/array.ts +0 -63
  893. package/common/src/util/axiom-only-log.ts +0 -79
  894. package/common/src/util/cache-debug.ts +0 -191
  895. package/common/src/util/credentials.ts +0 -12
  896. package/common/src/util/currency.ts +0 -25
  897. package/common/src/util/dates.ts +0 -81
  898. package/common/src/util/engagement-tracker.ts +0 -133
  899. package/common/src/util/error.ts +0 -580
  900. package/common/src/util/file.ts +0 -363
  901. package/common/src/util/format-code-search.ts +0 -115
  902. package/common/src/util/lazy-response-ads.ts +0 -93
  903. package/common/src/util/log-data.ts +0 -57
  904. package/common/src/util/log-mirror.ts +0 -34
  905. package/common/src/util/lru-cache.ts +0 -67
  906. package/common/src/util/messages.ts +0 -600
  907. package/common/src/util/model-utils.ts +0 -25
  908. package/common/src/util/object.ts +0 -137
  909. package/common/src/util/param-helpers.ts +0 -49
  910. package/common/src/util/partial-json-delta.ts +0 -100
  911. package/common/src/util/paths.ts +0 -194
  912. package/common/src/util/promise.ts +0 -87
  913. package/common/src/util/protocol-config.ts +0 -141
  914. package/common/src/util/random.ts +0 -15
  915. package/common/src/util/rate-limit.ts +0 -56
  916. package/common/src/util/reddit-capi-events.ts +0 -40
  917. package/common/src/util/reddit-savant-free-retention.ts +0 -69
  918. package/common/src/util/response-ad-positions.ts +0 -54
  919. package/common/src/util/savant-free-privacy.ts +0 -78
  920. package/common/src/util/savant-free-streak.ts +0 -153
  921. package/common/src/util/saxy.ts +0 -739
  922. package/common/src/util/skills.ts +0 -32
  923. package/common/src/util/stop-sequence.ts +0 -60
  924. package/common/src/util/string.ts +0 -419
  925. package/common/src/util/system-info.ts +0 -51
  926. package/common/src/util/type-narrowing.ts +0 -125
  927. package/common/src/util/xml.ts +0 -17
  928. package/common/src/util/zod-schema.ts +0 -80
  929. package/common/src/util/zoned-time.ts +0 -136
  930. package/common/src/utils/ask-user-bridge.ts +0 -47
  931. package/common/tsconfig.json +0 -8
  932. package/database.db +0 -0
  933. package/dev/LEARNINGS.md +0 -309
  934. package/dev/fids/.gitkeep +0 -0
  935. package/dev/fids/archive/.gitkeep +0 -0
  936. package/dev/fids/archive/FID-2026-0802-008-sdk-package-audit-client-run-run-state.md +0 -238
  937. package/dev/fids/archive/FID-2026-0803-001-echo-enforcement-layer-drift.md +0 -404
  938. package/dev/fids/archive/FID-2026-0803-002-llm-providers-database-audit.md +0 -348
  939. package/dev/fids/archive/FID-2026-0803-003-sdk-impl-common-util-audit.md +0 -277
  940. package/dev/fids/archive/FID-2026-0803-004-checkpoint-rewind.md +0 -181
  941. package/dev/fids/archive/FID-2026-0803-005-quality-scan-hygiene-fixes.md +0 -265
  942. package/dev/fids/archive/FID-2026-0803-006-code-map-audit-hygiene.md +0 -243
  943. package/dev/fids/archive/FID-2026-0803-007-evals-benchmark-audit-hygiene.md +0 -296
  944. package/dev/fids/archive/FID-2026-0803-009-echo-enforcement-doc-drift.md +0 -112
  945. package/dev/fids/archive/FID-2026-0803-010-database-llm-providers-low-fixes.md +0 -255
  946. package/dev/fids/archive/FID-2026-0803-011-build-artifact-hygiene.md +0 -169
  947. package/dev/fids/archive/FID-2026-0803-012-release-readiness-audit.md +0 -162
  948. package/dev/fids/archive/FID-2026-0803-013-agent-roster-over-reporting.md +0 -146
  949. package/dev/fids/archive/FID-2026-0803-014-freebuff-to-savant-rebrand-sweep.md +0 -185
  950. package/dev/nova/reports/2026-08-02-top-25-ai-inference-providers-competitive-intel.md +0 -213
  951. package/dev/nova/specs/echo-v0.1.2-freebuff.md +0 -391
  952. package/dev/nova/specs/goal-loop-feature-spec.md +0 -245
  953. package/dev/nova/specs/launch-strategy-research-prompt.md +0 -88
  954. package/dev/releases/README.md +0 -50
  955. package/dev/scratchpad/.gitkeep +0 -0
  956. package/dev/session-summaries/.gitkeep +0 -0
  957. package/dev/session-summaries/2026-07-16-1255.md +0 -128
  958. package/dev/session-summaries/2026-07-17-1000.md +0 -64
  959. package/dev/session-summaries/2026-07-19-eslint-zero-tolerance-cleanup.md +0 -145
  960. package/dev/session-summaries/2026-07-19-fid-026-debugging-and-rename.md +0 -79
  961. package/dev/session-summaries/2026-07-19-fid-026-phase-b-rebrand.md +0 -76
  962. package/dev/session-summaries/2026-07-19-fid-027-clean-break.md +0 -47
  963. package/dev/session-summaries/2026-07-19-pre-push-house-cleaning.md +0 -100
  964. package/dev/session-summaries/2026-07-19-v0.0.2-release-session.md +0 -229
  965. package/dev/session-summaries/2026-07-20-1805-fid-loop-closure.md +0 -95
  966. package/dev/session-summaries/2026-07-22-1800.md +0 -52
  967. package/dev/session-summaries/2026-07-23-fsm-optimization.md +0 -88
  968. package/dev/session-summaries/2026-07-25-0000.md +0 -80
  969. package/dev/session-summaries/2026-07-25-1200-context-compaction.md +0 -154
  970. package/dev/session-summaries/2026-07-25-1600-layer4-reactive-compact.md +0 -96
  971. package/dev/session-summaries/2026-07-25-1700-dev-folder-audit.md +0 -161
  972. package/dev/session-summaries/2026-07-25-2000-benchmark-v2-filters.md +0 -147
  973. package/dev/session-summaries/2026-07-25-2000.md +0 -77
  974. package/dev/session-summaries/2026-07-27-0000.md +0 -63
  975. package/dev/session-summaries/2026-07-28-history-capture-handoff.md +0 -206
  976. package/dev/session-summaries/2026-07-31-freebuff-echo-compliance-remediation.md +0 -156
  977. package/dev/session-summaries/2026-07-31-pre-launch-optimization-execution.md +0 -46
  978. package/dev/session-summaries/2026-0721-1800.md +0 -44
  979. package/dev/session-summaries/2026-08-01-sidebar-folded-startup.md +0 -159
  980. package/dev/session-summaries/2026-08-02-0.0.15-release-closeout.md +0 -32
  981. package/dev/session-summaries/2026-08-02-repository-hygiene.md +0 -51
  982. package/dev/session-summaries/2026-08-03-agent-roster-fix-and-ready-check-closeout.md +0 -73
  983. package/dev/session-summaries/2026-08-03-build-artifact-hygiene-closeout.md +0 -40
  984. package/dev/session-summaries/2026-08-03-code-map-audit-closeout.md +0 -44
  985. package/dev/session-summaries/2026-08-03-database-llm-providers-low-fixes-closeout.md +0 -47
  986. package/dev/session-summaries/2026-08-03-echo-enforcement-doc-drift-closeout.md +0 -43
  987. package/dev/session-summaries/2026-08-03-evals-benchmark-audit-closeout.md +0 -50
  988. package/dev/session-summaries/2026-08-03-quality-scan-hygiene-fixes-closeout.md +0 -57
  989. package/dev/session-summaries/2026-08-03-quality-session-checkpoint-rewind-closeout.md +0 -56
  990. package/dev/session-summaries/2026-08-03-release-readiness-audit-closeout.md +0 -60
  991. package/dev/test-prompts/archive/0.0.2-final-pass.md +0 -209
  992. package/dev/test-prompts/archive/agent-capabilities-full-test.md +0 -361
  993. package/dev/test-prompts/archive/agent-capabilities-test.md +0 -471
  994. package/dev/test-prompts/archive/comprehensive-az-system-test-v1.md +0 -441
  995. package/dev/test-prompts/archive/comprehensive-az-system-test-v2-report.md +0 -377
  996. package/dev/test-prompts/archive/comprehensive-az-system-test-v2.md +0 -544
  997. package/dev/test-prompts/archive/comprehensive-az-system-test.md +0 -396
  998. package/dev/test-prompts/archive/comprehensive-az-test-final.md +0 -900
  999. package/dev/test-prompts/archive/comprehensive-az-test-v11.md +0 -141
  1000. package/dev/test-prompts/archive/comprehensive-az-test-v12.md +0 -29
  1001. package/dev/test-prompts/archive/comprehensive-az-test-v6.md +0 -631
  1002. package/dev/test-prompts/archive/comprehensive-az-test-v7.md +0 -625
  1003. package/dev/test-prompts/archive/fid-007-ability-confirmation.md +0 -248
  1004. package/dev/test-prompts/archive/fid-2026-0801-006-thinker-tool-call-boundary.md +0 -86
  1005. package/dev/test-prompts/archive/fid-2026-0801-007-child-tool-set-fallback-cli.md +0 -101
  1006. package/dev/test-prompts/archive/fid-2026-0801-008-provider-tool-call-accumulation-cli.md +0 -111
  1007. package/dev/test-prompts/archive/fid-2026-0801-012-thinker-state-output-cli.md +0 -134
  1008. package/dev/test-prompts/archive/gemini-deep-research-sidebar-highlight.md +0 -598
  1009. package/dev/test-prompts/archive/goal-loop-cli-test.md +0 -142
  1010. package/dev/test-prompts/archive/rebrand-qa.md +0 -258
  1011. package/dev/test-prompts/archive/release-az-test-fid-085.md +0 -306
  1012. package/dev/test-prompts/archive/release-az-test-fid-087.md +0 -219
  1013. package/dev/test-prompts/archive/release-az-test-fid-2026-0726-001.md +0 -270
  1014. package/dev/test-prompts/archive/release-az-test-fid-2026-0728-008.md +0 -213
  1015. package/dev/test-prompts/archive/release-az-test-fid-2026-0728-launch-tracks.md +0 -253
  1016. package/dev/test-prompts/archive/release-az-test-fid-2026-0731-pre-launch.md +0 -102
  1017. package/docs/AI Coding Agents Market Research.md +0 -516
  1018. package/docs/Agent Harness Feature Pairing Research.md +0 -166
  1019. package/docs/CLI Agent Inference Backend Research.md +0 -446
  1020. package/docs/Codebuff Rebranding And Migration Plan.md +0 -418
  1021. package/docs/ECHO-EVOLUTION-ARCH.md +0 -77
  1022. package/docs/Harness Engineering for Coding Agents Research.md +0 -196
  1023. package/docs/Launch Plan Review and Optimization.md +0 -178
  1024. package/docs/OpenTUI Sidebar Bug Fix.md +0 -245
  1025. package/docs/SAVANT-VERSIONING.md +0 -31
  1026. package/docs/Savant Code Launch Strategy.md +0 -270
  1027. package/docs/Savant-Code Business And Backend Research.md +0 -222
  1028. package/docs/agents-and-tools.md +0 -217
  1029. package/docs/cloudflare-llms-full.md +0 -9623
  1030. package/docs/design/OpenTUI Terminal Visualization Guide.md +0 -471
  1031. package/docs/design/database-architecture.md +0 -217
  1032. package/docs/design/deep-research-report.md +0 -295
  1033. package/docs/design/thinker-sequentialthinking-regression-diagnostic.md +0 -366
  1034. package/docs/discord-server-design.md +0 -167
  1035. package/docs/gravity-integration-starter.md +0 -202
  1036. package/docs/launch/hn-first-comment.md +0 -44
  1037. package/docs/launch/hn-post.md +0 -35
  1038. package/docs/launch/incident-response.md +0 -88
  1039. package/docs/launch/landing/index.html +0 -233
  1040. package/docs/launch/mastodon-thread.md +0 -52
  1041. package/docs/launch/newsletter-pitch.md +0 -49
  1042. package/docs/launch/twitter-thread.md +0 -62
  1043. package/docs/privacy.md +0 -186
  1044. package/docs/reports/Savant-Code Benchmark Specification.md +0 -206
  1045. package/docs/reports/Thinker Agent Architecture Research.md +0 -284
  1046. package/docs/reports/adoptable-features-2026-07-25.md +0 -604
  1047. package/docs/reports/adoptable-features-master.md +0 -480
  1048. package/docs/reports/codebuff-discord-feedback.md +0 -43
  1049. package/docs/reports/feature-parity-report.md +0 -810
  1050. package/docs/reports/repos/AionUi.md +0 -75
  1051. package/docs/reports/repos/OpenHands.md +0 -76
  1052. package/docs/reports/repos/SWE-agent.md +0 -82
  1053. package/docs/reports/repos/agno.md +0 -89
  1054. package/docs/reports/repos/aider.md +0 -74
  1055. package/docs/reports/repos/cline.md +0 -83
  1056. package/docs/reports/repos/codex.md +0 -83
  1057. package/docs/reports/repos/gemini-cli.md +0 -92
  1058. package/docs/reports/repos/goose.md +0 -65
  1059. package/docs/reports/repos/gpt-pilot.md +0 -52
  1060. package/docs/reports/repos/hermes-agent.md +0 -101
  1061. package/docs/reports/repos/kilocode.md +0 -79
  1062. package/docs/reports/repos/openclaude.md +0 -99
  1063. package/docs/reports/repos/openclaw.md +0 -100
  1064. package/docs/reports/repos/opencode-dev.md +0 -78
  1065. package/docs/reports/repos/zero.md +0 -123
  1066. package/docs/reports/savant-code-benchmark-v2-2026-08-03.md +0 -76
  1067. package/docs/savant-code-modes.md +0 -338
  1068. package/docs/testing.md +0 -51
  1069. package/docs/visual-mockup-neon-slate.txt +0 -41
  1070. package/eslint.config.js +0 -211
  1071. package/evals/README.md +0 -45
  1072. package/evals/benchmark/README.md +0 -449
  1073. package/evals/benchmark/agent-runner.ts +0 -217
  1074. package/evals/benchmark/analyze-task-scores.ts +0 -496
  1075. package/evals/benchmark/eval-codebuff-hard.json +0 -3343
  1076. package/evals/benchmark/eval-codebuff.json +0 -3193
  1077. package/evals/benchmark/eval-codebuff2.json +0 -2494
  1078. package/evals/benchmark/eval-manifold-hard.json +0 -2525
  1079. package/evals/benchmark/eval-manifold.json +0 -1675
  1080. package/evals/benchmark/eval-manifold2.json +0 -1945
  1081. package/evals/benchmark/eval-plane-hard.json +0 -4372
  1082. package/evals/benchmark/eval-plane.json +0 -2028
  1083. package/evals/benchmark/eval-plane2.json +0 -3517
  1084. package/evals/benchmark/eval-saleor-hard.json +0 -6081
  1085. package/evals/benchmark/eval-saleor.json +0 -1829
  1086. package/evals/benchmark/eval-saleor2.json +0 -3273
  1087. package/evals/benchmark/eval-task-generator.ts +0 -158
  1088. package/evals/benchmark/filter-supplemental-files.ts +0 -225
  1089. package/evals/benchmark/format-output.ts +0 -215
  1090. package/evals/benchmark/gen-evals.ts +0 -294
  1091. package/evals/benchmark/gen-repo-eval.ts +0 -71
  1092. package/evals/benchmark/judge.ts +0 -308
  1093. package/evals/benchmark/lessons-extractor.ts +0 -247
  1094. package/evals/benchmark/main-hard-tasks.ts +0 -49
  1095. package/evals/benchmark/main-single-eval.ts +0 -23
  1096. package/evals/benchmark/main.ts +0 -29
  1097. package/evals/benchmark/meta-analyzer.ts +0 -329
  1098. package/evals/benchmark/pick-commits.ts +0 -627
  1099. package/evals/benchmark/run-benchmark.ts +0 -657
  1100. package/evals/benchmark/runners/claude.ts +0 -176
  1101. package/evals/benchmark/runners/codex.ts +0 -143
  1102. package/evals/benchmark/runners/index.ts +0 -4
  1103. package/evals/benchmark/runners/opencode.ts +0 -253
  1104. package/evals/benchmark/runners/runner.ts +0 -13
  1105. package/evals/benchmark/runners/savant.ts +0 -148
  1106. package/evals/benchmark/setup-test-repo.ts +0 -314
  1107. package/evals/benchmark/trace-analyzer.ts +0 -257
  1108. package/evals/benchmark/trace-utils.ts +0 -80
  1109. package/evals/benchmark/types.ts +0 -83
  1110. package/evals/bunfig.toml +0 -4
  1111. package/evals/logger.ts +0 -87
  1112. package/evals/package.json +0 -31
  1113. package/evals/subagents/test-repo-utils.ts +0 -131
  1114. package/evals/tsconfig.json +0 -13
  1115. package/evals/v2/README.md +0 -149
  1116. package/evals/v2/reports/report.json +0 -261
  1117. package/evals/v2/reports/report.md +0 -15
  1118. package/evals/v2/schema/task.schema.json +0 -172
  1119. package/evals/v2/src/cli.ts +0 -216
  1120. package/evals/v2/src/golden.ts +0 -73
  1121. package/evals/v2/src/harness.ts +0 -293
  1122. package/evals/v2/src/metrics.ts +0 -326
  1123. package/evals/v2/src/registry.ts +0 -179
  1124. package/evals/v2/src/reports.ts +0 -126
  1125. package/evals/v2/src/runner.ts +0 -143
  1126. package/evals/v2/src/runners/savant.ts +0 -105
  1127. package/evals/v2/src/sandbox.ts +0 -42
  1128. package/evals/v2/src/sandboxes/docker.ts +0 -49
  1129. package/evals/v2/src/sandboxes/tempdir.ts +0 -120
  1130. package/evals/v2/src/schema.ts +0 -83
  1131. package/evals/v2/src/trace.ts +0 -186
  1132. package/evals/v2/src/verify.ts +0 -199
  1133. package/evals/v2/tasks/error_recovery/env-fault/calculator.js +0 -11
  1134. package/evals/v2/tasks/error_recovery/env-fault/calculator.test.js +0 -15
  1135. package/evals/v2/tasks/error_recovery/env-fault/golden.patch +0 -15
  1136. package/evals/v2/tasks/error_recovery/env-fault/task.yaml +0 -20
  1137. package/evals/v2/tasks/multi_agent_orchestration/options-contract/app.js +0 -5
  1138. package/evals/v2/tasks/multi_agent_orchestration/options-contract/app.test.js +0 -7
  1139. package/evals/v2/tasks/multi_agent_orchestration/options-contract/golden.patch +0 -19
  1140. package/evals/v2/tasks/multi_agent_orchestration/options-contract/greet.js +0 -3
  1141. package/evals/v2/tasks/multi_agent_orchestration/options-contract/orchestration.test.js +0 -30
  1142. package/evals/v2/tasks/multi_agent_orchestration/options-contract/task.yaml +0 -29
  1143. package/evals/v2/tasks/pure_coding/add-fix/add.js +0 -3
  1144. package/evals/v2/tasks/pure_coding/add-fix/add.test.js +0 -11
  1145. package/evals/v2/tasks/pure_coding/add-fix/golden.patch +0 -7
  1146. package/evals/v2/tasks/pure_coding/add-fix/task.yaml +0 -20
  1147. package/evals/v2/tasks/pure_coding/rename-greet/app.js +0 -5
  1148. package/evals/v2/tasks/pure_coding/rename-greet/app.test.js +0 -32
  1149. package/evals/v2/tasks/pure_coding/rename-greet/golden.patch +0 -19
  1150. package/evals/v2/tasks/pure_coding/rename-greet/greet.js +0 -5
  1151. package/evals/v2/tasks/pure_coding/rename-greet/task.yaml +0 -21
  1152. package/evals/v2/tests/golden.test.ts +0 -104
  1153. package/evals/v2/tests/harness.test.ts +0 -204
  1154. package/evals/v2/tests/metrics.test.ts +0 -353
  1155. package/evals/v2/tests/registry.test.ts +0 -210
  1156. package/evals/v2/tests/reports.test.ts +0 -218
  1157. package/evals/v2/tests/savant-runner.test.ts +0 -218
  1158. package/evals/v2/tests/schema.test.ts +0 -127
  1159. package/evals/v2/tests/tempdir-sandbox.test.ts +0 -49
  1160. package/evals/v2/tests/trace.test.ts +0 -76
  1161. package/evals/v2/tests/verify.test.ts +0 -224
  1162. package/license +0 -202
  1163. package/packages/agent-runtime/README.md +0 -46
  1164. package/packages/agent-runtime/agent_exit +0 -1
  1165. package/packages/agent-runtime/bunfig.toml +0 -2
  1166. package/packages/agent-runtime/package.json +0 -34
  1167. package/packages/agent-runtime/src/__tests__/apply-patch-tool.test.ts +0 -202
  1168. package/packages/agent-runtime/src/__tests__/cost-aggregation.test.ts +0 -397
  1169. package/packages/agent-runtime/src/__tests__/generate-diffs-prompt.test.ts +0 -107
  1170. package/packages/agent-runtime/src/__tests__/get-file-reading-updates.test.ts +0 -59
  1171. package/packages/agent-runtime/src/__tests__/gravity-index-tool.test.ts +0 -501
  1172. package/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +0 -1512
  1173. package/packages/agent-runtime/src/__tests__/main-prompt.test.ts +0 -452
  1174. package/packages/agent-runtime/src/__tests__/n-parameter.test.ts +0 -984
  1175. package/packages/agent-runtime/src/__tests__/process-file-block.test.ts +0 -196
  1176. package/packages/agent-runtime/src/__tests__/process-str-replace.test.ts +0 -512
  1177. package/packages/agent-runtime/src/__tests__/programmatic-tool-authorization.test.ts +0 -219
  1178. package/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts +0 -749
  1179. package/packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts +0 -454
  1180. package/packages/agent-runtime/src/__tests__/propose-tools.test.ts +0 -824
  1181. package/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts +0 -394
  1182. package/packages/agent-runtime/src/__tests__/run-agent-step-prefill.test.ts +0 -161
  1183. package/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +0 -612
  1184. package/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +0 -1743
  1185. package/packages/agent-runtime/src/__tests__/sandbox-generator.test.ts +0 -154
  1186. package/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts +0 -354
  1187. package/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts +0 -280
  1188. package/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts +0 -664
  1189. package/packages/agent-runtime/src/__tests__/stream-parser-abort.test.ts +0 -250
  1190. package/packages/agent-runtime/src/__tests__/stream-parser-reasoning.test.ts +0 -194
  1191. package/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts +0 -230
  1192. package/packages/agent-runtime/src/__tests__/test-utils.ts +0 -96
  1193. package/packages/agent-runtime/src/__tests__/thinker-convergence-gate.test.ts +0 -242
  1194. package/packages/agent-runtime/src/__tests__/to-token-count-input-schema.test.ts +0 -81
  1195. package/packages/agent-runtime/src/__tests__/tool-executor-sandbox.test.ts +0 -167
  1196. package/packages/agent-runtime/src/__tests__/tool-stream-parser.test.ts +0 -682
  1197. package/packages/agent-runtime/src/__tests__/tool-validation-error.test.ts +0 -1380
  1198. package/packages/agent-runtime/src/__tests__/web-search-tool.test.ts +0 -402
  1199. package/packages/agent-runtime/src/__tests__/xml-tool-result-ordering.test.ts +0 -264
  1200. package/packages/agent-runtime/src/constants.ts +0 -12
  1201. package/packages/agent-runtime/src/context-compactor.ts +0 -425
  1202. package/packages/agent-runtime/src/find-files/__tests__/request-files-prompt.test.ts +0 -190
  1203. package/packages/agent-runtime/src/find-files/custom-file-picker-config.ts +0 -61
  1204. package/packages/agent-runtime/src/find-files/request-files-prompt.ts +0 -462
  1205. package/packages/agent-runtime/src/generate-diffs-prompt.ts +0 -38
  1206. package/packages/agent-runtime/src/get-file-reading-updates.ts +0 -27
  1207. package/packages/agent-runtime/src/llm-api/__tests__/gemini-with-fallbacks.test.ts +0 -267
  1208. package/packages/agent-runtime/src/llm-api/__tests__/serper-api.test.ts +0 -297
  1209. package/packages/agent-runtime/src/llm-api/claude.ts +0 -7
  1210. package/packages/agent-runtime/src/llm-api/context7-api.ts +0 -290
  1211. package/packages/agent-runtime/src/llm-api/gemini-with-fallbacks.ts +0 -104
  1212. package/packages/agent-runtime/src/llm-api/savant-code-web-api.ts +0 -370
  1213. package/packages/agent-runtime/src/llm-api/serper-api.ts +0 -193
  1214. package/packages/agent-runtime/src/main-prompt.ts +0 -251
  1215. package/packages/agent-runtime/src/mcp-constants.ts +0 -12
  1216. package/packages/agent-runtime/src/mcp.ts +0 -83
  1217. package/packages/agent-runtime/src/process-file-block.ts +0 -118
  1218. package/packages/agent-runtime/src/process-str-replace.ts +0 -213
  1219. package/packages/agent-runtime/src/prompt-agent-stream.ts +0 -119
  1220. package/packages/agent-runtime/src/run-agent-step.ts +0 -1632
  1221. package/packages/agent-runtime/src/run-programmatic-step.ts +0 -633
  1222. package/packages/agent-runtime/src/system-prompt/prompts.ts +0 -215
  1223. package/packages/agent-runtime/src/system-prompt/search-system-prompt.ts +0 -89
  1224. package/packages/agent-runtime/src/system-prompt/truncate-file-tree.ts +0 -414
  1225. package/packages/agent-runtime/src/templates/README.md +0 -160
  1226. package/packages/agent-runtime/src/templates/__tests__/agent-registry.test.ts +0 -489
  1227. package/packages/agent-runtime/src/templates/__tests__/strings.test.ts +0 -465
  1228. package/packages/agent-runtime/src/templates/agent-registry.ts +0 -122
  1229. package/packages/agent-runtime/src/templates/prompts.ts +0 -189
  1230. package/packages/agent-runtime/src/templates/strings.ts +0 -292
  1231. package/packages/agent-runtime/src/templates/types.ts +0 -61
  1232. package/packages/agent-runtime/src/tool-stream-parser.ts +0 -260
  1233. package/packages/agent-runtime/src/tools/filter-tool-set.ts +0 -18
  1234. package/packages/agent-runtime/src/tools/handlers/__tests__/glob.test.ts +0 -331
  1235. package/packages/agent-runtime/src/tools/handlers/__tests__/read-subtree.test.ts +0 -346
  1236. package/packages/agent-runtime/src/tools/handlers/__tests__/run-readonly-command.test.ts +0 -284
  1237. package/packages/agent-runtime/src/tools/handlers/handler-function-type.ts +0 -77
  1238. package/packages/agent-runtime/src/tools/handlers/list.ts +0 -105
  1239. package/packages/agent-runtime/src/tools/handlers/tool/__tests__/checkpoint-store.test.ts +0 -394
  1240. package/packages/agent-runtime/src/tools/handlers/tool/__tests__/skill.test.ts +0 -98
  1241. package/packages/agent-runtime/src/tools/handlers/tool/__tests__/write-file.test.ts +0 -24
  1242. package/packages/agent-runtime/src/tools/handlers/tool/add-message.ts +0 -37
  1243. package/packages/agent-runtime/src/tools/handlers/tool/add-subgoal.ts +0 -30
  1244. package/packages/agent-runtime/src/tools/handlers/tool/apply-patch.ts +0 -120
  1245. package/packages/agent-runtime/src/tools/handlers/tool/ask-user.ts +0 -25
  1246. package/packages/agent-runtime/src/tools/handlers/tool/browser-logs.ts +0 -21
  1247. package/packages/agent-runtime/src/tools/handlers/tool/checkpoint-store.ts +0 -378
  1248. package/packages/agent-runtime/src/tools/handlers/tool/code-search.ts +0 -21
  1249. package/packages/agent-runtime/src/tools/handlers/tool/composio.ts +0 -52
  1250. package/packages/agent-runtime/src/tools/handlers/tool/create-plan.ts +0 -63
  1251. package/packages/agent-runtime/src/tools/handlers/tool/end-turn.ts +0 -15
  1252. package/packages/agent-runtime/src/tools/handlers/tool/find-files.ts +0 -161
  1253. package/packages/agent-runtime/src/tools/handlers/tool/glob.ts +0 -22
  1254. package/packages/agent-runtime/src/tools/handlers/tool/gravity-index.ts +0 -230
  1255. package/packages/agent-runtime/src/tools/handlers/tool/list-directory.ts +0 -22
  1256. package/packages/agent-runtime/src/tools/handlers/tool/lookup-agent-info.ts +0 -105
  1257. package/packages/agent-runtime/src/tools/handlers/tool/propose-str-replace.ts +0 -111
  1258. package/packages/agent-runtime/src/tools/handlers/tool/propose-write-file.ts +0 -89
  1259. package/packages/agent-runtime/src/tools/handlers/tool/proposed-content-store.ts +0 -64
  1260. package/packages/agent-runtime/src/tools/handlers/tool/read-docs.ts +0 -160
  1261. package/packages/agent-runtime/src/tools/handlers/tool/read-files.ts +0 -43
  1262. package/packages/agent-runtime/src/tools/handlers/tool/read-subtree.ts +0 -198
  1263. package/packages/agent-runtime/src/tools/handlers/tool/read-url.ts +0 -21
  1264. package/packages/agent-runtime/src/tools/handlers/tool/render-ui.ts +0 -15
  1265. package/packages/agent-runtime/src/tools/handlers/tool/run-file-change-hooks.ts +0 -20
  1266. package/packages/agent-runtime/src/tools/handlers/tool/run-readonly-command.ts +0 -215
  1267. package/packages/agent-runtime/src/tools/handlers/tool/run-terminal-command.ts +0 -33
  1268. package/packages/agent-runtime/src/tools/handlers/tool/sequential-thinking.ts +0 -53
  1269. package/packages/agent-runtime/src/tools/handlers/tool/set-messages.ts +0 -19
  1270. package/packages/agent-runtime/src/tools/handlers/tool/set-output.ts +0 -104
  1271. package/packages/agent-runtime/src/tools/handlers/tool/set-scaffold-complete.ts +0 -32
  1272. package/packages/agent-runtime/src/tools/handlers/tool/skill.ts +0 -146
  1273. package/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-inline.ts +0 -149
  1274. package/packages/agent-runtime/src/tools/handlers/tool/spawn-agent-utils.ts +0 -436
  1275. package/packages/agent-runtime/src/tools/handlers/tool/spawn-agents.ts +0 -286
  1276. package/packages/agent-runtime/src/tools/handlers/tool/str-replace.ts +0 -135
  1277. package/packages/agent-runtime/src/tools/handlers/tool/suggest-followups.ts +0 -20
  1278. package/packages/agent-runtime/src/tools/handlers/tool/task-completed.ts +0 -15
  1279. package/packages/agent-runtime/src/tools/handlers/tool/think-deeply.ts +0 -25
  1280. package/packages/agent-runtime/src/tools/handlers/tool/transition-phase.ts +0 -160
  1281. package/packages/agent-runtime/src/tools/handlers/tool/update-subgoal.ts +0 -48
  1282. package/packages/agent-runtime/src/tools/handlers/tool/web-search.ts +0 -151
  1283. package/packages/agent-runtime/src/tools/handlers/tool/write-file.ts +0 -281
  1284. package/packages/agent-runtime/src/tools/handlers/tool/write-todos.ts +0 -19
  1285. package/packages/agent-runtime/src/tools/prompts.ts +0 -233
  1286. package/packages/agent-runtime/src/tools/sandbox/__tests__/engine.test.ts +0 -169
  1287. package/packages/agent-runtime/src/tools/sandbox/__tests__/shell-denylist.test.ts +0 -104
  1288. package/packages/agent-runtime/src/tools/sandbox/engine.ts +0 -109
  1289. package/packages/agent-runtime/src/tools/sandbox/index.ts +0 -14
  1290. package/packages/agent-runtime/src/tools/sandbox/shell-denylist.ts +0 -119
  1291. package/packages/agent-runtime/src/tools/stream-parser.ts +0 -407
  1292. package/packages/agent-runtime/src/tools/thinker-convergence-gate.ts +0 -162
  1293. package/packages/agent-runtime/src/tools/thought-session-store.ts +0 -43
  1294. package/packages/agent-runtime/src/tools/tool-executor.ts +0 -1098
  1295. package/packages/agent-runtime/src/util/__tests__/messages.test.ts +0 -995
  1296. package/packages/agent-runtime/src/util/__tests__/parse-tool-calls-from-text.test.ts +0 -363
  1297. package/packages/agent-runtime/src/util/__tests__/simplify-tool-results.test.ts +0 -382
  1298. package/packages/agent-runtime/src/util/__tests__/stream-xml-parser.test.ts +0 -285
  1299. package/packages/agent-runtime/src/util/__tests__/think-tags.test.ts +0 -49
  1300. package/packages/agent-runtime/src/util/__tests__/token-counter.test.ts +0 -121
  1301. package/packages/agent-runtime/src/util/activity-tracking.ts +0 -252
  1302. package/packages/agent-runtime/src/util/agent-output.ts +0 -102
  1303. package/packages/agent-runtime/src/util/cache-debug.ts +0 -330
  1304. package/packages/agent-runtime/src/util/format-value.ts +0 -16
  1305. package/packages/agent-runtime/src/util/messages.ts +0 -461
  1306. package/packages/agent-runtime/src/util/parse-tool-calls-from-text.ts +0 -122
  1307. package/packages/agent-runtime/src/util/render-read-files-result.ts +0 -18
  1308. package/packages/agent-runtime/src/util/simplify-tool-results.ts +0 -61
  1309. package/packages/agent-runtime/src/util/stream-xml-parser.ts +0 -259
  1310. package/packages/agent-runtime/src/util/think-tags.ts +0 -33
  1311. package/packages/agent-runtime/src/util/token-counter.ts +0 -136
  1312. package/packages/agent-runtime/tsconfig.json +0 -8
  1313. package/packages/code-map/README.md +0 -43
  1314. package/packages/code-map/__tests__/integration.test.ts +0 -278
  1315. package/packages/code-map/__tests__/languages.test.ts +0 -240
  1316. package/packages/code-map/__tests__/parse.test.ts +0 -640
  1317. package/packages/code-map/__tests__/test-langs/test.c +0 -42
  1318. package/packages/code-map/__tests__/test-langs/test.cpp +0 -34
  1319. package/packages/code-map/__tests__/test-langs/test.cs +0 -34
  1320. package/packages/code-map/__tests__/test-langs/test.go +0 -27
  1321. package/packages/code-map/__tests__/test-langs/test.java +0 -31
  1322. package/packages/code-map/__tests__/test-langs/test.js +0 -50
  1323. package/packages/code-map/__tests__/test-langs/test.php +0 -34
  1324. package/packages/code-map/__tests__/test-langs/test.py +0 -25
  1325. package/packages/code-map/__tests__/test-langs/test.rb +0 -28
  1326. package/packages/code-map/__tests__/test-langs/test.rs +0 -28
  1327. package/packages/code-map/__tests__/test-langs/test.ts +0 -31
  1328. package/packages/code-map/package.json +0 -33
  1329. package/packages/code-map/src/index.ts +0 -4
  1330. package/packages/code-map/src/init-node.ts +0 -185
  1331. package/packages/code-map/src/languages.ts +0 -344
  1332. package/packages/code-map/src/parse.ts +0 -410
  1333. package/packages/code-map/src/tree-sitter-queries/readme.md +0 -24
  1334. package/packages/code-map/src/tree-sitter-queries/tree-sitter-c-tags.scm +0 -16
  1335. package/packages/code-map/src/tree-sitter-queries/tree-sitter-c_sharp-tags.scm +0 -23
  1336. package/packages/code-map/src/tree-sitter-queries/tree-sitter-cpp-tags.scm +0 -29
  1337. package/packages/code-map/src/tree-sitter-queries/tree-sitter-go-tags.scm +0 -26
  1338. package/packages/code-map/src/tree-sitter-queries/tree-sitter-java-tags.scm +0 -19
  1339. package/packages/code-map/src/tree-sitter-queries/tree-sitter-javascript-tags.scm +0 -16
  1340. package/packages/code-map/src/tree-sitter-queries/tree-sitter-php-tags.scm +0 -23
  1341. package/packages/code-map/src/tree-sitter-queries/tree-sitter-python-tags.scm +0 -12
  1342. package/packages/code-map/src/tree-sitter-queries/tree-sitter-ruby-tags.scm +0 -58
  1343. package/packages/code-map/src/tree-sitter-queries/tree-sitter-rust-tags.scm +0 -26
  1344. package/packages/code-map/src/tree-sitter-queries/tree-sitter-typescript-tags.scm +0 -22
  1345. package/packages/code-map/src/types.ts +0 -11
  1346. package/packages/code-map/src/utils.ts +0 -12
  1347. package/packages/code-map/tsconfig.json +0 -10
  1348. package/packages/database/README.md +0 -43
  1349. package/packages/database/package.json +0 -28
  1350. package/packages/database/src/__tests__/service.test.ts +0 -167
  1351. package/packages/database/src/index.ts +0 -146
  1352. package/packages/database/src/service.ts +0 -342
  1353. package/packages/database/tsconfig.json +0 -9
  1354. package/packages/llm-providers/README.md +0 -42
  1355. package/packages/llm-providers/package.json +0 -42
  1356. package/packages/llm-providers/src/ollama/__tests__/detect.test.ts +0 -59
  1357. package/packages/llm-providers/src/ollama/detect.ts +0 -113
  1358. package/packages/llm-providers/src/ollama/index.ts +0 -2
  1359. package/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts +0 -855
  1360. package/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts +0 -177
  1361. package/packages/llm-providers/src/openai-compatible/chat/get-response-metadata.ts +0 -15
  1362. package/packages/llm-providers/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts +0 -19
  1363. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-api-types.ts +0 -60
  1364. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.test.ts +0 -549
  1365. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.ts +0 -474
  1366. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-options.ts +0 -25
  1367. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts +0 -49
  1368. package/packages/llm-providers/src/openai-compatible/chat/openai-compatible-prepare-tools.ts +0 -162
  1369. package/packages/llm-providers/src/openai-compatible/chat/stream-transform.test.ts +0 -106
  1370. package/packages/llm-providers/src/openai-compatible/chat/stream-transform.ts +0 -585
  1371. package/packages/llm-providers/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts +0 -98
  1372. package/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-language-model.test.ts +0 -173
  1373. package/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-language-model.ts +0 -414
  1374. package/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-options.ts +0 -33
  1375. package/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-model.ts +0 -143
  1376. package/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-options.ts +0 -21
  1377. package/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-model.ts +0 -129
  1378. package/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-settings.ts +0 -1
  1379. package/packages/llm-providers/src/openai-compatible/index.ts +0 -27
  1380. package/packages/llm-providers/src/openai-compatible/openai-compatible-error.ts +0 -34
  1381. package/packages/llm-providers/src/openai-compatible/openai-compatible-provider.ts +0 -174
  1382. package/packages/llm-providers/src/openai-compatible/version.ts +0 -5
  1383. package/packages/llm-providers/tsconfig.json +0 -9
  1384. package/protocol.config.yaml +0 -113
  1385. package/savant-free/README.md +0 -73
  1386. package/savant-free/SPEC.md +0 -385
  1387. package/savant-free/cli/build.ts +0 -49
  1388. package/savant-free/cli/release/README.md +0 -55
  1389. package/savant-free/cli/release/index.js +0 -37
  1390. package/savant-free/cli/release/package.json +0 -43
  1391. package/savant-free/cli/release.ts +0 -128
  1392. package/savant-free/cli/smoke-test.test.ts +0 -257
  1393. package/savant-free/e2e/README.md +0 -173
  1394. package/savant-free/e2e/agent/savant-free-tester.ts +0 -52
  1395. package/savant-free/e2e/tests/ads-behavior.e2e.test.ts +0 -51
  1396. package/savant-free/e2e/tests/agent-startup.e2e.test.ts +0 -61
  1397. package/savant-free/e2e/tests/code-edit.e2e.test.ts +0 -78
  1398. package/savant-free/e2e/tests/help-command.e2e.test.ts +0 -97
  1399. package/savant-free/e2e/tests/knowledge-file.e2e.test.ts +0 -66
  1400. package/savant-free/e2e/tests/slash-commands.e2e.test.ts +0 -119
  1401. package/savant-free/e2e/tests/startup.e2e.test.ts +0 -56
  1402. package/savant-free/e2e/tests/terminal-command.e2e.test.ts +0 -71
  1403. package/savant-free/e2e/tests/version.e2e.test.ts +0 -50
  1404. package/savant-free/e2e/utils/binary-helpers.ts +0 -24
  1405. package/savant-free/e2e/utils/index.ts +0 -17
  1406. package/savant-free/e2e/utils/savant-free-session.ts +0 -229
  1407. package/savant-free/e2e/utils/tmux-custom-tools.ts +0 -156
  1408. package/savant-free/e2e/utils/tmux-helpers.ts +0 -79
  1409. package/savant-free/package.json +0 -20
  1410. package/scripts/eslint-rules/__fixtures__/debug-ast.mjs +0 -20
  1411. package/scripts/eslint-rules/__fixtures__/probe.mjs +0 -41
  1412. package/scripts/eslint-rules/__fixtures__/probe2.mjs +0 -43
  1413. package/scripts/eslint-rules/__fixtures__/rule-test.mjs +0 -44
  1414. package/scripts/eslint-rules/__fixtures__/rule-test2.mjs +0 -27
  1415. package/scripts/eslint-rules/__fixtures__/unknown-bad.ts +0 -21
  1416. package/scripts/eslint-rules/__fixtures__/unknown-good.ts +0 -35
  1417. package/scripts/eslint-rules/no-unknown-in-signatures.js +0 -141
  1418. package/scripts/release.py +0 -360
  1419. package/scripts/run-az-test.sh +0 -1150
  1420. package/scripts/sync-agents.py +0 -337
  1421. package/scripts/tmux/README.md +0 -359
  1422. package/scripts/tmux/package.json +0 -9
  1423. package/scripts/tmux/tmux-capture.sh +0 -231
  1424. package/scripts/tmux/tmux-cli.sh +0 -160
  1425. package/scripts/tmux/tmux-env.sh +0 -34
  1426. package/scripts/tmux/tmux-send.sh +0 -339
  1427. package/scripts/tmux/tmux-start.sh +0 -264
  1428. package/scripts/tmux/tmux-stop.sh +0 -157
  1429. package/scripts/tmux/tmux-viewer/README.md +0 -254
  1430. package/scripts/tmux/tmux-viewer/components/session-viewer.tsx +0 -556
  1431. package/scripts/tmux/tmux-viewer/components/theme.ts +0 -54
  1432. package/scripts/tmux/tmux-viewer/gif-encoder-2.d.ts +0 -28
  1433. package/scripts/tmux/tmux-viewer/gif-exporter.ts +0 -278
  1434. package/scripts/tmux/tmux-viewer/index.tsx +0 -237
  1435. package/scripts/tmux/tmux-viewer/package.json +0 -9
  1436. package/scripts/tmux/tmux-viewer/session-loader.ts +0 -234
  1437. package/scripts/tmux/tmux-viewer/tsconfig.json +0 -9
  1438. package/scripts/tmux/tmux-viewer/types.ts +0 -76
  1439. package/sdk/CHANGELOG.md +0 -126
  1440. package/sdk/PUBLISHING.md +0 -55
  1441. package/sdk/README.md +0 -334
  1442. package/sdk/bunfig.toml +0 -6
  1443. package/sdk/e2e/README.md +0 -158
  1444. package/sdk/e2e/custom-agents/api-integration-agent.e2e.test.ts +0 -145
  1445. package/sdk/e2e/custom-agents/apply-patch-tool.e2e.test.ts +0 -62
  1446. package/sdk/e2e/custom-agents/database-query-agent.e2e.test.ts +0 -135
  1447. package/sdk/e2e/custom-agents/weather-agent.e2e.test.ts +0 -120
  1448. package/sdk/e2e/examples/code-explainer.example.ts +0 -62
  1449. package/sdk/e2e/examples/code-reviewer.example.ts +0 -52
  1450. package/sdk/e2e/examples/commit-message-generator.example.ts +0 -61
  1451. package/sdk/e2e/examples/sdk-lint.example.ts +0 -65
  1452. package/sdk/e2e/examples/sdk-refactor.example.ts +0 -63
  1453. package/sdk/e2e/examples/sdk-test-gen.example.ts +0 -62
  1454. package/sdk/e2e/features/knowledge-files.e2e.test.ts +0 -82
  1455. package/sdk/e2e/features/max-agent-steps.e2e.test.ts +0 -65
  1456. package/sdk/e2e/features/project-files.e2e.test.ts +0 -80
  1457. package/sdk/e2e/integration/connection-check.integration.test.ts +0 -33
  1458. package/sdk/e2e/integration/event-ordering.integration.test.ts +0 -186
  1459. package/sdk/e2e/integration/event-types.integration.test.ts +0 -183
  1460. package/sdk/e2e/integration/stream-chunks.integration.test.ts +0 -191
  1461. package/sdk/e2e/streaming/concurrent-streams.e2e.test.ts +0 -164
  1462. package/sdk/e2e/streaming/subagent-streaming.e2e.test.ts +0 -158
  1463. package/sdk/e2e/utils/__tests__/event-collector.test.ts +0 -297
  1464. package/sdk/e2e/utils/e2e-mocks.ts +0 -458
  1465. package/sdk/e2e/utils/event-collector.ts +0 -145
  1466. package/sdk/e2e/utils/get-api-key.ts +0 -76
  1467. package/sdk/e2e/utils/index.ts +0 -3
  1468. package/sdk/e2e/utils/test-fixtures.ts +0 -196
  1469. package/sdk/e2e/workflows/error-recovery.e2e.test.ts +0 -112
  1470. package/sdk/e2e/workflows/multi-turn-conversation.e2e.test.ts +0 -132
  1471. package/sdk/examples/readme-example-1.ts +0 -34
  1472. package/sdk/examples/readme-example-2.ts +0 -76
  1473. package/sdk/package.json +0 -82
  1474. package/sdk/scripts/build.ts +0 -349
  1475. package/sdk/scripts/fetch-ripgrep.ts +0 -172
  1476. package/sdk/scripts/publish.ts +0 -51
  1477. package/sdk/scripts/release.js +0 -100
  1478. package/sdk/scripts/verify.ts +0 -204
  1479. package/sdk/sdk_exit +0 -1
  1480. package/sdk/smoke-test-dist.ts +0 -248
  1481. package/sdk/src/__tests__/apply-overrides-resume.test.ts +0 -83
  1482. package/sdk/src/__tests__/apply-patch.test.ts +0 -438
  1483. package/sdk/src/__tests__/build-file-tree.test.ts +0 -31
  1484. package/sdk/src/__tests__/change-file.test.ts +0 -212
  1485. package/sdk/src/__tests__/client.test.ts +0 -142
  1486. package/sdk/src/__tests__/clone-session-state.test.ts +0 -158
  1487. package/sdk/src/__tests__/code-search.test.ts +0 -896
  1488. package/sdk/src/__tests__/composio.test.ts +0 -86
  1489. package/sdk/src/__tests__/credentials.test.ts +0 -344
  1490. package/sdk/src/__tests__/database.test.ts +0 -153
  1491. package/sdk/src/__tests__/env.test.ts +0 -137
  1492. package/sdk/src/__tests__/error-utils.test.ts +0 -253
  1493. package/sdk/src/__tests__/fixtures/windows-stubborn-grandchild.ts +0 -26
  1494. package/sdk/src/__tests__/initial-session-state.test.ts +0 -447
  1495. package/sdk/src/__tests__/knowledge-file-selection.test.ts +0 -335
  1496. package/sdk/src/__tests__/load-agents.test.ts +0 -935
  1497. package/sdk/src/__tests__/load-mcp-config.test.ts +0 -285
  1498. package/sdk/src/__tests__/load-skills.test.ts +0 -325
  1499. package/sdk/src/__tests__/model-provider.test.ts +0 -44
  1500. package/sdk/src/__tests__/path-utils.test.ts +0 -66
  1501. package/sdk/src/__tests__/read-files.test.ts +0 -573
  1502. package/sdk/src/__tests__/read-url.test.ts +0 -371
  1503. package/sdk/src/__tests__/researcher-web.integration.test.ts +0 -130
  1504. package/sdk/src/__tests__/run-cancellation.test.ts +0 -1321
  1505. package/sdk/src/__tests__/run-error-preserves-history.test.ts +0 -335
  1506. package/sdk/src/__tests__/run-event-dispatch.test.ts +0 -80
  1507. package/sdk/src/__tests__/run-file-filter.test.ts +0 -530
  1508. package/sdk/src/__tests__/run-handle-event.test.ts +0 -142
  1509. package/sdk/src/__tests__/run-mcp-tool-filter.test.ts +0 -124
  1510. package/sdk/src/__tests__/run-state-child-process.test.ts +0 -72
  1511. package/sdk/src/__tests__/run-terminal-command.test.ts +0 -236
  1512. package/sdk/src/__tests__/run.integration.test.ts +0 -170
  1513. package/sdk/src/__tests__/user-knowledge-files.test.ts +0 -361
  1514. package/sdk/src/__tests__/validate-agents.test.ts +0 -943
  1515. package/sdk/src/agents/load-agents.ts +0 -339
  1516. package/sdk/src/agents/load-mcp-config.ts +0 -275
  1517. package/sdk/src/client.ts +0 -87
  1518. package/sdk/src/composio.ts +0 -78
  1519. package/sdk/src/constants.ts +0 -29
  1520. package/sdk/src/credentials.ts +0 -303
  1521. package/sdk/src/custom-tool.ts +0 -66
  1522. package/sdk/src/env.ts +0 -116
  1523. package/sdk/src/error-utils.ts +0 -127
  1524. package/sdk/src/impl/__tests__/llm-chatgpt-oauth-policy.test.ts +0 -67
  1525. package/sdk/src/impl/__tests__/llm-native-tool-call.test.ts +0 -38
  1526. package/sdk/src/impl/__tests__/llm-stream-yielded-content.test.ts +0 -118
  1527. package/sdk/src/impl/__tests__/model-provider-free-mode.test.ts +0 -166
  1528. package/sdk/src/impl/__tests__/prompt-result.test.ts +0 -210
  1529. package/sdk/src/impl/__tests__/provider-options-metadata.test.ts +0 -67
  1530. package/sdk/src/impl/agent-runtime.ts +0 -159
  1531. package/sdk/src/impl/chatgpt-backend-fetch.ts +0 -588
  1532. package/sdk/src/impl/database.ts +0 -516
  1533. package/sdk/src/impl/llm.ts +0 -852
  1534. package/sdk/src/impl/model-provider.ts +0 -638
  1535. package/sdk/src/impl/openrouter-key-resolver.ts +0 -78
  1536. package/sdk/src/index.ts +0 -133
  1537. package/sdk/src/native/ripgrep.ts +0 -141
  1538. package/sdk/src/retry-config.ts +0 -51
  1539. package/sdk/src/run-state.ts +0 -897
  1540. package/sdk/src/run.ts +0 -1222
  1541. package/sdk/src/skills/load-skills.ts +0 -253
  1542. package/sdk/src/testing/env.ts +0 -19
  1543. package/sdk/src/tools/apply-patch.ts +0 -690
  1544. package/sdk/src/tools/change-file.ts +0 -155
  1545. package/sdk/src/tools/code-search.ts +0 -547
  1546. package/sdk/src/tools/glob.ts +0 -58
  1547. package/sdk/src/tools/index.ts +0 -19
  1548. package/sdk/src/tools/list-directory.ts +0 -52
  1549. package/sdk/src/tools/path-utils.ts +0 -101
  1550. package/sdk/src/tools/read-files.ts +0 -110
  1551. package/sdk/src/tools/read-url.ts +0 -469
  1552. package/sdk/src/tools/run-file-change-hooks.ts +0 -22
  1553. package/sdk/src/tools/run-terminal-command.ts +0 -483
  1554. package/sdk/src/tools/ssrf.ts +0 -120
  1555. package/sdk/src/types/env.ts +0 -40
  1556. package/sdk/src/utils/logger.ts +0 -15
  1557. package/sdk/src/validate-agents.ts +0 -157
  1558. package/sdk/test/cjs-compatibility/package-lock.json +0 -423
  1559. package/sdk/test/cjs-compatibility/package.json +0 -21
  1560. package/sdk/test/cjs-compatibility/test-imports.js +0 -77
  1561. package/sdk/test/cjs-compatibility/test-types.ts +0 -54
  1562. package/sdk/test/cjs-compatibility/tsconfig.json +0 -17
  1563. package/sdk/test/esm-compatibility/package-lock.json +0 -423
  1564. package/sdk/test/esm-compatibility/package.json +0 -21
  1565. package/sdk/test/esm-compatibility/test-imports.js +0 -83
  1566. package/sdk/test/esm-compatibility/test-types.ts +0 -59
  1567. package/sdk/test/esm-compatibility/tsconfig.json +0 -17
  1568. package/sdk/test/ripgrep-bundling/package-lock.json +0 -423
  1569. package/sdk/test/ripgrep-bundling/package.json +0 -21
  1570. package/sdk/test/ripgrep-bundling/test-ripgrep-types.ts +0 -132
  1571. package/sdk/test/ripgrep-bundling/test-ripgrep.js +0 -257
  1572. package/sdk/test/ripgrep-bundling/tsconfig.json +0 -15
  1573. package/sdk/test/setup-env.ts +0 -54
  1574. package/sdk/test/test-sdk.ts +0 -25
  1575. package/sdk/test/tree-sitter-queries/package-lock.json +0 -47
  1576. package/sdk/test/tree-sitter-queries/package.json +0 -12
  1577. package/sdk/test/tree-sitter-queries/test-query-files.js +0 -256
  1578. package/sdk/tsconfig.build.json +0 -32
  1579. package/sdk/tsconfig.json +0 -27
  1580. package/sdk/vendor/ripgrep/arm64-darwin/rg +0 -0
  1581. package/sdk/vendor/ripgrep/arm64-linux/rg +0 -0
  1582. package/sdk/vendor/ripgrep/x64-darwin/rg +0 -0
  1583. package/sdk/vendor/ripgrep/x64-linux/rg +0 -0
  1584. package/sdk/vendor/ripgrep/x64-win32/rg.exe +0 -0
  1585. package/templates/FID-TEMPLATE.md +0 -125
  1586. package/templates/README-TEMPLATE.md +0 -87
  1587. package/templates/SESSION-SUMMARY.md +0 -135
  1588. package/tsconfig.base.json +0 -18
  1589. package/tsconfig.json +0 -27
  1590. /package/{cli/release-core/http.js → http.js} +0 -0
  1591. /package/{cli/release-core/launcher.js → launcher.js} +0 -0
@@ -1,1829 +0,0 @@
1
- {
2
- "repoUrl": "https://github.com/saleor/saleor",
3
- "generationDate": "2025-10-12T19:07:56.091Z",
4
- "evalCommits": [
5
- {
6
- "id": "update-zero-checkout",
7
- "sha": "577d31f4dc699ebf1c88047321f958984b1595cb",
8
- "parentSha": "33f37867f02cb7ad00c0e72606c3a693eaa9189a",
9
- "spec": "Implement zero-total checkout auto-authorization on fetch and align tests and changelog:\n\n1) Checkout data fetch behavior\n- In saleor/checkout/calculations.py, within fetch_checkout_data, ensure that after totals are recalculated, update_checkout_payment_statuses is invoked not only when the total gross changes or when force_status_update is True, but also when:\n - current_total_gross equals zero_money(current_total_gross.currency), AND\n - checkout_info.checkout.authorize_status is not CheckoutAuthorizeStatus.FULL, AND\n - the checkout has at least one line (bool(lines) is True).\n- Add any necessary imports for CheckoutAuthorizeStatus and zero_money at the top of the file.\n\n2) Tests reflecting new behavior\n- In saleor/checkout/tests/test_calculations.py, add a test that verifies calling fetch_checkout_data for a checkout with item total equal to 0 and with lines updates its authorize_status and charge_status to FULL. The test should first assert initial statuses are NONE and totals are zero, then call fetch_checkout_data and assert statuses become FULL.\n- In saleor/checkout/tests/test_actions.py, remove or adjust tests that previously expected PluginsManager.checkout_fully_paid and automatic completion to be called from transaction_amounts_for_checkout_updated for zero-total checkouts. Ensure tests covering fully authorized flows for non-zero totals remain intact. Also remove unnecessary fully paid mock assertions in tests that only verify last_transaction_modified_at updates.\n\n3) Changelog update\n- In CHANGELOG.md under Breaking changes, add a note that logic tied to WebhookEventAsyncType.CHECKOUT_FULLY_PAID is no longer triggered when creating a transaction event for checkouts with zero total gross because such checkouts are considered fully paid at event creation time.\n- In the Fixes/Improvements section, add a note that zero-total checkouts will have their authorization status updated to CheckoutAuthorizeStatus.FULL upon fetching checkout data.\n\nAcceptance criteria\n- For a checkout with lines and total gross zero, calling fetch_checkout_data updates authorize_status and charge_status to FULL without requiring a transaction webhook.\n- No webhook or automatic completion is triggered by transaction updates for zero-total checkouts simply to establish fully paid state; tests reflect this change.\n- All updated and added tests pass.\n- The changelog clearly documents the behavioral change and the new status update behavior on fetch.",
10
- "prompt": "Enhance the checkout status update flow so that zero-amount checkouts with items are recognized as fully authorized when their data is fetched, without relying on transaction events. Update the data-fetching logic to set the appropriate statuses when the total gross is zero and the checkout has lines. Adjust tests to validate this behavior and remove expectations that zero-amount transaction events trigger fully paid webhooks. Add changelog entries describing the change in behavior and the new status update during checkout data fetching.",
11
- "supplementalFiles": [
12
- "saleor/checkout/payment_utils.py",
13
- "saleor/checkout/actions.py",
14
- "saleor/checkout/__init__.py",
15
- "saleor/checkout/fetch.py",
16
- "saleor/plugins/manager.py"
17
- ],
18
- "fileDiffs": [
19
- {
20
- "path": "CHANGELOG.md",
21
- "status": "modified",
22
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\t33f3786 (parent)\n+++ CHANGELOG.md\t577d31f (commit)\n@@ -7,8 +7,9 @@\n ### Breaking changes\n - The following changes were implemented to orders with a zero total amount:\n - No manual charge (`Transaction` or `Payment`) object will be created.\n - The `OrderEvents.ORDER_MARKED_AS_PAID` event will no longer be emitted.\n+- Logic associated with `WebhookEventAsyncType.CHECKOUT_FULLY_PAID` event will no longer be triggered when creating a transaction event from webhook response for checkouts with having total gross being 0. At the point of creating the transaction event checkout is already considered fully paid.\n \n ### GraphQL API\n - You can now filter and search orders using the new `where` and `search` fields on the `pages` query.\n - Use `where` to define complex conditions with `AND`/`OR` logic and operators like `eq`, `oneOf`, `range`.\n@@ -129,8 +130,10 @@\n - Fixed bug when not-authenticated staff user couldn't fetch `appExtension.app` without `MANAGE_APPS`. Now apps access is available by staff users and the app itself (for app and extension it owns)\n \n - Fixed bug in user email filtering to make it case-insensitive.\n \n+- Checkouts having total gross amount equal to 0 will get their authorization statuses updated to `CheckoutAuthorizeStatus.FULL` upon fetching checkout data.\n+\n ### Deprecations\n \n Following plugins are now marked as deprecated:\n \n"
23
- },
24
- {
25
- "path": "saleor/checkout/calculations.py",
26
- "status": "modified",
27
- "diff": "Index: saleor/checkout/calculations.py\n===================================================================\n--- saleor/checkout/calculations.py\t33f3786 (parent)\n+++ saleor/checkout/calculations.py\t577d31f (commit)\n@@ -7,9 +7,9 @@\n from django.db import transaction\n from django.utils import timezone\n from prices import Money, TaxedMoney\n \n-from ..checkout import base_calculations\n+from ..checkout import CheckoutAuthorizeStatus, base_calculations\n from ..core.db.connection import allow_writer\n from ..core.prices import quantize_price\n from ..core.taxes import (\n TaxData,\n@@ -797,9 +797,19 @@\n pregenerated_subscription_payloads=pregenerated_subscription_payloads,\n allow_sync_webhooks=allow_sync_webhooks,\n )\n current_total_gross = checkout_info.checkout.total.gross\n- if current_total_gross != previous_total_gross or force_status_update:\n+ if (\n+ current_total_gross != previous_total_gross\n+ or force_status_update\n+ or (\n+ # Checkout with total being zero is fully authorized therefore\n+ # if authorized status was not yet updated, do it now.\n+ current_total_gross == zero_money(current_total_gross.currency)\n+ and checkout_info.checkout.authorize_status != CheckoutAuthorizeStatus.FULL\n+ and bool(lines)\n+ )\n+ ):\n update_checkout_payment_statuses(\n checkout=checkout_info.checkout,\n checkout_total_gross=current_total_gross,\n checkout_has_lines=bool(lines),\n"
28
- },
29
- {
30
- "path": "saleor/checkout/tests/test_actions.py",
31
- "status": "modified",
32
- "diff": "Index: saleor/checkout/tests/test_actions.py\n===================================================================\n--- saleor/checkout/tests/test_actions.py\t33f3786 (parent)\n+++ saleor/checkout/tests/test_actions.py\t577d31f (commit)\n@@ -173,45 +173,8 @@\n \n \n @patch(\"saleor.checkout.tasks.automatic_checkout_completion_task.delay\")\n @patch(\"saleor.plugins.manager.PluginsManager.checkout_fully_paid\")\n-def test_transaction_amounts_for_checkout_updated_0_checkout_automatic_complete(\n- mocked_fully_paid,\n- mocked_automatic_checkout_completion_task,\n- checkout_with_item_total_0,\n- transaction_item_generator,\n- plugins_manager,\n- app,\n- django_capture_on_commit_callbacks,\n-):\n- # given\n- checkout = checkout_with_item_total_0\n- lines, _ = fetch_checkout_lines(checkout)\n- checkout_info = fetch_checkout_info(checkout, lines, plugins_manager)\n- checkout_info, _ = fetch_checkout_data(checkout_info, plugins_manager, lines)\n- transaction = transaction_item_generator(checkout_id=checkout.pk, charged_value=0)\n- channel = checkout_info.channel\n- channel.automatically_complete_fully_paid_checkouts = True\n- channel.save(update_fields=[\"automatically_complete_fully_paid_checkouts\"])\n-\n- # when\n- with django_capture_on_commit_callbacks(execute=True):\n- transaction_amounts_for_checkout_updated(\n- transaction, manager=plugins_manager, user=None, app=app\n- )\n-\n- # then\n- checkout.refresh_from_db()\n- assert checkout.charge_status == CheckoutChargeStatus.FULL\n- assert checkout.authorize_status == CheckoutAuthorizeStatus.FULL\n- mocked_fully_paid.assert_called_with(checkout, webhooks=set())\n- mocked_automatic_checkout_completion_task.assert_called_once_with(\n- checkout.pk, None, app.id\n- )\n-\n-\n-@patch(\"saleor.checkout.tasks.automatic_checkout_completion_task.delay\")\n-@patch(\"saleor.plugins.manager.PluginsManager.checkout_fully_paid\")\n def test_transaction_amounts_for_checkout_updated_fully_authorized(\n mocked_fully_paid,\n mocked_automatic_checkout_completion_task,\n checkout_with_items,\n@@ -335,12 +298,10 @@\n @pytest.mark.parametrize(\n \"previous_modified_at\",\n [None, datetime.datetime(2018, 5, 31, 12, 0, 0, tzinfo=datetime.UTC)],\n )\n-@patch(\"saleor.plugins.manager.PluginsManager.checkout_fully_paid\")\n @freeze_time(\"2023-05-31 12:00:01\")\n def test_transaction_amounts_for_checkout_updated_updates_last_transaction_modified_at(\n- mocked_fully_paid,\n previous_modified_at,\n checkout_with_items,\n transaction_item_generator,\n plugins_manager,\n@@ -370,9 +331,8 @@\n \n # then\n checkout.refresh_from_db()\n assert checkout.last_transaction_modified_at == transaction.modified_at\n- mocked_fully_paid.assert_called_with(checkout, webhooks=set())\n \n \n def test_get_checkout_refundable_with_transaction_and_last_refund_success(\n checkout_with_items,\n"
33
- },
34
- {
35
- "path": "saleor/checkout/tests/test_calculations.py",
36
- "status": "modified",
37
- "diff": "Index: saleor/checkout/tests/test_calculations.py\n===================================================================\n--- saleor/checkout/tests/test_calculations.py\t33f3786 (parent)\n+++ saleor/checkout/tests/test_calculations.py\t577d31f (commit)\n@@ -8,8 +8,9 @@\n from freezegun import freeze_time\n from graphene import Node\n from prices import Money, TaxedMoney\n \n+from ...checkout import CheckoutAuthorizeStatus, CheckoutChargeStatus\n from ...checkout.utils import (\n add_promo_code_to_checkout,\n assign_external_shipping_to_checkout,\n )\n@@ -18,8 +19,9 @@\n TaxData,\n TaxDataError,\n TaxDataErrorMessage,\n TaxLineData,\n+ zero_money,\n zero_taxed_money,\n )\n from ...graphql.core.utils import to_global_id_or_none\n from ...plugins import PLUGIN_IDENTIFIER_PREFIX\n@@ -1264,4 +1266,37 @@\n # then\n line.refresh_from_db()\n assert line.prior_unit_price_amount is None\n assert line.currency is not None\n+\n+\n+def test_fetch_checkout_data_updates_status_for_zero_amount_checkout_with_lines(\n+ checkout_with_item_total_0,\n+):\n+ # given\n+ lines, _ = fetch_checkout_lines(checkout_with_item_total_0)\n+ manager = get_plugins_manager(allow_replica=False)\n+ checkout_info = fetch_checkout_info(checkout_with_item_total_0, lines, manager)\n+ address = (\n+ checkout_with_item_total_0.shipping_address\n+ or checkout_with_item_total_0.billing_address,\n+ )\n+\n+ assert checkout_with_item_total_0.total.gross == zero_money(\n+ checkout_with_item_total_0.total.currency\n+ )\n+ assert checkout_with_item_total_0.authorize_status == CheckoutAuthorizeStatus.NONE\n+ assert checkout_with_item_total_0.charge_status == CheckoutChargeStatus.NONE\n+ assert bool(lines) is True\n+\n+ # when\n+ fetch_checkout_data(\n+ checkout_info=checkout_info,\n+ manager=manager,\n+ lines=lines,\n+ address=address,\n+ )\n+\n+ # then\n+ checkout_with_item_total_0.refresh_from_db()\n+ assert checkout_with_item_total_0.authorize_status == CheckoutAuthorizeStatus.FULL\n+ assert checkout_with_item_total_0.charge_status == CheckoutChargeStatus.FULL\n"
38
- }
39
- ]
40
- },
41
- {
42
- "id": "update-attribute-name",
43
- "sha": "902481408b41c0cb5be1e8f479f94548e01b9b70",
44
- "parentSha": "f7e322eafb1458c8dd142e2fa75cab2d48ed343a",
45
- "spec": "Implement dynamic name resolution for GraphQL AttributeValue when the value references another object and update tests accordingly.\n\nWhat to implement:\n1) AttributeValue.name dynamic resolver\n- File: saleor/graphql/attribute/types.py\n- Add a custom resolver for AttributeValue.name that:\n - If reference_product_id is set, fetch the Product by ID using the product dataloader and return product.name.\n - If reference_variant_id is set, fetch the ProductVariant by ID using the variant dataloader; then fetch its Product and return \"{product.name}: {variant.name}\".\n - If reference_page_id is set, fetch the Page by ID using the page dataloader and return page.title.\n - If no reference_* is set, return the stored attr_value.name (current behavior).\n- Ensure the resolver uses promise-based returns consistent with the codebase's use of DataLoader/Promise.\n- Import and use existing dataloaders:\n - saleor/graphql/product/dataloaders/products.py: ProductByIdLoader, ProductVariantByIdLoader\n - saleor/graphql/page/dataloaders.py: PageByIdLoader\n\n2) New query test for selected attribute values\n- File: saleor/graphql/attribute/tests/queries/test_selected_attribute.py\n- Add tests that verify that page.attributes.values.name reflects the current referenced object value when:\n - The referenced product name changes.\n - The referenced variant name changes (and product name shown with variant as \"{product_name}: {variant_name}\").\n - The referenced page title changes.\n- Use the Page query to fetch page(id) { attributes { values { id name } } } and assert the names equal the updated referenced objects' names.\n\n3) Adjust existing page update mutation tests to expect referenced-object names\n- File: saleor/graphql/page/tests/mutations/test_page_update.py\n- Update setup where AttributeValue is created for reference attributes so expected names match referenced objects, not the page title.\n- Update assertions to expect name equal to product.name for product reference, and \"{variant.product.name}: {variant.name}\" for variant reference.\n\nAcceptance criteria:\n- Querying page(id) { attributes { values { name } } } returns updated referenced object names as described above.\n- Existing and new tests pass, verifying name resolution when referenced entities are updated.\n- No changes to persistence; name remains stored but overridden at GraphQL resolution when reference_* fields are present.\n- Dataloader usage avoids N+1 queries and follows the codebase’s ResolveInfo/context patterns.",
46
- "prompt": "Update attribute value name resolution in the GraphQL API so that when an AttributeValue references a product, product variant, or page, the name field returned by GraphQL reflects the current name/title of the referenced object. Use the appropriate dataloaders to fetch the referenced entities and compose the variant case as \"{product_name}: {variant_name}\". Leave non-reference values returning their stored name. Add a new query test for page.attributes to cover product, variant, and page references changing after assignment, and update existing page update mutation tests so they expect the referenced-object names rather than the originally stored name.",
47
- "supplementalFiles": [
48
- "saleor/graphql/attribute/dataloaders.py",
49
- "saleor/graphql/page/dataloaders.py",
50
- "saleor/graphql/page/types.py",
51
- "saleor/graphql/product/dataloaders/products.py",
52
- "saleor/attribute/models/base.py"
53
- ],
54
- "fileDiffs": [
55
- {
56
- "path": "saleor/graphql/attribute/tests/queries/test_selected_attribute.py",
57
- "status": "added",
58
- "diff": "Index: saleor/graphql/attribute/tests/queries/test_selected_attribute.py\n===================================================================\n--- saleor/graphql/attribute/tests/queries/test_selected_attribute.py\tf7e322e (parent)\n+++ saleor/graphql/attribute/tests/queries/test_selected_attribute.py\t9024814 (commit)\n@@ -1,1 +1,140 @@\n-[NEW FILE]\n\\n+import graphene\n+\n+from .....attribute.models.base import AttributeValue\n+from .....attribute.utils import associate_attribute_values_to_instance\n+from ....tests.utils import get_graphql_content\n+\n+PAGE_QUERY = \"\"\"\n+ query PageQuery($id: ID) {\n+ page(id: $id) {\n+ attributes {\n+ values {\n+ id\n+ name\n+ }\n+ }\n+ }\n+ }\n+\"\"\"\n+\n+\n+def test_attribute_value_name_when_referenced_product_was_changed(\n+ staff_api_client,\n+ product,\n+ page,\n+ page_type_product_reference_attribute,\n+):\n+ # given\n+ page_type = page.page_type\n+ page_type.page_attributes.set([page_type_product_reference_attribute])\n+\n+ attr_value = AttributeValue.objects.create(\n+ attribute=page_type_product_reference_attribute,\n+ name=product.name,\n+ slug=f\"{page.pk}_{product.pk}\",\n+ reference_product_id=product.pk,\n+ )\n+ associate_attribute_values_to_instance(\n+ page, {page_type_product_reference_attribute.pk: [attr_value]}\n+ )\n+\n+ new_product_name = \"New Product Name\"\n+ product.name = new_product_name\n+ product.save(update_fields=[\"name\"])\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PAGE_QUERY,\n+ variables={\"id\": graphene.Node.to_global_id(\"Page\", page.pk)},\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+\n+ assert len(content[\"data\"][\"page\"][\"attributes\"]) == 1\n+ assert len(content[\"data\"][\"page\"][\"attributes\"][0][\"values\"]) == 1\n+ data = content[\"data\"][\"page\"][\"attributes\"][0][\"values\"][0]\n+ assert data[\"name\"] == new_product_name\n+\n+\n+def test_attribute_value_name_when_referenced_variant_was_changed(\n+ staff_api_client,\n+ variant,\n+ page,\n+ page_type_variant_reference_attribute,\n+):\n+ # given\n+ page_type = page.page_type\n+ page_type.page_attributes.set([page_type_variant_reference_attribute])\n+\n+ attr_value = AttributeValue.objects.create(\n+ attribute=page_type_variant_reference_attribute,\n+ name=variant.name,\n+ slug=f\"{page.pk}_{variant.pk}\",\n+ reference_variant_id=variant.pk,\n+ )\n+ associate_attribute_values_to_instance(\n+ page, {page_type_variant_reference_attribute.pk: [attr_value]}\n+ )\n+ product_name = \"Product Name\"\n+ variant.product.name = product_name\n+ variant.product.save(update_fields=[\"name\"])\n+\n+ new_variant_name = \"New Variant Name\"\n+ variant.name = new_variant_name\n+ variant.save(update_fields=[\"name\"])\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PAGE_QUERY,\n+ variables={\"id\": graphene.Node.to_global_id(\"Page\", page.pk)},\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+\n+ assert len(content[\"data\"][\"page\"][\"attributes\"]) == 1\n+ assert len(content[\"data\"][\"page\"][\"attributes\"][0][\"values\"]) == 1\n+ data = content[\"data\"][\"page\"][\"attributes\"][0][\"values\"][0]\n+ assert data[\"name\"] == f\"{product_name}: {new_variant_name}\"\n+\n+\n+def test_attribute_value_name_when_referenced_page_was_changed(\n+ staff_api_client,\n+ page,\n+ page_list,\n+ page_type_page_reference_attribute,\n+):\n+ # given\n+ referenced_page = page_list[0]\n+\n+ page_type = page.page_type\n+ page_type.page_attributes.set([page_type_page_reference_attribute])\n+\n+ attr_value = AttributeValue.objects.create(\n+ attribute=page_type_page_reference_attribute,\n+ name=referenced_page.title,\n+ slug=f\"{page.pk}_{referenced_page.pk}\",\n+ reference_page_id=referenced_page.pk,\n+ )\n+ associate_attribute_values_to_instance(\n+ page, {page_type_page_reference_attribute.pk: [attr_value]}\n+ )\n+\n+ new_page_title = \"New Page Title\"\n+ referenced_page.title = new_page_title\n+ referenced_page.save(update_fields=[\"title\"])\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PAGE_QUERY,\n+ variables={\"id\": graphene.Node.to_global_id(\"Page\", page.pk)},\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+\n+ assert len(content[\"data\"][\"page\"][\"attributes\"]) == 1\n+ assert len(content[\"data\"][\"page\"][\"attributes\"][0][\"values\"]) == 1\n+ data = content[\"data\"][\"page\"][\"attributes\"][0][\"values\"][0]\n+ assert data[\"name\"] == new_page_title\n"
59
- },
60
- {
61
- "path": "saleor/graphql/attribute/types.py",
62
- "status": "modified",
63
- "diff": "Index: saleor/graphql/attribute/types.py\n===================================================================\n--- saleor/graphql/attribute/types.py\tf7e322e (parent)\n+++ saleor/graphql/attribute/types.py\t9024814 (commit)\n@@ -1,5 +1,6 @@\n import graphene\n+from promise import Promise\n \n from ...attribute import AttributeInputType, models\n from ...permission.enums import (\n PagePermissions,\n@@ -38,8 +39,10 @@\n )\n from ..core.types.context import ChannelContextType, ChannelContextTypeForObjectType\n from ..decorators import check_attribute_required_permissions\n from ..meta.types import ObjectWithMetadata\n+from ..page.dataloaders import PageByIdLoader\n+from ..product.dataloaders.products import ProductByIdLoader, ProductVariantByIdLoader\n from ..translations.fields import TranslationField\n from ..translations.types import AttributeTranslation, AttributeValueTranslation\n from .dataloaders import (\n AttributesByAttributeId,\n@@ -68,8 +71,52 @@\n return None\n return reference_pk\n \n \n+def _resolve_referenced_product_name(\n+ reference_product_id: int | None, info: ResolveInfo\n+) -> Promise[None | str]:\n+ if not reference_product_id:\n+ return Promise.resolve(None)\n+ return (\n+ ProductByIdLoader(info.context)\n+ .load(reference_product_id)\n+ .then(lambda product: product.name if product else None)\n+ )\n+\n+\n+def _resolve_referenced_product_variant_name(\n+ reference_variant_id: int | None, info: ResolveInfo\n+) -> Promise[None | str]:\n+ if not reference_variant_id:\n+ return Promise.resolve(None)\n+\n+ def resolve_variant_name(variant):\n+ if variant is None:\n+ return None\n+ return _resolve_referenced_product_name(variant.product_id, info).then(\n+ lambda product_name: f\"{product_name}: {variant.name}\"\n+ )\n+\n+ return (\n+ ProductVariantByIdLoader(info.context)\n+ .load(reference_variant_id)\n+ .then(resolve_variant_name)\n+ )\n+\n+\n+def _resolve_referenced_page_name(\n+ reference_page_id: int | None, info: ResolveInfo\n+) -> Promise[None | str]:\n+ if not reference_page_id:\n+ return Promise.resolve(None)\n+ return (\n+ PageByIdLoader(info.context)\n+ .load(reference_page_id)\n+ .then(lambda page: page.title if page else None)\n+ )\n+\n+\n class AttributeValue(ChannelContextType[models.AttributeValue]):\n id = graphene.GlobalID(required=True, description=\"The ID of the attribute value.\")\n name = graphene.String(description=AttributeValueDescriptions.NAME)\n slug = graphene.String(description=AttributeValueDescriptions.SLUG)\n@@ -108,8 +155,25 @@\n description = \"Represents a value of an attribute.\"\n interfaces = [graphene.relay.Node]\n model = models.AttributeValue\n \n+ @staticmethod\n+ def resolve_name(root: ChannelContext[models.AttributeValue], info: ResolveInfo):\n+ attr_value = root.node\n+\n+ if attr_value.reference_product_id:\n+ return _resolve_referenced_product_name(\n+ attr_value.reference_product_id, info\n+ )\n+ if attr_value.reference_variant_id:\n+ return _resolve_referenced_product_variant_name(\n+ attr_value.reference_variant_id, info\n+ )\n+ if attr_value.reference_page_id:\n+ return _resolve_referenced_page_name(attr_value.reference_page_id, info)\n+ return attr_value.name\n+\n+ @staticmethod\n def resolve_input_type(\n root: ChannelContext[models.AttributeValue], info: ResolveInfo\n ):\n attr_value = root.node\n"
64
- },
65
- {
66
- "path": "saleor/graphql/page/tests/mutations/test_page_update.py",
67
- "status": "modified",
68
- "diff": "Index: saleor/graphql/page/tests/mutations/test_page_update.py\n===================================================================\n--- saleor/graphql/page/tests/mutations/test_page_update.py\tf7e322e (parent)\n+++ saleor/graphql/page/tests/mutations/test_page_update.py\t9024814 (commit)\n@@ -756,11 +756,12 @@\n \n page_type = page.page_type\n page_type.page_attributes.add(page_type_product_reference_attribute)\n \n+ expected_name = product.name\n attr_value = AttributeValue.objects.create(\n attribute=page_type_product_reference_attribute,\n- name=page.title,\n+ name=expected_name,\n slug=f\"{page.pk}_{product.pk}\",\n reference_product=product,\n )\n associate_attribute_values_to_instance(\n@@ -796,9 +797,9 @@\n \"values\": [\n {\n \"slug\": attr_value.slug,\n \"file\": None,\n- \"name\": page.title,\n+ \"name\": expected_name,\n \"reference\": reference,\n \"plainText\": None,\n }\n ],\n@@ -876,11 +877,12 @@\n \n page_type = page.page_type\n page_type.page_attributes.add(page_type_variant_reference_attribute)\n \n+ expected_name = f\"{variant.product.name}: {variant.name}\"\n attr_value = AttributeValue.objects.create(\n attribute=page_type_variant_reference_attribute,\n- name=page.title,\n+ name=expected_name,\n slug=f\"{page.pk}_{variant.pk}\",\n reference_variant=variant,\n )\n associate_attribute_values_to_instance(\n@@ -916,9 +918,9 @@\n \"values\": [\n {\n \"slug\": attr_value.slug,\n \"file\": None,\n- \"name\": page.title,\n+ \"name\": expected_name,\n \"reference\": reference,\n \"plainText\": None,\n }\n ],\n"
69
- }
70
- ]
71
- },
72
- {
73
- "id": "remove-attribute-values",
74
- "sha": "9cb9b2aee96e8ac94c28fe9a9b4f832a408ed53c",
75
- "parentSha": "e3bf06aea42a257a8d472acbebb922c0d7c1d2d2",
76
- "spec": "Implement the removal of the GraphQL Attribute.values field and its limit-based loader across the codebase.\n\nMake the following changes:\n\n1) GraphQL attribute dataloaders\n- In saleor/graphql/attribute/dataloaders.py:\n - Remove AttributeValuesByAttributeIdWithLimitLoader (the subclass of a limit-capable loader) and its logic based on Window/RowNumber.\n - Remove unused imports tied to it (django.db.models F, Window; RowNumber; DataLoaderWithLimit). Ensure only the basic DataLoader is used where applicable.\n\n2) GraphQL core dataloaders\n- In saleor/graphql/core/dataloaders.py:\n - Remove the DataLoaderWithLimit class entirely, including its limit validation and references to settings.NESTED_QUERY_LIMIT and GraphQLError.\n - Clean up imports that are no longer used by removal of DataLoaderWithLimit.\n\n3) GraphQL Attribute type and schema\n- In saleor/graphql/attribute/types.py:\n - Remove the values field definition from the Attribute GraphQL type, including the limit argument and its description.\n - Remove the resolve_values resolver and any imports that supported it (AttributeValuesByAttributeIdWithLimitLoader, settings if only used there).\n - Ensure Attribute.choices and other remaining fields continue to function without referencing values.\n\n- In saleor/graphql/schema.graphql:\n - Delete the Attribute.values field from the type definition, including its description and the limit: Int argument with default. Ensure the rest of the Attribute type remains intact.\n\n4) Query complexity map\n- In saleor/graphql/query_cost_map.py:\n - Remove the entry for Attribute.values and its multipliers (limit). Keep other entries like Attribute.choices and productTypes unchanged.\n\n5) Tests\n- In saleor/graphql/attribute/tests/queries/test_attribute_query.py:\n - Remove tests that query Attribute.values with or without a limit, including the associated ATTRIBUTE_VALUES_QUERY GraphQL query and assertions about limit enforcement and default behavior.\n - Adjust imports accordingly (e.g., remove Count, AttributeValue imports if only used by removed tests).\n\n6) Changelog\n- In CHANGELOG.md:\n - Remove the bullet item advertising the Attribute.values field capability.\n\nGeneral notes:\n- Verify there are no remaining references to DataLoaderWithLimit or AttributeValuesByAttributeIdWithLimitLoader anywhere in the repo. Remove dangling imports if present.\n- Do not remove underlying model relationships between Attribute and AttributeValue; mutations and other queries that rely on values should continue to function via existing fields like choices or other dedicated queries.\n- Ensure the codebase compiles and tests (aside from the intentionally removed ones) pass, with query complexity map consistent with the updated schema.\n",
77
- "prompt": "Remove the GraphQL Attribute.values field and all supporting infrastructure. This includes deleting the limit-based loader and its base class, updating the GraphQL type and schema to eliminate the field and its limit argument, cleaning up any query complexity configuration tied to it, and removing tests that exercised fetching attribute values with a limit. Make sure imports are cleaned, the schema and cost map are consistent, and other attribute functionality (like choices and filtering) remains unaffected.",
78
- "supplementalFiles": [
79
- "saleor/graphql/attribute/schema.py",
80
- "saleor/graphql/attribute/filters.py",
81
- "saleor/graphql/attribute/mutations/attribute_reorder_values.py",
82
- "saleor/attribute/models.py",
83
- "saleor/graphql/translations/types.py"
84
- ],
85
- "fileDiffs": [
86
- {
87
- "path": "CHANGELOG.md",
88
- "status": "modified",
89
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\te3bf06a (parent)\n+++ CHANGELOG.md\t9cb9b2a (commit)\n@@ -82,9 +82,8 @@\n - Extend `AttributeEntityType` with `CATEGORY` and `COLLECTION`. You can now assign category and collection as a attribute reference.\n - You can now filter and search attribute choices using the new `where` and `search` fields on the `attribute.choices` query.\n - Filtering products by `category` now also includes subcategories. The filter will return products that belong to the specified categories as well as their subcategories.\n - Deprecated `Transaction.gatewayResponse` field. Please migrate to Transaction API and Apps.\n-- Extend the `Attribute` type with a `values` field, allowing you to retrieve all values assigned to a specific attribute.\n - Add new `single-reference` attribute. You can now create a reference attribute that points to only one object (unlike the existing `reference` type, which supports multiple references).\n Like `reference`, the `single-reference` type can target entities defined in the `AttributeEntityTypeEnum`.\n - Extended support for filtering `products` by associated attributes\n - Attribute slug is now optional when filtering by attribute values\n"
90
- },
91
- {
92
- "path": "saleor/graphql/attribute/dataloaders.py",
93
- "status": "modified",
94
- "diff": "Index: saleor/graphql/attribute/dataloaders.py\n===================================================================\n--- saleor/graphql/attribute/dataloaders.py\te3bf06a (parent)\n+++ saleor/graphql/attribute/dataloaders.py\t9cb9b2a (commit)\n@@ -1,11 +1,8 @@\n from collections import defaultdict\n \n-from django.db.models import F, Window\n-from django.db.models.functions import RowNumber\n-\n from ...attribute.models import Attribute, AttributeValue\n-from ..core.dataloaders import DataLoader, DataLoaderWithLimit\n+from ..core.dataloaders import DataLoader\n \n \n class AttributeValuesByAttributeIdLoader(DataLoader[int, list[AttributeValue]]):\n context_key = \"attributevalues_by_attribute\"\n@@ -21,35 +18,8 @@\n )\n return [attribute_to_attributevalues[attribute_id] for attribute_id in keys]\n \n \n-class AttributeValuesByAttributeIdWithLimitLoader(\n- DataLoaderWithLimit[int, list[AttributeValue]]\n-):\n- context_key = \"attributevalues_by_attribute\"\n-\n- def batch_load(self, keys):\n- attribute_values = (\n- AttributeValue.objects.using(self.database_connection_name)\n- .filter(attribute_id__in=keys)\n- .annotate(\n- row_num=Window(\n- expression=RowNumber(),\n- partition_by=F(\"attribute_id\"),\n- order_by=F(\"id\").asc(),\n- )\n- )\n- .filter(row_num__lte=self.limit)\n- )\n-\n- attribute_to_attributevalues = defaultdict(list)\n- for attribute_value in attribute_values.iterator(chunk_size=1000):\n- attribute_to_attributevalues[attribute_value.attribute_id].append(\n- attribute_value\n- )\n- return [attribute_to_attributevalues[attribute_id] for attribute_id in keys]\n-\n-\n class AttributesByAttributeId(DataLoader[int, Attribute]):\n context_key = \"attributes_by_id\"\n \n def batch_load(self, keys):\n"
95
- },
96
- {
97
- "path": "saleor/graphql/attribute/tests/queries/test_attribute_query.py",
98
- "status": "modified",
99
- "diff": "Index: saleor/graphql/attribute/tests/queries/test_attribute_query.py\n===================================================================\n--- saleor/graphql/attribute/tests/queries/test_attribute_query.py\te3bf06a (parent)\n+++ saleor/graphql/attribute/tests/queries/test_attribute_query.py\t9cb9b2a (commit)\n@@ -1,11 +1,11 @@\n import graphene\n import pytest\n-from django.db.models import Count, Q\n+from django.db.models import Q\n from graphene.utils.str_converters import to_camel_case\n \n from .....attribute import AttributeInputType, AttributeType\n-from .....attribute.models import Attribute, AttributeValue\n+from .....attribute.models import Attribute\n from .....product import ProductTypeKind\n from .....product.models import Category, Collection, Product, ProductType\n from .....tests.utils import dummy_editorjs\n from ....tests.utils import (\n@@ -782,108 +782,4 @@\n # then\n data = content[\"data\"][\"attribute\"]\n assert data[\"externalReference\"] == ext_ref\n assert data[\"id\"] == graphene.Node.to_global_id(\"Attribute\", attribute.id)\n-\n-\n-ATTRIBUTE_VALUES_QUERY = \"\"\"\n-query ($limit: Int) {\n- attributes(first: 10) {\n- edges {\n- node {\n- id\n- name\n- slug\n- values(limit: $limit) {\n- name\n- slug\n- }\n- }\n- }\n- }\n-}\n-\"\"\"\n-\n-\n-def test_attributes_values_with_limit(\n- api_client, numeric_attribute, size_attribute, weight_attribute\n-):\n- # given\n- assert weight_attribute.values.count() > 1\n- assert size_attribute.values.count() > 1\n-\n- AttributeValue.objects.bulk_create(\n- [\n- AttributeValue(\n- slug=\"10\", name=\"10\", numeric=10, attribute=numeric_attribute\n- ),\n- AttributeValue(\n- slug=\"20\", name=\"20\", numeric=20, attribute=numeric_attribute\n- ),\n- AttributeValue(\n- slug=\"30\", name=\"30\", numeric=30, attribute=numeric_attribute\n- ),\n- ]\n- )\n- limit = 1\n- variables = {\"limit\": limit}\n-\n- # when\n- response = api_client.post_graphql(ATTRIBUTE_VALUES_QUERY, variables)\n-\n- # then\n- data = get_graphql_content(response)\n- attributes = data[\"data\"][\"attributes\"][\"edges\"]\n- for attribute in attributes:\n- assert len(attribute[\"node\"][\"values\"]) == limit\n-\n-\n-def test_attributes_values_default_limit(\n- api_client, numeric_attribute, size_attribute, weight_attribute\n-):\n- # given\n- AttributeValue.objects.bulk_create(\n- [\n- AttributeValue(\n- slug=\"10\", name=\"10\", numeric=10, attribute=numeric_attribute\n- ),\n- AttributeValue(\n- slug=\"20\", name=\"20\", numeric=20, attribute=numeric_attribute\n- ),\n- AttributeValue(\n- slug=\"30\", name=\"30\", numeric=30, attribute=numeric_attribute\n- ),\n- ]\n- )\n- attribute_count = {\n- att.slug: att.values_count\n- for att in Attribute.objects.annotate(values_count=Count(\"values\"))\n- }\n-\n- # when\n- response = api_client.post_graphql(ATTRIBUTE_VALUES_QUERY, {})\n-\n- # then\n- data = get_graphql_content(response)\n- attributes = data[\"data\"][\"attributes\"][\"edges\"]\n- for attribute in attributes:\n- assert (\n- len(attribute[\"node\"][\"values\"])\n- == attribute_count[attribute[\"node\"][\"slug\"]]\n- )\n-\n-\n-def test_attributes_values_limit_exceeded(api_client, weight_attribute):\n- # given\n- limit = 150\n- variables = {\"limit\": limit}\n-\n- # when\n- response = api_client.post_graphql(ATTRIBUTE_VALUES_QUERY, variables)\n-\n- # then\n- content = get_graphql_content_from_response(response)\n-\n- assert len(content[\"errors\"]) == 1\n- assert content[\"errors\"][0][\"message\"] == (\n- \"The limit for attribute values cannot be greater than 100.\"\n- )\n"
100
- },
101
- {
102
- "path": "saleor/graphql/attribute/types.py",
103
- "status": "modified",
104
- "diff": "Index: saleor/graphql/attribute/types.py\n===================================================================\n--- saleor/graphql/attribute/types.py\te3bf06a (parent)\n+++ saleor/graphql/attribute/types.py\t9cb9b2a (commit)\n@@ -1,6 +1,5 @@\n import graphene\n-from django.conf import settings\n \n from ...attribute import AttributeInputType, models\n from ...permission.enums import (\n PagePermissions,\n@@ -43,9 +42,8 @@\n from ..translations.fields import TranslationField\n from ..translations.types import AttributeTranslation, AttributeValueTranslation\n from .dataloaders import (\n AttributesByAttributeId,\n- AttributeValuesByAttributeIdWithLimitLoader,\n )\n from .descriptions import AttributeDescriptions, AttributeValueDescriptions\n from .enums import AttributeEntityTypeEnum, AttributeInputTypeEnum, AttributeTypeEnum\n from .filters import (\n@@ -215,23 +213,8 @@\n \"A list of predefined attribute choices available for selection. \"\n \"Available only for attributes with predefined choices.\"\n ),\n )\n- values = NonNullList(\n- AttributeValue,\n- description=(\n- \"List of all existing attribute values. This includes all values\"\n- \" that have been assigned to attributes.\" + ADDED_IN_322\n- ),\n- limit=graphene.Int(\n- description=(\n- \"Maximum number of attribute values to return. \"\n- \"The default value is also the maximum number of values \"\n- \"that can be fetched.\"\n- ),\n- default_value=settings.NESTED_QUERY_LIMIT,\n- ),\n- )\n \n value_required = graphene.Boolean(\n description=(\n f\"{AttributeDescriptions.VALUE_REQUIRED} Requires one of the following \"\n@@ -353,26 +336,8 @@\n channel_context_qs, info, kwargs, AttributeValueCountableConnection\n )\n \n @staticmethod\n- def resolve_values(\n- root: ChannelContext[models.Attribute], info: ResolveInfo, limit: int, **kwargs\n- ):\n- attr = root.node\n-\n- def map_channel_context(values):\n- return [\n- ChannelContext(node=value, channel_slug=root.channel_slug)\n- for value in values\n- ]\n-\n- return (\n- AttributeValuesByAttributeIdWithLimitLoader(info.context, limit=limit)\n- .load(attr.id)\n- .then(map_channel_context)\n- )\n-\n- @staticmethod\n @check_attribute_required_permissions()\n def resolve_value_required(\n root: ChannelContext[models.Attribute], _info: ResolveInfo\n ):\n"
105
- },
106
- {
107
- "path": "saleor/graphql/core/dataloaders.py",
108
- "status": "modified",
109
- "diff": "Index: saleor/graphql/core/dataloaders.py\n===================================================================\n--- saleor/graphql/core/dataloaders.py\te3bf06a (parent)\n+++ saleor/graphql/core/dataloaders.py\t9cb9b2a (commit)\n@@ -1,10 +1,8 @@\n from collections import defaultdict\n from collections.abc import Iterable\n from typing import TypeVar\n \n-from django.conf import settings\n-from graphql import GraphQLError\n from promise import Promise\n from promise.dataloader import DataLoader as BaseLoader\n \n from ...core.db.connection import allow_writer_in_context\n@@ -77,33 +75,8 @@\n def batch_load(self, keys: Iterable[K]) -> Promise[list[R]] | list[R]:\n raise NotImplementedError()\n \n \n-class DataLoaderWithLimit(DataLoader[K, R]):\n- \"\"\"Data loader base class that support a limit on the number of items returned.\"\"\"\n-\n- def __new__(cls, context: SaleorContext, limit: int = settings.NESTED_QUERY_LIMIT):\n- loader = super().__new__(cls, context)\n- cls.limit_validation(limit)\n- loader.limit = limit\n- return loader\n-\n- def __init__(\n- self, context: SaleorContext, limit: int = settings.NESTED_QUERY_LIMIT\n- ) -> None:\n- if getattr(self, \"limit\", None) != limit:\n- self.limit_validation(limit)\n- self.limit = limit\n- super().__init__(context=context)\n-\n- @staticmethod\n- def limit_validation(limit: int) -> None:\n- if limit > settings.NESTED_QUERY_LIMIT:\n- raise GraphQLError(\n- f\"The limit for attribute values cannot be greater than {settings.NESTED_QUERY_LIMIT}.\"\n- )\n-\n-\n class BaseThumbnailBySizeAndFormatLoader(\n DataLoader[tuple[int, int, str | None], Thumbnail]\n ):\n model_name: str\n"
110
- },
111
- {
112
- "path": "saleor/graphql/query_cost_map.py",
113
- "status": "modified",
114
- "diff": "Index: saleor/graphql/query_cost_map.py\n===================================================================\n--- saleor/graphql/query_cost_map.py\te3bf06a (parent)\n+++ saleor/graphql/query_cost_map.py\t9cb9b2a (commit)\n@@ -142,14 +142,8 @@\n \"webhooks\": {\"complexity\": 1},\n },\n \"Attribute\": {\n \"choices\": {\"complexity\": 1, \"multipliers\": [\"first\", \"last\"]},\n- \"values\": {\n- \"complexity\": 1,\n- \"multipliers\": [\n- \"limit\",\n- ],\n- },\n \"productTypes\": {\"complexity\": 1, \"multipliers\": [\"first\", \"last\"]},\n \"productVariantTypes\": {\"complexity\": 1, \"multipliers\": [\"first\", \"last\"]},\n },\n \"Category\": {\n"
115
- },
116
- {
117
- "path": "saleor/graphql/schema.graphql",
118
- "status": "modified",
119
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\te3bf06a (parent)\n+++ saleor/graphql/schema.graphql\t9cb9b2a (commit)\n@@ -6123,20 +6123,8 @@\n last: Int\n ): AttributeValueCountableConnection\n \n \"\"\"\n- List of all existing attribute values. This includes all values that have been assigned to attributes.\n- \n- Added in Saleor 3.22.\n- \"\"\"\n- values(\n- \"\"\"\n- Maximum number of attribute values to return. The default value is also the maximum number of values that can be fetched.\n- \"\"\"\n- limit: Int = 100\n- ): [AttributeValue!]\n-\n- \"\"\"\n Whether the attribute requires values to be passed or not. Requires one of the following permissions: MANAGE_PAGES, MANAGE_PAGE_TYPES_AND_ATTRIBUTES, MANAGE_PRODUCTS, MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES.\n \"\"\"\n valueRequired: Boolean!\n \n"
120
- }
121
- ]
122
- },
123
- {
124
- "id": "validate-ref-ids",
125
- "sha": "54987f827fbfd67cbb2043112070ae7acc053b30",
126
- "parentSha": "b29ad1e4d10109fdeff44c9486f74d18db8c83af",
127
- "spec": "Implement validation of referencedIds in attribute filter inputs for GraphQL where filters.\n\nScope:\n- Centralize validation in saleor/graphql/attribute/shared_filters.py within validate_attribute_value_reference_input.\n- Ensure that for any filter using the \"referencedIds\" key, both containsAll and containsAny lists are validated to contain only valid GraphQL global IDs and that those IDs correspond to allowed object types (Page, Product, ProductVariant).\n- Reuse the existing from_global_id_or_error utility to parse and validate IDs; treat GraphQLError during parsing as an invalid input case.\n- If any invalid IDs are present, raise a GraphQLError before executing the filtering logic with the message:\n \"Invalid input for reference attributes. For attribute input on positions: {comma-separated indices}. Provided values must contain valid global IDs.\"\n- Maintain existing behavior for other validations already present: duplicated keys, empty input values, and invalid input types should continue to raise GraphQLError with their current messages.\n\nImplementation structure:\n- In saleor/graphql/attribute/shared_filters.py:\n - Import from_global_id_or_error from ..core.utils.\n - Add a private helper to validate a single global ID’s type membership in the allowed set (Page, Product, ProductVariant) using from_global_id_or_error; return False on GraphQLError or disallowed type.\n - Add a private helper that validates both containsAll and containsAny lists in a single_key_value map by invoking the single-ID validator for each element.\n - Within validate_attribute_value_reference_input, when processing a key == \"referenced_ids\" entry, run the new multi-ID validation and collect indices with invalid inputs into a dedicated set. After the loop, if any indices were collected, raise the GraphQLError described above.\n - Preserve the pre-existing validation and exception paths (duplicated_error, empty_input_value_error, invalid_input_type_error) unchanged.\n\nTests:\n- Expand existing tests for pages, products, and variants where filters to assert that invalid referencedIds inputs fail validation:\n - Include cases for containsAny and containsAll containing: a non-global-ID string (e.g., \"non-existing-id\"); a valid global ID string of a wrong object type (e.g., an Order ID); and None (already present) to assert empty/invalid cases.\n - Expect a GraphQLError to be raised by the query with the new message format described above.\n\nNon-goals:\n- Do not change how valid IDs are fetched and used for filtering across products/pages/variants.\n- Do not alter the allowed object type set beyond Page, Product, ProductVariant.\n- Do not change error messages for other validation branches.\n",
128
- "prompt": "Add validation to GraphQL attribute reference filters so that referencedIds must be valid global IDs of the allowed node types, and surface a clear error if not. Integrate this validation in the shared attribute filter utilities used by pages, products, and variants. Update the tests for those queries to include invalid IDs and wrong node types and assert that a GraphQLError is raised with a helpful message.",
129
- "supplementalFiles": [
130
- "saleor/graphql/core/utils/__init__.py",
131
- "saleor/graphql/attribute/types.py",
132
- "saleor/graphql/product/filters/product_attributes.py",
133
- "saleor/graphql/page/filters.py",
134
- "saleor/graphql/attribute/tests/queries/test_attribute_where.py"
135
- ],
136
- "fileDiffs": [
137
- {
138
- "path": "saleor/graphql/attribute/shared_filters.py",
139
- "status": "modified",
140
- "diff": "Index: saleor/graphql/attribute/shared_filters.py\n===================================================================\n--- saleor/graphql/attribute/shared_filters.py\tb29ad1e (parent)\n+++ saleor/graphql/attribute/shared_filters.py\t54987f8 (commit)\n@@ -16,8 +16,9 @@\n from ..core.filters import DecimalFilterInput\n from ..core.filters.where_input import ContainsFilterInput, StringFilterInput\n from ..core.types import DateRangeInput, DateTimeRangeInput\n from ..core.types.base import BaseInputObjectType\n+from ..core.utils import from_global_id_or_error\n from ..utils.filters import (\n Number,\n filter_range_field,\n filter_where_by_numeric_field,\n@@ -264,8 +265,35 @@\n \"id\", ids, db_connection_name\n )\n \n \n+def _has_valid_reference_global_id(global_id: \"str\") -> bool:\n+ try:\n+ obj_type, _ = from_global_id_or_error(global_id)\n+ except GraphQLError:\n+ return False\n+\n+ if obj_type not in (\n+ \"Page\",\n+ \"Product\",\n+ \"ProductVariant\",\n+ ):\n+ return False\n+ return True\n+\n+\n+def _has_valid_reference_global_ids(\n+ single_key_value: CONTAINS_TYPING,\n+) -> bool:\n+ for global_id in single_key_value.get(\"contains_all\", []):\n+ if not _has_valid_reference_global_id(global_id):\n+ return False\n+ for global_id in single_key_value.get(\"contains_any\", []):\n+ if not _has_valid_reference_global_id(global_id):\n+ return False\n+ return True\n+\n+\n def validate_attribute_value_reference_input(\n index_with_values: list[\n tuple[\n str,\n@@ -290,8 +318,9 @@\n \n duplicated_error = set()\n empty_input_value_error = set()\n invalid_input_type_error = set()\n+ invalid_reference_global_id_error = set()\n for index, value in index_with_values:\n if not value:\n invalid_input_type_error.add(index)\n continue\n@@ -313,9 +342,20 @@\n \"contains_any\" in single_key_value\n and not single_key_value[\"contains_any\"]\n ):\n empty_input_value_error.add(index)\n+ if key == \"referenced_ids\":\n+ if not _has_valid_reference_global_ids(single_key_value):\n+ invalid_reference_global_id_error.add(index)\n \n+ if invalid_reference_global_id_error:\n+ raise GraphQLError(\n+ message=(\n+ \"Invalid input for reference attributes. For attribute input on positions: \"\n+ f\"{', '.join(invalid_reference_global_id_error)}. \"\n+ \"Provided values must contain valid global IDs.\"\n+ )\n+ )\n if invalid_input_type_error:\n raise GraphQLError(\n message=(\n \"Invalid input for reference attributes. For attribute input on positions: \"\n"
141
- },
142
- {
143
- "path": "saleor/graphql/page/tests/queries/pages_with_where/test_with_where_validation.py",
144
- "status": "modified",
145
- "diff": "Index: saleor/graphql/page/tests/queries/pages_with_where/test_with_where_validation.py\n===================================================================\n--- saleor/graphql/page/tests/queries/pages_with_where/test_with_where_validation.py\tb29ad1e (parent)\n+++ saleor/graphql/page/tests/queries/pages_with_where/test_with_where_validation.py\t54987f8 (commit)\n@@ -260,8 +260,13 @@\n {\"reference\": {\"pageSlugs\": {\"containsAny\": None}}},\n {\"reference\": {\"productSlugs\": {\"containsAny\": None}}},\n {\"reference\": {\"productVariantSkus\": {\"containsAny\": None}}},\n {\"reference\": {\"referencedIds\": {\"containsAny\": None}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAny\": [\"non-existing-id\"]}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"non-existing-id\"]}}},\n+ # ID of not valid object\n+ {\"reference\": {\"referencedIds\": {\"containsAny\": [\"T3JkZXI6MQ==\"]}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"T3JkZXI6MQ==\"]}}},\n ],\n )\n def test_pages_query_failed_filter_validation_for_reference_attribute_with_slug_input(\n attribute_value_filter,\n"
146
- },
147
- {
148
- "path": "saleor/graphql/product/tests/queries/products_filtrations/test_over_validation.py",
149
- "status": "modified",
150
- "diff": "Index: saleor/graphql/product/tests/queries/products_filtrations/test_over_validation.py\n===================================================================\n--- saleor/graphql/product/tests/queries/products_filtrations/test_over_validation.py\tb29ad1e (parent)\n+++ saleor/graphql/product/tests/queries/products_filtrations/test_over_validation.py\t54987f8 (commit)\n@@ -303,8 +303,13 @@\n {\"reference\": {\"pageSlugs\": {\"containsAny\": None}}},\n {\"reference\": {\"productSlugs\": {\"containsAny\": None}}},\n {\"reference\": {\"productVariantSkus\": {\"containsAny\": None}}},\n {\"reference\": {\"referencedIds\": {\"containsAny\": None}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAny\": [\"non-existing-id\"]}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"non-existing-id\"]}}},\n+ # ID of not valid object\n+ {\"reference\": {\"referencedIds\": {\"containsAny\": [\"T3JkZXI6MQ==\"]}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"T3JkZXI6MQ==\"]}}},\n ],\n )\n def test_products_query_failed_filter_validation_for_reference_attribute_with_slug_input(\n query,\n"
151
- },
152
- {
153
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_validation.py",
154
- "status": "modified",
155
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_validation.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_validation.py\tb29ad1e (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_validation.py\t54987f8 (commit)\n@@ -304,8 +304,13 @@\n {\"reference\": {\"pageSlugs\": {\"containsAny\": None}}},\n {\"reference\": {\"productSlugs\": {\"containsAny\": None}}},\n {\"reference\": {\"productVariantSkus\": {\"containsAny\": None}}},\n {\"reference\": {\"referencedIds\": {\"containsAny\": None}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAny\": [\"non-existing-id\"]}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"non-existing-id\"]}}},\n+ # ID of not valid object\n+ {\"reference\": {\"referencedIds\": {\"containsAny\": [\"T3JkZXI6MQ==\"]}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"T3JkZXI6MQ==\"]}}},\n ],\n )\n def test_product_variants_query_failed_filter_validation_for_reference_attribute_with_slug_input(\n attribute_value_filter,\n"
156
- }
157
- ]
158
- },
159
- {
160
- "id": "add-warehouse-filter",
161
- "sha": "b29ad1e4d10109fdeff44c9486f74d18db8c83af",
162
- "parentSha": "f4cf223ac3f6af5c9e080dcb261c60b982bb03e4",
163
- "spec": "Implement filtering of orders by the warehouses used to fulfill them via the orders 'where' API:\n\n1) GraphQL inputs\n- Add a new input type for fulfillment warehouses with three fields:\n - id: GlobalID-based filter\n - slug: string filter\n - externalReference: string filter\n- Add a new optional field 'warehouse' to the existing fulfillment filter input, accepting the new warehouse filter input.\n- Ensure descriptions and doc category for Orders are set consistently with existing filters.\n- Update the GraphQL SDL (schema.graphql) accordingly, including the 'warehouse' field on FulfillmentFilterInput and the FulfillmentWarehouseFilterInput definition.\n\n2) Order filtering logic\n- Extend the existing fulfillments-related filtering to support the nested warehouse filter:\n - Introduce a helper that, given a warehouse filter input and a Fulfillment queryset bound to the current DB alias, narrows fulfillments to those that have at least one FulfillmentLine whose Stock belongs to a Warehouse matching the provided id/slug/externalReference conditions.\n - Build the Warehouse queryset using the existing generic where helpers (GlobalID and string value filters) and respect the DB alias (using the queryset's .db).\n - If no warehouse subfilters are present or the input is empty, the helper should return an empty queryset for logical AND semantics in the parent filter.\n - Use Exists subqueries to traverse: Warehouse -> Stock -> FulfillmentLine -> Fulfillment, and then apply Exists on fulfillments to the Order queryset.\n - Integrate this helper into the existing fulfillments filter so it composes with other fulfillment filters (status, metadata). When an intermediate fulfillment queryset already exists, reuse and further constrain it instead of creating a new one; otherwise, start from Fulfillment.objects on the same DB alias.\n\n3) Utilities typing update\n- Broaden the type hints of the generic where filter helpers to accept Mapping and Sequence for input values instead of concrete dict/list types, without changing behavior.\n\n4) Tests\n- Add tests under GraphQL order queries for the 'where' filtering to validate the new functionality:\n - Filtering by warehouse id using eq and oneOf should return only orders whose fulfillments include lines using stocks from the specified warehouse(s).\n - Filtering by warehouse slug and by externalReference using eq and oneOf should work analogously.\n - Non-existing warehouse filters and conflicting combinations should yield no results.\n - Combining the warehouse filter with additional fulfillment filters (status, metadata) should correctly intersect conditions, returning 0 or 1 order as appropriate.\n- Test setup should create stocks for variants in two warehouses, generate order lines, create fulfillments and fulfillment lines pointing to those stocks, and then exercise the filters.\n\n5) Documentation\n- Update CHANGELOG to mention the new capability to filter orders by the warehouse used to fulfill the order.\n\nConstraints and consistency:\n- Use the existing where filter framework and helper functions for id/value filtering to ensure consistent behavior (eq, oneOf) and global ID resolution.\n- Respect the current queryset and DB alias throughout, chaining conditions rather than reinitializing querysets.\n- Keep naming and descriptions consistent with the codebase conventions for Orders doc category and input types.",
164
- "prompt": "Add support to filter orders by the warehouses used in their fulfillments through the existing orders 'where' API. Introduce a nested warehouse filter inside the fulfillments filter (with id, slug, and external reference options), integrate it into the order filtering so it composes with status and metadata, and ensure it properly matches orders whose fulfillments include lines fulfilled from stocks in the specified warehouses. Update the GraphQL schema and input types, keep the implementation consistent with the existing 'where' filtering helpers and patterns, and add comprehensive tests covering id/slug/external reference cases, non-matches, and combinations with other fulfillment filters. Also update the changelog to document the new filter.",
165
- "supplementalFiles": [
166
- "saleor/order/models.py",
167
- "saleor/warehouse/models.py",
168
- "saleor/graphql/core/filters.py",
169
- "saleor/graphql/core/doc_category.py"
170
- ],
171
- "fileDiffs": [
172
- {
173
- "path": "CHANGELOG.md",
174
- "status": "modified",
175
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\tf4cf223 (parent)\n+++ CHANGELOG.md\tb29ad1e (commit)\n@@ -29,8 +29,9 @@\n - Filter by associated event type and date.\n - Filter by associated payment method name and type.\n - Filter by associated billing and shipping address phone number and country code.\n - Filter by associated transactionItems metadata.\n+ - Filter by warehouse used to fulfill the order.\n - You can now filter and search orders using the new `where` and `search` fields on the `orders` query.\n - Use `where` to define complex conditions with `AND`/`OR` logic and operators like `eq`, `oneOf`, `range`.\n - Use `search` to perform full-text search across relevant fields.\n - Added filtering options for draft orders:\n"
176
- },
177
- {
178
- "path": "saleor/graphql/order/filters.py",
179
- "status": "modified",
180
- "diff": "Index: saleor/graphql/order/filters.py\n===================================================================\n--- saleor/graphql/order/filters.py\tf4cf223 (parent)\n+++ saleor/graphql/order/filters.py\tb29ad1e (commit)\n@@ -1,23 +1,25 @@\n+from collections.abc import Mapping\n from uuid import UUID\n \n import django_filters\n import graphene\n from django.core.exceptions import ValidationError\n from django.core.validators import validate_email\n-from django.db.models import Exists, OuterRef, Q, Value\n+from django.db.models import Exists, OuterRef, Q, QuerySet, Value\n from django.utils import timezone\n from graphql.error import GraphQLError\n \n from ...core.postgres import FlatConcat\n from ...giftcard import GiftCardEvents\n from ...giftcard.models import GiftCardEvent\n from ...invoice.models import Invoice\n-from ...order.models import Fulfillment, Order, OrderEvent, OrderLine\n+from ...order.models import Fulfillment, FulfillmentLine, Order, OrderEvent, OrderLine\n from ...order.search import search_orders\n from ...payment import ChargeStatus, PaymentMethodType\n from ...payment.models import TransactionItem\n from ...product.models import ProductVariant\n+from ...warehouse.models import Stock, Warehouse\n from ..account.filters import AddressFilterInput, filter_address\n from ..channel.filters import get_currency_from_filter_data\n from ..core.doc_category import DOC_CATEGORY_ORDERS\n from ..core.filters import (\n@@ -287,8 +289,43 @@\n return qs.filter(Exists(fulfillments))\n return qs.filter(~Exists(fulfillments))\n \n \n+def filter_fulfillments_by_warehouse_details(\n+ value: Mapping[str, Mapping[str, list[str] | str]], qs: QuerySet[Fulfillment]\n+) -> QuerySet[Fulfillment]:\n+ if not value:\n+ return qs.none()\n+\n+ warehouse_qs = None\n+ if warehouse_id_filter := value.get(\"id\"):\n+ warehouse_qs = filter_where_by_id_field(\n+ Warehouse.objects.using(qs.db), \"id\", warehouse_id_filter, \"Warehouse\"\n+ )\n+ if warehouse_slug_filter := value.get(\"slug\"):\n+ if warehouse_qs is None:\n+ warehouse_qs = Warehouse.objects.using(qs.db)\n+ warehouse_qs = filter_where_by_value_field(\n+ warehouse_qs, \"slug\", warehouse_slug_filter\n+ )\n+ if warehouse_external_reference := value.get(\"external_reference\"):\n+ if warehouse_qs is None:\n+ warehouse_qs = Warehouse.objects.using(qs.db)\n+ warehouse_qs = filter_where_by_value_field(\n+ warehouse_qs, \"external_reference\", warehouse_external_reference\n+ )\n+ if warehouse_qs is None:\n+ return qs.none()\n+\n+ stocks_qs = Stock.objects.using(qs.db).filter(\n+ Exists(warehouse_qs.filter(id=OuterRef(\"warehouse_id\")))\n+ )\n+ fulfillment_lines_qs = FulfillmentLine.objects.using(qs.db).filter(\n+ Exists(stocks_qs.filter(id=OuterRef(\"stock_id\")))\n+ )\n+ return qs.filter(Exists(fulfillment_lines_qs.filter(fulfillment_id=OuterRef(\"id\"))))\n+\n+\n def filter_fulfillments(qs, value):\n if not value:\n return qs.none()\n \n@@ -299,10 +336,16 @@\n fulfillment_qs = filter_where_by_value_field(\n Fulfillment.objects.using(qs.db), \"status\", status_value\n )\n if metadata_value := input_data.get(\"metadata\"):\n- fulfillment_qs = filter_where_metadata(\n- fulfillment_qs or Fulfillment.objects.using(qs.db), None, metadata_value\n+ if fulfillment_qs is None:\n+ fulfillment_qs = Fulfillment.objects.using(qs.db)\n+ fulfillment_qs = filter_where_metadata(fulfillment_qs, None, metadata_value)\n+ if warehouse_value := input_data.get(\"warehouse\"):\n+ if fulfillment_qs is None:\n+ fulfillment_qs = Fulfillment.objects.using(qs.db)\n+ fulfillment_qs = filter_fulfillments_by_warehouse_details(\n+ value=warehouse_value, qs=fulfillment_qs\n )\n if fulfillment_qs is not None:\n lookup &= Q(Exists(fulfillment_qs.filter(order_id=OuterRef(\"id\"))))\n if lookup:\n@@ -429,13 +472,36 @@\n doc_category = DOC_CATEGORY_ORDERS\n description = \"Filter by fulfillment status.\"\n \n \n+class FulfillmentWarehouseFilterInput(BaseInputObjectType):\n+ id = GlobalIDFilterInput(\n+ description=\"Filter fulfillments by warehouse ID.\",\n+ required=False,\n+ )\n+ slug = StringFilterInput(\n+ description=\"Filter fulfillments by warehouse slug.\",\n+ required=False,\n+ )\n+ external_reference = StringFilterInput(\n+ description=\"Filter fulfillments by warehouse external reference.\",\n+ required=False,\n+ )\n+\n+ class Meta:\n+ doc_category = DOC_CATEGORY_ORDERS\n+ description = \"Filter input for fulfillment warehouses.\"\n+\n+\n class FulfillmentFilterInput(BaseInputObjectType):\n status = FulfillmentStatusEnumFilterInput(\n description=\"Filter by fulfillment status.\"\n )\n metadata = MetadataFilterInput(description=\"Filter by metadata fields.\")\n+ warehouse = FulfillmentWarehouseFilterInput(\n+ description=\"Filter by fulfillment warehouse.\",\n+ required=False,\n+ )\n \n class Meta:\n doc_category = DOC_CATEGORY_ORDERS\n description = \"Filter input for order fulfillments data.\"\n"
181
- },
182
- {
183
- "path": "saleor/graphql/order/tests/queries/test_order_with_where.py",
184
- "status": "modified",
185
- "diff": "Index: saleor/graphql/order/tests/queries/test_order_with_where.py\n===================================================================\n--- saleor/graphql/order/tests/queries/test_order_with_where.py\tf4cf223 (parent)\n+++ saleor/graphql/order/tests/queries/test_order_with_where.py\tb29ad1e (commit)\n@@ -16,15 +16,19 @@\n OrderChargeStatus,\n OrderEvents,\n OrderStatus,\n )\n-from .....order.models import Order, OrderEvent, OrderLine\n+from .....order.models import FulfillmentLine, Order, OrderEvent, OrderLine\n from .....order.search import prepare_order_search_vector_value\n+from .....warehouse.models import Stock\n+from ....core.utils import to_global_id_or_none\n from ....tests.utils import get_graphql_content, get_graphql_content_from_response\n \n \n @pytest.fixture\n-def orders_with_fulfillments(order_list):\n+def orders_with_fulfillments(\n+ order_list, warehouses, order_lines_generator, product_variant_list\n+):\n statuses = [\n FulfillmentStatus.FULFILLED,\n FulfillmentStatus.REFUNDED,\n FulfillmentStatus.RETURNED,\n@@ -33,14 +37,46 @@\n {\"foo\": \"bar\"},\n {\"foo\": \"zaz\"},\n {},\n ]\n+ variant_1 = product_variant_list[0]\n+ variant_2 = product_variant_list[1]\n+ variant_1_quantity = 10\n+ variant_2_quantity = 5\n+ stock_1, stock_2 = Stock.objects.bulk_create(\n+ [\n+ Stock(\n+ product_variant=variant_1,\n+ warehouse=warehouses[0],\n+ quantity=variant_1_quantity * len(order_list),\n+ ),\n+ Stock(\n+ product_variant=variant_2,\n+ warehouse=warehouses[1],\n+ quantity=variant_2_quantity * len(order_list),\n+ ),\n+ ]\n+ )\n for order, status, metadata in zip(\n order_list, statuses, metadata_values, strict=True\n ):\n- order.fulfillments.create(\n+ fulfillment = order.fulfillments.create(\n tracking_number=\"123\", status=status, metadata=metadata\n )\n+ line_1, line_2 = order_lines_generator(\n+ order,\n+ [variant_1, variant_2],\n+ [10, 20],\n+ [variant_1_quantity, variant_2_quantity],\n+ create_allocations=False,\n+ )\n+\n+ fulfillment.lines.create(\n+ order_line=line_1, quantity=line_1.quantity, stock=stock_1\n+ )\n+ fulfillment.lines.create(\n+ order_line=line_2, quantity=line_2.quantity, stock=stock_2\n+ )\n return order_list\n \n \n def test_order_query_with_filter_and_where(\n@@ -1975,9 +2011,306 @@\n str(orders_with_fulfillments[1].number),\n }\n \n \n+def test_orders_filter_fulfillment_warehouse_id_eq(\n+ orders_with_fulfillments,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ fulfilled_order,\n+):\n+ # given\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+ expected_order = fulfilled_order\n+ fulfillment = expected_order.fulfillments.first()\n+ warehouse = fulfillment.lines.first().stock.warehouse\n+\n+ variables = {\n+ \"where\": {\n+ \"fulfillments\": [\n+ {\"warehouse\": {\"id\": {\"eq\": to_global_id_or_none(warehouse)}}}\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_WHERE_QUERY, variables)\n+ content = get_graphql_content(response)\n+ orders = content[\"data\"][\"orders\"][\"edges\"]\n+\n+ # then\n+ assert len(orders) == 1\n+ order_number_from_api = orders[0][\"node\"][\"number\"]\n+ assert order_number_from_api == str(expected_order.number)\n+\n+\n+def test_orders_filter_fulfillment_warehouse_id_one_of(\n+ orders_with_fulfillments,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ fulfilled_order,\n+):\n+ # given\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+ expected_order = fulfilled_order\n+ fulfillment = expected_order.fulfillments.first()\n+ warehouse = fulfillment.lines.first().stock.warehouse\n+\n+ variables = {\n+ \"where\": {\n+ \"fulfillments\": [\n+ {\"warehouse\": {\"id\": {\"oneOf\": [to_global_id_or_none(warehouse)]}}}\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_WHERE_QUERY, variables)\n+ content = get_graphql_content(response)\n+ orders = content[\"data\"][\"orders\"][\"edges\"]\n+\n+ # then\n+ assert len(orders) == 1\n+ order_number_from_api = orders[0][\"node\"][\"number\"]\n+ assert order_number_from_api == str(expected_order.number)\n+\n+\n @pytest.mark.parametrize(\n+ \"where_warehouse_slug\",\n+ [\n+ {\"slug\": {\"eq\": \"warehouse-to-get\"}},\n+ {\"slug\": {\"oneOf\": [\"warehouse-to-get\"]}},\n+ ],\n+)\n+def test_orders_filter_fulfillment_warehouse_slug(\n+ where_warehouse_slug,\n+ orders_with_fulfillments,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ fulfilled_order,\n+):\n+ # given\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+ expected_order = fulfilled_order\n+ fulfillment = expected_order.fulfillments.first()\n+\n+ assert FulfillmentLine.objects.count() > 1\n+\n+ warehouse = fulfillment.lines.first().stock.warehouse\n+\n+ expected_warehouse_slug = \"warehouse-to-get\"\n+ warehouse.slug = expected_warehouse_slug\n+ warehouse.save()\n+\n+ variables = {\"where\": {\"fulfillments\": [{\"warehouse\": where_warehouse_slug}]}}\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_WHERE_QUERY, variables)\n+ content = get_graphql_content(response)\n+ orders = content[\"data\"][\"orders\"][\"edges\"]\n+\n+ # then\n+ assert len(orders) == 1\n+ order_number_from_api = orders[0][\"node\"][\"number\"]\n+ assert order_number_from_api == str(expected_order.number)\n+\n+\n+@pytest.mark.parametrize(\n+ \"where_warehouse_external_reference\",\n+ [\n+ {\"externalReference\": {\"eq\": \"warehouse-to-get\"}},\n+ {\"externalReference\": {\"oneOf\": [\"warehouse-to-get\"]}},\n+ ],\n+)\n+def test_orders_filter_fulfillment_warehouse_external_reference(\n+ where_warehouse_external_reference,\n+ orders_with_fulfillments,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ fulfilled_order,\n+):\n+ # given\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+ expected_order = fulfilled_order\n+ fulfillment = expected_order.fulfillments.first()\n+\n+ assert FulfillmentLine.objects.count() > 1\n+\n+ warehouse = fulfillment.lines.first().stock.warehouse\n+\n+ expected_warehouse_external_reference = \"warehouse-to-get\"\n+ warehouse.external_reference = expected_warehouse_external_reference\n+ warehouse.save()\n+\n+ variables = {\n+ \"where\": {\"fulfillments\": [{\"warehouse\": where_warehouse_external_reference}]}\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_WHERE_QUERY, variables)\n+ content = get_graphql_content(response)\n+ orders = content[\"data\"][\"orders\"][\"edges\"]\n+\n+ # then\n+ assert len(orders) == 1\n+ order_number_from_api = orders[0][\"node\"][\"number\"]\n+ assert order_number_from_api == str(expected_order.number)\n+\n+\n+@pytest.mark.parametrize(\n+ \"where_warehouse_non_existing_input\",\n+ [\n+ {\"externalReference\": {\"eq\": \"non-existing-warehouse\"}},\n+ {\"externalReference\": {\"oneOf\": [\"non-existing-warehouse\"]}},\n+ {\"slug\": {\"eq\": \"non-existing-warehouse\"}},\n+ {\"slug\": {\"oneOf\": [\"non-existing-warehouse\"]}},\n+ {\n+ \"id\": {\n+ \"eq\": \"V2FyZWhvdXNlOjJjMGNiODAwLTU0N2ItNDM1ZS04Y2UwLTkyYTFiOTE1ZmFkMQ==\"\n+ }\n+ },\n+ {\n+ \"id\": {\n+ \"oneOf\": [\n+ \"V2FyZWhvdXNlOjJjMGNiODAwLTU0N2ItNDM1ZS04Y2UwLTkyYTFiOTE1ZmFkMQ==\"\n+ ]\n+ }\n+ },\n+ {\n+ \"slug\": {\"oneOf\": [\"non-existing-warehouse\"]},\n+ \"externalReference\": {\"eq\": \"existing-warehouse-ref\"},\n+ },\n+ ],\n+)\n+def test_orders_filter_fulfillment_warehouse_non_existing(\n+ where_warehouse_non_existing_input,\n+ orders_with_fulfillments,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ fulfilled_order,\n+):\n+ # given\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ fulfillment = fulfilled_order.fulfillments.first()\n+\n+ assert FulfillmentLine.objects.count() > 1\n+\n+ existing_warehouse = fulfillment.lines.first().stock.warehouse\n+ existing_warehouse.slug = \"existing-warehouse-slug\"\n+ existing_warehouse.external_reference = \"existing-warehouse-ref\"\n+ existing_warehouse.save()\n+\n+ variables = {\n+ \"where\": {\"fulfillments\": [{\"warehouse\": where_warehouse_non_existing_input}]}\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_WHERE_QUERY, variables)\n+ content = get_graphql_content(response)\n+ orders = content[\"data\"][\"orders\"][\"edges\"]\n+\n+ # then\n+ assert len(orders) == 0\n+\n+\n+@pytest.mark.parametrize(\n+ \"where_additional_filters\",\n+ [\n+ {\"status\": {\"eq\": FulfillmentStatus.FULFILLED.upper()}},\n+ {\"metadata\": {\"key\": \"notfound\"}},\n+ ],\n+)\n+def test_orders_filter_fulfillment_warehouse_with_multiple_filters_with_no_match(\n+ where_additional_filters,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ fulfilled_order,\n+):\n+ # given\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+ expected_order = fulfilled_order\n+ fulfillment = expected_order.fulfillments.first()\n+ fulfillment.status = FulfillmentStatus.WAITING_FOR_APPROVAL\n+ fulfillment.metadata = {\"key\": \"value\"}\n+ fulfillment.save()\n+\n+ warehouse = fulfillment.lines.first().stock.warehouse\n+\n+ variables = {\n+ \"where\": {\n+ \"fulfillments\": [\n+ {\n+ \"warehouse\": {\"id\": {\"eq\": to_global_id_or_none(warehouse)}},\n+ **where_additional_filters,\n+ },\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_WHERE_QUERY, variables)\n+ content = get_graphql_content(response)\n+ orders = content[\"data\"][\"orders\"][\"edges\"]\n+\n+ # then\n+ assert len(orders) == 0\n+\n+\n+@pytest.mark.parametrize(\n+ \"where_additional_filters\",\n+ [\n+ {\"status\": {\"eq\": FulfillmentStatus.FULFILLED.upper()}},\n+ {\"metadata\": {\"key\": \"meta-key\"}},\n+ ],\n+)\n+def test_orders_filter_fulfillment_warehouse_multiple_filters(\n+ where_additional_filters,\n+ orders_with_fulfillments,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ fulfilled_order,\n+):\n+ # given\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+ expected_order = fulfilled_order\n+\n+ fulfillment = expected_order.fulfillments.first()\n+ fulfillment.status = FulfillmentStatus.FULFILLED\n+ fulfillment.metadata = {\"meta-key\": \"meta-value\"}\n+ fulfillment.save()\n+\n+ assert FulfillmentLine.objects.count() > 1\n+\n+ warehouse = fulfillment.lines.first().stock.warehouse\n+\n+ expected_warehouse_external_reference = \"warehouse-to-get\"\n+ warehouse.external_reference = expected_warehouse_external_reference\n+ warehouse.save()\n+\n+ variables = {\n+ \"where\": {\n+ \"fulfillments\": [\n+ {\n+ \"warehouse\": {\"id\": {\"eq\": to_global_id_or_none(warehouse)}},\n+ **where_additional_filters,\n+ },\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_WHERE_QUERY, variables)\n+ content = get_graphql_content(response)\n+ orders = content[\"data\"][\"orders\"][\"edges\"]\n+\n+ # then\n+ assert len(orders) == 1\n+ order_number_from_api = orders[0][\"node\"][\"number\"]\n+ assert order_number_from_api == str(expected_order.number)\n+\n+\n+@pytest.mark.parametrize(\n (\"filter_input\", \"expected_indexes\"),\n [\n ([{\"metadata\": {\"key\": \"foo\"}}], [0, 1]),\n ([{\"metadata\": {\"key\": \"foo\", \"value\": {\"eq\": \"bar\"}}}], [0]),\n"
186
- },
187
- {
188
- "path": "saleor/graphql/schema.graphql",
189
- "status": "modified",
190
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\tf4cf223 (parent)\n+++ saleor/graphql/schema.graphql\tb29ad1e (commit)\n@@ -13386,8 +13386,11 @@\n status: FulfillmentStatusEnumFilterInput\n \n \"\"\"Filter by metadata fields.\"\"\"\n metadata: MetadataFilterInput\n+\n+ \"\"\"Filter by fulfillment warehouse.\"\"\"\n+ warehouse: FulfillmentWarehouseFilterInput\n }\n \n \"\"\"Filter by fulfillment status.\"\"\"\n input FulfillmentStatusEnumFilterInput @doc(category: \"Orders\") {\n@@ -13397,8 +13400,20 @@\n \"\"\"The value included in.\"\"\"\n oneOf: [FulfillmentStatus!]\n }\n \n+\"\"\"Filter input for fulfillment warehouses.\"\"\"\n+input FulfillmentWarehouseFilterInput @doc(category: \"Orders\") {\n+ \"\"\"Filter fulfillments by warehouse ID.\"\"\"\n+ id: GlobalIDFilterInput\n+\n+ \"\"\"Filter fulfillments by warehouse slug.\"\"\"\n+ slug: StringFilterInput\n+\n+ \"\"\"Filter fulfillments by warehouse external reference.\"\"\"\n+ externalReference: StringFilterInput\n+}\n+\n \"\"\"Filter input for order lines data.\"\"\"\n input LinesFilterInput @doc(category: \"Orders\") {\n \"\"\"Filter by metadata fields of order lines.\"\"\"\n metadata: MetadataFilterInput\n"
191
- },
192
- {
193
- "path": "saleor/graphql/utils/filters.py",
194
- "status": "modified",
195
- "diff": "Index: saleor/graphql/utils/filters.py\n===================================================================\n--- saleor/graphql/utils/filters.py\tf4cf223 (parent)\n+++ saleor/graphql/utils/filters.py\tb29ad1e (commit)\n@@ -1,4 +1,5 @@\n+from collections.abc import Mapping, Sequence\n from decimal import Decimal\n from typing import TYPE_CHECKING\n from uuid import UUID\n \n@@ -112,9 +113,9 @@\n ValueT = str | UUID\n \n \n def filter_where_by_value_field(\n- qs: \"QuerySet\", field: str, value: dict[str, ValueT | list[ValueT]]\n+ qs: \"QuerySet\", field: str, value: Mapping[str, ValueT | Sequence[ValueT]]\n ):\n if value is None:\n return qs.none()\n if \"eq\" in value:\n@@ -127,9 +128,9 @@\n return qs.none()\n \n \n def filter_where_by_id_field(\n- qs: \"QuerySet\", field: str, value: dict[str, str | list[str]], type: str\n+ qs: \"QuerySet\", field: str, value: Mapping[str, str | list[str]], type: str\n ):\n from . import resolve_global_ids_to_primary_keys\n \n eq = value.get(\"eq\")\n"
196
- }
197
- ]
198
- },
199
- {
200
- "id": "refactor-page-attributes",
201
- "sha": "f4cf223ac3f6af5c9e080dcb261c60b982bb03e4",
202
- "parentSha": "8eb11da07f09437374cb82daa6f10a80a11a1c90",
203
- "spec": "Implement a refactor that relocates page attribute filtering logic from the shared attribute filters module into the page module, keeping shared, model-agnostic helpers in the shared module and building page-specific Q expressions in the page module.\n\nRequired changes:\n1) saleor/graphql/attribute/shared_filters.py\n- Remove imports and functions that build Q expressions against Assigned* models and are page-specific or generic assignment-based filters:\n - Drop importing Q from django.db.models as it is no longer used in this module.\n - Remove the following functions: filter_by_contains_referenced_object_ids, _filter_contains_single_expression, _filter_contains_all_condition, _filter_contains_any_condition, filter_by_contains_referenced_pages, filter_by_contains_referenced_products, filter_by_contains_referenced_variants, filter_by_slug_or_name, filter_by_numeric_attribute, filter_by_boolean_attribute, filter_by_date_attribute, filter_by_date_time_attribute, filter_objects_by_attributes, filter_objects_by_reference_attributes.\n- Retain and export the low-level, model-agnostic helpers that return AttributeValue querysets based on input criteria, including but not limited to: get_attribute_values_by_slug_or_name_value, get_attribute_values_by_numeric_value, get_attribute_values_by_boolean_value, get_attribute_values_by_date_value, get_attribute_values_by_date_time_value, get_attribute_values_by_referenced_page_slugs, get_attribute_values_by_referenced_product_slugs, get_attribute_values_by_referenced_variant_skus, get_attribute_values_by_referenced_page_ids, get_attribute_values_by_referenced_product_ids, get_attribute_values_by_referenced_variant_ids. Keep input validation helpers such as validate_attribute_value_input and validate_attribute_value_reference_input. Keep shared types like AssignedAttributeWhereInput, CONTAINS_TYPING, and related TypedDicts.\n- Ensure remaining imports cover only what’s used (Exists/OuterRef/QuerySet as needed by remaining helpers, GraphQLError, graphene, models/enums).\n\n2) saleor/graphql/page/filters.py\n- Import the shared AttributeValue retrieval helpers from saleor/graphql/attribute/shared_filters and the necessary models/utilities:\n - From ...attribute.models import AssignedPageAttributeValue, Attribute, AttributeValue\n - From django.db.models import Q, Exists, OuterRef, QuerySet\n - From ..utils.filters import Number\n - From graphene and any other existing imports already present in the file\n- Add a helper to translate AttributeValue querysets into a page-level assignment existence check:\n - _get_assigned_page_attribute_for_attribute_value(attribute_values: QuerySet[AttributeValue], db_connection_name: str) -> Q that returns Q(Exists(AssignedPageAttributeValue.objects.using(db).filter(Exists(attribute_values.filter(id=OuterRef(\"value_id\"))), page_id=OuterRef(\"id\"))))\n- Implement page-specific filter builders that consume the shared get_attribute_values_* helpers and return Q expressions scoped to pages:\n - filter_by_slug_or_name(attr_id: int | None, attr_value: dict, db_connection_name: str) -> Q\n - filter_by_numeric_attribute(attr_id: int | None, numeric_value: dict[str, Number | list[Number] | dict[str, Number]], db_connection_name: str) -> Q\n - filter_by_boolean_attribute(attr_id: int | None, boolean_value: bool, db_connection_name: str) -> Q\n - filter_by_date_attribute(attr_id: int | None, date_value: dict[str, str], db_connection_name: str) -> Q\n - filter_by_date_time_attribute(attr_id: int | None, date_value: dict[str, str], db_connection_name: str) -> Q\n- Implement referenced-entity filters for pages supporting both contains_all and contains_any semantics:\n - A private _filter_contains_single_expression(attr_id: int | None, db_connection_name: str, referenced_attr_values: QuerySet[AttributeValue]) -> Q that optionally constrains by attribute_id and wraps via _get_assigned_page_attribute_for_attribute_value.\n - filter_by_contains_referenced_page_slugs(attr_id: int | None, attr_value: CONTAINS_TYPING, db_connection_name: str) -> Q\n - filter_by_contains_referenced_product_slugs(attr_id: int | None, attr_value: CONTAINS_TYPING, db_connection_name: str) -> Q\n - filter_by_contains_referenced_variant_skus(attr_id: int | None, attr_value: CONTAINS_TYPING, db_connection_name: str) -> Q\n - filter_by_contains_referenced_object_ids(attr_id: int | None, attr_value: CONTAINS_TYPING, db_connection_name: str) -> Q that parses global IDs (Page, Product, ProductVariant) using graphene.Node.from_global_id, groups them by type, and applies contains_all by AND-ing single checks and contains_any by OR-ing batched checks. Use the corresponding get_attribute_values_by_referenced_*_ids helpers.\n - Two internal helpers for the above to build the final Q for contains_all (AND each individual referenced id) and contains_any (OR across lists), e.g., _filter_by_contains_all_referenced_object_ids(...) and _filter_by_contains_any_referenced_object_ids(...).\n- Implement filter_objects_by_reference_attributes(attr_id: int | None, attr_value: dict of {\"referenced_ids\"|\"page_slugs\"|\"product_slugs\"|\"product_variant_skus\": CONTAINS_TYPING}, db_connection_name: str) -> Q that combines the above referenced filters with logical AND for provided keys.\n- Re-implement filter_pages_by_attributes(qs, value) with the following behavior:\n - Construct a map of requested attribute slugs to Attribute objects from the current DB alias (qs.db). If any requested slug doesn’t exist, return qs.none().\n - If any attribute input has a slug but no value object, treat it as a presence check: build a Q that asserts existence of any AssignedPageAttributeValue for those attributes on the page.\n - For each attribute input with a value, determine the type of filter to apply by keys present in the value object: slug/name, numeric, boolean, date, date_time, or reference. Delegate to the corresponding page-specific filter builder above. AND the resulting Q expressions together.\n - If the final attribute filter expression is non-empty, return qs.filter(expression); otherwise return qs.none().\n- Ensure PageWhere continues to expose the attributes where-filter using AssignedAttributeWhereInput and applies the above filter_pages_by_attributes in its attributes resolver.\n\nAcceptance criteria:\n- GraphQL Page queries using the attributes where clause continue to support filtering by: slug or name, numeric, boolean, date, date_time, and references via page_slugs, product_slugs, product_variant_skus, and global referenced_ids with correct contains_all/contains_any semantics.\n- The shared_filters module no longer contains page-specific Q builders; it exposes only the get_attribute_values_* and validation utilities.\n- Filtering performs existence checks via subqueries (Exists/OuterRef) against AssignedPageAttributeValue.\n- No import errors or unused imports remain in the modified modules.",
204
- "prompt": "Refactor the page attribute filtering so it lives in the page module and uses shared, model-agnostic helpers. Keep the low-level helpers that return AttributeValue querysets in the shared attribute filters, and implement page-specific Q expressions and composition logic in the page filters. Ensure Page filtering by attributes still supports slug/name, numeric, boolean, date/date_time, and reference-based filters (by slugs, SKUs, and global IDs) with correct contains_all and contains_any behavior, and that attributes specified without a value assert the presence of any value. Use Exists/OuterRef subqueries against AssignedPageAttributeValue to scope filters to pages. Update imports accordingly and remove page-specific filtering functions from the shared module.",
205
- "supplementalFiles": [
206
- "saleor/graphql/utils/filters.py",
207
- "saleor/attribute/models.py",
208
- "saleor/page/models.py",
209
- "saleor/graphql/schema.graphql"
210
- ],
211
- "fileDiffs": [
212
- {
213
- "path": "saleor/graphql/attribute/shared_filters.py",
214
- "status": "modified",
215
- "diff": "Index: saleor/graphql/attribute/shared_filters.py\n===================================================================\n--- saleor/graphql/attribute/shared_filters.py\t8eb11da (parent)\n+++ saleor/graphql/attribute/shared_filters.py\tf4cf223 (commit)\n@@ -1,8 +1,8 @@\n from typing import Literal, TypedDict\n \n import graphene\n-from django.db.models import Exists, OuterRef, Q, QuerySet\n+from django.db.models import Exists, OuterRef, QuerySet\n from graphql import GraphQLError\n \n from ...attribute import AttributeInputType\n from ...attribute.models import (\n@@ -97,484 +97,8 @@\n assigned_id_field_name: Literal[\"page_id\", \"product_id\"]\n identifier_field_name: Literal[\"slug\", \"id\", \"sku\"]\n \n \n-def filter_by_contains_referenced_object_ids(\n- attr_id: int | None,\n- attr_value: CONTAINS_TYPING,\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- \"\"\"Build a filter expression for objects referencing other entities by global IDs.\n-\n- Returns a Q expression to filter objects based on their references\n- to other entities (like: variants, products, pages), identified by\n- global IDs.\n-\n- - If `contains_all` is provided, only objects that reference all of the\n- specified global IDs will match.\n- - If `contains_any` is provided, objects that reference at least one of\n- the specified global IDs will match.\n- \"\"\"\n-\n- contains_all = attr_value.get(\"contains_all\")\n- contains_any = attr_value.get(\"contains_any\")\n-\n- variant_ids = set()\n- product_ids = set()\n- page_ids = set()\n-\n- for obj_id in contains_any or contains_all or []:\n- type_, id_ = graphene.Node.from_global_id(obj_id)\n- if type_ == \"Page\":\n- page_ids.add(id_)\n- elif type_ == \"Product\":\n- product_ids.add(id_)\n- elif type_ == \"ProductVariant\":\n- variant_ids.add(id_)\n-\n- expression = Q()\n- shared_filter_params: SharedContainsFilterParams = {\n- \"attr_id\": attr_id,\n- \"db_connection_name\": db_connection_name,\n- \"assigned_attr_model\": assigned_attr_model,\n- \"assigned_id_field_name\": assigned_id_field_name,\n- \"identifier_field_name\": \"id\",\n- }\n- if contains_all:\n- if page_ids:\n- expression &= _filter_contains_all_condition(\n- contains_all=list(page_ids),\n- referenced_model=page_models.Page,\n- attr_value_reference_field_name=\"reference_page_id\",\n- **shared_filter_params,\n- )\n- if product_ids:\n- expression &= _filter_contains_all_condition(\n- contains_all=list(product_ids),\n- referenced_model=product_models.Product,\n- attr_value_reference_field_name=\"reference_product_id\",\n- **shared_filter_params,\n- )\n- if variant_ids:\n- expression &= _filter_contains_all_condition(\n- contains_all=list(variant_ids),\n- referenced_model=product_models.ProductVariant,\n- attr_value_reference_field_name=\"reference_variant_id\",\n- **shared_filter_params,\n- )\n- return expression\n-\n- if contains_any:\n- if page_ids:\n- expression |= _filter_contains_any_condition(\n- contains_any=list(page_ids),\n- referenced_model=page_models.Page,\n- attr_value_reference_field_name=\"reference_page_id\",\n- **shared_filter_params,\n- )\n-\n- if product_ids:\n- expression |= _filter_contains_any_condition(\n- contains_any=list(product_ids),\n- referenced_model=product_models.Product,\n- attr_value_reference_field_name=\"reference_product_id\",\n- **shared_filter_params,\n- )\n-\n- if variant_ids:\n- expression |= _filter_contains_any_condition(\n- contains_any=list(variant_ids),\n- referenced_model=product_models.ProductVariant,\n- attr_value_reference_field_name=\"reference_variant_id\",\n- **shared_filter_params,\n- )\n- return expression\n-\n-\n-def _filter_contains_single_expression(\n- attr_id: int | None,\n- db_connection_name: str,\n- reference_objs: QuerySet[\n- page_models.Page | product_models.Product | product_models.ProductVariant\n- ],\n- attr_value_reference_field_name: Literal[\n- \"reference_page_id\", \"reference_product_id\", \"reference_variant_id\"\n- ],\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- single_reference_qs = AttributeValue.objects.using(db_connection_name).filter(\n- Exists(reference_objs.filter(id=OuterRef(attr_value_reference_field_name))),\n- )\n- if attr_id:\n- attr_query = Attribute.objects.using(db_connection_name).filter(id=attr_id)\n- single_reference_qs = single_reference_qs.filter(\n- Exists(attr_query.filter(id=OuterRef(\"attribute_id\"))),\n- )\n- assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n- Exists(single_reference_qs.filter(id=OuterRef(\"value_id\"))),\n- **{str(assigned_id_field_name): OuterRef(\"id\")},\n- )\n- return Q(Exists(assigned_attr_value))\n-\n-\n-def _filter_contains_all_condition(\n- attr_id: int | None,\n- db_connection_name: str,\n- contains_all: list[str],\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n- identifier_field_name: Literal[\"slug\", \"id\", \"sku\"],\n- referenced_model: type[\n- page_models.Page | product_models.Product | product_models.ProductVariant\n- ],\n- attr_value_reference_field_name: Literal[\n- \"reference_page_id\", \"reference_product_id\", \"reference_variant_id\"\n- ],\n-):\n- \"\"\"Build a filter expression that ensures all specified references are present.\n-\n- Constructs a Q expression that checks for references to all entities from\n- `referenced_model`, matched using the provided identifiers in `contains_all`.\n-\n- For each identifier, it resolves the corresponding object using\n- `identifier_field_name` and adds a subquery to verify the presence\n- of that reference. The subqueries are combined using logical AND.\n- \"\"\"\n-\n- identifiers = contains_all\n- expression = Q()\n-\n- for identifier in identifiers:\n- reference_obj = referenced_model.objects.using(db_connection_name).filter(\n- **{str(identifier_field_name): identifier}\n- )\n- expression &= _filter_contains_single_expression(\n- attr_id,\n- db_connection_name,\n- reference_obj,\n- attr_value_reference_field_name,\n- assigned_attr_model,\n- assigned_id_field_name,\n- )\n- return expression\n-\n-\n-def _filter_contains_any_condition(\n- attr_id: int | None,\n- db_connection_name: str,\n- contains_any: list[str],\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n- identifier_field_name: Literal[\"slug\", \"id\", \"sku\"],\n- referenced_model: type[\n- page_models.Page | product_models.Product | product_models.ProductVariant\n- ],\n- attr_value_reference_field_name: Literal[\n- \"reference_page_id\", \"reference_product_id\", \"reference_variant_id\"\n- ],\n-):\n- \"\"\"Build a filter expression that ensures at least one specified reference is present.\n-\n- Constructs a Q expression that checks for a reference to any entity from\n- `referenced_model`, matched using the provided identifiers in `contains_any`.\n-\n- All matching references are resolved using `identifier_field_name`,\n- and passed as a single queryset to be checked in a single subquery.\n-\n- \"\"\"\n- identifiers = contains_any\n- reference_objs = referenced_model.objects.using(db_connection_name).filter(\n- **{f\"{identifier_field_name}__in\": identifiers}\n- )\n- return _filter_contains_single_expression(\n- attr_id,\n- db_connection_name,\n- reference_objs,\n- attr_value_reference_field_name,\n- assigned_attr_model,\n- assigned_id_field_name,\n- )\n-\n-\n-def filter_by_contains_referenced_pages(\n- attr_id: int | None,\n- attr_value: CONTAINS_TYPING,\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- \"\"\"Build a filter expression for referenced pages.\n-\n- Returns a Q expression to filter objects based on their references\n- to pages.\n-\n- - If `contains_all` is provided, only objects that reference all of the\n- specified pages will match.\n- - If `contains_any` is provided, objects that reference at least one of\n- the specified pages will match.\n- \"\"\"\n- contains_all = attr_value.get(\"contains_all\")\n- contains_any = attr_value.get(\"contains_any\")\n-\n- shared_filter_params: SharedContainsFilterParams = {\n- \"attr_id\": attr_id,\n- \"db_connection_name\": db_connection_name,\n- \"assigned_attr_model\": assigned_attr_model,\n- \"assigned_id_field_name\": assigned_id_field_name,\n- \"identifier_field_name\": \"slug\",\n- }\n- if contains_all:\n- return _filter_contains_all_condition(\n- contains_all=contains_all,\n- referenced_model=page_models.Page,\n- attr_value_reference_field_name=\"reference_page_id\",\n- **shared_filter_params,\n- )\n-\n- if contains_any:\n- return _filter_contains_any_condition(\n- contains_any=contains_any,\n- referenced_model=page_models.Page,\n- attr_value_reference_field_name=\"reference_page_id\",\n- **shared_filter_params,\n- )\n- return Q()\n-\n-\n-def filter_by_contains_referenced_products(\n- attr_id: int | None,\n- attr_value: CONTAINS_TYPING,\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- \"\"\"Build a filter expression for referenced products.\n-\n- Returns a Q expression to filter objects based on their references\n- to products.\n-\n- - If `contains_all` is provided, only objects that reference all of the\n- specified products will match.\n- - If `contains_any` is provided, objects that reference at least one of\n- the specified products will match.\n- \"\"\"\n- contains_all = attr_value.get(\"contains_all\")\n- contains_any = attr_value.get(\"contains_any\")\n-\n- shared_filter_params: SharedContainsFilterParams = {\n- \"attr_id\": attr_id,\n- \"db_connection_name\": db_connection_name,\n- \"assigned_attr_model\": assigned_attr_model,\n- \"assigned_id_field_name\": assigned_id_field_name,\n- \"identifier_field_name\": \"slug\",\n- }\n-\n- if contains_all:\n- return _filter_contains_all_condition(\n- contains_all=contains_all,\n- referenced_model=product_models.Product,\n- attr_value_reference_field_name=\"reference_product_id\",\n- **shared_filter_params,\n- )\n-\n- if contains_any:\n- return _filter_contains_any_condition(\n- contains_any=contains_any,\n- referenced_model=product_models.Product,\n- attr_value_reference_field_name=\"reference_product_id\",\n- **shared_filter_params,\n- )\n- return Q()\n-\n-\n-def filter_by_contains_referenced_variants(\n- attr_id: int | None,\n- attr_value: CONTAINS_TYPING,\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- \"\"\"Build a filter expression for referenced product variants.\n-\n- Returns a Q expression to filter objects based on their references\n- to product variants.\n-\n- - If `contains_all` is provided, only objects that reference all of the\n- specified variants will match.\n- - If `contains_any` is provided, objects that reference at least one of\n- the specified variants will match.\n- \"\"\"\n-\n- contains_all = attr_value.get(\"contains_all\")\n- contains_any = attr_value.get(\"contains_any\")\n-\n- shared_filter_params: SharedContainsFilterParams = {\n- \"attr_id\": attr_id,\n- \"db_connection_name\": db_connection_name,\n- \"assigned_attr_model\": assigned_attr_model,\n- \"assigned_id_field_name\": assigned_id_field_name,\n- \"identifier_field_name\": \"sku\",\n- }\n-\n- if contains_all:\n- return _filter_contains_all_condition(\n- contains_all=contains_all,\n- referenced_model=product_models.ProductVariant,\n- attr_value_reference_field_name=\"reference_variant_id\",\n- **shared_filter_params,\n- )\n-\n- if contains_any:\n- return _filter_contains_any_condition(\n- contains_any=contains_any,\n- referenced_model=product_models.ProductVariant,\n- attr_value_reference_field_name=\"reference_variant_id\",\n- **shared_filter_params,\n- )\n- return Q()\n-\n-\n-def filter_by_slug_or_name(\n- attr_id: int | None,\n- attr_value: dict,\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- attribute_values = AttributeValue.objects.using(db_connection_name).filter(\n- **{\"attribute_id\": attr_id} if attr_id else {}\n- )\n- if \"slug\" in attr_value:\n- attribute_values = filter_where_by_value_field(\n- attribute_values, \"slug\", attr_value[\"slug\"]\n- )\n- if \"name\" in attr_value:\n- attribute_values = filter_where_by_value_field(\n- attribute_values, \"name\", attr_value[\"name\"]\n- )\n- assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n- Exists(attribute_values.filter(id=OuterRef(\"value_id\"))),\n- **{str(assigned_id_field_name): OuterRef(\"id\")},\n- )\n- return Q(Exists(assigned_attr_value))\n-\n-\n-def filter_by_numeric_attribute(\n- attr_id: int | None,\n- numeric_value,\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- qs_by_numeric = AttributeValue.objects.using(db_connection_name).filter(\n- attribute__input_type=AttributeInputType.NUMERIC,\n- **{\"attribute_id\": attr_id} if attr_id else {},\n- )\n- qs_by_numeric = filter_where_by_numeric_field(\n- qs_by_numeric,\n- \"numeric\",\n- numeric_value,\n- )\n-\n- assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n- value__in=qs_by_numeric,\n- **{str(assigned_id_field_name): OuterRef(\"id\")},\n- )\n- return Q(Exists(assigned_attr_value))\n-\n-\n-def filter_by_boolean_attribute(\n- attr_id: int | None,\n- boolean_value,\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- qs_by_boolean = AttributeValue.objects.using(db_connection_name).filter(\n- attribute__input_type=AttributeInputType.BOOLEAN,\n- **{\"attribute_id\": attr_id} if attr_id else {},\n- )\n- qs_by_boolean = qs_by_boolean.filter(boolean=boolean_value)\n- assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n- value__in=qs_by_boolean,\n- **{str(assigned_id_field_name): OuterRef(\"id\")},\n- )\n- return Q(Exists(assigned_attr_value))\n-\n-\n-def filter_by_date_attribute(\n- attr_id: int | None,\n- date_value,\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- qs_by_date = AttributeValue.objects.using(db_connection_name).filter(\n- attribute__input_type=AttributeInputType.DATE,\n- **{\"attribute_id\": attr_id} if attr_id else {},\n- )\n- qs_by_date = filter_range_field(\n- qs_by_date,\n- \"date_time__date\",\n- date_value,\n- )\n- assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n- value__in=qs_by_date,\n- **{str(assigned_id_field_name): OuterRef(\"id\")},\n- )\n- return Q(Exists(assigned_attr_value))\n-\n-\n-def filter_by_date_time_attribute(\n- attr_id: int | None,\n- date_time_value,\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- qs_by_date_time = AttributeValue.objects.using(db_connection_name).filter(\n- attribute__input_type=AttributeInputType.DATE_TIME,\n- **{\"attribute_id\": attr_id} if attr_id else {},\n- )\n- qs_by_date_time = filter_range_field(\n- qs_by_date_time,\n- \"date_time\",\n- date_time_value,\n- )\n- assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n- value__in=qs_by_date_time,\n- **{str(assigned_id_field_name): OuterRef(\"id\")},\n- )\n- return Exists(assigned_attr_value)\n-\n-\n def get_attribute_values_by_slug_or_name_value(\n attr_id: int | None,\n attr_value: dict,\n db_connection_name: str,\n@@ -740,161 +264,8 @@\n \"id\", ids, db_connection_name\n )\n \n \n-def filter_objects_by_attributes[T: (page_models.Page, product_models.Product)](\n- qs: QuerySet[T],\n- value: list[dict],\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-) -> QuerySet[T]:\n- attribute_slugs = {\n- attr_filter[\"slug\"] for attr_filter in value if \"slug\" in attr_filter\n- }\n- attributes_map = {\n- attr.slug: attr\n- for attr in Attribute.objects.using(qs.db).filter(slug__in=attribute_slugs)\n- }\n- if len(attribute_slugs) != len(attributes_map.keys()):\n- # Filter over non existing attribute\n- return qs.none()\n-\n- attr_filter_expression = Q()\n-\n- attr_without_values_input = []\n- for attr_filter in value:\n- if \"slug\" in attr_filter and \"value\" not in attr_filter:\n- attr_without_values_input.append(attributes_map[attr_filter[\"slug\"]])\n-\n- if attr_without_values_input:\n- atr_value_qs = AttributeValue.objects.using(qs.db).filter(\n- attribute_id__in=[attr.id for attr in attr_without_values_input]\n- )\n- assigned_attr_value = assigned_attr_model.objects.using(qs.db).filter(\n- Exists(atr_value_qs.filter(id=OuterRef(\"value_id\"))),\n- **{str(assigned_id_field_name): OuterRef(\"id\")},\n- )\n- attr_filter_expression = Q(Exists(assigned_attr_value))\n-\n- for attr_filter in value:\n- attr_value = attr_filter.get(\"value\")\n- if not attr_value:\n- # attrs without value input are handled separately\n- continue\n-\n- attr_id = None\n- if attr_slug := attr_filter.get(\"slug\"):\n- attr = attributes_map[attr_slug]\n- attr_id = attr.id\n-\n- attr_value = attr_filter[\"value\"]\n-\n- if \"slug\" in attr_value or \"name\" in attr_value:\n- attr_filter_expression &= filter_by_slug_or_name(\n- attr_id,\n- attr_value,\n- qs.db,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- elif \"numeric\" in attr_value:\n- attr_filter_expression &= filter_by_numeric_attribute(\n- attr_id,\n- attr_value[\"numeric\"],\n- qs.db,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- elif \"boolean\" in attr_value:\n- attr_filter_expression &= filter_by_boolean_attribute(\n- attr_id,\n- attr_value[\"boolean\"],\n- qs.db,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- elif \"date\" in attr_value:\n- attr_filter_expression &= filter_by_date_attribute(\n- attr_id,\n- attr_value[\"date\"],\n- qs.db,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- elif \"date_time\" in attr_value:\n- attr_filter_expression &= filter_by_date_time_attribute(\n- attr_id,\n- attr_value[\"date_time\"],\n- qs.db,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- elif \"reference\" in attr_value:\n- attr_filter_expression &= filter_objects_by_reference_attributes(\n- attr_id,\n- attr_value[\"reference\"],\n- qs.db,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- if attr_filter_expression != Q():\n- return qs.filter(attr_filter_expression)\n- return qs.none()\n-\n-\n-def filter_objects_by_reference_attributes(\n- attr_id: int | None,\n- attr_value: dict[\n- Literal[\n- \"referenced_ids\", \"page_slugs\", \"product_slugs\", \"product_variant_skus\"\n- ],\n- CONTAINS_TYPING,\n- ],\n- db_connection_name: str,\n- assigned_attr_model: type[\n- AssignedPageAttributeValue | AssignedProductAttributeValue\n- ],\n- assigned_id_field_name: Literal[\"page_id\", \"product_id\"],\n-):\n- filter_expression = Q()\n-\n- if \"referenced_ids\" in attr_value:\n- filter_expression &= filter_by_contains_referenced_object_ids(\n- attr_id,\n- attr_value[\"referenced_ids\"],\n- db_connection_name,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- if \"page_slugs\" in attr_value:\n- filter_expression &= filter_by_contains_referenced_pages(\n- attr_id,\n- attr_value[\"page_slugs\"],\n- db_connection_name,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- if \"product_slugs\" in attr_value:\n- filter_expression &= filter_by_contains_referenced_products(\n- attr_id,\n- attr_value[\"product_slugs\"],\n- db_connection_name,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- if \"product_variant_skus\" in attr_value:\n- filter_expression &= filter_by_contains_referenced_variants(\n- attr_id,\n- attr_value[\"product_variant_skus\"],\n- db_connection_name,\n- assigned_attr_model=assigned_attr_model,\n- assigned_id_field_name=assigned_id_field_name,\n- )\n- return filter_expression\n-\n-\n def validate_attribute_value_reference_input(\n index_with_values: list[\n tuple[\n str,\n"
216
- },
217
- {
218
- "path": "saleor/graphql/page/filters.py",
219
- "status": "modified",
220
- "diff": "Index: saleor/graphql/page/filters.py\n===================================================================\n--- saleor/graphql/page/filters.py\t8eb11da (parent)\n+++ saleor/graphql/page/filters.py\tf4cf223 (commit)\n@@ -1,13 +1,26 @@\n+from typing import Literal\n+\n import django_filters\n import graphene\n-from django.db.models import Q\n+from django.db.models import Exists, OuterRef, Q, QuerySet\n \n-from ...attribute.models import AssignedPageAttributeValue\n+from ...attribute.models import AssignedPageAttributeValue, Attribute, AttributeValue\n from ...page import models\n from ..attribute.shared_filters import (\n+ CONTAINS_TYPING,\n AssignedAttributeWhereInput,\n- filter_objects_by_attributes,\n+ get_attribute_values_by_boolean_value,\n+ get_attribute_values_by_date_time_value,\n+ get_attribute_values_by_date_value,\n+ get_attribute_values_by_numeric_value,\n+ get_attribute_values_by_referenced_page_ids,\n+ get_attribute_values_by_referenced_page_slugs,\n+ get_attribute_values_by_referenced_product_ids,\n+ get_attribute_values_by_referenced_product_slugs,\n+ get_attribute_values_by_referenced_variant_ids,\n+ get_attribute_values_by_referenced_variant_skus,\n+ get_attribute_values_by_slug_or_name_value,\n validate_attribute_value_input,\n )\n from ..core.context import ChannelQsContext\n from ..core.doc_category import DOC_CATEGORY_PAGES\n@@ -29,8 +42,9 @@\n WhereInputObjectType,\n )\n from ..utils import resolve_global_ids_to_primary_keys\n from ..utils.filters import (\n+ Number,\n filter_by_id,\n filter_by_ids,\n filter_slug_list,\n filter_where_by_id_field,\n@@ -62,17 +76,478 @@\n return qs\n return qs.filter(Q(name__trigram_similar=value) | Q(slug__trigram_similar=value))\n \n \n-def filter_pages_by_attributes(qs, value):\n- return filter_objects_by_attributes(\n- qs,\n- value,\n- AssignedPageAttributeValue,\n- \"page_id\",\n+def _get_assigned_page_attribute_for_attribute_value(\n+ attribute_values: QuerySet[AttributeValue],\n+ db_connection_name: str,\n+):\n+ return Q(\n+ Exists(\n+ AssignedPageAttributeValue.objects.using(db_connection_name).filter(\n+ Exists(attribute_values.filter(id=OuterRef(\"value_id\"))),\n+ page_id=OuterRef(\"id\"),\n+ )\n+ )\n )\n \n \n+def filter_by_slug_or_name(\n+ attr_id: int | None,\n+ attr_value: dict,\n+ db_connection_name: str,\n+):\n+ attribute_values = get_attribute_values_by_slug_or_name_value(\n+ attr_id=attr_id,\n+ attr_value=attr_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_page_attribute_for_attribute_value(\n+ attribute_values=attribute_values,\n+ db_connection_name=db_connection_name,\n+ )\n+\n+\n+def filter_by_numeric_attribute(\n+ attr_id: int | None,\n+ numeric_value: dict[str, Number | list[Number] | dict[str, Number]],\n+ db_connection_name: str,\n+):\n+ attribute_values = get_attribute_values_by_numeric_value(\n+ attr_id=attr_id,\n+ numeric_value=numeric_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_page_attribute_for_attribute_value(\n+ attribute_values=attribute_values,\n+ db_connection_name=db_connection_name,\n+ )\n+\n+\n+def filter_by_boolean_attribute(\n+ attr_id: int | None,\n+ boolean_value: bool,\n+ db_connection_name: str,\n+):\n+ attribute_values = get_attribute_values_by_boolean_value(\n+ attr_id=attr_id,\n+ boolean_value=boolean_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_page_attribute_for_attribute_value(\n+ attribute_values=attribute_values,\n+ db_connection_name=db_connection_name,\n+ )\n+\n+\n+def filter_by_date_attribute(\n+ attr_id: int | None,\n+ date_value: dict[str, str],\n+ db_connection_name: str,\n+):\n+ attribute_values = get_attribute_values_by_date_value(\n+ attr_id=attr_id,\n+ date_value=date_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_page_attribute_for_attribute_value(\n+ attribute_values=attribute_values,\n+ db_connection_name=db_connection_name,\n+ )\n+\n+\n+def filter_by_date_time_attribute(\n+ attr_id: int | None,\n+ date_value: dict[str, str],\n+ db_connection_name: str,\n+):\n+ attribute_values = get_attribute_values_by_date_time_value(\n+ attr_id=attr_id,\n+ date_value=date_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_page_attribute_for_attribute_value(\n+ attribute_values=attribute_values,\n+ db_connection_name=db_connection_name,\n+ )\n+\n+\n+def _filter_contains_single_expression(\n+ attr_id: int | None,\n+ db_connection_name: str,\n+ referenced_attr_values: QuerySet[AttributeValue],\n+):\n+ if attr_id:\n+ referenced_attr_values = referenced_attr_values.filter(\n+ attribute_id=attr_id,\n+ )\n+ return _get_assigned_page_attribute_for_attribute_value(\n+ referenced_attr_values,\n+ db_connection_name,\n+ )\n+\n+\n+def filter_by_contains_referenced_page_slugs(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+):\n+ \"\"\"Build an expression to filter pages based on their references to pages.\n+\n+ - If `contains_all` is provided, only pages that reference all of the\n+ specified pages will match.\n+ - If `contains_any` is provided, pages that reference at least one of\n+ the specified pages will match.\n+ \"\"\"\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ if contains_all:\n+ expression = Q()\n+ for page_slug in contains_all:\n+ referenced_attr_values = get_attribute_values_by_referenced_page_slugs(\n+ slugs=[page_slug], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+ if contains_any:\n+ referenced_attr_values = get_attribute_values_by_referenced_page_slugs(\n+ slugs=contains_any, db_connection_name=db_connection_name\n+ )\n+ return _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return Q()\n+\n+\n+def filter_by_contains_referenced_product_slugs(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+):\n+ \"\"\"Build an expression to filter pages based on their references to products.\n+\n+ - If `contains_all` is provided, only pages that reference all of the\n+ specified products will match.\n+ - If `contains_any` is provided, pages that reference at least one of\n+ the specified products will match.\n+ \"\"\"\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ if contains_all:\n+ expression = Q()\n+ for product_slug in contains_all:\n+ referenced_attr_values = get_attribute_values_by_referenced_product_slugs(\n+ slugs=[product_slug], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+ if contains_any:\n+ referenced_attr_values = get_attribute_values_by_referenced_product_slugs(\n+ slugs=contains_any, db_connection_name=db_connection_name\n+ )\n+ return _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return Q()\n+\n+\n+def filter_by_contains_referenced_variant_skus(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+):\n+ \"\"\"Build an expression to filter pages based on their references to variants.\n+\n+ - If `contains_all` is provided, only pages that reference all of the\n+ specified variants will match.\n+ - If `contains_any` is provided, pages that reference at least one of\n+ the specified variants will match.\n+ \"\"\"\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ if contains_all:\n+ expression = Q()\n+ for variant_sku in contains_all:\n+ referenced_attr_values = get_attribute_values_by_referenced_variant_skus(\n+ slugs=[variant_sku], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+ if contains_any:\n+ referenced_attr_values = get_attribute_values_by_referenced_variant_skus(\n+ slugs=contains_any, db_connection_name=db_connection_name\n+ )\n+ return _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return Q()\n+\n+\n+def _filter_by_contains_all_referenced_object_ids(\n+ variant_ids: set[int],\n+ product_ids: set[int],\n+ page_ids: set[int],\n+ attr_id: int | None,\n+ db_connection_name: str,\n+) -> Q:\n+ expression = Q()\n+ if page_ids:\n+ for page_id in page_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_page_ids(\n+ ids=[page_id], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ if product_ids:\n+ for product_id in product_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_product_ids(\n+ ids=[product_id], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ if variant_ids:\n+ for variant_id in variant_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_variant_ids(\n+ ids=[variant_id], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+\n+def _filter_by_contains_any_referenced_object_ids(\n+ variant_ids: set[int],\n+ product_ids: set[int],\n+ page_ids: set[int],\n+ attr_id: int | None,\n+ db_connection_name: str,\n+) -> Q:\n+ expression = Q()\n+ if page_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_page_ids(\n+ ids=list(page_ids), db_connection_name=db_connection_name\n+ )\n+ expression |= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ if product_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_product_ids(\n+ ids=list(product_ids), db_connection_name=db_connection_name\n+ )\n+ expression |= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ if variant_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_variant_ids(\n+ ids=list(variant_ids), db_connection_name=db_connection_name\n+ )\n+ expression |= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+\n+def filter_by_contains_referenced_object_ids(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+) -> Q:\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ variant_ids = set()\n+ product_ids = set()\n+ page_ids = set()\n+\n+ for obj_id in contains_any or contains_all or []:\n+ type_, id_ = graphene.Node.from_global_id(obj_id)\n+ if type_ == \"Page\":\n+ page_ids.add(id_)\n+ elif type_ == \"Product\":\n+ product_ids.add(id_)\n+ elif type_ == \"ProductVariant\":\n+ variant_ids.add(id_)\n+\n+ if contains_all:\n+ return _filter_by_contains_all_referenced_object_ids(\n+ variant_ids=variant_ids,\n+ product_ids=product_ids,\n+ page_ids=page_ids,\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ )\n+ if contains_any:\n+ return _filter_by_contains_any_referenced_object_ids(\n+ variant_ids=variant_ids,\n+ product_ids=product_ids,\n+ page_ids=page_ids,\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ )\n+ return Q()\n+\n+\n+def filter_objects_by_reference_attributes(\n+ attr_id: int | None,\n+ attr_value: dict[\n+ Literal[\n+ \"referenced_ids\", \"page_slugs\", \"product_slugs\", \"product_variant_skus\"\n+ ],\n+ CONTAINS_TYPING,\n+ ],\n+ db_connection_name: str,\n+):\n+ filter_expression = Q()\n+\n+ if \"referenced_ids\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_object_ids(\n+ attr_id,\n+ attr_value[\"referenced_ids\"],\n+ db_connection_name,\n+ )\n+ if \"page_slugs\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_page_slugs(\n+ attr_id,\n+ attr_value[\"page_slugs\"],\n+ db_connection_name,\n+ )\n+ if \"product_slugs\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_product_slugs(\n+ attr_id,\n+ attr_value[\"product_slugs\"],\n+ db_connection_name,\n+ )\n+ if \"product_variant_skus\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_variant_skus(\n+ attr_id,\n+ attr_value[\"product_variant_skus\"],\n+ db_connection_name,\n+ )\n+ return filter_expression\n+\n+\n+def filter_pages_by_attributes(qs, value):\n+ attribute_slugs = {\n+ attr_filter[\"slug\"] for attr_filter in value if \"slug\" in attr_filter\n+ }\n+ attributes_map = {\n+ attr.slug: attr\n+ for attr in Attribute.objects.using(qs.db).filter(slug__in=attribute_slugs)\n+ }\n+ if len(attribute_slugs) != len(attributes_map.keys()):\n+ # Filter over non existing attribute\n+ return qs.none()\n+\n+ attr_filter_expression = Q()\n+\n+ attr_without_values_input = []\n+ for attr_filter in value:\n+ if \"slug\" in attr_filter and \"value\" not in attr_filter:\n+ attr_without_values_input.append(attributes_map[attr_filter[\"slug\"]])\n+\n+ if attr_without_values_input:\n+ atr_value_qs = AttributeValue.objects.using(qs.db).filter(\n+ attribute_id__in=[attr.id for attr in attr_without_values_input]\n+ )\n+ assigned_attr_value = AssignedPageAttributeValue.objects.using(qs.db).filter(\n+ Exists(atr_value_qs.filter(id=OuterRef(\"value_id\"))),\n+ page_id=OuterRef(\"id\"),\n+ )\n+ attr_filter_expression = Q(Exists(assigned_attr_value))\n+\n+ for attr_filter in value:\n+ attr_value = attr_filter.get(\"value\")\n+ if not attr_value:\n+ # attrs without value input are handled separately\n+ continue\n+\n+ attr_id = None\n+ if attr_slug := attr_filter.get(\"slug\"):\n+ attr = attributes_map[attr_slug]\n+ attr_id = attr.id\n+\n+ attr_value = attr_filter[\"value\"]\n+\n+ if \"slug\" in attr_value or \"name\" in attr_value:\n+ attr_filter_expression &= filter_by_slug_or_name(\n+ attr_id,\n+ attr_value,\n+ qs.db,\n+ )\n+ elif \"numeric\" in attr_value:\n+ attr_filter_expression &= filter_by_numeric_attribute(\n+ attr_id,\n+ attr_value[\"numeric\"],\n+ qs.db,\n+ )\n+ elif \"boolean\" in attr_value:\n+ attr_filter_expression &= filter_by_boolean_attribute(\n+ attr_id,\n+ attr_value[\"boolean\"],\n+ qs.db,\n+ )\n+ elif \"date\" in attr_value:\n+ attr_filter_expression &= filter_by_date_attribute(\n+ attr_id,\n+ attr_value[\"date\"],\n+ qs.db,\n+ )\n+ elif \"date_time\" in attr_value:\n+ attr_filter_expression &= filter_by_date_time_attribute(\n+ attr_id,\n+ attr_value[\"date_time\"],\n+ qs.db,\n+ )\n+ elif \"reference\" in attr_value:\n+ attr_filter_expression &= filter_objects_by_reference_attributes(\n+ attr_id,\n+ attr_value[\"reference\"],\n+ qs.db,\n+ )\n+ if attr_filter_expression != Q():\n+ return qs.filter(attr_filter_expression)\n+ return qs.none()\n+\n+\n class PageWhere(MetadataWhereBase):\n ids = GlobalIDMultipleChoiceWhereFilter(method=filter_by_ids(\"Page\"))\n slug = OperationObjectTypeWhereFilter(\n input_class=StringFilterInput,\n"
221
- }
222
- ]
223
- },
224
- {
225
- "id": "remove-valuenames-support",
226
- "sha": "8eb11da07f09437374cb82daa6f10a80a11a1c90",
227
- "parentSha": "bdb2f9d05d339fb0f07d9aa6946c60284b08a836",
228
- "spec": "Implement removal of name-based attribute value filtering from the GraphQL API and product filtering internals, leaving only slug-based filtering.\n\nScope of changes:\n1) GraphQL input type (Python/Graphene):\n- In saleor/graphql/attribute/types.py, remove the value_names field from AttributeInput (previously a NonNullList of strings with ADDED_IN_322). Ensure deprecation notes for other fields remain unchanged and that AttributeInput supports slug and the deprecated values field only for slugs.\n\n2) Filtering internals (product attribute filters):\n- In saleor/graphql/product/filters/product_attributes.py, update the attribute filter parsing and query construction to use only slug-based values.\n - Change the signature of _clean_product_attributes_filter_input to drop the field parameter so it becomes (filter_values, queries, database_connection_name).\n - Replace the helper that previously handled generic field-based lookups (_populate_value_map) with a slug-only variant (_populate_slug_value_map) that filters AttributeValue by slug__in and maps attribute->value slugs to value PKs. Update all call sites accordingly.\n - Remove parsing and handling of value_names from the attribute input processing (i.e., do not detect or collect name_value_list when iterating inputs).\n - In _filter_products_by_deprecated_attributes_input, drop the filter_name_values parameter and associated logic so only slug-based, range, boolean, date, and date_time filters are processed. Update invocations accordingly.\n\n3) GraphQL schema (SDL):\n- In saleor/graphql/schema.graphql, remove the valueNames: [String!] field from the AttributeInput definition and its accompanying description and release note. Ensure the deprecated values field for slugs remains and that descriptions reflect slug-only value filtering.\n\n4) Tests:\n- In saleor/graphql/product/tests/queries/test_products_query_with_where.py, remove the test that asserts filtering products by attributes using valueNames (GraphQL casing) from the where input. Ensure no references to valueNames/value_names remain in this suite.\n- Verify any remaining tests exercising attribute filters are updated or remain valid under slug-only filtering.\n\n5) Documentation/Changelog:\n- In CHANGELOG.md, remove the entry that announced support for filtering products by attribute value names via AttributeInput.valueNames under GraphQL API, aligning the changelog with the slug-only behavior.\n\nAcceptance criteria:\n- The GraphQL schema (both Python definition and schema.graphql) no longer exposes AttributeInput.valueNames/value_names.\n- Product filtering via AttributeInput works with slugs and deprecated slug-based fields as before; name-based filtering is not accepted and no longer parsed.\n- All helper functions and signatures compile and tests pass after removing name-based code paths.\n- No occurrences of valueNames or value_names remain in code or tests, except potentially in historical documentation outside the scope specified.",
229
- "prompt": "Remove name-based attribute value filtering from the GraphQL API and product filtering logic, leaving only slug-based filtering. Update the GraphQL AttributeInput input type to drop the field that allowed passing attribute value names, adjust the product attribute filtering code to only parse and resolve value slugs, and remove the corresponding tests. Ensure the SDL schema and changelog reflect this change. Keep deprecated slug-based fields working, but eliminate any code paths that parse or query by attribute value names.",
230
- "supplementalFiles": [
231
- "saleor/graphql/product/filters/product.py",
232
- "saleor/graphql/product/tests/queries/test_products_query_with_filter.py",
233
- "saleor/graphql/product/tests/queries/products_filtrations/test_over_attributes.py",
234
- "saleor/graphql/attribute/filters.py",
235
- "saleor/attribute/models/base.py"
236
- ],
237
- "fileDiffs": [
238
- {
239
- "path": "CHANGELOG.md",
240
- "status": "modified",
241
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\tbdb2f9d (parent)\n+++ CHANGELOG.md\t8eb11da (commit)\n@@ -7,10 +7,8 @@\n ### Breaking changes\n - Increased query cost for attribute-related operations due to the addition of `AttributeValue.referencedObject`.\n \n ### GraphQL API\n-\n-- Added support for filtering products by attribute value names. The `AttributeInput` now includes a `valueNames` field, enabling filtering by the names of attribute values, in addition to the existing filtering by value slugs.\n - You can now filter and search orders using the new `where` and `search` fields on the `pages` query.\n - Use `where` to define complex conditions with `AND`/`OR` logic and operators like `eq`, `oneOf`, `range`.\n - Use `search` to perform full-text search across relevant fields.\n - Add support for filtering `pages` by associated attributes\n"
242
- },
243
- {
244
- "path": "saleor/graphql/attribute/types.py",
245
- "status": "modified",
246
- "diff": "Index: saleor/graphql/attribute/types.py\n===================================================================\n--- saleor/graphql/attribute/types.py\tbdb2f9d (parent)\n+++ saleor/graphql/attribute/types.py\t8eb11da (commit)\n@@ -543,18 +543,8 @@\n \"If provided more than one, the error will be raised. Cannot be combined \"\n \"with deprecated fields of `AttributeInput`. \"\n ),\n )\n- value_names = NonNullList(\n- graphene.String,\n- required=False,\n- description=(\n- \"Names corresponding to the attributeValues associated with the Attribute. \"\n- \"When specified, it filters the results to include only records with \"\n- \"one of the matching values.\"\n- )\n- + ADDED_IN_322,\n- )\n values = NonNullList(\n graphene.String,\n required=False,\n description=(\n"
247
- },
248
- {
249
- "path": "saleor/graphql/product/filters/product_attributes.py",
250
- "status": "modified",
251
- "diff": "Index: saleor/graphql/product/filters/product_attributes.py\n===================================================================\n--- saleor/graphql/product/filters/product_attributes.py\tbdb2f9d (parent)\n+++ saleor/graphql/product/filters/product_attributes.py\t8eb11da (commit)\n@@ -36,9 +36,9 @@\n T_PRODUCT_FILTER_QUERIES = dict[int, list[int]]\n \n \n def _clean_product_attributes_filter_input(\n- filter_values, field, queries, database_connection_name\n+ filter_values, queries, database_connection_name\n ):\n attribute_slugs = []\n values = []\n \n@@ -54,31 +54,31 @@\n for attr_slug, attr_pk in attributes.values_list(\"slug\", \"id\"):\n attributes_slug_pk_map[attr_slug] = attr_pk\n attributes_pk_slug_map[attr_pk] = attr_slug\n \n- values_map = _populate_value_map(\n- database_connection_name, field, values, attributes, attributes_pk_slug_map\n+ values_map = _populate_slug_value_map(\n+ database_connection_name, values, attributes, attributes_pk_slug_map\n )\n \n _update_queries(queries, filter_values, attributes_slug_pk_map, values_map)\n \n \n-def _populate_value_map(\n- database_connection_name, field, values, attribute_qs, attributes_pk_slug_map\n+def _populate_slug_value_map(\n+ database_connection_name, slugs, attribute_qs, attributes_pk_slug_map\n ):\n value_maps: dict[str, dict[str, list[int]]] = defaultdict(lambda: defaultdict(list))\n for (\n attr_pk,\n value_pk,\n- field_value,\n+ value_slug,\n ) in (\n AttributeValue.objects.using(database_connection_name)\n .filter(Exists(attribute_qs.filter(pk=OuterRef(\"attribute_id\"))))\n- .filter(**{f\"{field}__in\": values})\n- .values_list(\"attribute_id\", \"pk\", field)\n+ .filter(slug__in=slugs)\n+ .values_list(\"attribute_id\", \"pk\", \"slug\")\n ):\n attr_slug = attributes_pk_slug_map[attr_pk]\n- value_maps[attr_slug][field_value].append(value_pk)\n+ value_maps[attr_slug][value_slug].append(value_pk)\n \n return value_maps\n \n \n@@ -251,24 +251,17 @@\n \n def _filter_products_by_deprecated_attributes_input(\n qs,\n filter_slug_values,\n- filter_name_values,\n filter_range_values,\n filter_boolean_values,\n date_range_list,\n date_time_range_list,\n ):\n queries: dict[int, list[int]] = defaultdict(list)\n try:\n if filter_slug_values:\n- _clean_product_attributes_filter_input(\n- filter_slug_values, \"slug\", queries, qs.db\n- )\n- if filter_name_values:\n- _clean_product_attributes_filter_input(\n- filter_name_values, \"name\", queries, qs.db\n- )\n+ _clean_product_attributes_filter_input(filter_slug_values, queries, qs.db)\n if filter_range_values:\n _clean_product_attributes_range_filter_input(\n filter_range_values, queries, qs.db\n )\n@@ -295,9 +288,8 @@\n if not value:\n return qs.none()\n \n slug_value_list = []\n- name_value_list = []\n boolean_list = []\n value_range_list = []\n date_range_list = []\n date_time_range_list = []\n@@ -305,10 +297,8 @@\n for v in value:\n slug = v[\"slug\"]\n if \"values\" in v:\n slug_value_list.append((slug, v[\"values\"]))\n- elif \"value_names\" in v:\n- name_value_list.append((slug, v[\"value_names\"]))\n elif \"values_range\" in v:\n value_range_list.append((slug, v[\"values_range\"]))\n elif \"date\" in v:\n date_range_list.append((slug, v[\"date\"]))\n@@ -319,9 +309,8 @@\n \n qs = _filter_products_by_deprecated_attributes_input(\n qs,\n slug_value_list,\n- name_value_list,\n value_range_list,\n boolean_list,\n date_range_list,\n date_time_range_list,\n"
252
- },
253
- {
254
- "path": "saleor/graphql/product/tests/queries/test_products_query_with_where.py",
255
- "status": "modified",
256
- "diff": "Index: saleor/graphql/product/tests/queries/test_products_query_with_where.py\n===================================================================\n--- saleor/graphql/product/tests/queries/test_products_query_with_where.py\tbdb2f9d (parent)\n+++ saleor/graphql/product/tests/queries/test_products_query_with_where.py\t8eb11da (commit)\n@@ -743,54 +743,8 @@\n returned_ids = {product[\"node\"][\"id\"] for product in products}\n assert returned_ids == {product1_id, product2_id}\n \n \n-def test_products_filter_by_attributes_value_name(\n- api_client,\n- product_list,\n- channel_USD,\n-):\n- # given\n- product_type = ProductType.objects.create(\n- name=\"Custom Type\",\n- slug=\"custom-type\",\n- has_variants=True,\n- is_shipping_required=True,\n- kind=ProductTypeKind.NORMAL,\n- )\n- attribute = Attribute.objects.create(slug=\"new_attr\", name=\"Attr\")\n- attribute.product_types.add(product_type)\n- attr_value = AttributeValue.objects.create(\n- attribute=attribute, name=\"First\", slug=\"first\"\n- )\n- product = product_list[0]\n- product.product_type = product_type\n- product.save()\n- associate_attribute_values_to_instance(\n- product,\n- {attribute.pk: [attr_value]},\n- )\n-\n- variables = {\n- \"channel\": channel_USD.slug,\n- \"where\": {\n- \"attributes\": [{\"slug\": attribute.slug, \"valueNames\": [attr_value.name]}],\n- },\n- }\n-\n- # when\n- response = api_client.post_graphql(PRODUCTS_WHERE_QUERY, variables)\n- content = get_graphql_content(response)\n-\n- # then\n- product_id = graphene.Node.to_global_id(\"Product\", product.id)\n- products = content[\"data\"][\"products\"][\"edges\"]\n-\n- assert len(products) == 1\n- assert products[0][\"node\"][\"id\"] == product_id\n- assert products[0][\"node\"][\"name\"] == product.name\n-\n-\n def test_products_filter_by_attributes_empty_list(\n api_client,\n product_list,\n channel_USD,\n"
257
- },
258
- {
259
- "path": "saleor/graphql/schema.graphql",
260
- "status": "modified",
261
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\tbdb2f9d (parent)\n+++ saleor/graphql/schema.graphql\t8eb11da (commit)\n@@ -7779,15 +7779,8 @@\n \"\"\"\n value: AssignedAttributeValueInput\n \n \"\"\"\n- Names corresponding to the attributeValues associated with the Attribute. When specified, it filters the results to include only records with one of the matching values.\n- \n- Added in Saleor 3.22.\n- \"\"\"\n- valueNames: [String!]\n-\n- \"\"\"\n Slugs identifying the attributeValues associated with the Attribute. When specified, it filters the results to include only records with one of the matching values. Requires `slug` to be provided.\n \"\"\"\n values: [String!] @deprecated(reason: \"Use `value` instead.\")\n \n"
262
- }
263
- ]
264
- },
265
- {
266
- "id": "add-reference-filtering",
267
- "sha": "d273fd97a46ac0c5a009816dc6bab1147adb4b2d",
268
- "parentSha": "9c83ad8ba8e23f64e1fb24e534f4aa4ac4c1f01e",
269
- "spec": "Implement reference-based filtering for product variants in GraphQL where filters.\n\nScope:\n- Update shared attribute filter utilities to support retrieving AttributeValue rows referencing Pages, Products, and ProductVariants by various identifiers, and expose entry points for page slugs, product slugs, variant SKUs, and global IDs.\n- Extend product variant filtering to accept a new reference field under variants.where.attributes.value and combine results using containsAll/containsAny logic.\n- Provide tests for filtering by reference across pages, products, and variants (by slugs/SKUs and by global IDs), including mixed multi-argument cases and validation errors for malformed inputs.\n\nRequirements:\n1) In saleor/graphql/attribute/shared_filters.py\n - Add internal helpers to fetch AttributeValue by referenced objects using EXISTS subqueries:\n - _get_attribute_values_by_referenced_page_identifiers(field_name, identifiers, db_connection_name)\n - _get_attribute_values_by_referenced_product_identifiers(field_name, identifiers, db_connection_name)\n - _get_attribute_values_by_referenced_variant_identifiers(field_name, identifiers, db_connection_name)\n - Add public wrappers to support identifiers by type:\n - get_attribute_values_by_referenced_page_slugs(slugs, db_connection_name)\n - get_attribute_values_by_referenced_page_ids(ids, db_connection_name)\n - get_attribute_values_by_referenced_product_slugs(slugs, db_connection_name)\n - get_attribute_values_by_referenced_product_ids(ids, db_connection_name)\n - get_attribute_values_by_referenced_variant_skus(slugs, db_connection_name)\n - get_attribute_values_by_referenced_variant_ids(ids, db_connection_name)\n - Ensure these functions return QuerySet[AttributeValue] and use .using(db_connection_name) consistently.\n\n2) In saleor/graphql/product/filters/product_variant.py\n - Import the new shared_filters symbols: CONTAINS_TYPING, get_attribute_values_by_referenced_* helpers.\n - Implement Q-expression builders that encapsulate containsAll/containsAny logic for each reference type:\n - filter_by_contains_referenced_page_slugs(attr_id, attr_value, db_connection_name)\n - filter_by_contains_referenced_product_slugs(attr_id, attr_value, db_connection_name)\n - filter_by_contains_referenced_variant_skus(attr_id, attr_value, db_connection_name)\n - Implement support for global IDs by decoding to Page/Product/ProductVariant and grouping IDs by type:\n - filter_by_contains_referenced_object_ids(attr_id, attr_value, db_connection_name), delegating into _filter_by_contains_all_referenced_object_ids and _filter_by_contains_any_referenced_object_ids which OR/AND combine expressions for id sets from the three types.\n - Create a unified dispatcher:\n - filter_objects_by_reference_attributes(attr_id, attr_value, db_connection_name), which looks for keys \"referenced_ids\", \"page_slugs\", \"product_slugs\", \"product_variant_skus\" and accumulates Q expressions.\n - Integrate into filter_variants_by_attributes by adding a branch for when the attribute filter provides a \"reference\" key (alongside existing boolean, numeric, slug, name, date, date_time branches). Combine into the existing attr_filter_expression and return qs.filter(attr_filter_expression).\n\n3) Tests in saleor/graphql/product/tests/queries/variants_where/\n - Add tests covering filtering variants by:\n - Page references using page slugs (with both containsAny and containsAll) by attribute slug and without attribute slug.\n - Product references using product slugs (containsAny/containsAll) by attribute slug and without attribute slug.\n - Variant references using variant SKUs (containsAny/containsAll) by attribute slug and without attribute slug.\n - Referenced global IDs for pages, products, and variants (containsAny/containsAll) by attribute slug and in multi-argument combinations.\n - Update validation tests to assert errors for invalid reference filters including empty or None containsAll/containsAny arrays and mismatched structures when reference field is provided.\n\nBehavioral expectations:\n- Variants.where.attributes.value.reference supports the following shapes (mutually exclusive keys within reference block, but multiple keys may be combined and ANDed together across keys):\n - { referencedIds: { containsAny: [globalId...] } } or { referencedIds: { containsAll: [globalId...] } }\n - { pageSlugs: { containsAny: [slug...] } } or { pageSlugs: { containsAll: [slug...] } }\n - { productSlugs: { containsAny: [slug...] } } or { productSlugs: { containsAll: [slug...] } }\n - { productVariantSkus: { containsAny: [sku...] } } or { productVariantSkus: { containsAll: [sku...] } }\n- When an attribute slug is specified in the where clause, filtering is constrained to that attribute ID; otherwise the reference matches across all reference-type attributes assigned to variants.\n- containsAll requires matching all provided targets; containsAny requires at least one match.\n- Invalid inputs (missing reference content, empty lists, None in containsAny/containsAll) yield GraphQL errors and return null for productVariants, consistent with existing validation patterns.\n\nNon-functional:\n- Follow existing import organization and typing conventions in these modules.\n- Use Exists subqueries and OuterRef patterns mirroring existing attribute filter implementations to ensure DB-efficient filtering.\n\nFiles to modify:\n- saleor/graphql/attribute/shared_filters.py\n- saleor/graphql/product/filters/product_variant.py\n- Add/update tests under saleor/graphql/product/tests/queries/variants_where/ to cover pages, products, variants, multi-argument scenarios, and validation, as shown above.",
270
- "prompt": "Add support for filtering product variants by reference-type attribute values in the GraphQL productVariants where input. Extend the existing attribute filtering to handle references to pages, products, and product variants, allowing clients to filter by page slugs, product slugs, variant SKUs, and global IDs. Support both containsAny and containsAll semantics and allow combining multiple reference criteria. Ensure the new reference filter integrates with the current AssignedAttribute filtering pipeline and that invalid reference inputs are properly rejected. Include comprehensive tests demonstrating filtering by each reference type and by global IDs, with and without specifying the attribute slug, as well as negative validation cases.",
271
- "supplementalFiles": [
272
- "saleor/graphql/product/tests/queries/variants_where/shared.py",
273
- "saleor/graphql/schema.graphql",
274
- "saleor/product/models.py",
275
- "saleor/graphql/core/filters/where_input.py",
276
- "saleor/graphql/attribute/utils/type_handlers.py"
277
- ],
278
- "fileDiffs": [
279
- {
280
- "path": "saleor/graphql/attribute/shared_filters.py",
281
- "status": "modified",
282
- "diff": "Index: saleor/graphql/attribute/shared_filters.py\n===================================================================\n--- saleor/graphql/attribute/shared_filters.py\t9c83ad8 (parent)\n+++ saleor/graphql/attribute/shared_filters.py\td273fd9 (commit)\n@@ -653,8 +653,95 @@\n date_value,\n )\n \n \n+def _get_attribute_values_by_referenced_page_identifiers(\n+ field_name: str,\n+ identifiers: list[str] | list[int],\n+ db_connection_name: str,\n+):\n+ pages = page_models.Page.objects.using(db_connection_name).filter(\n+ **{f\"{field_name}__in\": identifiers}\n+ )\n+ return AttributeValue.objects.using(db_connection_name).filter(\n+ Exists(pages.filter(id=OuterRef(\"reference_page_id\"))),\n+ )\n+\n+\n+def get_attribute_values_by_referenced_page_slugs(\n+ slugs: list[str], db_connection_name: str\n+) -> QuerySet[AttributeValue]:\n+ return _get_attribute_values_by_referenced_page_identifiers(\n+ \"slug\", slugs, db_connection_name\n+ )\n+\n+\n+def get_attribute_values_by_referenced_page_ids(\n+ ids: list[int], db_connection_name: str\n+) -> QuerySet[AttributeValue]:\n+ return _get_attribute_values_by_referenced_page_identifiers(\n+ \"id\", ids, db_connection_name\n+ )\n+\n+\n+def _get_attribute_values_by_referenced_product_identifiers(\n+ field_name: str,\n+ identifiers: list[str] | list[int],\n+ db_connection_name: str,\n+) -> QuerySet[AttributeValue]:\n+ products = product_models.Product.objects.using(db_connection_name).filter(\n+ **{f\"{field_name}__in\": identifiers}\n+ )\n+ return AttributeValue.objects.using(db_connection_name).filter(\n+ Exists(products.filter(id=OuterRef(\"reference_product_id\"))),\n+ )\n+\n+\n+def get_attribute_values_by_referenced_product_slugs(\n+ slugs: list[str], db_connection_name: str\n+) -> QuerySet[AttributeValue]:\n+ return _get_attribute_values_by_referenced_product_identifiers(\n+ \"slug\", slugs, db_connection_name\n+ )\n+\n+\n+def get_attribute_values_by_referenced_product_ids(\n+ ids: list[int], db_connection_name: str\n+) -> QuerySet[AttributeValue]:\n+ return _get_attribute_values_by_referenced_product_identifiers(\n+ \"id\", ids, db_connection_name\n+ )\n+\n+\n+def _get_attribute_values_by_referenced_variant_identifiers(\n+ field_name: str,\n+ identifiers: list[str] | list[int],\n+ db_connection_name: str,\n+):\n+ variants = product_models.ProductVariant.objects.using(db_connection_name).filter(\n+ **{f\"{field_name}__in\": identifiers}\n+ )\n+ return AttributeValue.objects.using(db_connection_name).filter(\n+ Exists(variants.filter(id=OuterRef(\"reference_variant_id\"))),\n+ )\n+\n+\n+def get_attribute_values_by_referenced_variant_skus(\n+ slugs: list[str], db_connection_name: str\n+) -> QuerySet[AttributeValue]:\n+ return _get_attribute_values_by_referenced_variant_identifiers(\n+ \"sku\", slugs, db_connection_name\n+ )\n+\n+\n+def get_attribute_values_by_referenced_variant_ids(\n+ ids: list[int], db_connection_name: str\n+) -> QuerySet[AttributeValue]:\n+ return _get_attribute_values_by_referenced_variant_identifiers(\n+ \"id\", ids, db_connection_name\n+ )\n+\n+\n def filter_objects_by_attributes[T: (page_models.Page, product_models.Product)](\n qs: QuerySet[T],\n value: list[dict],\n assigned_attr_model: type[\n"
283
- },
284
- {
285
- "path": "saleor/graphql/product/filters/product_variant.py",
286
- "status": "modified",
287
- "diff": "Index: saleor/graphql/product/filters/product_variant.py\n===================================================================\n--- saleor/graphql/product/filters/product_variant.py\t9c83ad8 (parent)\n+++ saleor/graphql/product/filters/product_variant.py\td273fd9 (commit)\n@@ -1,4 +1,6 @@\n+from typing import Literal\n+\n import django_filters\n import graphene\n from django.db.models import Exists, OuterRef, Q\n from django.db.models.query import QuerySet\n@@ -11,13 +13,20 @@\n AttributeValue,\n )\n from ....product.models import Product, ProductVariant\n from ...attribute.shared_filters import (\n+ CONTAINS_TYPING,\n AssignedAttributeWhereInput,\n get_attribute_values_by_boolean_value,\n get_attribute_values_by_date_time_value,\n get_attribute_values_by_date_value,\n get_attribute_values_by_numeric_value,\n+ get_attribute_values_by_referenced_page_ids,\n+ get_attribute_values_by_referenced_page_slugs,\n+ get_attribute_values_by_referenced_product_ids,\n+ get_attribute_values_by_referenced_product_slugs,\n+ get_attribute_values_by_referenced_variant_ids,\n+ get_attribute_values_by_referenced_variant_skus,\n get_attribute_values_by_slug_or_name_value,\n validate_attribute_value_input,\n )\n from ...core.descriptions import ADDED_IN_322\n@@ -156,8 +165,310 @@\n )\n )\n \n \n+def _filter_contains_single_expression(\n+ attr_id: int | None,\n+ db_connection_name: str,\n+ referenced_attr_values: QuerySet[AttributeValue],\n+):\n+ if attr_id:\n+ referenced_attr_values = referenced_attr_values.filter(\n+ attribute_id=attr_id,\n+ )\n+ assigned_attr_value = AssignedVariantAttributeValue.objects.using(\n+ db_connection_name\n+ ).filter(\n+ value__in=referenced_attr_values,\n+ assignment_id=OuterRef(\"id\"),\n+ )\n+ return Q(\n+ Exists(\n+ AssignedVariantAttribute.objects.using(db_connection_name).filter(\n+ Exists(assigned_attr_value), variant_id=OuterRef(\"pk\")\n+ )\n+ )\n+ )\n+\n+\n+def filter_by_contains_referenced_page_slugs(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+):\n+ \"\"\"Build an expression to filter variants based on their references to pages.\n+\n+ - If `contains_all` is provided, only variants that reference all of the\n+ specified pages will match.\n+ - If `contains_any` is provided, variants that reference at least one of\n+ the specified pages will match.\n+ \"\"\"\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ if contains_all:\n+ expression = Q()\n+ for page_slug in contains_all:\n+ referenced_attr_values = get_attribute_values_by_referenced_page_slugs(\n+ slugs=[page_slug], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+ if contains_any:\n+ referenced_attr_values = get_attribute_values_by_referenced_page_slugs(\n+ slugs=contains_any, db_connection_name=db_connection_name\n+ )\n+ return _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return Q()\n+\n+\n+def filter_by_contains_referenced_product_slugs(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+):\n+ \"\"\"Build an expression to filter variants based on their references to products.\n+\n+ - If `contains_all` is provided, only variants that reference all of the\n+ specified products will match.\n+ - If `contains_any` is provided, variants that reference at least one of\n+ the specified products will match.\n+ \"\"\"\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ if contains_all:\n+ expression = Q()\n+ for product_slug in contains_all:\n+ referenced_attr_values = get_attribute_values_by_referenced_product_slugs(\n+ slugs=[product_slug], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+ if contains_any:\n+ referenced_attr_values = get_attribute_values_by_referenced_product_slugs(\n+ slugs=contains_any, db_connection_name=db_connection_name\n+ )\n+ return _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return Q()\n+\n+\n+def filter_by_contains_referenced_variant_skus(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+):\n+ \"\"\"Build an expression to filter variants based on their references to variants.\n+\n+ - If `contains_all` is provided, only variants that reference all of the\n+ specified variants will match.\n+ - If `contains_any` is provided, variants that reference at least one of\n+ the specified variants will match.\n+ \"\"\"\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ if contains_all:\n+ expression = Q()\n+ for variant_sku in contains_all:\n+ referenced_attr_values = get_attribute_values_by_referenced_variant_skus(\n+ slugs=[variant_sku], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+ if contains_any:\n+ referenced_attr_values = get_attribute_values_by_referenced_variant_skus(\n+ slugs=contains_any, db_connection_name=db_connection_name\n+ )\n+ return _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return Q()\n+\n+\n+def _filter_by_contains_all_referenced_object_ids(\n+ variant_ids: set[int],\n+ product_ids: set[int],\n+ page_ids: set[int],\n+ attr_id: int | None,\n+ db_connection_name: str,\n+) -> Q:\n+ expression = Q()\n+ if page_ids:\n+ for page_id in page_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_page_ids(\n+ ids=[page_id], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ if product_ids:\n+ for product_id in product_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_product_ids(\n+ ids=[product_id], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ if variant_ids:\n+ for variant_id in variant_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_variant_ids(\n+ ids=[variant_id], db_connection_name=db_connection_name\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+\n+def _filter_by_contains_any_referenced_object_ids(\n+ variant_ids: set[int],\n+ product_ids: set[int],\n+ page_ids: set[int],\n+ attr_id: int | None,\n+ db_connection_name: str,\n+) -> Q:\n+ expression = Q()\n+ if page_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_page_ids(\n+ ids=list(page_ids), db_connection_name=db_connection_name\n+ )\n+ expression |= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ if product_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_product_ids(\n+ ids=list(product_ids), db_connection_name=db_connection_name\n+ )\n+ expression |= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ if variant_ids:\n+ referenced_attr_values = get_attribute_values_by_referenced_variant_ids(\n+ ids=list(variant_ids), db_connection_name=db_connection_name\n+ )\n+ expression |= _filter_contains_single_expression(\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ referenced_attr_values=referenced_attr_values,\n+ )\n+ return expression\n+\n+\n+def filter_by_contains_referenced_object_ids(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+) -> Q:\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ variant_ids = set()\n+ product_ids = set()\n+ page_ids = set()\n+\n+ for obj_id in contains_any or contains_all or []:\n+ type_, id_ = graphene.Node.from_global_id(obj_id)\n+ if type_ == \"Page\":\n+ page_ids.add(id_)\n+ elif type_ == \"Product\":\n+ product_ids.add(id_)\n+ elif type_ == \"ProductVariant\":\n+ variant_ids.add(id_)\n+\n+ if contains_all:\n+ return _filter_by_contains_all_referenced_object_ids(\n+ variant_ids=variant_ids,\n+ product_ids=product_ids,\n+ page_ids=page_ids,\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ )\n+ if contains_any:\n+ return _filter_by_contains_any_referenced_object_ids(\n+ variant_ids=variant_ids,\n+ product_ids=product_ids,\n+ page_ids=page_ids,\n+ attr_id=attr_id,\n+ db_connection_name=db_connection_name,\n+ )\n+ return Q()\n+\n+\n+def filter_objects_by_reference_attributes(\n+ attr_id: int | None,\n+ attr_value: dict[\n+ Literal[\n+ \"referenced_ids\", \"page_slugs\", \"product_slugs\", \"product_variant_skus\"\n+ ],\n+ CONTAINS_TYPING,\n+ ],\n+ db_connection_name: str,\n+):\n+ filter_expression = Q()\n+\n+ if \"referenced_ids\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_object_ids(\n+ attr_id,\n+ attr_value[\"referenced_ids\"],\n+ db_connection_name,\n+ )\n+ if \"page_slugs\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_page_slugs(\n+ attr_id,\n+ attr_value[\"page_slugs\"],\n+ db_connection_name,\n+ )\n+ if \"product_slugs\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_product_slugs(\n+ attr_id,\n+ attr_value[\"product_slugs\"],\n+ db_connection_name,\n+ )\n+ if \"product_variant_skus\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_variant_skus(\n+ attr_id,\n+ attr_value[\"product_variant_skus\"],\n+ db_connection_name,\n+ )\n+ return filter_expression\n+\n+\n def filter_variants_by_attributes(\n qs: QuerySet[ProductVariant], value: list[dict]\n ) -> QuerySet[ProductVariant]:\n attribute_slugs = {\n@@ -229,8 +540,14 @@\n attr_id,\n attr_value[\"date_time\"],\n qs.db,\n )\n+ elif \"reference\" in attr_value:\n+ attr_filter_expression &= filter_objects_by_reference_attributes(\n+ attr_id,\n+ attr_value[\"reference\"],\n+ qs.db,\n+ )\n return qs.filter(attr_filter_expression)\n \n \n class ProductVariantFilter(MetadataFilterBase):\n"
288
- },
289
- {
290
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_multiple_arguments.py",
291
- "status": "modified",
292
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_multiple_arguments.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_multiple_arguments.py\t9c83ad8 (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_multiple_arguments.py\td273fd9 (commit)\n@@ -1,7 +1,12 @@\n import pytest\n \n+from ......attribute import AttributeEntityType, AttributeInputType, AttributeType\n+from ......attribute.models.base import Attribute, AttributeValue\n from ......attribute.utils import associate_attribute_values_to_instance\n+from ......page.models import Page\n+from ......product.models import Product\n+from .....core.utils import to_global_id_or_none\n from .....tests.utils import get_graphql_content\n from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n \n \n@@ -268,4 +273,137 @@\n # then\n content = get_graphql_content(response)\n product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n assert len(product_variants_nodes) == expected_count_result\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 3), (\"containsAll\", 1)]\n+)\n+def test_product_variants_query_with_multiple_attribute_referenced_ids(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ page_type,\n+ channel_USD,\n+ product_type_product_reference_attribute,\n+ product_type_page_reference_attribute,\n+ variant,\n+):\n+ # given\n+ assert len(product_variant_list) > expected_count\n+\n+ product_type = product_variant_list[0].product.product_type\n+\n+ variant_reference_attribute = Attribute.objects.create(\n+ slug=\"second-variant-reference\",\n+ name=\"variant reference\",\n+ type=AttributeType.PRODUCT_TYPE,\n+ input_type=AttributeInputType.REFERENCE,\n+ entity_type=AttributeEntityType.PRODUCT_VARIANT,\n+ )\n+\n+ product_type.variant_attributes.set(\n+ [\n+ product_type_product_reference_attribute,\n+ product_type_page_reference_attribute,\n+ variant_reference_attribute,\n+ ]\n+ )\n+\n+ referenced_page = Page.objects.create(\n+ title=\"Referenced Page 1\",\n+ slug=\"referenced-page-1\",\n+ page_type=page_type,\n+ is_published=True,\n+ )\n+ referenced_product = Product.objects.create(\n+ name=\"Reference Product 1\",\n+ slug=\"ref-1\",\n+ product_type=product_type,\n+ )\n+ referenced_variant = variant\n+\n+ attr_ref_product, attr_ref_page, attr_ref_variant = (\n+ AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_product_reference_attribute,\n+ name=f\"Product {referenced_product.pk}\",\n+ slug=f\"product-{referenced_product.pk}\",\n+ reference_product=referenced_product,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_page_reference_attribute,\n+ name=f\"Page {referenced_page.pk}\",\n+ slug=f\"page-{referenced_page.pk}\",\n+ reference_page=referenced_page,\n+ ),\n+ AttributeValue(\n+ attribute=variant_reference_attribute,\n+ name=f\"Variant {referenced_variant.pk}\",\n+ slug=f\"variant-{referenced_variant.pk}\",\n+ reference_variant=referenced_variant,\n+ ),\n+ ]\n+ )\n+ )\n+ product_variant_with_all_references = product_variant_list[0]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_all_references,\n+ {\n+ product_type_product_reference_attribute.pk: [attr_ref_product],\n+ product_type_page_reference_attribute.pk: [attr_ref_page],\n+ variant_reference_attribute.pk: [attr_ref_variant],\n+ },\n+ )\n+ product_variant_with_two_references = product_variant_list[1]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_two_references,\n+ {\n+ product_type_product_reference_attribute.pk: [attr_ref_product],\n+ product_type_page_reference_attribute.pk: [attr_ref_page],\n+ },\n+ )\n+ product_variant_with_single_reference = product_variant_list[3]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_reference,\n+ {\n+ product_type_product_reference_attribute.pk: [attr_ref_product],\n+ },\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"value\": {\n+ \"reference\": {\n+ \"referencedIds\": {\n+ filter_type: [\n+ to_global_id_or_none(referenced_product),\n+ to_global_id_or_none(referenced_page),\n+ to_global_id_or_none(referenced_variant),\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n+ returned_ids = [node[\"node\"][\"id\"] for node in product_variants_nodes]\n+ # Returned in both cases\n+ assert to_global_id_or_none(product_variant_with_all_references) in returned_ids\n"
293
- },
294
- {
295
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_references_pages.py",
296
- "status": "added",
297
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_references_pages.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_references_pages.py\t9c83ad8 (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_references_pages.py\td273fd9 (commit)\n@@ -1,1 +1,346 @@\n-[NEW FILE]\n\\n+import pytest\n+\n+from ......attribute import AttributeEntityType, AttributeInputType, AttributeType\n+from ......attribute.models import Attribute, AttributeValue\n+from ......attribute.utils import associate_attribute_values_to_instance\n+from ......page.models import Page\n+from .....core.utils import to_global_id_or_none\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 2), (\"containsAll\", 1)]\n+)\n+def test_product_variants_query_with_attr_slug_and_attribute_value_reference_to_pages(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ page_type,\n+ product_type_page_reference_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(product_type_page_reference_attribute)\n+\n+ reference_page_1_slug = \"referenced-page-1\"\n+ reference_page_2_slug = \"referenced-page-2\"\n+ referenced_page_1, referenced_page_2 = Page.objects.bulk_create(\n+ [\n+ Page(\n+ title=\"Referenced Page 1\",\n+ slug=reference_page_1_slug,\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ Page(\n+ title=\"Referenced Page 2\",\n+ slug=reference_page_2_slug,\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ ]\n+ )\n+\n+ attribute_value_1, attribute_value_2 = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_page_reference_attribute,\n+ name=f\"Page {referenced_page_1.pk}\",\n+ slug=f\"page-{referenced_page_1.pk}\",\n+ reference_page=referenced_page_1,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_page_reference_attribute,\n+ name=f\"Page {referenced_page_2.pk}\",\n+ slug=f\"page-{referenced_page_2.pk}\",\n+ reference_page=referenced_page_2,\n+ ),\n+ ]\n+ )\n+ product_variant_with_both_references = product_variant_list[0]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_both_references,\n+ {\n+ product_type_page_reference_attribute.pk: [\n+ attribute_value_1,\n+ attribute_value_2,\n+ ]\n+ },\n+ )\n+\n+ product_variant_with_single_reference = product_variant_list[1]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_reference,\n+ {product_type_page_reference_attribute.pk: [attribute_value_2]},\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": \"page-reference\",\n+ \"value\": {\n+ \"reference\": {\n+ \"pageSlugs\": {\n+ filter_type: [\n+ reference_page_1_slug,\n+ reference_page_2_slug,\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 2), (\"containsAll\", 1)]\n+)\n+def test_product_variants_query_with_attribute_value_reference_to_pages(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ product_type,\n+ page_type,\n+ product_type_page_reference_attribute,\n+ channel_USD,\n+):\n+ # given\n+ second_page_reference_attribute = Attribute.objects.create(\n+ slug=\"second-page-reference\",\n+ name=\"Page reference\",\n+ type=AttributeType.PRODUCT_TYPE,\n+ input_type=AttributeInputType.REFERENCE,\n+ entity_type=AttributeEntityType.PAGE,\n+ )\n+ product_type.variant_attributes.add(\n+ product_type_page_reference_attribute,\n+ second_page_reference_attribute,\n+ )\n+\n+ reference_1 = \"referenced-page-1\"\n+ reference_2 = \"referenced-page-2\"\n+ referenced_page_1, referenced_page_2 = Page.objects.bulk_create(\n+ [\n+ Page(\n+ title=\"Referenced Page 1\",\n+ slug=reference_1,\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ Page(\n+ title=\"Referenced Page 2\",\n+ slug=reference_2,\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ ]\n+ )\n+\n+ attribute_value_1, attribute_value_2 = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_page_reference_attribute,\n+ name=f\"Page {referenced_page_1.pk}\",\n+ slug=f\"page-{referenced_page_1.pk}\",\n+ reference_page=referenced_page_1,\n+ ),\n+ AttributeValue(\n+ attribute=second_page_reference_attribute,\n+ name=f\"Page {referenced_page_2.pk}\",\n+ slug=f\"page-{referenced_page_2.pk}\",\n+ reference_page=referenced_page_2,\n+ ),\n+ ]\n+ )\n+ product_variant_with_both_references = product_variant_list[0]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_both_references,\n+ {\n+ product_type_page_reference_attribute.pk: [attribute_value_1],\n+ second_page_reference_attribute.pk: [attribute_value_2],\n+ },\n+ )\n+\n+ product_variant_with_single_reference = product_variant_list[1]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_reference,\n+ {second_page_reference_attribute.pk: [attribute_value_2]},\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"value\": {\n+ \"reference\": {\n+ \"pageSlugs\": {filter_type: [reference_1, reference_2]}\n+ }\n+ },\n+ }\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 3), (\"containsAll\", 2)]\n+)\n+def test_product_variants_query_with_attr_slug_and_attribute_value_referenced_page_ids(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ product_type,\n+ page_type,\n+ product_type_page_reference_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product_type.variant_attributes.add(product_type_page_reference_attribute)\n+\n+ referenced_first_page, referenced_second_page, referenced_third_page = (\n+ Page.objects.bulk_create(\n+ [\n+ Page(\n+ title=\"Referenced Page\",\n+ slug=\"referenced-page\",\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ Page(\n+ title=\"Referenced Page\",\n+ slug=\"referenced-page2\",\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ Page(\n+ title=\"Referenced Page\",\n+ slug=\"referenced-page3\",\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ ]\n+ )\n+ )\n+\n+ first_attr_value, second_attr_value, third_attr_value = (\n+ AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_page_reference_attribute,\n+ name=f\"Page {referenced_first_page.pk}\",\n+ slug=f\"page-{referenced_first_page.pk}\",\n+ reference_page=referenced_first_page,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_page_reference_attribute,\n+ name=f\"Page {referenced_second_page.pk}\",\n+ slug=f\"page-{referenced_second_page.pk}\",\n+ reference_page=referenced_second_page,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_page_reference_attribute,\n+ name=f\"Page {referenced_third_page.pk}\",\n+ slug=f\"page-{referenced_third_page.pk}\",\n+ reference_page=referenced_third_page,\n+ ),\n+ ]\n+ )\n+ )\n+ first_product_variant_with_all_ids = product_variant_list[0]\n+ second_product_variant_with_all_ids = product_variant_list[1]\n+ product_variant_with_single_id = product_variant_list[3]\n+ associate_attribute_values_to_instance(\n+ first_product_variant_with_all_ids,\n+ {\n+ product_type_page_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ second_product_variant_with_all_ids,\n+ {\n+ product_type_page_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_id,\n+ {product_type_page_reference_attribute.pk: [first_attr_value]},\n+ )\n+\n+ referenced_first_global_id = to_global_id_or_none(referenced_first_page)\n+ referenced_second_global_id = to_global_id_or_none(referenced_second_page)\n+ referenced_third_global_id = to_global_id_or_none(referenced_third_page)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": product_type_page_reference_attribute.slug,\n+ \"value\": {\n+ \"reference\": {\n+ \"referencedIds\": {\n+ filter_type: [\n+ referenced_first_global_id,\n+ referenced_second_global_id,\n+ referenced_third_global_id,\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n"
298
- },
299
- {
300
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_references_products.py",
301
- "status": "added",
302
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_references_products.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_references_products.py\t9c83ad8 (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_references_products.py\td273fd9 (commit)\n@@ -1,1 +1,346 @@\n-[NEW FILE]\n\\n+import graphene\n+import pytest\n+\n+from ......attribute import AttributeEntityType, AttributeInputType, AttributeType\n+from ......attribute.models import Attribute, AttributeValue\n+from ......attribute.utils import associate_attribute_values_to_instance\n+from ......product.models import Product\n+from .....core.utils import to_global_id_or_none\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"),\n+ [(\"containsAny\", 2), (\"containsAll\", 1)],\n+)\n+def test_product_variants_query_with_attr_slug_and_attribute_value_reference_to_products(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ product_type_product_reference_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(product_type_product_reference_attribute)\n+\n+ ref_product_1, ref_product_2 = Product.objects.bulk_create(\n+ [\n+ Product(\n+ name=\"Reference Product 1\",\n+ slug=\"ref-1\",\n+ product_type=product_type,\n+ ),\n+ Product(\n+ name=\"Reference Product 2\",\n+ slug=\"ref-2\",\n+ product_type=product_type,\n+ ),\n+ ]\n+ )\n+\n+ attribute_value_1, attribute_value_2 = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_product_reference_attribute,\n+ name=f\"Product {ref_product_1.pk}\",\n+ slug=f\"product-{ref_product_1.pk}\",\n+ reference_product=ref_product_1,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_product_reference_attribute,\n+ name=f\"Product {ref_product_2.pk}\",\n+ slug=f\"product-{ref_product_2.pk}\",\n+ reference_product=ref_product_2,\n+ ),\n+ ]\n+ )\n+\n+ product_variant_with_both_references = product_variant_list[0]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_both_references,\n+ {\n+ product_type_product_reference_attribute.pk: [\n+ attribute_value_1,\n+ attribute_value_2,\n+ ]\n+ },\n+ )\n+\n+ product_variant_with_single_reference = product_variant_list[1]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_reference,\n+ {product_type_product_reference_attribute.pk: [attribute_value_2]},\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": \"product-reference\",\n+ \"value\": {\n+ \"reference\": {\n+ \"productSlugs\": {\n+ filter_type: [ref_product_1.slug, ref_product_2.slug]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n+ assert product_variants_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"ProductVariant\", product_variant_list[0].pk\n+ )\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"),\n+ [(\"containsAny\", 2), (\"containsAll\", 1)],\n+)\n+def test_product_variants_query_with_attribute_value_reference_to_products(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ product_type_product_reference_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product_type = product_variant_list[0].product.product_type\n+ second_product_reference_attribute = Attribute.objects.create(\n+ slug=\"second-product-reference\",\n+ name=\"Product reference\",\n+ type=AttributeType.PRODUCT_TYPE,\n+ input_type=AttributeInputType.REFERENCE,\n+ entity_type=AttributeEntityType.PRODUCT,\n+ )\n+\n+ product_type.variant_attributes.add(\n+ product_type_product_reference_attribute,\n+ second_product_reference_attribute,\n+ )\n+\n+ ref_product_1, ref_product_2 = Product.objects.bulk_create(\n+ [\n+ Product(\n+ name=\"Reference Product 1\",\n+ slug=\"ref-1\",\n+ product_type=product_type,\n+ ),\n+ Product(\n+ name=\"Reference Product 2\",\n+ slug=\"ref-2\",\n+ product_type=product_type,\n+ ),\n+ ]\n+ )\n+\n+ attribute_value_1, attribute_value_2 = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_product_reference_attribute,\n+ name=f\"Product {ref_product_1.pk}\",\n+ slug=f\"product-{ref_product_1.pk}\",\n+ reference_product=ref_product_1,\n+ ),\n+ AttributeValue(\n+ attribute=second_product_reference_attribute,\n+ name=f\"Product {ref_product_2.pk}\",\n+ slug=f\"product-{ref_product_2.pk}\",\n+ reference_product=ref_product_2,\n+ ),\n+ ]\n+ )\n+\n+ product_variant_with_both_references = product_variant_list[0]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_both_references,\n+ {\n+ product_type_product_reference_attribute.pk: [attribute_value_1],\n+ second_product_reference_attribute.pk: [attribute_value_2],\n+ },\n+ )\n+\n+ product_variant_with_single_reference = product_variant_list[1]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_reference,\n+ {second_product_reference_attribute.pk: [attribute_value_2]},\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"value\": {\n+ \"reference\": {\n+ \"productSlugs\": {\n+ filter_type: [ref_product_1.slug, ref_product_2.slug]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n+ assert product_variants_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"ProductVariant\", product_variant_list[0].pk\n+ )\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 3), (\"containsAll\", 2)]\n+)\n+def test_product_variants_query_with_attr_slug_and_attribute_value_referenced_product_ids(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ product_type_product_reference_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(\n+ product_type_product_reference_attribute,\n+ )\n+ ref_product_1, ref_product_2, ref_product_3 = Product.objects.bulk_create(\n+ [\n+ Product(\n+ name=\"Reference Product 1\",\n+ slug=\"ref-1\",\n+ product_type=product_type,\n+ ),\n+ Product(\n+ name=\"Reference Product 2\",\n+ slug=\"ref-2\",\n+ product_type=product_type,\n+ ),\n+ Product(\n+ name=\"Reference Product 3\",\n+ slug=\"ref-3\",\n+ product_type=product_type,\n+ ),\n+ ]\n+ )\n+\n+ first_attr_value, second_attr_value, third_attr_value = (\n+ AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_product_reference_attribute,\n+ name=f\"Product {ref_product_1.pk}\",\n+ slug=f\"product-{ref_product_1.pk}\",\n+ reference_product=ref_product_1,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_product_reference_attribute,\n+ name=f\"Product {ref_product_2.pk}\",\n+ slug=f\"product-{ref_product_2.pk}\",\n+ reference_product=ref_product_2,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_product_reference_attribute,\n+ name=f\"Product {ref_product_3.pk}\",\n+ slug=f\"product-{ref_product_3.pk}\",\n+ reference_product=ref_product_3,\n+ ),\n+ ]\n+ )\n+ )\n+ first_product_variant_with_all_ids = product_variant_list[0]\n+ second_product_variant_with_all_ids = product_variant_list[1]\n+ product_variant_with_single_id = product_variant_list[3]\n+\n+ associate_attribute_values_to_instance(\n+ first_product_variant_with_all_ids,\n+ {\n+ product_type_product_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ second_product_variant_with_all_ids,\n+ {\n+ product_type_product_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_id,\n+ {\n+ product_type_product_reference_attribute.pk: [\n+ first_attr_value,\n+ ],\n+ },\n+ )\n+ ref_1_global_id = to_global_id_or_none(ref_product_1)\n+ ref_2_global_id = to_global_id_or_none(ref_product_2)\n+ ref_3_global_id = to_global_id_or_none(ref_product_3)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": product_type_product_reference_attribute.slug,\n+ \"value\": {\n+ \"reference\": {\n+ \"referencedIds\": {\n+ filter_type: [\n+ ref_1_global_id,\n+ ref_2_global_id,\n+ ref_3_global_id,\n+ ]\n+ }\n+ }\n+ },\n+ },\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n"
303
- },
304
- {
305
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_references_variants.py",
306
- "status": "added",
307
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_references_variants.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_references_variants.py\t9c83ad8 (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_references_variants.py\td273fd9 (commit)\n@@ -1,1 +1,322 @@\n-[NEW FILE]\n\\n+import graphene\n+import pytest\n+\n+from ......attribute import AttributeEntityType, AttributeInputType, AttributeType\n+from ......attribute.models import Attribute, AttributeValue\n+from ......attribute.utils import associate_attribute_values_to_instance\n+from .....core.utils import to_global_id_or_none\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 2), (\"containsAll\", 1)]\n+)\n+def test_product_variants_query_with_attribute_value_reference_to_product_variants(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ product_type_variant_reference_attribute,\n+ channel_USD,\n+ variant,\n+ variant_without_inventory_tracking,\n+):\n+ # given\n+ product_type = product_variant_list[0].product.product_type\n+\n+ second_variant_reference_attribute = Attribute.objects.create(\n+ slug=\"second-product-reference\",\n+ name=\"Product reference\",\n+ type=AttributeType.PRODUCT_TYPE,\n+ input_type=AttributeInputType.REFERENCE,\n+ entity_type=AttributeEntityType.PRODUCT_VARIANT,\n+ )\n+ product_type.variant_attributes.set(\n+ [product_type_variant_reference_attribute, second_variant_reference_attribute]\n+ )\n+\n+ first_variant_sku = \"test-variant-1\"\n+ second_variant_sku = \"test-variant-2\"\n+\n+ first_variant = variant\n+ first_variant.sku = first_variant_sku\n+ first_variant.save()\n+\n+ second_variant = variant_without_inventory_tracking\n+ second_variant.sku = second_variant_sku\n+ second_variant.save()\n+\n+ attribute_value_1, attribute_value_2 = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_variant_reference_attribute,\n+ name=f\"Variant {first_variant.pk}\",\n+ slug=f\"variant-{first_variant.pk}\",\n+ reference_variant=first_variant,\n+ ),\n+ AttributeValue(\n+ attribute=second_variant_reference_attribute,\n+ name=f\"Variant {second_variant.pk}\",\n+ slug=f\"variant-{second_variant.pk}\",\n+ reference_variant=second_variant,\n+ ),\n+ ]\n+ )\n+\n+ product_variant_with_both_references = product_variant_list[0]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_both_references,\n+ {\n+ product_type_variant_reference_attribute.pk: [attribute_value_1],\n+ second_variant_reference_attribute.pk: [attribute_value_2],\n+ },\n+ )\n+\n+ product_variant_with_single_reference = product_variant_list[1]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_reference,\n+ {second_variant_reference_attribute.pk: [attribute_value_2]},\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"value\": {\n+ \"reference\": {\n+ \"productVariantSkus\": {\n+ filter_type: [\n+ first_variant_sku,\n+ second_variant_sku,\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n+ assert product_variants_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"ProductVariant\", product_variant_list[0].pk\n+ )\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 2), (\"containsAll\", 1)]\n+)\n+def test_product_variants_query_with_attr_slug_and_attribute_value_reference_to_product_variants(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ product_type_variant_reference_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(product_type_variant_reference_attribute)\n+\n+ first_variant_sku = \"test-variant-1\"\n+ second_variant_sku = \"test-variant-2\"\n+\n+ first_variant = product_variant_list[0]\n+ first_variant.sku = first_variant_sku\n+ first_variant.save()\n+\n+ second_variant = product_variant_list[1]\n+ second_variant.sku = second_variant_sku\n+ second_variant.save()\n+\n+ attribute_value_1, attribute_value_2 = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_variant_reference_attribute,\n+ name=f\"Variant {first_variant.pk}\",\n+ slug=f\"variant-{first_variant.pk}\",\n+ reference_variant=first_variant,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_variant_reference_attribute,\n+ name=f\"Variant {second_variant.pk}\",\n+ slug=f\"variant-{second_variant.pk}\",\n+ reference_variant=second_variant,\n+ ),\n+ ]\n+ )\n+\n+ product_variant_with_both_references = product_variant_list[0]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_both_references,\n+ {\n+ product_type_variant_reference_attribute.pk: [\n+ attribute_value_1,\n+ attribute_value_2,\n+ ]\n+ },\n+ )\n+\n+ product_variant_with_single_reference = product_variant_list[1]\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_reference,\n+ {product_type_variant_reference_attribute.pk: [attribute_value_2]},\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": \"variant-reference\",\n+ \"value\": {\n+ \"reference\": {\n+ \"productVariantSkus\": {\n+ filter_type: [\n+ first_variant_sku,\n+ second_variant_sku,\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n+ assert product_variants_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"ProductVariant\", product_variant_list[0].pk\n+ )\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 3), (\"containsAll\", 2)]\n+)\n+def test_product_variants_query_with_attr_slug_attribute_value_referenced_variant_ids(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ product_type_variant_reference_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(\n+ product_type_variant_reference_attribute,\n+ )\n+\n+ first_variant = product_variant_list[0]\n+ second_variant = product_variant_list[1]\n+ third_variant = product_variant_list[3]\n+\n+ first_attr_value, second_attr_value, third_attr_value = (\n+ AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=product_type_variant_reference_attribute,\n+ name=f\"Variant {first_variant.pk}\",\n+ slug=f\"variant-{first_variant.pk}\",\n+ reference_variant=first_variant,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_variant_reference_attribute,\n+ name=f\"Variant {second_variant.pk}\",\n+ slug=f\"variant-{second_variant.pk}\",\n+ reference_variant=second_variant,\n+ ),\n+ AttributeValue(\n+ attribute=product_type_variant_reference_attribute,\n+ name=f\"Variant {third_variant.pk}\",\n+ slug=f\"variant-{third_variant.pk}\",\n+ reference_variant=third_variant,\n+ ),\n+ ]\n+ )\n+ )\n+ first_product_variant_with_all_ids = product_variant_list[0]\n+ second_product_variant_with_all_ids = product_variant_list[1]\n+ product_variant_with_single_id = product_variant_list[3]\n+ associate_attribute_values_to_instance(\n+ first_product_variant_with_all_ids,\n+ {\n+ product_type_variant_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ second_product_variant_with_all_ids,\n+ {\n+ product_type_variant_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_with_single_id,\n+ {product_type_variant_reference_attribute.pk: [first_attr_value]},\n+ )\n+ referenced_first_global_id = to_global_id_or_none(first_variant)\n+ referenced_second_global_id = to_global_id_or_none(second_variant)\n+ referenced_third_global_id = to_global_id_or_none(third_variant)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": product_type_variant_reference_attribute.slug,\n+ \"value\": {\n+ \"reference\": {\n+ \"referencedIds\": {\n+ filter_type: [\n+ referenced_first_global_id,\n+ referenced_second_global_id,\n+ referenced_third_global_id,\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n"
308
- },
309
- {
310
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_validation.py",
311
- "status": "modified",
312
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_validation.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_validation.py\t9c83ad8 (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_validation.py\td273fd9 (commit)\n@@ -5,9 +5,15 @@\n \n \n @pytest.mark.parametrize(\n \"attribute_value_filter\",\n- [{\"numeric\": None}, {\"name\": None}, {\"slug\": None}, {\"boolean\": False}],\n+ [\n+ {\"numeric\": None},\n+ {\"name\": None},\n+ {\"slug\": None},\n+ {\"boolean\": False},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"global-id-1\"]}}},\n+ ],\n )\n def test_product_variants_query_failed_filter_validation_for_numeric_with_slug_input(\n attribute_value_filter,\n staff_api_client,\n@@ -43,9 +49,15 @@\n \n \n @pytest.mark.parametrize(\n \"attribute_value_filter\",\n- [{\"boolean\": None}, {\"name\": None}, {\"slug\": None}, {\"numeric\": {\"eq\": 1.2}}],\n+ [\n+ {\"boolean\": None},\n+ {\"name\": None},\n+ {\"slug\": None},\n+ {\"numeric\": {\"eq\": 1.2}},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"global-id-1\"]}}},\n+ ],\n )\n def test_product_variants_query_failed_filter_validation_for_boolean_with_slug_input(\n attribute_value_filter,\n staff_api_client,\n@@ -86,8 +98,9 @@\n {\"dateTime\": None},\n {\"name\": None},\n {\"slug\": None},\n {\"numeric\": {\"eq\": 1.2}},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"global-id-1\"]}}},\n ],\n )\n def test_product_variants_query_failed_filter_validation_for_date_attribute_with_slug_input(\n attribute_value_filter,\n@@ -130,8 +143,9 @@\n {\"name\": None},\n {\"slug\": None},\n {\"numeric\": {\"eq\": 1.2}},\n {\"date\": None},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": [\"global-id-1\"]}}},\n ],\n )\n def test_product_variants_query_failed_filter_validation_for_datetime_attribute_with_slug_input(\n attribute_value_filter,\n@@ -262,4 +276,58 @@\n # then\n content = get_graphql_content(response, ignore_errors=True)\n assert \"errors\" in content\n assert content[\"data\"][\"productVariants\"] is None\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_value_filter\",\n+ [\n+ {},\n+ {\"reference\": {}},\n+ {\"reference\": None},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": []}}},\n+ {\"reference\": {\"pageSlugs\": {\"containsAll\": []}}},\n+ {\"reference\": {\"productSlugs\": {\"containsAll\": []}}},\n+ {\"reference\": {\"productVariantSkus\": {\"containsAll\": []}}},\n+ {\"reference\": {\"pageSlugs\": {\"containsAny\": []}}},\n+ {\"reference\": {\"productSlugs\": {\"containsAny\": []}}},\n+ {\"reference\": {\"productVariantSkus\": {\"containsAny\": []}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAny\": []}}},\n+ {\"reference\": {\"pageSlugs\": {\"containsAny\": [], \"containsAll\": []}}},\n+ {\"reference\": {\"productSlugs\": {\"containsAny\": [], \"containsAll\": []}}},\n+ {\"reference\": {\"productVariantSkus\": {\"containsAny\": [], \"containsAll\": []}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAny\": [], \"containsAll\": []}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAll\": None}}},\n+ {\"reference\": {\"pageSlugs\": {\"containsAll\": None}}},\n+ {\"reference\": {\"productSlugs\": {\"containsAll\": None}}},\n+ {\"reference\": {\"productVariantSkus\": {\"containsAll\": None}}},\n+ {\"reference\": {\"pageSlugs\": {\"containsAny\": None}}},\n+ {\"reference\": {\"productSlugs\": {\"containsAny\": None}}},\n+ {\"reference\": {\"productVariantSkus\": {\"containsAny\": None}}},\n+ {\"reference\": {\"referencedIds\": {\"containsAny\": None}}},\n+ ],\n+)\n+def test_product_variants_query_failed_filter_validation_for_reference_attribute_with_slug_input(\n+ attribute_value_filter,\n+ staff_api_client,\n+ channel_USD,\n+):\n+ # given\n+ attr_slug_input = \"reference-product\"\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [{\"slug\": attr_slug_input, \"value\": attribute_value_filter}]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response, ignore_errors=True)\n+ assert \"errors\" in content\n+ assert content[\"data\"][\"productVariants\"] is None\n"
313
- }
314
- ]
315
- },
316
- {
317
- "id": "add-variant-attr-filter",
318
- "sha": "9c83ad8ba8e23f64e1fb24e534f4aa4ac4c1f01e",
319
- "parentSha": "4bba6cf490cfbabeb7831d30236ab513a58706c4",
320
- "spec": "Implement attribute-based filtering for GraphQL productVariants, reusing the existing assigned attribute where-input model and shared filtering utilities.\n\nScope and behavior\n- Add support to the productVariants connection for filtering by assigned attributes using the same AssignedAttributeWhereInput used by products/pages. Attribute slug must be optional; when a slug is provided it narrows results to that attribute, and when omitted, any matching attribute on the variant should qualify. Multiple attribute filters provided in a list must be combined with logical AND.\n- Support value filters for the following attribute input types:\n - Basic value fields: slug and name (string where filters with operators eq and oneOf)\n - Numeric attributes: numeric equality, oneOf, and range (gte/lte within range)\n - Boolean attributes: exact boolean match\n - Date attributes: range over the date component of date_time (using date range operators)\n - DateTime attributes: range over date_time (using datetime range operators)\n- If an attribute filter entry specifies a slug with no value, match variants that have any value for that attribute.\n- Validate inputs using the existing validate_attribute_value_input helper and raise GraphQL validation errors for invalid combinations (e.g., more than one value field set, nulls where a value is required, mismatched value types for the attribute input type, duplicate attribute slugs in a single filter list).\n\nCode changes\n1) Shared attribute filtering helpers\n- In saleor/graphql/attribute/shared_filters.py, add helper functions that produce querysets of AttributeValue by different value criteria, optionally scoped by attribute id and database alias:\n - get_attribute_values_by_slug_or_name_value(attr_id, attr_value, db_connection_name)\n - get_attribute_values_by_numeric_value(attr_id, numeric_value, db_connection_name)\n - get_attribute_values_by_boolean_value(attr_id, boolean_value, db_connection_name)\n - get_attribute_values_by_date_value(attr_id, date_value, db_connection_name)\n - get_attribute_values_by_date_time_value(attr_id, date_value, db_connection_name)\n- Each must use existing utilities (filter_where_by_value_field, filter_where_by_numeric_field, filter_range_field) and constrain by AttributeInputType where relevant.\n\n2) Product variant filters\n- In saleor/graphql/product/filters/product_variant.py:\n - Import AssignedAttributeWhereInput, the new shared helpers added above, and Number type.\n - Implement a helper _get_assigned_variant_attribute_for_attribute_value_qs that receives a QuerySet[AttributeValue] and returns a Q expression using Exists over AssignedVariantAttributeValue linked via assignment to AssignedVariantAttribute for the variant (OuterRef to variant pk).\n - Implement filter functions that translate attribute value filters into Q expressions for variants by calling the shared helper query builders and then mapping them via _get_assigned_variant_attribute_for_attribute_value_qs:\n - filter_by_slug_or_name(attr_id, attr_value, db_connection_name)\n - filter_by_numeric_attribute(attr_id, numeric_value, db_connection_name)\n - filter_by_boolean_attribute(attr_id, boolean_value, db_connection_name)\n - filter_by_date_attribute(attr_id, date_value, db_connection_name)\n - filter_by_date_time_attribute(attr_id, date_value, db_connection_name)\n - Implement filter_variants_by_attributes(qs, value) that:\n - Resolves attribute slugs to Attribute ids; if any provided slug does not exist, return qs.none().\n - Builds a cumulative Q expression combining all attribute conditions with AND.\n - Handles entries without a value (slug-only) by matching any value for those attributes.\n - For entries with a value, dispatch based on the provided subfield (slug/name, numeric, boolean, date, date_time) to add to the cumulative Q.\n - Returns qs.filter(the cumulative Q expression).\n - Extend ProductVariantFilter:\n - Add a new attributes = ListObjectTypeWhereFilter(input_class=AssignedAttributeWhereInput, method=\"filter_attributes\", help_text indicating it filters by attributes and reference the appropriate version added text).\n - Implement filter_attributes to call filter_variants_by_attributes when value is provided and otherwise return the queryset.\n - Override is_valid to invoke validate_attribute_value_input(self.data.get(\"attributes\"), self.queryset.db) before super().is_valid().\n\n3) GraphQL schema\n- In saleor/graphql/schema.graphql:\n - Add attributes: [AssignedAttributeWhereInput!] to ProductVariantWhereInput with a description noting it filters by attributes and the version added.\n - Ensure the AssignedAttributeWhereInput input type appears once in the schema (remove duplicates if present elsewhere due to reordering).\n\n4) Developer docs/changelog\n- Update CHANGELOG.md to include a line under the relevant section noting that productVariants now support filtering by associated attributes.\n\n5) Tests\n- Add GraphQL tests under saleor/graphql/product/tests/queries/variants_where covering:\n - Filtering by attribute slug only (variants that have any value for that attribute).\n - Filtering by attribute value slug/name both with and without specifying the attribute slug; eq and oneOf.\n - Numeric attribute filtering (eq, oneOf, range), including mixing with name/slug where filters; with and without attribute slug.\n - Boolean attribute filtering via boolean field and via basic slug/name fields for the true value; with and without attribute slug; ensure non-matching cases return empty.\n - Date attribute filtering via date range and via slug/name matching of the value; with/without attribute slug; multiple attributes present.\n - DateTime attribute filtering via dateTime range; slug/name filters; with/without attribute slug; multiple attributes present.\n - Multiple attributes combined (AND semantics), including cases where one filter cannot match such that the result is empty.\n - Validation failures: null in value fields, unsupported combinations for a given attribute type, duplicate attribute slugs in a single filter input list; assert GraphQL errors are produced and data is null for productVariants.\n\nAcceptance criteria\n- GraphQL schema exposes ProductVariantWhereInput.attributes using AssignedAttributeWhereInput.\n- Queries against productVariants(where: {attributes: [...]}) return variants that satisfy all provided attribute conditions.\n- Attribute slug is optional; omitting it searches across all variant attributes.\n- Value filtering supports name/slug, numeric, boolean, date, dateTime as outlined, with correct range semantics and type constraints.\n- Invalid inputs produce GraphQL errors consistent with existing validation behavior for product attribute filtering.\n- Tests validating the above scenarios pass.\n- Changelog entry present.\n",
321
- "prompt": "Add attribute-based filtering to the GraphQL productVariants connection, consistent with how products/pages can be filtered by assigned attributes. Allow callers to provide a list of attribute filters (combining them with AND), where each filter can optionally specify the attribute slug and a single value criterion. Support matching by value slug or name, numeric equality/one-of/range, boolean, date range, and datetime range. When only an attribute slug is provided with no value, consider variants that have any value for that attribute. Reuse the existing assigned attribute where-input and shared validation; invalid combinations or nulls should raise GraphQL validation errors. Update the schema to surface the new field in ProductVariantWhereInput, wire the filter into the variant filterset, and add tests covering positive, negative, and validation cases. Document the change in the changelog.",
322
- "supplementalFiles": [
323
- "saleor/attribute/models.py",
324
- "saleor/product/models.py",
325
- "saleor/graphql/attribute/types.py",
326
- "saleor/graphql/core/filters/where_input.py",
327
- "saleor/graphql/core/filters/where_filters.py",
328
- "saleor/graphql/product/filters/product.py"
329
- ],
330
- "fileDiffs": [
331
- {
332
- "path": "CHANGELOG.md",
333
- "status": "modified",
334
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\t4bba6cf (parent)\n+++ CHANGELOG.md\t9c83ad8 (commit)\n@@ -91,8 +91,9 @@\n - Extended support for filtering `products` by associated attributes\n - Attribute slug is now optional when filtering by attribute values\n - Added support for filtering by associated reference objects (e.g., `products`, `pages`, `variants`)\n - Added `fractionalAmount` and `fractionDigits` fields to the `Money` type. These fields allow monetary values to be represented as a pair of integers, which is often required when integrating with payment service providers.\n+- Add support for filtering `productVariants` by associated attributes\n \n ### Webhooks\n - Transaction webhooks responsible for processing payments can now return payment method details`, which will be associated with the corresponding transaction. See [docs](https://docs.saleor.io/developer/extending/webhooks/synchronous-events/transaction#response-4) to learn more.\n \n"
335
- },
336
- {
337
- "path": "saleor/graphql/attribute/shared_filters.py",
338
- "status": "modified",
339
- "diff": "Index: saleor/graphql/attribute/shared_filters.py\n===================================================================\n--- saleor/graphql/attribute/shared_filters.py\t4bba6cf (parent)\n+++ saleor/graphql/attribute/shared_filters.py\t9c83ad8 (commit)\n@@ -17,8 +17,9 @@\n from ..core.filters.where_input import ContainsFilterInput, StringFilterInput\n from ..core.types import DateRangeInput, DateTimeRangeInput\n from ..core.types.base import BaseInputObjectType\n from ..utils.filters import (\n+ Number,\n filter_range_field,\n filter_where_by_numeric_field,\n filter_where_by_value_field,\n )\n@@ -572,8 +573,88 @@\n )\n return Exists(assigned_attr_value)\n \n \n+def get_attribute_values_by_slug_or_name_value(\n+ attr_id: int | None,\n+ attr_value: dict,\n+ db_connection_name: str,\n+) -> QuerySet[AttributeValue]:\n+ attribute_values = AttributeValue.objects.using(db_connection_name).filter(\n+ **{\"attribute_id\": attr_id} if attr_id else {}\n+ )\n+ if \"slug\" in attr_value:\n+ attribute_values = filter_where_by_value_field(\n+ attribute_values, \"slug\", attr_value[\"slug\"]\n+ )\n+ if \"name\" in attr_value:\n+ attribute_values = filter_where_by_value_field(\n+ attribute_values, \"name\", attr_value[\"name\"]\n+ )\n+ return attribute_values\n+\n+\n+def get_attribute_values_by_numeric_value(\n+ attr_id: int | None,\n+ numeric_value: dict[str, Number | list[Number] | dict[str, Number]],\n+ db_connection_name: str,\n+) -> QuerySet[AttributeValue]:\n+ qs_by_numeric = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute__input_type=AttributeInputType.NUMERIC,\n+ **{\"attribute_id\": attr_id} if attr_id else {},\n+ )\n+ qs_by_numeric = filter_where_by_numeric_field(\n+ qs_by_numeric,\n+ \"numeric\",\n+ numeric_value,\n+ )\n+ return qs_by_numeric\n+\n+\n+def get_attribute_values_by_boolean_value(\n+ attr_id: int | None,\n+ boolean_value: bool,\n+ db_connection_name: str,\n+) -> QuerySet[AttributeValue]:\n+ qs_by_boolean = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute__input_type=AttributeInputType.BOOLEAN,\n+ **{\"attribute_id\": attr_id} if attr_id else {},\n+ )\n+ return qs_by_boolean.filter(boolean=boolean_value)\n+\n+\n+def get_attribute_values_by_date_value(\n+ attr_id: int | None,\n+ date_value: dict[str, str],\n+ db_connection_name: str,\n+) -> QuerySet[AttributeValue]:\n+ qs_by_date = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute__input_type=AttributeInputType.DATE,\n+ **{\"attribute_id\": attr_id} if attr_id else {},\n+ )\n+ return filter_range_field(\n+ qs_by_date,\n+ \"date_time__date\",\n+ date_value,\n+ )\n+\n+\n+def get_attribute_values_by_date_time_value(\n+ attr_id: int | None,\n+ date_value: dict[str, str],\n+ db_connection_name: str,\n+) -> QuerySet[AttributeValue]:\n+ qs_by_date = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute__input_type=AttributeInputType.DATE_TIME,\n+ **{\"attribute_id\": attr_id} if attr_id else {},\n+ )\n+ return filter_range_field(\n+ qs_by_date,\n+ \"date_time\",\n+ date_value,\n+ )\n+\n+\n def filter_objects_by_attributes[T: (page_models.Page, product_models.Product)](\n qs: QuerySet[T],\n value: list[dict],\n assigned_attr_model: type[\n"
340
- },
341
- {
342
- "path": "saleor/graphql/product/filters/product_variant.py",
343
- "status": "modified",
344
- "diff": "Index: saleor/graphql/product/filters/product_variant.py\n===================================================================\n--- saleor/graphql/product/filters/product_variant.py\t4bba6cf (parent)\n+++ saleor/graphql/product/filters/product_variant.py\t9c83ad8 (commit)\n@@ -1,31 +1,42 @@\n import django_filters\n import graphene\n from django.db.models import Exists, OuterRef, Q\n+from django.db.models.query import QuerySet\n from django.utils import timezone\n \n-from ....product.models import (\n- Product,\n- ProductVariant,\n+from ....attribute.models import (\n+ AssignedVariantAttribute,\n+ AssignedVariantAttributeValue,\n+ Attribute,\n+ AttributeValue,\n )\n+from ....product.models import Product, ProductVariant\n+from ...attribute.shared_filters import (\n+ AssignedAttributeWhereInput,\n+ get_attribute_values_by_boolean_value,\n+ get_attribute_values_by_date_time_value,\n+ get_attribute_values_by_date_value,\n+ get_attribute_values_by_numeric_value,\n+ get_attribute_values_by_slug_or_name_value,\n+ validate_attribute_value_input,\n+)\n+from ...core.descriptions import ADDED_IN_322\n from ...core.doc_category import DOC_CATEGORY_PRODUCTS\n from ...core.filters import (\n FilterInputObjectType,\n GlobalIDMultipleChoiceWhereFilter,\n ListObjectTypeFilter,\n+ ListObjectTypeWhereFilter,\n MetadataFilterBase,\n MetadataWhereFilterBase,\n ObjectTypeFilter,\n ObjectTypeWhereFilter,\n )\n-from ...core.filters.where_input import (\n- StringFilterInput,\n- WhereInputObjectType,\n-)\n-from ...core.types import (\n- DateTimeRangeInput,\n-)\n+from ...core.filters.where_input import StringFilterInput, WhereInputObjectType\n+from ...core.types import DateTimeRangeInput\n from ...utils.filters import (\n+ Number,\n filter_by_ids,\n filter_where_by_range_field,\n filter_where_by_value_field,\n )\n@@ -46,8 +57,183 @@\n | (Q(is_preorder=True)) & Q(preorder_end_date__lt=timezone.now())\n )\n \n \n+def filter_by_slug_or_name(\n+ attr_id: int | None,\n+ attr_value: dict,\n+ db_connection_name: str,\n+):\n+ attribute_values = get_attribute_values_by_slug_or_name_value(\n+ attr_id=attr_id,\n+ attr_value=attr_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_variant_attribute_for_attribute_value_qs(\n+ attribute_values,\n+ db_connection_name,\n+ )\n+\n+\n+def filter_by_numeric_attribute(\n+ attr_id: int | None,\n+ numeric_value: dict[str, Number | list[Number] | dict[str, Number]],\n+ db_connection_name: str,\n+):\n+ qs_by_numeric = get_attribute_values_by_numeric_value(\n+ attr_id=attr_id,\n+ numeric_value=numeric_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_variant_attribute_for_attribute_value_qs(\n+ qs_by_numeric,\n+ db_connection_name,\n+ )\n+\n+\n+def filter_by_boolean_attribute(\n+ attr_id: int | None,\n+ boolean_value,\n+ db_connection_name: str,\n+):\n+ qs_by_boolean = get_attribute_values_by_boolean_value(\n+ attr_id=attr_id,\n+ boolean_value=boolean_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_variant_attribute_for_attribute_value_qs(\n+ qs_by_boolean,\n+ db_connection_name,\n+ )\n+\n+\n+def filter_by_date_attribute(\n+ attr_id: int | None,\n+ date_value,\n+ db_connection_name: str,\n+):\n+ qs_by_date = get_attribute_values_by_date_value(\n+ attr_id=attr_id,\n+ date_value=date_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_variant_attribute_for_attribute_value_qs(\n+ qs_by_date,\n+ db_connection_name,\n+ )\n+\n+\n+def filter_by_date_time_attribute(\n+ attr_id: int | None,\n+ date_value,\n+ db_connection_name: str,\n+):\n+ qs_by_date_time = get_attribute_values_by_date_time_value(\n+ attr_id=attr_id,\n+ date_value=date_value,\n+ db_connection_name=db_connection_name,\n+ )\n+ return _get_assigned_variant_attribute_for_attribute_value_qs(\n+ qs_by_date_time,\n+ db_connection_name,\n+ )\n+\n+\n+def _get_assigned_variant_attribute_for_attribute_value_qs(\n+ attribute_values: QuerySet[AttributeValue],\n+ db_connection_name: str,\n+):\n+ assigned_attr_value = AssignedVariantAttributeValue.objects.using(\n+ db_connection_name\n+ ).filter(\n+ value__in=attribute_values,\n+ assignment_id=OuterRef(\"id\"),\n+ )\n+ return Q(\n+ Exists(\n+ AssignedVariantAttribute.objects.using(db_connection_name).filter(\n+ Exists(assigned_attr_value), variant_id=OuterRef(\"pk\")\n+ )\n+ )\n+ )\n+\n+\n+def filter_variants_by_attributes(\n+ qs: QuerySet[ProductVariant], value: list[dict]\n+) -> QuerySet[ProductVariant]:\n+ attribute_slugs = {\n+ attr_filter[\"slug\"] for attr_filter in value if \"slug\" in attr_filter\n+ }\n+ attributes_map = {\n+ attr.slug: attr\n+ for attr in Attribute.objects.using(qs.db).filter(slug__in=attribute_slugs)\n+ }\n+ if len(attribute_slugs) != len(attributes_map.keys()):\n+ # Filter over non existing attribute\n+ return qs.none()\n+\n+ attr_filter_expression = Q()\n+\n+ attr_without_values_input = []\n+ for attr_filter in value:\n+ if \"slug\" in attr_filter and \"value\" not in attr_filter:\n+ attr_without_values_input.append(attributes_map[attr_filter[\"slug\"]])\n+\n+ if attr_without_values_input:\n+ atr_value_qs = AttributeValue.objects.using(qs.db).filter(\n+ attribute_id__in=[attr.id for attr in attr_without_values_input]\n+ )\n+ attr_filter_expression = _get_assigned_variant_attribute_for_attribute_value_qs(\n+ atr_value_qs,\n+ qs.db,\n+ )\n+\n+ for attr_filter in value:\n+ attr_value = attr_filter.get(\"value\")\n+ if not attr_value:\n+ # attrs without value input are handled separately\n+ continue\n+\n+ attr_id = None\n+ if attr_slug := attr_filter.get(\"slug\"):\n+ attr = attributes_map[attr_slug]\n+ attr_id = attr.id\n+\n+ attr_value = attr_filter[\"value\"]\n+\n+ if \"slug\" in attr_value or \"name\" in attr_value:\n+ attr_filter_expression &= filter_by_slug_or_name(\n+ attr_id,\n+ attr_value,\n+ qs.db,\n+ )\n+ elif \"numeric\" in attr_value:\n+ attr_filter_expression &= filter_by_numeric_attribute(\n+ attr_id,\n+ attr_value[\"numeric\"],\n+ qs.db,\n+ )\n+ elif \"boolean\" in attr_value:\n+ attr_filter_expression &= filter_by_boolean_attribute(\n+ attr_id,\n+ attr_value[\"boolean\"],\n+ qs.db,\n+ )\n+ elif \"date\" in attr_value:\n+ attr_filter_expression &= filter_by_date_attribute(\n+ attr_id,\n+ attr_value[\"date\"],\n+ qs.db,\n+ )\n+ elif \"date_time\" in attr_value:\n+ attr_filter_expression &= filter_by_date_time_attribute(\n+ attr_id,\n+ attr_value[\"date_time\"],\n+ qs.db,\n+ )\n+ return qs.filter(attr_filter_expression)\n+\n+\n class ProductVariantFilter(MetadataFilterBase):\n search = django_filters.CharFilter(method=\"product_variant_filter_search\")\n sku = ListObjectTypeFilter(input_class=graphene.String, method=filter_sku_list)\n is_preorder = django_filters.BooleanFilter(method=filter_is_preorder)\n@@ -81,8 +267,13 @@\n input_class=DateTimeRangeInput,\n method=\"filter_updated_at\",\n help_text=\"Filter by when was the most recent update.\",\n )\n+ attributes = ListObjectTypeWhereFilter(\n+ input_class=AssignedAttributeWhereInput,\n+ method=\"filter_attributes\",\n+ help_text=\"Filter by attributes associated with the variant.\" + ADDED_IN_322,\n+ )\n \n class Meta:\n model = ProductVariant\n fields = []\n@@ -94,9 +285,20 @@\n @staticmethod\n def filter_updated_at(qs, _, value):\n return filter_where_by_range_field(qs, \"updated_at\", value)\n \n+ @staticmethod\n+ def filter_attributes(qs, _, value):\n+ if not value:\n+ return qs\n+ return filter_variants_by_attributes(qs, value)\n \n+ def is_valid(self):\n+ if attributes := self.data.get(\"attributes\"):\n+ validate_attribute_value_input(attributes, self.queryset.db)\n+ return super().is_valid()\n+\n+\n class ProductVariantFilterInput(FilterInputObjectType):\n class Meta:\n doc_category = DOC_CATEGORY_PRODUCTS\n filterset_class = ProductVariantFilter\n"
345
- },
346
- {
347
- "path": "saleor/graphql/product/tests/queries/variants_where/__init__.py",
348
- "status": "added",
349
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/__init__.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/__init__.py\t4bba6cf (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/__init__.py\t9c83ad8 (commit)\n@@ -1,1 +0,0 @@\n-[NEW FILE]\n\\n"
350
- },
351
- {
352
- "path": "saleor/graphql/product/tests/queries/variants_where/shared.py",
353
- "status": "added",
354
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/shared.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/shared.py\t4bba6cf (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/shared.py\t9c83ad8 (commit)\n@@ -1,1 +1,13 @@\n-[NEW FILE]\n\\n+PRODUCT_VARIANTS_WHERE_QUERY = \"\"\"\n+ query($where: ProductVariantWhereInput!, $channel: String) {\n+ productVariants(first: 10, where: $where, channel: $channel) {\n+ edges {\n+ node {\n+ id\n+ name\n+ sku\n+ }\n+ }\n+ }\n+ }\n+\"\"\"\n"
355
- },
356
- {
357
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_attributes.py",
358
- "status": "added",
359
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_attributes.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_attributes.py\t4bba6cf (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_attributes.py\t9c83ad8 (commit)\n@@ -1,1 +1,166 @@\n-[NEW FILE]\n\\n+import graphene\n+import pytest\n+\n+from ......attribute.utils import associate_attribute_values_to_instance\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+def test_product_variants_query_with_attribute_slug(\n+ staff_api_client, product_variant_list, weight_attribute, channel_USD\n+):\n+ # given\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(weight_attribute)\n+ attr_value = weight_attribute.values.first()\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[0], {weight_attribute.pk: [attr_value]}\n+ )\n+\n+ variables = {\n+ \"where\": {\"attributes\": [{\"slug\": weight_attribute.slug}]},\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == 1\n+ assert product_variants_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"ProductVariant\", product_variant_list[0].pk\n+ )\n+\n+\n+@pytest.mark.parametrize(\n+ (\"attribute_input\", \"expected_count\"),\n+ [\n+ ({\"value\": {\"slug\": {\"eq\": \"test-slug-1\"}}}, 1),\n+ ({\"value\": {\"slug\": {\"oneOf\": [\"test-slug-1\", \"test-slug-2\"]}}}, 2),\n+ ({\"slug\": \"weight_attribute\", \"value\": {\"slug\": {\"eq\": \"test-slug-1\"}}}, 1),\n+ (\n+ {\n+ \"slug\": \"weight_attribute\",\n+ \"value\": {\"slug\": {\"oneOf\": [\"test-slug-1\", \"test-slug-2\"]}},\n+ },\n+ 2,\n+ ),\n+ ],\n+)\n+def test_product_variants_query_with_attribute_value_slug(\n+ attribute_input,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ weight_attribute,\n+ channel_USD,\n+):\n+ # given\n+ weight_attribute.slug = \"weight_attribute\"\n+ weight_attribute.save()\n+\n+ product_variant_list[0].product.product_type.variant_attributes.add(\n+ weight_attribute\n+ )\n+\n+ attr_value_1 = weight_attribute.values.first()\n+ attr_value_1.slug = \"test-slug-1\"\n+ attr_value_1.save()\n+\n+ attr_value_2 = weight_attribute.values.last()\n+ attr_value_2.slug = \"test-slug-2\"\n+ attr_value_2.save()\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[0], {weight_attribute.pk: [attr_value_1]}\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[1], {weight_attribute.pk: [attr_value_2]}\n+ )\n+\n+ variables = {\n+ \"where\": {\"attributes\": [attribute_input]},\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"attribute_input\", \"expected_count\"),\n+ [\n+ ({\"value\": {\"name\": {\"eq\": \"test-name-1\"}}}, 1),\n+ ({\"value\": {\"name\": {\"oneOf\": [\"test-name-1\", \"test-name-2\"]}}}, 2),\n+ ({\"slug\": \"weight_attribute\", \"value\": {\"name\": {\"eq\": \"test-name-1\"}}}, 1),\n+ (\n+ {\n+ \"slug\": \"weight_attribute\",\n+ \"value\": {\"name\": {\"oneOf\": [\"test-name-1\", \"test-name-2\"]}},\n+ },\n+ 2,\n+ ),\n+ ],\n+)\n+def test_product_variants_query_with_attribute_value_name(\n+ attribute_input,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ weight_attribute,\n+ channel_USD,\n+):\n+ # given\n+ weight_attribute.slug = \"weight_attribute\"\n+ weight_attribute.save()\n+\n+ product_variant_list[0].product.product_type.variant_attributes.add(\n+ weight_attribute\n+ )\n+\n+ attr_value_1 = weight_attribute.values.first()\n+ attr_value_1.name = \"test-name-1\"\n+ attr_value_1.save()\n+\n+ attr_value_2 = weight_attribute.values.last()\n+ attr_value_2.name = \"test-name-2\"\n+ attr_value_2.save()\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[0], {weight_attribute.pk: [attr_value_1]}\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[1], {weight_attribute.pk: [attr_value_2]}\n+ )\n+\n+ variables = {\n+ \"where\": {\"attributes\": [attribute_input]},\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n"
360
- },
361
- {
362
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_attributes_boolean.py",
363
- "status": "added",
364
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_attributes_boolean.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_attributes_boolean.py\t4bba6cf (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_attributes_boolean.py\t9c83ad8 (commit)\n@@ -1,1 +1,84 @@\n-[NEW FILE]\n\\n+import graphene\n+import pytest\n+\n+from ......attribute import AttributeInputType, AttributeType\n+from ......attribute.models import Attribute, AttributeValue\n+from ......attribute.utils import associate_attribute_values_to_instance\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+@pytest.mark.parametrize(\n+ \"boolean_input\",\n+ [\n+ {\"value\": {\"boolean\": True}},\n+ {\"value\": {\"name\": {\"eq\": \"True-name\"}}},\n+ {\"value\": {\"slug\": {\"eq\": \"true_slug\"}}},\n+ {\"value\": {\"name\": {\"oneOf\": [\"True-name\", \"non-existing\"]}}},\n+ {\"value\": {\"slug\": {\"oneOf\": [\"true_slug\"]}}},\n+ {\"slug\": \"b_s\", \"value\": {\"boolean\": True}},\n+ {\"slug\": \"b_s\", \"value\": {\"name\": {\"eq\": \"True-name\"}}},\n+ {\"slug\": \"b_s\", \"value\": {\"slug\": {\"eq\": \"true_slug\"}}},\n+ {\"slug\": \"b_s\", \"value\": {\"name\": {\"oneOf\": [\"True-name\", \"non-existing\"]}}},\n+ {\"slug\": \"b_s\", \"value\": {\"slug\": {\"oneOf\": [\"true_slug\"]}}},\n+ ],\n+)\n+def test_product_variants_query_with_attribute_value_boolean(\n+ boolean_input,\n+ staff_api_client,\n+ product_variant_list,\n+ boolean_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product = product_variant_list[0].product\n+ product_type = product.product_type\n+\n+ boolean_attribute.slug = \"b_s\"\n+ boolean_attribute.save()\n+\n+ second_attribute = Attribute.objects.create(\n+ slug=\"s_boolean\",\n+ name=\"Boolean\",\n+ type=AttributeType.PRODUCT_TYPE,\n+ input_type=AttributeInputType.BOOLEAN,\n+ )\n+\n+ product_type.variant_attributes.add(boolean_attribute, second_attribute)\n+\n+ true_value = boolean_attribute.values.filter(boolean=True).first()\n+ true_value.name = \"True-name\"\n+ true_value.slug = \"true_slug\"\n+ true_value.save()\n+\n+ variant_1 = product_variant_list[0]\n+ associate_attribute_values_to_instance(\n+ variant_1, {boolean_attribute.pk: [true_value]}\n+ )\n+\n+ variant_2 = product_variant_list[1]\n+ value_for_second_attr = AttributeValue.objects.create(\n+ attribute=second_attribute,\n+ name=f\"{second_attribute.name}: Yes\",\n+ slug=f\"{second_attribute.id}_false\",\n+ boolean=False,\n+ )\n+ associate_attribute_values_to_instance(\n+ variant_2, {second_attribute.pk: [value_for_second_attr]}\n+ )\n+\n+ variables = {\"where\": {\"attributes\": [boolean_input]}, \"channel\": channel_USD.slug}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(variants_nodes) == 1\n+ assert variants_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"ProductVariant\", variant_1.pk\n+ )\n"
365
- },
366
- {
367
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_attributes_date.py",
368
- "status": "added",
369
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_attributes_date.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_attributes_date.py\t4bba6cf (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_attributes_date.py\t9c83ad8 (commit)\n@@ -1,1 +1,104 @@\n-[NEW FILE]\n\\n+import datetime\n+\n+import pytest\n+\n+from ......attribute import AttributeInputType, AttributeType\n+from ......attribute.models import Attribute\n+from ......attribute.utils import associate_attribute_values_to_instance\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+@pytest.mark.parametrize(\n+ (\"date_input\", \"expected_count\"),\n+ [\n+ ({\"slug\": \"date\", \"value\": {\"date\": {\"gte\": \"2021-01-01\"}}}, 1),\n+ ({\"slug\": \"date\", \"value\": {\"name\": {\"eq\": \"date-name-1\"}}}, 1),\n+ ({\"slug\": \"date\", \"value\": {\"slug\": {\"eq\": \"date-slug-1\"}}}, 1),\n+ (\n+ {\n+ \"slug\": \"date\",\n+ \"value\": {\"name\": {\"oneOf\": [\"date-name-1\", \"date-name-2\"]}},\n+ },\n+ 1,\n+ ),\n+ (\n+ {\n+ \"slug\": \"date\",\n+ \"value\": {\"slug\": {\"oneOf\": [\"date-slug-1\", \"date-slug-2\"]}},\n+ },\n+ 1,\n+ ),\n+ (\n+ {\n+ \"slug\": \"date\",\n+ \"value\": {\"date\": {\"gte\": \"2021-01-02\", \"lte\": \"2021-01-03\"}},\n+ },\n+ 1,\n+ ),\n+ ({\"value\": {\"date\": {\"gte\": \"2021-01-01\"}}}, 2),\n+ ({\"value\": {\"name\": {\"eq\": \"date-name-1\"}}}, 1),\n+ ({\"value\": {\"slug\": {\"eq\": \"date-slug-1\"}}}, 1),\n+ ({\"value\": {\"name\": {\"oneOf\": [\"date-name-1\", \"date-name-2\"]}}}, 2),\n+ ({\"value\": {\"slug\": {\"oneOf\": [\"date-slug-1\", \"date-slug-2\"]}}}, 2),\n+ ({\"value\": {\"date\": {\"gte\": \"2021-01-01\", \"lte\": \"2021-01-02\"}}}, 1),\n+ ],\n+)\n+def test_product_variants_query_with_attribute_value_date(\n+ date_input,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ date_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product = product_variant_list[0].product\n+ product_type = product.product_type\n+\n+ date_attribute.type = \"PRODUCT_TYPE\"\n+ date_attribute.slug = \"date\"\n+ date_attribute.save()\n+\n+ second_date_attribute = Attribute.objects.create(\n+ slug=\"second_date\",\n+ name=\"Second date\",\n+ type=AttributeType.PRODUCT_TYPE,\n+ input_type=AttributeInputType.DATE,\n+ )\n+ product_type.variant_attributes.add(date_attribute, second_date_attribute)\n+\n+ attr_value_1 = date_attribute.values.first()\n+ attr_value_1.date_time = datetime.datetime(2021, 1, 3, tzinfo=datetime.UTC)\n+ attr_value_1.name = \"date-name-1\"\n+ attr_value_1.slug = \"date-slug-1\"\n+ attr_value_1.save()\n+\n+ variant_1 = product_variant_list[0]\n+ associate_attribute_values_to_instance(\n+ variant_1, {date_attribute.pk: [attr_value_1]}\n+ )\n+\n+ second_attr_value = second_date_attribute.values.create(\n+ date_time=datetime.datetime(2021, 1, 2, tzinfo=datetime.UTC),\n+ name=\"date-name-2\",\n+ slug=\"date-slug-2\",\n+ )\n+\n+ variant_2 = product_variant_list[1]\n+ associate_attribute_values_to_instance(\n+ variant_2, {second_date_attribute.pk: [second_attr_value]}\n+ )\n+\n+ variables = {\"where\": {\"attributes\": [date_input]}, \"channel\": channel_USD.slug}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(variants_nodes) == expected_count\n"
370
- },
371
- {
372
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_attributes_datetime.py",
373
- "status": "added",
374
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_attributes_datetime.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_attributes_datetime.py\t4bba6cf (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_attributes_datetime.py\t9c83ad8 (commit)\n@@ -1,1 +1,131 @@\n-[NEW FILE]\n\\n+import datetime\n+\n+import pytest\n+\n+from ......attribute import AttributeInputType, AttributeType\n+from ......attribute.models import Attribute\n+from ......attribute.utils import associate_attribute_values_to_instance\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+@pytest.mark.parametrize(\n+ (\"date_time_input\", \"expected_count\"),\n+ [\n+ ({\"slug\": \"dt\", \"value\": {\"name\": {\"eq\": \"datetime-name-1\"}}}, 1),\n+ ({\"slug\": \"dt\", \"value\": {\"slug\": {\"eq\": \"datetime-slug-1\"}}}, 1),\n+ (\n+ {\n+ \"slug\": \"dt\",\n+ \"value\": {\"name\": {\"oneOf\": [\"datetime-name-1\", \"datetime-name-2\"]}},\n+ },\n+ 2,\n+ ),\n+ (\n+ {\n+ \"slug\": \"dt\",\n+ \"value\": {\"slug\": {\"oneOf\": [\"datetime-slug-1\", \"datetime-slug-2\"]}},\n+ },\n+ 2,\n+ ),\n+ ({\"slug\": \"dt\", \"value\": {\"dateTime\": {\"gte\": \"2021-01-01T00:00:00Z\"}}}, 2),\n+ (\n+ {\n+ \"slug\": \"dt\",\n+ \"value\": {\n+ \"dateTime\": {\n+ \"gte\": \"2021-01-01T00:00:00Z\",\n+ \"lte\": \"2021-01-02T00:00:00Z\",\n+ }\n+ },\n+ },\n+ 1,\n+ ),\n+ ({\"value\": {\"name\": {\"eq\": \"datetime-name-1\"}}}, 1),\n+ ({\"value\": {\"slug\": {\"eq\": \"datetime-slug-1\"}}}, 1),\n+ ({\"value\": {\"name\": {\"oneOf\": [\"datetime-name-1\", \"datetime-name-2\"]}}}, 2),\n+ ({\"value\": {\"slug\": {\"oneOf\": [\"datetime-slug-1\", \"datetime-slug-2\"]}}}, 2),\n+ ({\"value\": {\"dateTime\": {\"gte\": \"2021-01-01T00:00:00Z\"}}}, 3),\n+ (\n+ {\n+ \"value\": {\n+ \"dateTime\": {\n+ \"gte\": \"2021-01-01T00:00:00Z\",\n+ \"lte\": \"2021-01-02T00:00:00Z\",\n+ }\n+ }\n+ },\n+ 2,\n+ ),\n+ ],\n+)\n+def test_product_variants_query_with_attribute_value_date_time(\n+ date_time_input,\n+ expected_count,\n+ staff_api_client,\n+ product_variant_list,\n+ date_time_attribute,\n+ channel_USD,\n+):\n+ # given\n+ product = product_variant_list[0].product\n+ product_type = product.product_type\n+\n+ date_time_attribute.slug = \"dt\"\n+ date_time_attribute.type = \"PRODUCT_TYPE\"\n+ date_time_attribute.save()\n+\n+ second_date_attribute = Attribute.objects.create(\n+ slug=\"second_dt\",\n+ name=\"Second dt\",\n+ type=AttributeType.PRODUCT_TYPE,\n+ input_type=AttributeInputType.DATE_TIME,\n+ )\n+\n+ product_type.variant_attributes.set([date_time_attribute, second_date_attribute])\n+\n+ attr_value_1 = date_time_attribute.values.first()\n+ attr_value_1.date_time = datetime.datetime(2021, 1, 3, tzinfo=datetime.UTC)\n+ attr_value_1.name = \"datetime-name-1\"\n+ attr_value_1.slug = \"datetime-slug-1\"\n+ attr_value_1.save()\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[0], {date_time_attribute.pk: [attr_value_1]}\n+ )\n+\n+ second_attr_value = date_time_attribute.values.last()\n+ second_attr_value.date_time = datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC)\n+ second_attr_value.name = \"datetime-name-2\"\n+ second_attr_value.slug = \"datetime-slug-2\"\n+ second_attr_value.save()\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[1], {date_time_attribute.pk: [second_attr_value]}\n+ )\n+\n+ value_for_second_attr = second_date_attribute.values.create(\n+ date_time=datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC),\n+ name=\"second-datetime-name\",\n+ slug=\"second-datetime-slug\",\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[3], {second_date_attribute.pk: [value_for_second_attr]}\n+ )\n+\n+ variables = {\n+ \"where\": {\"attributes\": [date_time_input]},\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(variants_nodes) == expected_count\n"
375
- },
376
- {
377
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_attributes_numeric.py",
378
- "status": "added",
379
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_attributes_numeric.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_attributes_numeric.py\t4bba6cf (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_attributes_numeric.py\t9c83ad8 (commit)\n@@ -1,1 +1,88 @@\n-[NEW FILE]\n\\n+import pytest\n+\n+from ......attribute.utils import associate_attribute_values_to_instance\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+@pytest.mark.parametrize(\n+ (\"numeric_input\", \"expected_count\"),\n+ [\n+ ({\"slug\": \"num-slug\", \"value\": {\"numeric\": {\"eq\": 1.2}}}, 1),\n+ ({\"slug\": \"num-slug\", \"value\": {\"numeric\": {\"oneOf\": [1.2, 2]}}}, 2),\n+ (\n+ {\"slug\": \"num-slug\", \"value\": {\"numeric\": {\"range\": {\"gte\": 1, \"lte\": 2}}}},\n+ 2,\n+ ),\n+ ({\"slug\": \"num-slug\", \"value\": {\"name\": {\"eq\": \"1.2\"}}}, 1),\n+ ({\"slug\": \"num-slug\", \"value\": {\"slug\": {\"eq\": \"1.2\"}}}, 1),\n+ ({\"slug\": \"num-slug\", \"value\": {\"name\": {\"oneOf\": [\"1.2\", \"2\"]}}}, 2),\n+ ({\"slug\": \"num-slug\", \"value\": {\"slug\": {\"oneOf\": [\"1.2\", \"2\"]}}}, 2),\n+ ({\"value\": {\"numeric\": {\"eq\": 1.2}}}, 1),\n+ ({\"value\": {\"numeric\": {\"oneOf\": [1.2, 2]}}}, 2),\n+ ({\"value\": {\"numeric\": {\"range\": {\"gte\": 1, \"lte\": 2}}}}, 2),\n+ ({\"value\": {\"numeric\": {\"range\": {\"gte\": 1}}}}, 3),\n+ ({\"value\": {\"name\": {\"eq\": \"1.2\"}}}, 1),\n+ ({\"value\": {\"slug\": {\"eq\": \"1.2\"}}}, 1),\n+ ({\"value\": {\"name\": {\"oneOf\": [\"1.2\", \"2\"]}}}, 2),\n+ ({\"value\": {\"slug\": {\"oneOf\": [\"1.2\", \"2\"]}}}, 2),\n+ ],\n+)\n+def test_product_variants_query_with_attribute_value_numeric(\n+ numeric_input,\n+ expected_count,\n+ staff_api_client,\n+ product_type,\n+ product_variant_list,\n+ numeric_attribute_without_unit,\n+ numeric_attribute,\n+ channel_USD,\n+):\n+ # given\n+ numeric_attribute_without_unit.slug = \"num-slug\"\n+ numeric_attribute_without_unit.save()\n+\n+ product_type.variant_attributes.set(\n+ [numeric_attribute_without_unit, numeric_attribute]\n+ )\n+\n+ attr_value_1 = numeric_attribute_without_unit.values.first()\n+ attr_value_1.name = \"1.2\"\n+ attr_value_1.slug = \"1.2\"\n+ attr_value_1.numeric = 1.2\n+ attr_value_1.save()\n+\n+ attr_value_2 = numeric_attribute_without_unit.values.last()\n+ attr_value_2.name = \"2\"\n+ attr_value_2.slug = \"2\"\n+ attr_value_2.numeric = 2\n+ attr_value_2.save()\n+\n+ second_attr_value = numeric_attribute.values.first()\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[0],\n+ {\n+ numeric_attribute_without_unit.pk: [attr_value_1],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[1], {numeric_attribute_without_unit.pk: [attr_value_2]}\n+ )\n+ associate_attribute_values_to_instance(\n+ product_variant_list[3], {numeric_attribute.pk: [second_attr_value]}\n+ )\n+\n+ variables = {\"where\": {\"attributes\": [numeric_input]}, \"channel\": channel_USD.slug}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count\n"
380
- },
381
- {
382
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_multiple_arguments.py",
383
- "status": "added",
384
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_multiple_arguments.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_multiple_arguments.py\t4bba6cf (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_multiple_arguments.py\t9c83ad8 (commit)\n@@ -1,1 +1,271 @@\n-[NEW FILE]\n\\n+import pytest\n+\n+from ......attribute.utils import associate_attribute_values_to_instance\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_filter\",\n+ [\n+ # Non-existing attribute slug\n+ [{\"slug\": \"non-existing-attribute\"}],\n+ # Existing attribute with non-existing value name\n+ [{\"slug\": \"tag\", \"value\": {\"name\": {\"eq\": \"Non-existing Name\"}}}],\n+ [{\"value\": {\"name\": {\"eq\": \"Non-existing Name\"}}}],\n+ # Existing numeric attribute with out-of-range value\n+ [{\"slug\": \"count\", \"value\": {\"numeric\": {\"eq\": 999}}}],\n+ [{\"value\": {\"numeric\": {\"eq\": 999}}}],\n+ # Existing boolean attribute with no matching boolean value\n+ [{\"slug\": \"boolean\", \"value\": {\"boolean\": False}}],\n+ [{\"value\": {\"boolean\": False}}],\n+ # Multiple attributes where one doesn't exist\n+ [\n+ {\"slug\": \"weight_attribute\", \"value\": {\"slug\": {\"eq\": \"cotton\"}}},\n+ {\"slug\": \"non-existing-attr\", \"value\": {\"slug\": {\"eq\": \"some-value\"}}},\n+ ],\n+ [\n+ {\"value\": {\"slug\": {\"eq\": \"large\"}}},\n+ {\"slug\": \"non-existing-attr\", \"value\": {\"slug\": {\"eq\": \"some-value\"}}},\n+ ],\n+ ],\n+)\n+def test_product_variants_query_with_non_matching_records(\n+ attribute_filter,\n+ staff_api_client,\n+ product_variant_list,\n+ weight_attribute,\n+ tag_page_attribute,\n+ boolean_attribute,\n+ numeric_attribute_without_unit,\n+ date_attribute,\n+ date_time_attribute,\n+ channel_USD,\n+):\n+ # given\n+ tag_attribute = tag_page_attribute\n+ tag_attribute.type = \"PRODUCT_TYPE\"\n+ tag_attribute.save()\n+\n+ weight_attribute.slug = \"weight_attribute\"\n+ weight_attribute.save()\n+\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.set(\n+ [\n+ weight_attribute,\n+ tag_attribute,\n+ boolean_attribute,\n+ numeric_attribute_without_unit,\n+ date_attribute,\n+ date_time_attribute,\n+ ]\n+ )\n+\n+ weight_value = weight_attribute.values.get(slug=\"cotton\")\n+ tag_value = tag_attribute.values.get(name=\"About\")\n+ boolean_value = boolean_attribute.values.filter(boolean=True).first()\n+ numeric_value = numeric_attribute_without_unit.values.first()\n+ date_time_value = date_time_attribute.values.first()\n+ date_value = date_attribute.values.first()\n+\n+ date_attribute.slug = \"date\"\n+ date_attribute.save()\n+ date_time_attribute.slug = \"date_time\"\n+ date_time_attribute.save()\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[0],\n+ {\n+ weight_attribute.pk: [weight_value],\n+ tag_attribute.pk: [tag_value],\n+ boolean_attribute.pk: [boolean_value],\n+ numeric_attribute_without_unit.pk: [numeric_value],\n+ date_attribute.pk: [date_value],\n+ date_time_attribute.pk: [date_time_value],\n+ },\n+ )\n+\n+ variables = {\n+ \"where\": {\"attributes\": attribute_filter},\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == 0\n+\n+\n+@pytest.mark.parametrize(\n+ (\"attribute_where_input\", \"expected_count_result\"),\n+ [\n+ (\n+ [\n+ {\"slug\": \"material\", \"value\": {\"slug\": {\"eq\": \"cotton\"}}},\n+ {\"slug\": \"tag\", \"value\": {\"name\": {\"oneOf\": [\"About\", \"Help\"]}}},\n+ {\"slug\": \"color\", \"value\": {\"slug\": {\"oneOf\": [\"red\"]}}},\n+ {\"slug\": \"boolean\", \"value\": {\"boolean\": True}},\n+ ],\n+ 1,\n+ ),\n+ (\n+ [\n+ {\"slug\": \"material\", \"value\": {\"slug\": {\"eq\": \"cotton\"}}},\n+ {\"slug\": \"tag\", \"value\": {\"name\": {\"oneOf\": [\"About\", \"Help\"]}}},\n+ ],\n+ 1,\n+ ),\n+ (\n+ [\n+ {\"slug\": \"material\", \"value\": {\"slug\": {\"eq\": \"cotton\"}}},\n+ {\"slug\": \"boolean\", \"value\": {\"boolean\": False}},\n+ ],\n+ 0,\n+ ),\n+ (\n+ [\n+ {\"slug\": \"tag\", \"value\": {\"name\": {\"eq\": \"About\"}}},\n+ {\"slug\": \"material\", \"value\": {\"slug\": {\"eq\": \"cotton\"}}},\n+ ],\n+ 1,\n+ ),\n+ (\n+ [\n+ {\"slug\": \"material\", \"value\": {\"slug\": {\"eq\": \"poliester\"}}},\n+ {\"slug\": \"tag\", \"value\": {\"name\": {\"eq\": \"Help\"}}},\n+ {\"slug\": \"boolean\", \"value\": {\"boolean\": False}},\n+ ],\n+ 0,\n+ ),\n+ (\n+ [\n+ {\n+ \"slug\": \"color\",\n+ \"value\": {\"slug\": {\"oneOf\": [\"red\", \"blue\"]}},\n+ },\n+ {\"slug\": \"material\", \"value\": {\"slug\": {\"eq\": \"cotton\"}}},\n+ ],\n+ 1,\n+ ),\n+ (\n+ [\n+ {\"slug\": \"material\", \"value\": {\"slug\": {\"eq\": \"cotton\"}}},\n+ {\"slug\": \"color\", \"value\": {\"name\": {\"eq\": \"Red\"}}},\n+ ],\n+ 1,\n+ ),\n+ (\n+ [\n+ {\"slug\": \"material\", \"value\": {\"slug\": {\"eq\": \"cotton\"}}},\n+ {\"slug\": \"tag\", \"value\": {\"name\": {\"eq\": \"About\"}}},\n+ {\"slug\": \"color\", \"value\": {\"slug\": {\"eq\": \"red\"}}},\n+ ],\n+ 1,\n+ ),\n+ (\n+ [\n+ {\n+ \"slug\": \"material\",\n+ \"value\": {\"slug\": {\"oneOf\": [\"cotton\", \"poliester\"]}},\n+ },\n+ {\"slug\": \"tag\", \"value\": {\"name\": {\"oneOf\": [\"About\", \"Help\"]}}},\n+ ],\n+ 2,\n+ ),\n+ (\n+ [\n+ {\n+ \"slug\": \"material\",\n+ \"value\": {\"slug\": {\"oneOf\": [\"cotton\", \"poliester\"]}},\n+ },\n+ {\"slug\": \"boolean\", \"value\": {\"boolean\": True}},\n+ ],\n+ 1,\n+ ),\n+ ([{\"value\": {\"slug\": {\"oneOf\": [\"red\", \"blue\"]}}}], 2),\n+ (\n+ [\n+ {\"value\": {\"slug\": {\"oneOf\": [\"cotton\", \"poliester\"]}}},\n+ {\"value\": {\"boolean\": True}},\n+ ],\n+ 1,\n+ ),\n+ ],\n+)\n+def test_product_variants_query_with_multiple_attribute_filters(\n+ attribute_where_input,\n+ expected_count_result,\n+ staff_api_client,\n+ product_variant_list,\n+ weight_attribute,\n+ tag_page_attribute,\n+ color_attribute,\n+ boolean_attribute,\n+ channel_USD,\n+):\n+ # given\n+ material_attribute = weight_attribute\n+ material_attribute.slug = \"material\"\n+ material_attribute.save()\n+\n+ tag_attribute = tag_page_attribute\n+ tag_attribute.slug = \"tag\"\n+ tag_attribute.type = \"PRODUCT_TYPE\"\n+ tag_attribute.save()\n+\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.set(\n+ [material_attribute, tag_attribute, color_attribute, boolean_attribute]\n+ )\n+\n+ material_value = material_attribute.values.get(slug=\"cotton\")\n+ tag_value = tag_attribute.values.get(name=\"About\")\n+ color_value = color_attribute.values.get(slug=\"red\")\n+ second_color_value = color_attribute.values.get(slug=\"blue\")\n+\n+ boolean_value = boolean_attribute.values.filter(boolean=True).first()\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[0],\n+ {\n+ material_attribute.pk: [material_value],\n+ tag_attribute.pk: [tag_value],\n+ color_attribute.pk: [color_value],\n+ boolean_attribute.pk: [boolean_value],\n+ },\n+ )\n+\n+ tag_value_2 = tag_attribute.values.get(name=\"Help\")\n+ second_material_value = material_attribute.values.get(slug=\"poliester\")\n+\n+ associate_attribute_values_to_instance(\n+ product_variant_list[1],\n+ {\n+ material_attribute.pk: [second_material_value],\n+ tag_attribute.pk: [tag_value_2],\n+ color_attribute.pk: [second_color_value],\n+ },\n+ )\n+\n+ variables = {\n+ \"where\": {\"attributes\": attribute_where_input},\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ product_variants_nodes = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(product_variants_nodes) == expected_count_result\n"
385
- },
386
- {
387
- "path": "saleor/graphql/product/tests/queries/variants_where/test_over_validation.py",
388
- "status": "added",
389
- "diff": "Index: saleor/graphql/product/tests/queries/variants_where/test_over_validation.py\n===================================================================\n--- saleor/graphql/product/tests/queries/variants_where/test_over_validation.py\t4bba6cf (parent)\n+++ saleor/graphql/product/tests/queries/variants_where/test_over_validation.py\t9c83ad8 (commit)\n@@ -1,1 +1,265 @@\n-[NEW FILE]\n\\n+import pytest\n+\n+from .....tests.utils import get_graphql_content\n+from .shared import PRODUCT_VARIANTS_WHERE_QUERY\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_value_filter\",\n+ [{\"numeric\": None}, {\"name\": None}, {\"slug\": None}, {\"boolean\": False}],\n+)\n+def test_product_variants_query_failed_filter_validation_for_numeric_with_slug_input(\n+ attribute_value_filter,\n+ staff_api_client,\n+ numeric_attribute_without_unit,\n+ product_variant_list,\n+ channel_USD,\n+):\n+ # given\n+ attr_slug_input = \"numeric\"\n+ numeric_attribute_without_unit.slug = attr_slug_input\n+ numeric_attribute_without_unit.save()\n+\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(numeric_attribute_without_unit)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [{\"slug\": attr_slug_input, \"value\": attribute_value_filter}]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response, ignore_errors=True)\n+ assert \"errors\" in content\n+ assert content[\"data\"][\"productVariants\"] is None\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_value_filter\",\n+ [{\"boolean\": None}, {\"name\": None}, {\"slug\": None}, {\"numeric\": {\"eq\": 1.2}}],\n+)\n+def test_product_variants_query_failed_filter_validation_for_boolean_with_slug_input(\n+ attribute_value_filter,\n+ staff_api_client,\n+ boolean_attribute,\n+ product_variant_list,\n+ channel_USD,\n+):\n+ # given\n+ attr_slug_input = \"boolean\"\n+ boolean_attribute.slug = attr_slug_input\n+ boolean_attribute.save()\n+\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(boolean_attribute)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [{\"slug\": attr_slug_input, \"value\": attribute_value_filter}]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response, ignore_errors=True)\n+ assert \"errors\" in content\n+ assert content[\"data\"][\"productVariants\"] is None\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_value_filter\",\n+ [\n+ {\"dateTime\": None},\n+ {\"name\": None},\n+ {\"slug\": None},\n+ {\"numeric\": {\"eq\": 1.2}},\n+ ],\n+)\n+def test_product_variants_query_failed_filter_validation_for_date_attribute_with_slug_input(\n+ attribute_value_filter,\n+ staff_api_client,\n+ date_attribute,\n+ product_variant_list,\n+ channel_USD,\n+):\n+ # given\n+ attr_slug_input = \"date\"\n+ date_attribute.slug = attr_slug_input\n+ date_attribute.save()\n+\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(date_attribute)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [{\"slug\": attr_slug_input, \"value\": attribute_value_filter}]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response, ignore_errors=True)\n+ assert \"errors\" in content\n+ assert content[\"data\"][\"productVariants\"] is None\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_value_filter\",\n+ [\n+ {\"dateTime\": None},\n+ {\"name\": None},\n+ {\"slug\": None},\n+ {\"numeric\": {\"eq\": 1.2}},\n+ {\"date\": None},\n+ ],\n+)\n+def test_product_variants_query_failed_filter_validation_for_datetime_attribute_with_slug_input(\n+ attribute_value_filter,\n+ staff_api_client,\n+ date_time_attribute,\n+ product_variant_list,\n+ channel_USD,\n+):\n+ # given\n+ attr_slug_input = \"date_time\"\n+ date_time_attribute.slug = attr_slug_input\n+ date_time_attribute.save()\n+\n+ product_type = product_variant_list[0].product.product_type\n+ product_type.variant_attributes.add(date_time_attribute)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [{\"slug\": attr_slug_input, \"value\": attribute_value_filter}]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response, ignore_errors=True)\n+ assert \"errors\" in content\n+ assert content[\"data\"][\"productVariants\"] is None\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_value_filter\",\n+ [\n+ {\"slug\": None, \"value\": None},\n+ {\"slug\": None, \"value\": {\"name\": {\"eq\": \"name\"}}},\n+ ],\n+)\n+def test_product_variants_query_failed_filter_validation_null_in_input(\n+ attribute_value_filter,\n+ staff_api_client,\n+ channel_USD,\n+):\n+ # given\n+ variables = {\n+ \"where\": {\"attributes\": [attribute_value_filter]},\n+ \"channel\": channel_USD.slug,\n+ }\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response, ignore_errors=True)\n+ assert \"errors\" in content\n+ assert content[\"data\"][\"productVariants\"] is None\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_value_filter\",\n+ [\n+ {\"slug\": None},\n+ {\"name\": None},\n+ {\n+ \"slug\": {\"eq\": \"true_slug\"},\n+ \"name\": {\"eq\": \"name\"},\n+ },\n+ {\n+ \"slug\": {\"oneOf\": [\"true_slug\"]},\n+ \"name\": {\"oneOf\": [\"name\"]},\n+ },\n+ ],\n+)\n+def test_product_variants_query_failed_filter_validation_for_basic_value_fields_with_attr_slug(\n+ attribute_value_filter,\n+ staff_api_client,\n+ channel_USD,\n+):\n+ # given\n+ attr_slug_input = \"product-size\"\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [{\"slug\": attr_slug_input, \"value\": attribute_value_filter}]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response, ignore_errors=True)\n+ assert \"errors\" in content\n+ assert content[\"data\"][\"productVariants\"] is None\n+\n+\n+def test_product_variants_query_failed_filter_validation_for_duplicated_attr_slug(\n+ staff_api_client,\n+ channel_USD,\n+):\n+ # given\n+ attr_slug_input = \"product-size\"\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\"slug\": attr_slug_input},\n+ {\"slug\": attr_slug_input},\n+ ]\n+ },\n+ \"channel\": channel_USD.slug,\n+ }\n+ # when\n+ response = staff_api_client.post_graphql(\n+ PRODUCT_VARIANTS_WHERE_QUERY,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response, ignore_errors=True)\n+ assert \"errors\" in content\n+ assert content[\"data\"][\"productVariants\"] is None\n"
390
- },
391
- {
392
- "path": "saleor/graphql/schema.graphql",
393
- "status": "modified",
394
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\t4bba6cf (parent)\n+++ saleor/graphql/schema.graphql\t9c83ad8 (commit)\n@@ -8360,15 +8360,32 @@\n \n \"\"\"Filter by when was the most recent update.\"\"\"\n updatedAt: DateTimeRangeInput\n \n+ \"\"\"\n+ Filter by attributes associated with the variant.\n+ \n+ Added in Saleor 3.22.\n+ \"\"\"\n+ attributes: [AssignedAttributeWhereInput!]\n+\n \"\"\"List of conditions that must be met.\"\"\"\n AND: [ProductVariantWhereInput!]\n \n \"\"\"A list of conditions of which at least one must be met.\"\"\"\n OR: [ProductVariantWhereInput!]\n }\n \n+input AssignedAttributeWhereInput {\n+ \"\"\"Filter by attribute slug.\"\"\"\n+ slug: String\n+\n+ \"\"\"\n+ Filter by value of the attribute. Only one value input field is allowed. If provided more than one, the error will be raised.\n+ \"\"\"\n+ value: AssignedAttributeValueInput\n+}\n+\n input ProductVariantSortingInput @doc(category: \"Products\") {\n \"\"\"Specifies the direction in which to sort productVariants.\"\"\"\n direction: OrderDirection!\n \n@@ -13078,18 +13095,8 @@\n \"\"\"The value included in.\"\"\"\n oneOf: [String!]\n }\n \n-input AssignedAttributeWhereInput {\n- \"\"\"Filter by attribute slug.\"\"\"\n- slug: String\n-\n- \"\"\"\n- Filter by value of the attribute. Only one value input field is allowed. If provided more than one, the error will be raised.\n- \"\"\"\n- value: AssignedAttributeValueInput\n-}\n-\n type PageTypeCountableConnection @doc(category: \"Pages\") {\n \"\"\"Pagination data for this connection.\"\"\"\n pageInfo: PageInfo!\n edges: [PageTypeCountableEdge!]!\n"
395
- }
396
- ]
397
- },
398
- {
399
- "id": "extend-money-type",
400
- "sha": "697cbdc85b4a184c1b07c91303e47506d30b0b1e",
401
- "parentSha": "1162f6e2edc76e399aa6c1ac6c8ef73a0772a16a",
402
- "spec": "Implement minor-unit support on the GraphQL Money type and update related artifacts.\n\nRequirements:\n1) GraphQL type changes\n- In saleor/graphql/core/types/money.py, extend the Money graphene.ObjectType with two required integer fields:\n - fractional_amount: the amount represented as an integer in the smallest currency unit.\n - fraction_digits: the number of digits after the decimal point for the currency.\n- Implement resolvers:\n - resolve_amount: keep returning the quantized Decimal using quantize_price(root.amount, root.currency).\n - resolve_fractional_amount: compute precision using Babel’s get_currency_precision(root.currency), then return the integer result of quantize_price(root.amount, root.currency) multiplied by 10^precision.\n - resolve_fraction_digits: return get_currency_precision(root.currency).\n- Import get_currency_precision from babel.numbers at the top of the file.\n\n2) Schema SDL update\n- Update saleor/graphql/schema.graphql Money type to include the new fields with camelCase names and descriptions:\n - fractionalAmount: Int! (amount in smallest currency unit)\n - fractionDigits: Int! (number of decimal digits for the currency)\n\n3) Changelog entry\n- In CHANGELOG.md, add an entry noting the addition of fractionalAmount and fractionDigits to the Money type and their purpose (representing monetary values as a pair of integers for PSP integrations).\n\n4) Unit tests\n- Add tests in saleor/graphql/core/types/tests/test_money.py that validate resolvers using prices.Money:\n - For USD with amount 12.950000: amount resolves to 12.95, fractional_amount resolves to 1295, and fraction_digits resolves to 2.\n - For JPY with amount 1234: amount resolves to 1234, fractional_amount resolves to 1234, and fraction_digits resolves to 0.\n- Ensure tests import the Money GraphQL object (as MoneyObject) and call its static resolvers directly.\n\nNotes/Constraints:\n- Do not change how MoneyRange or TaxedMoney types work; they should automatically reflect the new Money fields where nested.\n- Maintain existing quantization behavior via quantize_price to avoid float precision errors.\n- Graphene’s automatic snake_case to camelCase mapping should be relied upon; define snake_case in Python, camelCase is emitted in the SDL.\n- Ensure babel is used only for determining currency precision and that the project’s existing dependency constraint is respected.\n",
403
- "prompt": "Add support for exposing currency minor units on the Money GraphQL type to make it easier to integrate with payment providers. Extend the Money type with fields that return the amount in the smallest currency unit and the number of fractional digits for the currency. Update the schema and changelog accordingly, and add unit tests verifying behavior for currencies with 2 digits (like USD) and 0 digits (like JPY). Reuse the project’s existing price quantization and currency precision utilities.",
404
- "supplementalFiles": [
405
- "saleor/core/prices.py",
406
- "saleor/payment/utils.py",
407
- "saleor/graphql/core/scalars.py",
408
- "pyproject.toml"
409
- ],
410
- "fileDiffs": [
411
- {
412
- "path": "CHANGELOG.md",
413
- "status": "modified",
414
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\t1162f6e (parent)\n+++ CHANGELOG.md\t697cbdc (commit)\n@@ -90,8 +90,9 @@\n Like `reference`, the `single-reference` type can target entities defined in the `AttributeEntityTypeEnum`.\n - Extended support for filtering `products` by associated attributes\n - Attribute slug is now optional when filtering by attribute values\n - Added support for filtering by associated reference objects (e.g., `products`, `pages`, `variants`)\n+- Added `fractionalAmount` and `fractionDigits` fields to the `Money` type. These fields allow monetary values to be represented as a pair of integers, which is often required when integrating with payment service providers.\n \n ### Webhooks\n - Transaction webhooks responsible for processing payments can now return payment method details`, which will be associated with the corresponding transaction. See [docs](https://docs.saleor.io/developer/extending/webhooks/synchronous-events/transaction#response-4) to learn more.\n \n"
415
- },
416
- {
417
- "path": "saleor/graphql/core/types/money.py",
418
- "status": "modified",
419
- "diff": "Index: saleor/graphql/core/types/money.py\n===================================================================\n--- saleor/graphql/core/types/money.py\t1162f6e (parent)\n+++ saleor/graphql/core/types/money.py\t697cbdc (commit)\n@@ -1,5 +1,6 @@\n import graphene\n+from babel.numbers import get_currency_precision\n \n from ....core.prices import quantize_price\n from ...core.doc_category import DOC_CATEGORY_TAXES\n from ...core.types import BaseObjectType\n@@ -7,17 +8,34 @@\n \n class Money(graphene.ObjectType):\n currency = graphene.String(description=\"Currency code.\", required=True)\n amount = graphene.Float(description=\"Amount of money.\", required=True)\n+ fractional_amount = graphene.Int(\n+ description=\"Amount of money represented as an integer in the smallest currency unit.\",\n+ required=True,\n+ )\n+ fraction_digits = graphene.Int(\n+ description=\"Number of digits after the decimal point in the currency.\",\n+ required=True,\n+ )\n \n class Meta:\n description = \"Represents amount of money in specific currency.\"\n \n @staticmethod\n def resolve_amount(root, _info):\n return quantize_price(root.amount, root.currency)\n \n+ @staticmethod\n+ def resolve_fractional_amount(root, _info):\n+ precision = get_currency_precision(root.currency)\n+ return int(quantize_price(root.amount, root.currency) * (10**precision))\n \n+ @staticmethod\n+ def resolve_fraction_digits(root, _info):\n+ return get_currency_precision(root.currency)\n+\n+\n class MoneyRange(graphene.ObjectType):\n start = graphene.Field(Money, description=\"Lower bound of a price range.\")\n stop = graphene.Field(Money, description=\"Upper bound of a price range.\")\n \n"
420
- },
421
- {
422
- "path": "saleor/graphql/core/types/tests/__init__.py",
423
- "status": "added",
424
- "diff": "Index: saleor/graphql/core/types/tests/__init__.py\n===================================================================\n--- saleor/graphql/core/types/tests/__init__.py\t1162f6e (parent)\n+++ saleor/graphql/core/types/tests/__init__.py\t697cbdc (commit)\n@@ -1,1 +0,0 @@\n-[NEW FILE]\n\\n"
425
- },
426
- {
427
- "path": "saleor/graphql/core/types/tests/test_money.py",
428
- "status": "added",
429
- "diff": "Index: saleor/graphql/core/types/tests/test_money.py\n===================================================================\n--- saleor/graphql/core/types/tests/test_money.py\t1162f6e (parent)\n+++ saleor/graphql/core/types/tests/test_money.py\t697cbdc (commit)\n@@ -1,1 +1,23 @@\n-[NEW FILE]\n\\n+from decimal import Decimal\n+\n+from prices import Money\n+\n+from ..money import Money as MoneyObject\n+\n+\n+def test_money_object_usd():\n+ money = Money(Decimal(\"12.950000\"), \"USD\")\n+ resolve_info = None\n+\n+ assert MoneyObject.resolve_amount(money, resolve_info) == Decimal(\"12.95\")\n+ assert MoneyObject.resolve_fractional_amount(money, resolve_info) == 1295\n+ assert MoneyObject.resolve_fraction_digits(money, resolve_info) == 2\n+\n+\n+def test_money_object_jpy():\n+ money = Money(Decimal(1234), \"JPY\")\n+ resolve_info = None\n+\n+ assert MoneyObject.resolve_amount(money, resolve_info) == Decimal(1234)\n+ assert MoneyObject.resolve_fractional_amount(money, resolve_info) == 1234\n+ assert MoneyObject.resolve_fraction_digits(money, resolve_info) == 0\n"
430
- },
431
- {
432
- "path": "saleor/graphql/schema.graphql",
433
- "status": "modified",
434
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\t1162f6e (parent)\n+++ saleor/graphql/schema.graphql\t697cbdc (commit)\n@@ -3780,8 +3780,16 @@\n currency: String!\n \n \"\"\"Amount of money.\"\"\"\n amount: Float!\n+\n+ \"\"\"\n+ Amount of money represented as an integer in the smallest currency unit.\n+ \"\"\"\n+ fractionalAmount: Int!\n+\n+ \"\"\"Number of digits after the decimal point in the currency.\"\"\"\n+ fractionDigits: Int!\n }\n \n \"\"\"\n Shipping method are the methods you'll use to get customer's orders to them. They are directly exposed to the customers.\n"
435
- }
436
- ]
437
- },
438
- {
439
- "id": "refactor-attribute-filters",
440
- "sha": "8642326ad030ed499dfc331417ec4db603c861f2",
441
- "parentSha": "dcb2deafd7d3b6f28724f054e8fefe2c0ae931cd",
442
- "spec": "Implement a refactor that centralizes generic attribute filtering and validation into saleor/graphql/attribute/shared_filters.py and updates the page GraphQL filters to consume these shared utilities.\n\nRequirements:\n\n1) Centralize generic attribute filtering utilities in saleor/graphql/attribute/shared_filters.py\n- Add imports: GraphQLError from graphql, AttributeInputType from saleor.attribute, and utility functions filter_range_field, filter_where_by_numeric_field, filter_where_by_value_field from saleor/graphql/utils/filters.\n- Implement the following reusable functions that work with any assigned attribute model and assigned id field:\n - filter_by_slug_or_name(attr_id, attr_value, db_connection_name, assigned_attr_model, assigned_id_field_name): Build a Q expression matching assigned attribute values by slug/name, optionally constrained by attribute id.\n - filter_by_numeric_attribute(attr_id, numeric_value, db_connection_name, assigned_attr_model, assigned_id_field_name): Build a Q expression to match numeric attribute values using numeric filters.\n - filter_by_boolean_attribute(attr_id, boolean_value, db_connection_name, assigned_attr_model, assigned_id_field_name): Build a Q expression to match boolean attribute values.\n - filter_by_date_attribute(attr_id, date_value, db_connection_name, assigned_attr_model, assigned_id_field_name): Build a Q expression to match date attributes using date range on date_time__date.\n - filter_by_date_time_attribute(attr_id, date_time_value, db_connection_name, assigned_attr_model, assigned_id_field_name): Build a Q/Exists matching date-time attributes using date range on date_time.\n - filter_objects_by_reference_attributes(attr_id, attr_value, db_connection_name, assigned_attr_model, assigned_id_field_name): Compose Q expressions using existing shared reference helpers (contains referenced ids/pages/products/variants) based on provided keys.\n - filter_objects_by_attributes(qs, value, assigned_attr_model, assigned_id_field_name): Resolve attribute slugs, handle attributes provided without a value by requiring existence of any value, and compose Q expressions for provided values (slug/name, numeric, boolean, date, date_time, reference) by delegating to the functions above. Return qs.filter(expr) or qs.none() if nothing matches/non-existent attributes.\n- Implement validation functions to be shared:\n - validate_attribute_value_reference_input(index_with_values): Validate reference attribute input shape. Raise GraphQLError on: missing containsAny/containsAll, empty lists for containsAny/containsAll, or both keys provided.\n - validate_attribute_value_input(attributes, db_connection_name): Validate the list of attribute inputs: duplicate slugs, null/empty value or slug, multiple keys in value, null values, and mismatched type-specific keys versus attribute input type (numeric/date/date_time/boolean/reference). Build an attribute slug→type map from the DB to check type mismatches. Delegate reference input validation to validate_attribute_value_reference_input. Raise GraphQLError with informative messages including 0-based index positions.\n\n2) Update page filters to use shared functions in saleor/graphql/page/filters.py\n- Remove local implementations of attribute filtering and validation (slug/name, numeric, boolean, date, date_time, and reference-specific variants) from page filters.\n- Import from saleor/graphql/attribute/shared_filters: AssignedAttributeWhereInput, filter_objects_by_attributes, validate_attribute_value_input.\n- Implement a thin wrapper function filter_pages_by_attributes(qs, value) that delegates to filter_objects_by_attributes with AssignedPageAttributeValue model and assigned id field name \"page_id\".\n- Ensure the remainder of Page filters continue to function by using the shared validation in places where attribute inputs are validated.\n\n3) Maintain API behavior\n- Attribute filtering for pages must support: matching by value slug/name, numeric ranges, boolean exact match, date and date_time ranges, and reference filters (containsAny/containsAll for referenced ids/pages/products/variants).\n- Validation errors must be raised as GraphQLError with clear messages mirroring the moved logic and include index positions of invalid entries.\n- If a provided attribute slug does not exist, filtering over that attribute should result in an empty queryset (qs.none()).\n\n4) Keep typing, imports, and shared constants consistent\n- Ensure types like CONTAINS_TYPING and AssignedAttributeWhereInput remain available from shared_filters.py as before (no changes to their definitions unless necessary). New functions should accept Literal fields for assigned_id_field_name appropriate to the entity using them.\n\n5) Do not change product filters in this task, but ensure the shared functions are generic and suitable for future reuse by the product module or others.\n\nAcceptance criteria:\n- Page attribute filtering relies exclusively on functions imported from saleor/graphql/attribute/shared_filters.py.\n- All validation for page attribute inputs is performed by validate_attribute_value_input imported from the shared module.\n- Unit/integration tests for page filtering and attribute reference filters continue to pass without modification.\n- The shared functions accept assigned attribute model and id field name parameters and build Q/Exists expressions correctly on the specified entity.",
443
- "prompt": "Refactor the GraphQL attribute filtering and validation so pages use shared, reusable helpers:\n\n- Move the generic attribute filtering and validation logic into the shared attribute filters module. Create reusable functions to filter by slug/name, numeric, boolean, date, date_time, and to compose reference-based filters. Add a generic entry point to filter a queryset by a list of attribute filters, parameterized by the assigned attribute model and the entity id field.\n- Add shared validators for attribute value inputs, including reference-specific validation. They should enforce correct input shape and attribute type compatibility and raise descriptive GraphQL errors.\n- Update page filters to remove their local implementations and delegate to the shared functions, keeping behavior unchanged.\n\nMake the shared utilities flexible enough that other entity modules can adopt them later, but only update page filters as part of this change.",
444
- "supplementalFiles": [
445
- "saleor/graphql/attribute/filters.py",
446
- "saleor/graphql/product/filters/product_attributes.py",
447
- "saleor/graphql/utils/filters.py",
448
- "saleor/graphql/core/filters/where_input.py",
449
- "saleor/attribute/models.py",
450
- "saleor/graphql/page/types.py",
451
- "saleor/graphql/page/schema.graphql"
452
- ],
453
- "fileDiffs": [
454
- {
455
- "path": "saleor/graphql/attribute/shared_filters.py",
456
- "status": "modified",
457
- "diff": "Index: saleor/graphql/attribute/shared_filters.py\n===================================================================\n--- saleor/graphql/attribute/shared_filters.py\tdcb2dea (parent)\n+++ saleor/graphql/attribute/shared_filters.py\t8642326 (commit)\n@@ -1,21 +1,23 @@\n from typing import Literal, TypedDict\n \n import graphene\n from django.db.models import Exists, OuterRef, Q, QuerySet\n+from graphql import GraphQLError\n \n+from ...attribute import AttributeInputType\n from ...attribute.models import AssignedPageAttributeValue, Attribute, AttributeValue\n from ...page import models as page_models\n from ...product import models as product_models\n-from ..core.filters import (\n- DecimalFilterInput,\n-)\n-from ..core.filters.where_input import (\n- ContainsFilterInput,\n- StringFilterInput,\n-)\n+from ..core.filters import DecimalFilterInput\n+from ..core.filters.where_input import ContainsFilterInput, StringFilterInput\n from ..core.types import DateRangeInput, DateTimeRangeInput\n from ..core.types.base import BaseInputObjectType\n+from ..utils.filters import (\n+ filter_range_field,\n+ filter_where_by_numeric_field,\n+ filter_where_by_value_field,\n+)\n \n \n class AssignedAttributeReferenceInput(BaseInputObjectType):\n referenced_ids = ContainsFilterInput(\n@@ -423,4 +425,437 @@\n attr_value_reference_field_name=\"reference_variant_id\",\n **shared_filter_params,\n )\n return Q()\n+\n+\n+def filter_by_slug_or_name(\n+ attr_id: int | None,\n+ attr_value: dict,\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ attribute_values = AttributeValue.objects.using(db_connection_name).filter(\n+ **{\"attribute_id\": attr_id} if attr_id else {}\n+ )\n+ if \"slug\" in attr_value:\n+ attribute_values = filter_where_by_value_field(\n+ attribute_values, \"slug\", attr_value[\"slug\"]\n+ )\n+ if \"name\" in attr_value:\n+ attribute_values = filter_where_by_value_field(\n+ attribute_values, \"name\", attr_value[\"name\"]\n+ )\n+ assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n+ Exists(attribute_values.filter(id=OuterRef(\"value_id\"))),\n+ **{str(assigned_id_field_name): OuterRef(\"id\")},\n+ )\n+ return Q(Exists(assigned_attr_value))\n+\n+\n+def filter_by_numeric_attribute(\n+ attr_id: int | None,\n+ numeric_value,\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ qs_by_numeric = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute__input_type=AttributeInputType.NUMERIC,\n+ **{\"attribute_id\": attr_id} if attr_id else {},\n+ )\n+ qs_by_numeric = filter_where_by_numeric_field(\n+ qs_by_numeric,\n+ \"numeric\",\n+ numeric_value,\n+ )\n+\n+ assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n+ value__in=qs_by_numeric,\n+ **{str(assigned_id_field_name): OuterRef(\"id\")},\n+ )\n+ return Q(Exists(assigned_attr_value))\n+\n+\n+def filter_by_boolean_attribute(\n+ attr_id: int | None,\n+ boolean_value,\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ qs_by_boolean = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute__input_type=AttributeInputType.BOOLEAN,\n+ **{\"attribute_id\": attr_id} if attr_id else {},\n+ )\n+ qs_by_boolean = qs_by_boolean.filter(boolean=boolean_value)\n+ assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n+ value__in=qs_by_boolean,\n+ **{str(assigned_id_field_name): OuterRef(\"id\")},\n+ )\n+ return Q(Exists(assigned_attr_value))\n+\n+\n+def filter_by_date_attribute(\n+ attr_id: int | None,\n+ date_value,\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ qs_by_date = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute__input_type=AttributeInputType.DATE,\n+ **{\"attribute_id\": attr_id} if attr_id else {},\n+ )\n+ qs_by_date = filter_range_field(\n+ qs_by_date,\n+ \"date_time__date\",\n+ date_value,\n+ )\n+ assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n+ value__in=qs_by_date,\n+ **{str(assigned_id_field_name): OuterRef(\"id\")},\n+ )\n+ return Q(Exists(assigned_attr_value))\n+\n+\n+def filter_by_date_time_attribute(\n+ attr_id: int | None,\n+ date_time_value,\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ qs_by_date_time = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute__input_type=AttributeInputType.DATE_TIME,\n+ **{\"attribute_id\": attr_id} if attr_id else {},\n+ )\n+ qs_by_date_time = filter_range_field(\n+ qs_by_date_time,\n+ \"date_time\",\n+ date_time_value,\n+ )\n+ assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n+ value__in=qs_by_date_time,\n+ **{str(assigned_id_field_name): OuterRef(\"id\")},\n+ )\n+ return Exists(assigned_attr_value)\n+\n+\n+def filter_objects_by_attributes(\n+ qs: QuerySet[page_models.Page],\n+ value: list[dict],\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ attribute_slugs = {\n+ attr_filter[\"slug\"] for attr_filter in value if \"slug\" in attr_filter\n+ }\n+ attributes_map = {\n+ attr.slug: attr\n+ for attr in Attribute.objects.using(qs.db).filter(slug__in=attribute_slugs)\n+ }\n+ if len(attribute_slugs) != len(attributes_map.keys()):\n+ # Filter over non existing attribute\n+ return qs.none()\n+\n+ attr_filter_expression = Q()\n+\n+ attr_without_values_input = []\n+ for attr_filter in value:\n+ if \"slug\" in attr_filter and \"value\" not in attr_filter:\n+ attr_without_values_input.append(attributes_map[attr_filter[\"slug\"]])\n+\n+ if attr_without_values_input:\n+ atr_value_qs = AttributeValue.objects.using(qs.db).filter(\n+ attribute_id__in=[attr.id for attr in attr_without_values_input]\n+ )\n+ assigned_attr_value = assigned_attr_model.objects.using(qs.db).filter(\n+ Exists(atr_value_qs.filter(id=OuterRef(\"value_id\"))),\n+ **{str(assigned_id_field_name): OuterRef(\"id\")},\n+ )\n+ attr_filter_expression = Q(Exists(assigned_attr_value))\n+\n+ for attr_filter in value:\n+ attr_value = attr_filter.get(\"value\")\n+ if not attr_value:\n+ # attrs without value input are handled separately\n+ continue\n+\n+ attr_id = None\n+ if attr_slug := attr_filter.get(\"slug\"):\n+ attr = attributes_map[attr_slug]\n+ attr_id = attr.id\n+\n+ attr_value = attr_filter[\"value\"]\n+\n+ if \"slug\" in attr_value or \"name\" in attr_value:\n+ attr_filter_expression &= filter_by_slug_or_name(\n+ attr_id,\n+ attr_value,\n+ qs.db,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ elif \"numeric\" in attr_value:\n+ attr_filter_expression &= filter_by_numeric_attribute(\n+ attr_id,\n+ attr_value[\"numeric\"],\n+ qs.db,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ elif \"boolean\" in attr_value:\n+ attr_filter_expression &= filter_by_boolean_attribute(\n+ attr_id,\n+ attr_value[\"boolean\"],\n+ qs.db,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ elif \"date\" in attr_value:\n+ attr_filter_expression &= filter_by_date_attribute(\n+ attr_id,\n+ attr_value[\"date\"],\n+ qs.db,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ elif \"date_time\" in attr_value:\n+ attr_filter_expression &= filter_by_date_time_attribute(\n+ attr_id,\n+ attr_value[\"date_time\"],\n+ qs.db,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ elif \"reference\" in attr_value:\n+ attr_filter_expression &= filter_objects_by_reference_attributes(\n+ attr_id,\n+ attr_value[\"reference\"],\n+ qs.db,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ if attr_filter_expression != Q():\n+ return qs.filter(attr_filter_expression)\n+ return qs.none()\n+\n+\n+def filter_objects_by_reference_attributes(\n+ attr_id: int | None,\n+ attr_value: dict[\n+ Literal[\n+ \"referenced_ids\", \"page_slugs\", \"product_slugs\", \"product_variant_skus\"\n+ ],\n+ CONTAINS_TYPING,\n+ ],\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ filter_expression = Q()\n+\n+ if \"referenced_ids\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_object_ids(\n+ attr_id,\n+ attr_value[\"referenced_ids\"],\n+ db_connection_name,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ if \"page_slugs\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_pages(\n+ attr_id,\n+ attr_value[\"page_slugs\"],\n+ db_connection_name,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ if \"product_slugs\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_products(\n+ attr_id,\n+ attr_value[\"product_slugs\"],\n+ db_connection_name,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ if \"product_variant_skus\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_variants(\n+ attr_id,\n+ attr_value[\"product_variant_skus\"],\n+ db_connection_name,\n+ assigned_attr_model=assigned_attr_model,\n+ assigned_id_field_name=assigned_id_field_name,\n+ )\n+ return filter_expression\n+\n+\n+def validate_attribute_value_reference_input(\n+ index_with_values: list[\n+ tuple[\n+ str,\n+ dict[\n+ Literal[\n+ \"referenced_ids\",\n+ \"page_slugs\",\n+ \"product_slugs\",\n+ \"product_variant_skus\",\n+ ],\n+ CONTAINS_TYPING,\n+ ]\n+ | None,\n+ ]\n+ ],\n+):\n+ \"\"\"Validate the input for reference attributes.\n+\n+ This function checks if the input for reference attributes is valid.\n+ It raises a GraphQLError if the input is invalid.\n+ \"\"\"\n+\n+ duplicated_error = set()\n+ empty_input_value_error = set()\n+ invalid_input_type_error = set()\n+ for index, value in index_with_values:\n+ if not value:\n+ invalid_input_type_error.add(index)\n+ continue\n+ for key in value:\n+ single_key_value = value[key]\n+ if (\n+ \"contains_all\" in single_key_value\n+ and \"contains_any\" in single_key_value\n+ ):\n+ duplicated_error.add(index)\n+ continue\n+ if (\n+ \"contains_all\" in single_key_value\n+ and not single_key_value[\"contains_all\"]\n+ ):\n+ empty_input_value_error.add(index)\n+ continue\n+ if (\n+ \"contains_any\" in single_key_value\n+ and not single_key_value[\"contains_any\"]\n+ ):\n+ empty_input_value_error.add(index)\n+\n+ if invalid_input_type_error:\n+ raise GraphQLError(\n+ message=(\n+ \"Invalid input for reference attributes. For attribute input on positions: \"\n+ f\"{', '.join(invalid_input_type_error)}. \"\n+ \"Provided values must contains 'containsAll' or 'containsAny' key.\"\n+ )\n+ )\n+ if empty_input_value_error:\n+ raise GraphQLError(\n+ message=(\n+ \"Invalid input for reference attributes. For attribute input on positions: \"\n+ f\"{', '.join(empty_input_value_error)}. \"\n+ \"Provided values cannot be null or empty.\"\n+ )\n+ )\n+ if duplicated_error:\n+ raise GraphQLError(\n+ message=(\n+ \"Invalid input for reference attributes. For attribute input on positions: \"\n+ f\"{', '.join(duplicated_error)}. \"\n+ \"Cannot provide both 'containsAll' and 'containsAny' for the same reference filter.\"\n+ )\n+ )\n+\n+\n+def validate_attribute_value_input(attributes: list[dict], db_connection_name: str):\n+ slug_list = [attr.get(\"slug\") for attr in attributes if \"slug\" in attr]\n+ value_as_empty_or_null_list = []\n+ value_more_than_one_list = []\n+ invalid_input_type_list = []\n+ reference_value_list = []\n+ if len(slug_list) != len(set(slug_list)):\n+ raise GraphQLError(\n+ message=\"Duplicated attribute slugs in attribute 'where' input are not allowed.\"\n+ )\n+\n+ type_specific_value_with_attr_slug_list = {}\n+ for index, attr in enumerate(attributes):\n+ if not attr.get(\"value\") and not attr.get(\"slug\"):\n+ value_as_empty_or_null_list.append(str(index))\n+ continue\n+\n+ attr_slug = attr.get(\"slug\")\n+ attr_slug_provided_as_none = attr_slug is None and \"slug\" in attr\n+ if attr_slug_provided_as_none:\n+ value_as_empty_or_null_list.append(str(index))\n+ continue\n+\n+ value_as_empty = \"value\" in attr and not attr[\"value\"]\n+ if value_as_empty:\n+ value_as_empty_or_null_list.append(str(index))\n+ continue\n+\n+ value = attr.get(\"value\")\n+ if not value:\n+ continue\n+\n+ value_keys = value.keys()\n+ if len(value_keys) > 1:\n+ value_more_than_one_list.append(str(index))\n+ continue\n+ value_key = list(value_keys)[0]\n+ if value_key not in [\"slug\", \"name\"] and attr_slug:\n+ type_specific_value_with_attr_slug_list[attr_slug] = (str(index), value_key)\n+ if value[value_key] is None:\n+ value_as_empty_or_null_list.append(str(index))\n+ continue\n+ if value_key == \"reference\":\n+ reference_value_list.append((str(index), value[\"reference\"]))\n+\n+ if type_specific_value_with_attr_slug_list:\n+ attribute_input_type_map = Attribute.objects.using(db_connection_name).in_bulk(\n+ type_specific_value_with_attr_slug_list.keys(),\n+ field_name=\"slug\",\n+ )\n+ for attr_slug, (\n+ index_str,\n+ value_key,\n+ ) in type_specific_value_with_attr_slug_list.items():\n+ if attr_slug not in attribute_input_type_map:\n+ continue\n+\n+ input_type = attribute_input_type_map[attr_slug].input_type\n+ if \"numeric\" == value_key and input_type != AttributeInputType.NUMERIC:\n+ invalid_input_type_list.append(index_str)\n+ if \"date\" == value_key and input_type != AttributeInputType.DATE:\n+ invalid_input_type_list.append(index_str)\n+ if \"date_time\" == value_key and input_type != AttributeInputType.DATE_TIME:\n+ invalid_input_type_list.append(index_str)\n+ if \"boolean\" == value_key and input_type != AttributeInputType.BOOLEAN:\n+ invalid_input_type_list.append(index_str)\n+ if \"reference\" == value_key and input_type != AttributeInputType.REFERENCE:\n+ invalid_input_type_list.append(index_str)\n+\n+ validate_attribute_value_reference_input(reference_value_list)\n+\n+ if value_as_empty_or_null_list:\n+ raise GraphQLError(\n+ message=(\n+ f\"Incorrect input for attributes on position: {','.join(value_as_empty_or_null_list)}. \"\n+ \"Provided 'value' cannot be empty or null.\"\n+ )\n+ )\n+ if value_more_than_one_list:\n+ raise GraphQLError(\n+ message=(\n+ f\"Incorrect input for attributes on position: {','.join(value_more_than_one_list)}. \"\n+ \"Provided 'value' must have only one input key.\"\n+ )\n+ )\n+ if invalid_input_type_list:\n+ raise GraphQLError(\n+ message=(\n+ f\"Incorrect input for attributes on position: {','.join(invalid_input_type_list)}. \"\n+ \"Provided 'value' do not match the attribute input type.\"\n+ )\n+ )\n"
458
- },
459
- {
460
- "path": "saleor/graphql/page/filters.py",
461
- "status": "modified",
462
- "diff": "Index: saleor/graphql/page/filters.py\n===================================================================\n--- saleor/graphql/page/filters.py\tdcb2dea (parent)\n+++ saleor/graphql/page/filters.py\t8642326 (commit)\n@@ -1,21 +1,14 @@\n-from typing import Literal\n-\n import django_filters\n import graphene\n-from django.db.models import Exists, OuterRef, Q\n-from graphql import GraphQLError\n+from django.db.models import Q\n \n-from ...attribute import AttributeInputType\n-from ...attribute.models import AssignedPageAttributeValue, Attribute, AttributeValue\n+from ...attribute.models import AssignedPageAttributeValue\n from ...page import models\n from ..attribute.shared_filters import (\n- CONTAINS_TYPING,\n AssignedAttributeWhereInput,\n- filter_by_contains_referenced_object_ids,\n- filter_by_contains_referenced_pages,\n- filter_by_contains_referenced_products,\n- filter_by_contains_referenced_variants,\n+ filter_objects_by_attributes,\n+ validate_attribute_value_input,\n )\n from ..core.context import ChannelQsContext\n from ..core.doc_category import DOC_CATEGORY_PAGES\n from ..core.filters import (\n@@ -38,12 +31,10 @@\n from ..utils import resolve_global_ids_to_primary_keys\n from ..utils.filters import (\n filter_by_id,\n filter_by_ids,\n- filter_range_field,\n filter_slug_list,\n filter_where_by_id_field,\n- filter_where_by_numeric_field,\n filter_where_by_value_field,\n )\n from .types import Page, PageType\n \n@@ -71,400 +62,17 @@\n return qs\n return qs.filter(Q(name__trigram_similar=value) | Q(slug__trigram_similar=value))\n \n \n-def filter_by_slug_or_name(\n- attr_id: int | None, attr_value: dict, db_connection_name: str\n-):\n- attribute_values = AttributeValue.objects.using(db_connection_name).filter(\n- **{\"attribute_id\": attr_id} if attr_id else {}\n+def filter_pages_by_attributes(qs, value):\n+ return filter_objects_by_attributes(\n+ qs,\n+ value,\n+ AssignedPageAttributeValue,\n+ \"page_id\",\n )\n- if \"slug\" in attr_value:\n- attribute_values = filter_where_by_value_field(\n- attribute_values, \"slug\", attr_value[\"slug\"]\n- )\n- if \"name\" in attr_value:\n- attribute_values = filter_where_by_value_field(\n- attribute_values, \"name\", attr_value[\"name\"]\n- )\n- assigned_attr_value = AssignedPageAttributeValue.objects.using(\n- db_connection_name\n- ).filter(\n- Exists(attribute_values.filter(id=OuterRef(\"value_id\"))),\n- page_id=OuterRef(\"id\"),\n- )\n- return Q(Exists(assigned_attr_value))\n \n \n-def filter_by_numeric_attribute(\n- attr_id: int | None, numeric_value, db_connection_name: str\n-):\n- qs_by_numeric = AttributeValue.objects.using(db_connection_name).filter(\n- attribute__input_type=AttributeInputType.NUMERIC,\n- **{\"attribute_id\": attr_id} if attr_id else {},\n- )\n- qs_by_numeric = filter_where_by_numeric_field(\n- qs_by_numeric,\n- \"numeric\",\n- numeric_value,\n- )\n-\n- assigned_attr_value = AssignedPageAttributeValue.objects.using(\n- db_connection_name\n- ).filter(\n- value__in=qs_by_numeric,\n- page_id=OuterRef(\"id\"),\n- )\n- return Q(Exists(assigned_attr_value))\n-\n-\n-def filter_by_boolean_attribute(\n- attr_id: int | None, boolean_value, db_connection_name: str\n-):\n- qs_by_boolean = AttributeValue.objects.using(db_connection_name).filter(\n- attribute__input_type=AttributeInputType.BOOLEAN,\n- **{\"attribute_id\": attr_id} if attr_id else {},\n- )\n- qs_by_boolean = qs_by_boolean.filter(boolean=boolean_value)\n- assigned_attr_value = AssignedPageAttributeValue.objects.using(\n- db_connection_name\n- ).filter(\n- value__in=qs_by_boolean,\n- page_id=OuterRef(\"id\"),\n- )\n- return Q(Exists(assigned_attr_value))\n-\n-\n-def filter_by_date_attribute(attr_id: int | None, date_value, db_connection_name: str):\n- qs_by_date = AttributeValue.objects.using(db_connection_name).filter(\n- attribute__input_type=AttributeInputType.DATE,\n- **{\"attribute_id\": attr_id} if attr_id else {},\n- )\n- qs_by_date = filter_range_field(\n- qs_by_date,\n- \"date_time__date\",\n- date_value,\n- )\n- assigned_attr_value = AssignedPageAttributeValue.objects.using(\n- db_connection_name\n- ).filter(\n- value__in=qs_by_date,\n- page_id=OuterRef(\"id\"),\n- )\n- return Q(Exists(assigned_attr_value))\n-\n-\n-def filter_by_date_time_attribute(\n- attr_id: int | None, date_time_value, db_connection_name: str\n-):\n- qs_by_date_time = AttributeValue.objects.using(db_connection_name).filter(\n- attribute__input_type=AttributeInputType.DATE_TIME,\n- **{\"attribute_id\": attr_id} if attr_id else {},\n- )\n- qs_by_date_time = filter_range_field(\n- qs_by_date_time,\n- \"date_time\",\n- date_time_value,\n- )\n- assigned_attr_value = AssignedPageAttributeValue.objects.using(\n- db_connection_name\n- ).filter(\n- value__in=qs_by_date_time,\n- page_id=OuterRef(\"id\"),\n- )\n- return Exists(assigned_attr_value)\n-\n-\n-def filter_pages_by_attributes(qs, value):\n- attribute_slugs = {\n- attr_filter[\"slug\"] for attr_filter in value if \"slug\" in attr_filter\n- }\n- attributes_map = {\n- attr.slug: attr\n- for attr in Attribute.objects.using(qs.db).filter(slug__in=attribute_slugs)\n- }\n- if len(attribute_slugs) != len(attributes_map.keys()):\n- # Filter over non existing attribute\n- return qs.none()\n-\n- attr_filter_expression = Q()\n-\n- attr_without_values_input = []\n- for attr_filter in value:\n- if \"slug\" in attr_filter and \"value\" not in attr_filter:\n- attr_without_values_input.append(attributes_map[attr_filter[\"slug\"]])\n-\n- if attr_without_values_input:\n- atr_value_qs = AttributeValue.objects.using(qs.db).filter(\n- attribute_id__in=[attr.id for attr in attr_without_values_input]\n- )\n- assigned_attr_value = AssignedPageAttributeValue.objects.using(qs.db).filter(\n- Exists(atr_value_qs.filter(id=OuterRef(\"value_id\"))),\n- page_id=OuterRef(\"id\"),\n- )\n- attr_filter_expression = Q(Exists(assigned_attr_value))\n-\n- for attr_filter in value:\n- attr_value = attr_filter.get(\"value\")\n- if not attr_value:\n- # attrs without value input are handled separately\n- continue\n-\n- attr_id = None\n- if attr_slug := attr_filter.get(\"slug\"):\n- attr = attributes_map[attr_slug]\n- attr_id = attr.id\n-\n- attr_value = attr_filter[\"value\"]\n-\n- if \"slug\" in attr_value or \"name\" in attr_value:\n- attr_filter_expression &= filter_by_slug_or_name(\n- attr_id,\n- attr_value,\n- qs.db,\n- )\n- elif \"numeric\" in attr_value:\n- attr_filter_expression &= filter_by_numeric_attribute(\n- attr_id, attr_value[\"numeric\"], qs.db\n- )\n- elif \"boolean\" in attr_value:\n- attr_filter_expression &= filter_by_boolean_attribute(\n- attr_id, attr_value[\"boolean\"], qs.db\n- )\n- elif \"date\" in attr_value:\n- attr_filter_expression &= filter_by_date_attribute(\n- attr_id, attr_value[\"date\"], qs.db\n- )\n- elif \"date_time\" in attr_value:\n- attr_filter_expression &= filter_by_date_time_attribute(\n- attr_id, attr_value[\"date_time\"], qs.db\n- )\n- elif \"reference\" in attr_value:\n- attr_filter_expression &= filter_pages_by_reference_attributes(\n- attr_id, attr_value[\"reference\"], qs.db\n- )\n- if attr_filter_expression != Q():\n- return qs.filter(attr_filter_expression)\n- return qs.none()\n-\n-\n-def filter_pages_by_reference_attributes(\n- attr_id: int | None,\n- attr_value: dict[\n- Literal[\n- \"referenced_ids\", \"page_slugs\", \"product_slugs\", \"product_variant_skus\"\n- ],\n- CONTAINS_TYPING,\n- ],\n- db_connection_name: str,\n-):\n- filter_expression = Q()\n-\n- if \"referenced_ids\" in attr_value:\n- filter_expression &= filter_by_contains_referenced_object_ids(\n- attr_id,\n- attr_value[\"referenced_ids\"],\n- db_connection_name,\n- assigned_attr_model=AssignedPageAttributeValue,\n- assigned_id_field_name=\"page_id\",\n- )\n- if \"page_slugs\" in attr_value:\n- filter_expression &= filter_by_contains_referenced_pages(\n- attr_id,\n- attr_value[\"page_slugs\"],\n- db_connection_name,\n- assigned_attr_model=AssignedPageAttributeValue,\n- assigned_id_field_name=\"page_id\",\n- )\n- if \"product_slugs\" in attr_value:\n- filter_expression &= filter_by_contains_referenced_products(\n- attr_id,\n- attr_value[\"product_slugs\"],\n- db_connection_name,\n- assigned_attr_model=AssignedPageAttributeValue,\n- assigned_id_field_name=\"page_id\",\n- )\n- if \"product_variant_skus\" in attr_value:\n- filter_expression &= filter_by_contains_referenced_variants(\n- attr_id,\n- attr_value[\"product_variant_skus\"],\n- db_connection_name,\n- assigned_attr_model=AssignedPageAttributeValue,\n- assigned_id_field_name=\"page_id\",\n- )\n- return filter_expression\n-\n-\n-def validate_attribute_value_reference_input(\n- index_with_values: list[\n- tuple[\n- str,\n- dict[\n- Literal[\n- \"referenced_ids\",\n- \"page_slugs\",\n- \"product_slugs\",\n- \"product_variant_skus\",\n- ],\n- CONTAINS_TYPING,\n- ]\n- | None,\n- ]\n- ],\n-):\n- \"\"\"Validate the input for reference attributes.\n-\n- This function checks if the input for reference attributes is valid.\n- It raises a GraphQLError if the input is invalid.\n- \"\"\"\n-\n- duplicated_error = set()\n- empty_input_value_error = set()\n- invalid_input_type_error = set()\n- for index, value in index_with_values:\n- if not value:\n- invalid_input_type_error.add(index)\n- continue\n- for key in value:\n- single_key_value = value[key]\n- if (\n- \"contains_all\" in single_key_value\n- and \"contains_any\" in single_key_value\n- ):\n- duplicated_error.add(index)\n- continue\n- if (\n- \"contains_all\" in single_key_value\n- and not single_key_value[\"contains_all\"]\n- ):\n- empty_input_value_error.add(index)\n- continue\n- if (\n- \"contains_any\" in single_key_value\n- and not single_key_value[\"contains_any\"]\n- ):\n- empty_input_value_error.add(index)\n-\n- if invalid_input_type_error:\n- raise GraphQLError(\n- message=(\n- \"Invalid input for reference attributes. For attribute input on positions: \"\n- f\"{', '.join(invalid_input_type_error)}. \"\n- \"Provided values must contains 'containsAll' or 'containsAny' key.\"\n- )\n- )\n- if empty_input_value_error:\n- raise GraphQLError(\n- message=(\n- \"Invalid input for reference attributes. For attribute input on positions: \"\n- f\"{', '.join(empty_input_value_error)}. \"\n- \"Provided values cannot be null or empty.\"\n- )\n- )\n- if duplicated_error:\n- raise GraphQLError(\n- message=(\n- \"Invalid input for reference attributes. For attribute input on positions: \"\n- f\"{', '.join(duplicated_error)}. \"\n- \"Cannot provide both 'containsAll' and 'containsAny' for the same reference filter.\"\n- )\n- )\n-\n-\n-def validate_attribute_value_input(attributes: list[dict], db_connection_name: str):\n- slug_list = [attr.get(\"slug\") for attr in attributes if \"slug\" in attr]\n- value_as_empty_or_null_list = []\n- value_more_than_one_list = []\n- invalid_input_type_list = []\n- reference_value_list = []\n- if len(slug_list) != len(set(slug_list)):\n- raise GraphQLError(\n- message=\"Duplicated attribute slugs in attribute 'where' input are not allowed.\"\n- )\n-\n- type_specific_value_with_attr_slug_list = {}\n- for index, attr in enumerate(attributes):\n- if not attr.get(\"value\") and not attr.get(\"slug\"):\n- value_as_empty_or_null_list.append(str(index))\n- continue\n-\n- attr_slug = attr.get(\"slug\")\n- attr_slug_provided_as_none = attr_slug is None and \"slug\" in attr\n- if attr_slug_provided_as_none:\n- value_as_empty_or_null_list.append(str(index))\n- continue\n-\n- value_as_empty = \"value\" in attr and not attr[\"value\"]\n- if value_as_empty:\n- value_as_empty_or_null_list.append(str(index))\n- continue\n-\n- value = attr.get(\"value\")\n- if not value:\n- continue\n-\n- value_keys = value.keys()\n- if len(value_keys) > 1:\n- value_more_than_one_list.append(str(index))\n- continue\n- value_key = list(value_keys)[0]\n- if value_key not in [\"slug\", \"name\"] and attr_slug:\n- type_specific_value_with_attr_slug_list[attr_slug] = (str(index), value_key)\n- if value[value_key] is None:\n- value_as_empty_or_null_list.append(str(index))\n- continue\n- if value_key == \"reference\":\n- reference_value_list.append((str(index), value[\"reference\"]))\n-\n- if type_specific_value_with_attr_slug_list:\n- attribute_input_type_map = Attribute.objects.using(db_connection_name).in_bulk(\n- type_specific_value_with_attr_slug_list.keys(),\n- field_name=\"slug\",\n- )\n- for attr_slug, (\n- index_str,\n- value_key,\n- ) in type_specific_value_with_attr_slug_list.items():\n- if attr_slug not in attribute_input_type_map:\n- continue\n-\n- input_type = attribute_input_type_map[attr_slug].input_type\n- if \"numeric\" == value_key and input_type != AttributeInputType.NUMERIC:\n- invalid_input_type_list.append(index_str)\n- if \"date\" == value_key and input_type != AttributeInputType.DATE:\n- invalid_input_type_list.append(index_str)\n- if \"date_time\" == value_key and input_type != AttributeInputType.DATE_TIME:\n- invalid_input_type_list.append(index_str)\n- if \"boolean\" == value_key and input_type != AttributeInputType.BOOLEAN:\n- invalid_input_type_list.append(index_str)\n- if \"reference\" == value_key and input_type != AttributeInputType.REFERENCE:\n- invalid_input_type_list.append(index_str)\n-\n- validate_attribute_value_reference_input(reference_value_list)\n-\n- if value_as_empty_or_null_list:\n- raise GraphQLError(\n- message=(\n- f\"Incorrect input for attributes on position: {','.join(value_as_empty_or_null_list)}. \"\n- \"Provided 'value' cannot be empty or null.\"\n- )\n- )\n- if value_more_than_one_list:\n- raise GraphQLError(\n- message=(\n- f\"Incorrect input for attributes on position: {','.join(value_more_than_one_list)}. \"\n- \"Provided 'value' must have only one input key.\"\n- )\n- )\n- if invalid_input_type_list:\n- raise GraphQLError(\n- message=(\n- f\"Incorrect input for attributes on position: {','.join(invalid_input_type_list)}. \"\n- \"Provided 'value' do not match the attribute input type.\"\n- )\n- )\n-\n-\n class PageWhere(MetadataWhereBase):\n ids = GlobalIDMultipleChoiceWhereFilter(method=filter_by_ids(\"Page\"))\n slug = OperationObjectTypeWhereFilter(\n input_class=StringFilterInput,\n"
463
- }
464
- ]
465
- },
466
- {
467
- "id": "add-adyen-legacy",
468
- "sha": "55731f1b4bd544455874680101ee82911e62bdb8",
469
- "parentSha": "055f6d5fae5c85db931b2d8ad935c5101988336f",
470
- "spec": "Goal: Introduce dedicated Adyen-only fields on transactions to replace reliance on the generic gateway_response JSON blob for result/status lookups, while preserving a temporary fallback for backward compatibility.\n\nScope of changes:\n1) Data model and interface\n- Add two new nullable TextField columns to the Transaction model:\n - legacy_adyen_plugin_result_code\n - legacy_adyen_plugin_payment_method\n Include a brief model-level comment that these are temporary migration aids to remove gateway_response usage by the Adyen plugin and will be removed once the plugin is retired.\n- Create a Django migration adding both fields (depending on the latest payment app migration) and a merge migration if necessary to resolve migration graph conflicts.\n- Extend the GatewayResponse dataclass (saleor/payment/interface.py) with two optional string fields mirroring the above names and annotate raw_response as deprecated. Document that these new fields are temporary Adyen-only and will be removed after the plugin is gone.\n\n2) Transaction creation pipeline\n- In create_transaction (saleor/payment/utils.py), when saving a Transaction from a GatewayResponse, persist values of legacy_adyen_plugin_result_code and legacy_adyen_plugin_payment_method onto the Transaction object alongside existing fields. Continue to persist raw_response into gateway_response for now (but mark as deprecated in comments).\n\n3) Adyen plugin behavior updates\n- In AdyenGatewayPlugin (saleor/payment/gateways/adyen/plugin.py):\n - Add a logger and a private helper _normalize_response_field(field: str) -> str that lowercases and trims spaces.\n - Wherever resultCode was previously read, normalize via the helper rather than ad-hoc strip().lower(), and use the normalized value for comparisons to FAILED_STATUSES/PENDING_STATUSES/etc.\n - When creating GatewayResponse in process_payment, process_payment_details, capture, refund, and void flows:\n - Continue to set raw_response (annotated as deprecated in an inline comment) and other existing fields.\n - Additionally set legacy_adyen_plugin_result_code and legacy_adyen_plugin_payment_method using normalized values from the Adyen API response (default empty string when missing).\n - In confirm_payment, when deriving result_code and payment_method for post-redirect/additional action handling:\n - Prefer the values from the new Transaction fields (legacy_adyen_plugin_result_code and legacy_adyen_plugin_payment_method) if present.\n - If both are absent, emit a warning log noting that deprecated gateway_response is being read to allow a grace period for in-flight/enqueued messages.\n - If absent, fall back to reading and normalizing from transaction.gateway_response (resultCode/paymentMethod keys).\n - Preserve existing logic for selecting TransactionKind based on normalized values (including the iDEAL special-case capture on AUTH).\n - Keep existing behavior for action-required, PSP reference, and payment_method_info population.\n\n4) Adyen webhook updates\n- In Adyen webhooks handler(s) (saleor/payment/gateways/adyen/webhooks.py):\n - When constructing GatewayResponse from Adyen notifications and webhook follow-up actions, continue to set raw_response (deprecated) and other fields as before.\n - Additionally set legacy_adyen_plugin_result_code and legacy_adyen_plugin_payment_method using normalized (lowercased, trimmed) values from the notification payload.\n\n5) Tests adjustments\n- Update Adyen plugin tests (saleor/payment/gateways/adyen/tests/test_plugin.py) to assert the presence and correctness of legacy_adyen_plugin_result_code and legacy_adyen_plugin_payment_method in the GatewayResponse objects created by the plugin/webhooks.\n\n6) Deprecation and logging\n- Add inline comments indicating raw_response and gateway_response are deprecated for Adyen usage and being phased out.\n- Add a warning log in confirm_payment when falling back to reading from transaction.gateway_response due to missing legacy fields to track legacy reads during the grace period.\n\nAcceptance criteria:\n- Adyen plugin and webhooks populate the new legacy_* fields on GatewayResponse, which are persisted onto Transaction by create_transaction.\n- confirm_payment prefers the new Transaction fields and only reads gateway_response as a fallback, emitting a warning on fallback.\n- All resultCode/paymentMethod reads in the plugin are normalized via a single helper.\n- Migrations add the new fields and the migration graph remains consistent (merge migration included if multiple heads exist).\n- Existing behavior for payment processing, pending statuses, capture-on-auth for iDEAL, PSP reference tracking, and action-required flows remain unchanged aside from sourcing normalized values.\n",
471
- "prompt": "Improve the Adyen payment integration to stop relying on the raw transaction blob for status and payment method checks. Add temporary, Adyen-specific transaction fields that store the normalized result code and payment method. Update the plugin and webhooks to populate these fields, prefer them when confirming payments, and only fall back to the old raw data with a warning for a limited grace period. Centralize normalization of response fields to avoid duplication. Include the necessary data model changes, migrations, and test updates to validate the new fields are set and used.",
472
- "supplementalFiles": [
473
- "saleor/payment/gateway.py",
474
- "saleor/payment/gateways/adyen/README or docs if present",
475
- "saleor/payment/gateways/adyen/constants.py (or the module defining FAILED_STATUSES/PENDING_STATUSES if separate)",
476
- "saleor/payment/gateways/adyen/api.py (or the module where Adyen API calls and result.message are structured)"
477
- ],
478
- "fileDiffs": [
479
- {
480
- "path": "saleor/payment/gateways/adyen/plugin.py",
481
- "status": "modified",
482
- "diff": "Index: saleor/payment/gateways/adyen/plugin.py\n===================================================================\n--- saleor/payment/gateways/adyen/plugin.py\t055f6d5 (parent)\n+++ saleor/payment/gateways/adyen/plugin.py\t55731f1 (commit)\n@@ -1,5 +1,6 @@\n import json\n+import logging\n from typing import Optional\n from urllib.parse import urlencode, urljoin\n \n from django.contrib.auth.hashers import make_password\n@@ -48,9 +49,11 @@\n GATEWAY_NAME = \"Adyen\"\n WEBHOOK_PATH = \"/webhooks\"\n ADDITIONAL_ACTION_PATH = \"/additional-actions\"\n \n+logger = logging.getLogger(__name__)\n \n+\n class AdyenGatewayPlugin(BasePlugin):\n PLUGIN_ID = \"mirumee.payments.adyen\"\n PLUGIN_NAME = GATEWAY_NAME\n CONFIGURATION_PER_CHANNEL = True\n@@ -375,8 +378,12 @@\n if self.channel is None:\n return False\n return self.channel.automatically_confirm_all_new_orders\n \n+ def _normalize_response_field(self, field: str) -> str:\n+ \"\"\"Normalize response field to lowercase and remove spaces.\"\"\"\n+ return field.strip().lower()\n+\n def process_payment(\n self, payment_information: \"PaymentData\", previous_value\n ) -> \"GatewayResponse\":\n \"\"\"Process a payment on Adyen's side.\n@@ -425,9 +432,9 @@\n )\n with tracer.start_as_current_span(\"adyen.checkout.payments\") as span:\n span.set_attribute(saleor_attributes.COMPONENT, \"payment\")\n result = api_call(request_data, self.adyen.checkout.payments)\n- result_code = result.message[\"resultCode\"].strip().lower()\n+ result_code = self._normalize_response_field(result.message[\"resultCode\"])\n is_success = result_code not in FAILED_STATUSES\n adyen_auto_capture = self.config.connection_params[\"adyen_auto_capture\"]\n kind = TransactionKind.AUTH\n if result_code in PENDING_STATUSES:\n@@ -464,12 +471,19 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=error_message,\n+ # @deprecated\n raw_response=result.message,\n action_required_data=action,\n payment_method_info=payment_method_info,\n psp_reference=psp_reference,\n+ legacy_adyen_plugin_payment_method=self._normalize_response_field(\n+ result.message.get(\"paymentMethod\", \"\")\n+ ),\n+ legacy_adyen_plugin_result_code=self._normalize_response_field(\n+ result.message.get(\"resultCode\", \"\")\n+ ),\n )\n \n @classmethod\n def _update_config_items(\n@@ -509,9 +523,9 @@\n \n with tracer.start_as_current_span(\"adyen.checkout.payment_details\") as span:\n span.set_attribute(saleor_attributes.COMPONENT, \"payment\")\n result = api_call(additional_data, self.adyen.checkout.payments_details)\n- result_code = result.message[\"resultCode\"].strip().lower()\n+ result_code = self._normalize_response_field(result.message[\"resultCode\"])\n is_success = result_code not in FAILED_STATUSES\n action_required = \"action\" in result.message\n if result_code in PENDING_STATUSES:\n kind = TransactionKind.PENDING\n@@ -541,11 +555,18 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=result.message.get(\"refusalReason\"),\n+ # @deprecated\n raw_response=result.message,\n psp_reference=result.message.get(\"pspReference\", \"\"),\n payment_method_info=payment_method_info,\n+ legacy_adyen_plugin_payment_method=self._normalize_response_field(\n+ result.message.get(\"paymentMethod\", \"\")\n+ ),\n+ legacy_adyen_plugin_result_code=self._normalize_response_field(\n+ result.message.get(\"resultCode\", \"\")\n+ ),\n )\n \n def confirm_payment(\n self, payment_information: \"PaymentData\", previous_value\n@@ -594,12 +615,29 @@\n # We don't have async notification for this payment so we try to proceed\n # standard flow for confirming an additional action\n return self._process_additional_action(payment_information, kind)\n \n- result_code = transaction.gateway_response.get(\"resultCode\", \"\").strip().lower()\n- payment_method = (\n- transaction.gateway_response.get(\"paymentMethod\", \"\").strip().lower()\n- )\n+ result_code_temporary_field = transaction.legacy_adyen_plugin_result_code\n+ payment_method_temporary_field = transaction.legacy_adyen_plugin_payment_method\n+\n+ if (not result_code_temporary_field) and (not payment_method_temporary_field):\n+ # Track legacy reads, so we keep grace period in case of enqueued messages\n+ logger.warning(\"Reading deprecated raw_response from Adyen plugin.\")\n+\n+ if result_code_temporary_field:\n+ result_code = result_code_temporary_field\n+ else:\n+ result_code = self._normalize_response_field(\n+ transaction.gateway_response.get(\"resultCode\", \"\")\n+ )\n+\n+ if payment_method_temporary_field:\n+ payment_method = payment_method_temporary_field\n+ else:\n+ payment_method = self._normalize_response_field(\n+ transaction.gateway_response.get(\"paymentMethod\", \"\")\n+ )\n+\n if result_code and result_code in PENDING_STATUSES:\n kind = TransactionKind.PENDING\n elif result_code == AUTH_STATUS and payment_method == \"ideal\":\n kind = TransactionKind.CAPTURE\n@@ -637,8 +675,9 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=token,\n error=None,\n+ # @deprecated\n raw_response={},\n transaction_already_processed=bool(transaction_already_processed),\n psp_reference=token,\n )\n@@ -705,10 +744,17 @@\n amount=amount,\n currency=currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=\"\",\n+ # @deprecated\n raw_response=result.message,\n psp_reference=result.message.get(\"pspReference\", \"\"),\n+ legacy_adyen_plugin_payment_method=self._normalize_response_field(\n+ result.message.get(\"paymentMethod\", \"\")\n+ ),\n+ legacy_adyen_plugin_result_code=self._normalize_response_field(\n+ result.message.get(\"resultCode\", \"\")\n+ ),\n )\n \n def capture_payment(\n self, payment_information: \"PaymentData\", previous_value\n@@ -735,11 +781,18 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=\"\",\n+ # @deprecated\n raw_response=result.message,\n payment_method_info=payment_method_info,\n psp_reference=result.message.get(\"pspReference\", \"\"),\n+ legacy_adyen_plugin_payment_method=self._normalize_response_field(\n+ result.message.get(\"paymentMethod\", \"\")\n+ ),\n+ legacy_adyen_plugin_result_code=self._normalize_response_field(\n+ result.message.get(\"resultCode\", \"\")\n+ ),\n )\n \n def void_payment(\n self, payment_information: \"PaymentData\", previous_value\n@@ -766,10 +819,17 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=\"\",\n+ # @deprecated\n raw_response=result.message,\n psp_reference=result.message.get(\"pspReference\", \"\"),\n+ legacy_adyen_plugin_payment_method=self._normalize_response_field(\n+ result.message.get(\"paymentMethod\", \"\")\n+ ),\n+ legacy_adyen_plugin_result_code=self._normalize_response_field(\n+ result.message.get(\"resultCode\", \"\")\n+ ),\n )\n \n @classmethod\n def validate_plugin_configuration(\n"
483
- },
484
- {
485
- "path": "saleor/payment/gateways/adyen/tests/test_plugin.py",
486
- "status": "modified",
487
- "diff": "Index: saleor/payment/gateways/adyen/tests/test_plugin.py\n===================================================================\n--- saleor/payment/gateways/adyen/tests/test_plugin.py\t055f6d5 (parent)\n+++ saleor/payment/gateways/adyen/tests/test_plugin.py\t55731f1 (commit)\n@@ -46,8 +46,10 @@\n error=None,\n raw_response=expected_message,\n psp_reference=\"ref-id\",\n payment_method_info=PaymentMethodInfo(),\n+ legacy_adyen_plugin_result_code=expected_message.get(\"resultCode\"),\n+ legacy_adyen_plugin_payment_method=\"\",\n )\n mocked_api_call.assert_called_with(\n dummy_payment_data.data, plugin.adyen.checkout.payments_details\n )\n"
488
- },
489
- {
490
- "path": "saleor/payment/gateways/adyen/webhooks.py",
491
- "status": "modified",
492
- "diff": "Index: saleor/payment/gateways/adyen/webhooks.py\n===================================================================\n--- saleor/payment/gateways/adyen/webhooks.py\t055f6d5 (parent)\n+++ saleor/payment/gateways/adyen/webhooks.py\t55731f1 (commit)\n@@ -152,8 +152,14 @@\n currency=currency,\n error=\"\",\n raw_response=notification,\n psp_reference=transaction_id,\n+ legacy_adyen_plugin_payment_method=notification.get(\"paymentMethod\", \"\")\n+ .strip()\n+ .lower(),\n+ legacy_adyen_plugin_result_code=notification.get(\"resultCode\", \"\")\n+ .strip()\n+ .lower(),\n )\n return create_transaction(\n payment,\n kind=kind,\n@@ -1167,8 +1173,14 @@\n error=error_message,\n raw_response=response.message,\n action_required_data=response.message.get(\"action\"),\n psp_reference=response.message.get(\"pspReference\", \"\"),\n+ legacy_adyen_plugin_payment_method=response.message.get(\"paymentMethod\", \"\")\n+ .strip()\n+ .lower(),\n+ legacy_adyen_plugin_result_code=response.message.get(\"resultCode\", \"\")\n+ .strip()\n+ .lower(),\n )\n \n create_transaction(\n payment=payment,\n"
493
- },
494
- {
495
- "path": "saleor/payment/interface.py",
496
- "status": "modified",
497
- "diff": "Index: saleor/payment/interface.py\n===================================================================\n--- saleor/payment/interface.py\t055f6d5 (parent)\n+++ saleor/payment/interface.py\t55731f1 (commit)\n@@ -315,16 +315,22 @@\n transaction_id: str\n error: str | None\n customer_id: str | None = None\n payment_method_info: PaymentMethodInfo | None = None\n+ # @deprecated\n raw_response: dict[str, str] | None = None\n action_required_data: JSONType | None = None\n # Some gateway can process transaction asynchronously. This value define if we\n # should create new transaction based on this response\n transaction_already_processed: bool = False\n psp_reference: str | None = None\n \n+ # Temporary pass Adyen-plugin-specific data to model, so we can drop raw_response\n+ # After the plugin is gone, this should be removed\n+ legacy_adyen_plugin_result_code: str | None = None\n+ legacy_adyen_plugin_payment_method: str | None = None\n \n+\n @dataclass\n class AddressData:\n first_name: str\n last_name: str\n"
498
- },
499
- {
500
- "path": "saleor/payment/migrations/0061_transaction_legacy_adyen_plugin_payment_method_and_more.py",
501
- "status": "added",
502
- "diff": "Index: saleor/payment/migrations/0061_transaction_legacy_adyen_plugin_payment_method_and_more.py\n===================================================================\n--- saleor/payment/migrations/0061_transaction_legacy_adyen_plugin_payment_method_and_more.py\t055f6d5 (parent)\n+++ saleor/payment/migrations/0061_transaction_legacy_adyen_plugin_payment_method_and_more.py\t55731f1 (commit)\n@@ -1,1 +1,22 @@\n-[NEW FILE]\n\\n+# Generated by Django 4.2.15 on 2025-07-10 09:16\n+\n+from django.db import migrations, models\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"payment\", \"0060_alter_payment_captured_amount_alter_payment_total_and_more\"),\n+ ]\n+\n+ operations = [\n+ migrations.AddField(\n+ model_name=\"transaction\",\n+ name=\"legacy_adyen_plugin_payment_method\",\n+ field=models.TextField(null=True),\n+ ),\n+ migrations.AddField(\n+ model_name=\"transaction\",\n+ name=\"legacy_adyen_plugin_result_code\",\n+ field=models.TextField(null=True),\n+ ),\n+ ]\n"
503
- },
504
- {
505
- "path": "saleor/payment/migrations/0064_merge_20250716_0709.py",
506
- "status": "added",
507
- "diff": "Index: saleor/payment/migrations/0064_merge_20250716_0709.py\n===================================================================\n--- saleor/payment/migrations/0064_merge_20250716_0709.py\t055f6d5 (parent)\n+++ saleor/payment/migrations/0064_merge_20250716_0709.py\t55731f1 (commit)\n@@ -1,1 +1,12 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-07-16 07:09\n+\n+from django.db import migrations\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"payment\", \"0061_transaction_legacy_adyen_plugin_payment_method_and_more\"),\n+ (\"payment\", \"0063_transactionitem_payment_method_type_ids_and_more\"),\n+ ]\n+\n+ operations = []\n"
508
- },
509
- {
510
- "path": "saleor/payment/models.py",
511
- "status": "modified",
512
- "diff": "Index: saleor/payment/models.py\n===================================================================\n--- saleor/payment/models.py\t055f6d5 (parent)\n+++ saleor/payment/models.py\t55731f1 (commit)\n@@ -466,11 +466,24 @@\n default=Decimal(\"0.0\"),\n )\n error = models.TextField(null=True)\n customer_id = models.CharField(max_length=256, null=True)\n+ # @deprecated\n gateway_response = JSONField(encoder=DjangoJSONEncoder)\n already_processed = models.BooleanField(default=False)\n \n+ \"\"\"\n+ Legacy fields that allow Adyen plugin to work until it's removed.\n+\n+ Previously Adyen plugin was using gateway_response which holds entire response for every Payment plugin.\n+ Adyen plugin is the only plugin using this field, it has access to result_code and payment_method.\n+\n+ To remove gateway_response we introduce two legacy fields that Adyen can write to and gateway_response can be removed.\n+ Once plugin is removed, these fields should be removed from the model.\n+ \"\"\"\n+ legacy_adyen_plugin_result_code = models.TextField(null=True)\n+ legacy_adyen_plugin_payment_method = models.TextField(null=True)\n+\n class Meta:\n ordering = (\"pk\",)\n indexes = [\n GinIndex(\n"
513
- },
514
- {
515
- "path": "saleor/payment/utils.py",
516
- "status": "modified",
517
- "diff": "Index: saleor/payment/utils.py\n===================================================================\n--- saleor/payment/utils.py\t055f6d5 (parent)\n+++ saleor/payment/utils.py\t55731f1 (commit)\n@@ -496,8 +496,10 @@\n error=gateway_response.error,\n customer_id=gateway_response.customer_id,\n gateway_response=gateway_response.raw_response or {},\n action_required_data=gateway_response.action_required_data or {},\n+ legacy_adyen_plugin_result_code=gateway_response.legacy_adyen_plugin_result_code,\n+ legacy_adyen_plugin_payment_method=gateway_response.legacy_adyen_plugin_payment_method,\n )\n return txn\n \n \n"
518
- }
519
- ]
520
- },
521
- {
522
- "id": "fix-reference-typing",
523
- "sha": "055f6d5fae5c85db931b2d8ad935c5101988336f",
524
- "parentSha": "1bc9613072469ce9f4bddd426bd8937940a48086",
525
- "spec": "Implement a typing and data flow refinement for reference attributes in GraphQL attribute handlers.\n\nRequired changes:\n\n1) Add resolved object field to AttrValuesInput\n- In saleor/graphql/attribute/utils/shared.py:\n - Define a T_REFERENCE type alias that unions the supported referenced models (Product, ProductVariant, Category, Collection, Page).\n - Extend the AttrValuesInput dataclass with a new optional list field named reference_objects: list[T_REFERENCE] | None.\n - Keep existing reference: str | None and references: list[str] | None fields to hold raw GraphQL IDs.\n\n2) Tighten get_references typing\n- In saleor/graphql/attribute/utils/type_handlers.py (ReferenceAttributeHandler):\n - Change get_references to return Sequence[str] (not including None).\n - For SINGLE_REFERENCE, return a one-element list when a reference is present, otherwise an empty list; for multi-reference, return the references list or an empty list.\n\n3) Populate resolved objects instead of mutating ID fields\n- In ReferenceAttributeHandler.clean_and_validate:\n - After resolving incoming IDs to model instances, set self.values_input.reference_objects = ref_instances.\n - Do not overwrite self.values_input.reference or self.values_input.references with object instances.\n - Preserve existing error handling: if invalid references are provided, append AttributeInputErrors.INVALID_REFERENCE for the attribute.\n\n4) Use resolved objects for persistence\n- In ReferenceAttributeHandler.pre_save_value:\n - Read references from self.values_input.reference_objects.\n - If there are no resolved references or the attribute has no entity_type, return an empty list.\n - Proceed to construct AttributeValue data for each resolved object as before.\n - Ensure the slugify line operates on instance.id and ref.id without type ignores (the objects are now correctly typed via T_REFERENCE).\n\n5) Update tests to assert new behavior\n- In saleor/graphql/attribute/tests/type_handlers/test_reference_handler.py:\n - Where tests previously asserted that handler.values_input.references contained model instances, change them to assert that handler.values_input.reference_objects contains the corresponding instances. Retain assertions that handler.values_input.references is truthy to reflect the presence of raw IDs.\n - For single-reference tests, change assertions from handler.values_input.reference == <obj> to handler.values_input.reference_objects == [<obj>].\n - In tests that construct AttrValuesInput with model instances for pre-save flows, pass them via reference_objects=[...] instead of reference=... or references=....\n\nConstraints and scope:\n- Do not change the external GraphQL API: clients should continue to pass reference (ID) and references (list of IDs) as before.\n- The new reference_objects field is an internal, resolved representation for handlers and saving logic.\n- Ensure mypy/typing consistency with the new types and adjusted return signatures.\n\nAcceptance criteria:\n- All reference handler tests in saleor/graphql/attribute/tests/type_handlers/test_reference_handler.py pass with the updated assertions.\n- No regressions in attribute assignment flows: multi and single reference attributes resolve, validate, and persist correctly using resolved objects.\n- No changes to GraphQL input/output schemas are required.",
526
- "prompt": "Refactor the reference attribute handling to keep raw GraphQL IDs in the existing fields and store resolved model instances separately. Introduce a new field on the attribute values input to hold resolved objects, have the handler populate it during validation, and consume it when saving. Tighten the typing on helper methods and remove any type ignores made unnecessary by the new structure. Update the reference handler tests to assert against the resolved objects collection while preserving the existing inputs and error behaviors. Do not alter the external GraphQL API.",
527
- "supplementalFiles": [
528
- "saleor/graphql/attribute/types.py",
529
- "saleor/graphql/attribute/schema.py",
530
- "saleor/attribute/models.py",
531
- "saleor/graphql/attribute/mutations/attribute_update.py",
532
- "saleor/graphql/attribute/mutations/attribute_create.py"
533
- ],
534
- "fileDiffs": [
535
- {
536
- "path": "saleor/graphql/attribute/tests/type_handlers/test_reference_handler.py",
537
- "status": "modified",
538
- "diff": "Index: saleor/graphql/attribute/tests/type_handlers/test_reference_handler.py\n===================================================================\n--- saleor/graphql/attribute/tests/type_handlers/test_reference_handler.py\t1bc9613 (parent)\n+++ saleor/graphql/attribute/tests/type_handlers/test_reference_handler.py\t055f6d5 (commit)\n@@ -25,9 +25,10 @@\n \n # then\n assert not attribute_errors\n assert handler.values_input.references\n- assert set(handler.values_input.references) == set(product_list)\n+ assert handler.values_input.reference_objects\n+ assert set(handler.values_input.reference_objects) == set(product_list)\n \n \n def test_reference_handler_clean_and_validate_page_reference(\n product_type_page_reference_attribute, page_list\n@@ -45,9 +46,10 @@\n \n # then\n assert not attribute_errors\n assert handler.values_input.references\n- assert set(handler.values_input.references) == set(page_list)\n+ assert handler.values_input.reference_objects\n+ assert set(handler.values_input.reference_objects) == set(page_list)\n \n \n def test_reference_handler_clean_and_validate_variant_reference(\n page_type_variant_reference_attribute, product_variant_list\n@@ -67,9 +69,10 @@\n \n # then\n assert not attribute_errors\n assert handler.values_input.references\n- assert set(handler.values_input.references) == set(product_variant_list)\n+ assert handler.values_input.reference_objects\n+ assert set(handler.values_input.reference_objects) == set(product_variant_list)\n \n \n def test_reference_handler_clean_and_validate_category_reference(\n product_type_category_reference_attribute, category_list\n@@ -86,10 +89,11 @@\n handler.clean_and_validate(attribute_errors)\n \n # then\n assert not attribute_errors\n+ assert handler.values_input.reference_objects\n assert handler.values_input.references\n- assert set(handler.values_input.references) == set(category_list)\n+ assert set(handler.values_input.reference_objects) == set(category_list)\n \n \n def test_reference_handler_clean_and_validate_collection_reference(\n product_type_collection_reference_attribute, collection_list\n@@ -109,9 +113,10 @@\n \n # then\n assert not attribute_errors\n assert handler.values_input.references\n- assert set(handler.values_input.references) == set(collection_list)\n+ assert handler.values_input.reference_objects\n+ assert set(handler.values_input.reference_objects) == set(collection_list)\n \n \n def test_single_reference_handler_clean_and_validate_page_reference(\n product_type_page_single_reference_attribute, page\n@@ -128,9 +133,9 @@\n handler.clean_and_validate(attribute_errors)\n \n # then\n assert not attribute_errors\n- assert handler.values_input.reference == page\n+ assert handler.values_input.reference_objects == [page]\n \n \n def test_single_reference_handler_clean_and_validate_variant_reference(\n product_type_variant_single_reference_attribute, variant\n@@ -147,9 +152,9 @@\n handler.clean_and_validate(attribute_errors)\n \n # then\n assert not attribute_errors\n- assert handler.values_input.reference == variant\n+ assert handler.values_input.reference_objects == [variant]\n \n \n def test_single_reference_handler_clean_and_validate_category_reference(\n product_type_category_single_reference_attribute, category\n@@ -166,9 +171,9 @@\n handler.clean_and_validate(attribute_errors)\n \n # then\n assert not attribute_errors\n- assert handler.values_input.reference == category\n+ assert handler.values_input.reference_objects == [category]\n \n \n def test_single_reference_handler_clean_and_validate_collection_reference(\n page_type_collection_single_reference_attribute, collection\n@@ -185,9 +190,9 @@\n handler.clean_and_validate(attribute_errors)\n \n # then\n assert not attribute_errors\n- assert handler.values_input.reference == collection\n+ assert handler.values_input.reference_objects == [collection]\n \n \n def test_single_reference_handler_clean_and_validate_success(\n product_type_product_single_reference_attribute, product\n@@ -204,9 +209,9 @@\n handler.clean_and_validate(attribute_errors)\n \n # then\n assert not attribute_errors\n- assert handler.values_input.reference == product\n+ assert handler.values_input.reference_objects == [product]\n \n \n def test_reference_handler_clean_and_validate_value_required(\n product_type_product_reference_attribute,\n@@ -296,9 +301,9 @@\n # given\n attribute = product_type_product_reference_attribute\n values_input = AttrValuesInput(\n global_id=graphene.Node.to_global_id(\"Attribute\", attribute.id),\n- references=product_list,\n+ reference_objects=product_list,\n )\n handler = ReferenceAttributeHandler(attribute, values_input)\n instance = product\n \n@@ -325,9 +330,9 @@\n attribute = product_type_product_single_reference_attribute\n ref_product = product_list[0]\n values_input = AttrValuesInput(\n global_id=graphene.Node.to_global_id(\"Attribute\", attribute.id),\n- reference=ref_product,\n+ reference_objects=[ref_product],\n )\n handler = ReferenceAttributeHandler(attribute, values_input)\n instance = product\n \n"
539
- },
540
- {
541
- "path": "saleor/graphql/attribute/utils/shared.py",
542
- "status": "modified",
543
- "diff": "Index: saleor/graphql/attribute/utils/shared.py\n===================================================================\n--- saleor/graphql/attribute/utils/shared.py\t1bc9613 (parent)\n+++ saleor/graphql/attribute/utils/shared.py\t055f6d5 (commit)\n@@ -17,8 +17,15 @@\n from ....attribute.models import Attribute\n \n T_INSTANCE = product_models.Product | product_models.ProductVariant | page_models.Page\n T_ERROR_DICT = dict[tuple[str, str], list]\n+T_REFERENCE = (\n+ product_models.Product\n+ | product_models.ProductVariant\n+ | product_models.Category\n+ | product_models.Collection\n+ | page_models.Page\n+)\n \n \n @dataclass\n class AttrValuesForSelectableFieldInput:\n@@ -37,8 +44,9 @@\n multiselect: list[AttrValuesForSelectableFieldInput] | None = None\n numeric: str | None = None\n reference: str | None = None\n references: list[str] | None = None\n+ reference_objects: list[T_REFERENCE] | None = None\n file_url: str | None = None\n content_type: str | None = None\n rich_text: dict | None = None\n plain_text: str | None = None\n"
544
- },
545
- {
546
- "path": "saleor/graphql/attribute/utils/type_handlers.py",
547
- "status": "modified",
548
- "diff": "Index: saleor/graphql/attribute/utils/type_handlers.py\n===================================================================\n--- saleor/graphql/attribute/utils/type_handlers.py\t1bc9613 (parent)\n+++ saleor/graphql/attribute/utils/type_handlers.py\t055f6d5 (commit)\n@@ -424,9 +424,9 @@\n \n class ReferenceAttributeHandler(AttributeTypeHandler):\n \"\"\"Handler for Reference and Single Reference attribute type.\"\"\"\n \n- def get_references(self) -> Sequence[str | None]:\n+ def get_references(self) -> Sequence[str]:\n if self.attribute.input_type == AttributeInputType.SINGLE_REFERENCE:\n return [self.values_input.reference] if self.values_input.reference else []\n return self.values_input.references or []\n \n@@ -459,15 +459,12 @@\n attribute_errors[AttributeInputErrors.INVALID_REFERENCE].append(\n self.attribute_identifier\n )\n return\n- if self.attribute.input_type == AttributeInputType.SINGLE_REFERENCE:\n- self.values_input.reference = ref_instances[0] if ref_instances else None\n- else:\n- self.values_input.references = ref_instances\n+ self.values_input.reference_objects = ref_instances\n \n def pre_save_value(self, instance: T_INSTANCE) -> list[tuple]:\n- references = self.get_references()\n+ references = self.values_input.reference_objects\n entity_type = self.attribute.entity_type\n if not references or not entity_type:\n return []\n \n@@ -478,9 +475,9 @@\n if entity_type == AttributeEntityType.PRODUCT_VARIANT:\n name = f\"{ref.product.name}: {name}\" # type: ignore[union-attr]\n \n # Reference values are unique per referenced entity\n- slug = slugify(unidecode(f\"{instance.id}_{ref.id}\")) # type: ignore[union-attr]\n+ slug = slugify(unidecode(f\"{instance.id}_{ref.id}\"))\n defaults = {\"name\": name}\n value_data = {\n \"attribute\": self.attribute,\n \"slug\": slug,\n"
549
- }
550
- ]
551
- },
552
- {
553
- "id": "honor-price-override",
554
- "sha": "4a044c694fb67cc2a6fbbe1f700bfa716f752507",
555
- "parentSha": "836d01d8429ff250a78abbc5439743c28bc1f772",
556
- "spec": "Implement price override precedence in checkout price computation and validate via tests.\n\nChanges to implement:\n1) Update CheckoutLineInfo price selection logic\n- File: saleor/checkout/fetch.py\n- In the property that returns the variant discounted price for a checkout line (variant_discounted_price), add an early return that, when line.price_override is not None, returns a Money constructed from price_override and line.currency.\n- Preserve existing behavior as fallback: if channel_listing has a discounted_price, return it; otherwise compute from undiscounted_unit_price minus catalogue promotion discounts.\n- Add a short comment explaining that price_override takes precedence for further calculations.\n\n2) Add/adjust unit tests for fetch logic\n- File: saleor/checkout/tests/test_fetch.py\n- Ensure Decimal and Money are imported where needed.\n- Add a new test named test_checkout_line_info_variant_discounted_price_with_price_override which:\n - Retrieves a line from a checkout with an item on promotion, sets price_override to a small Decimal value (e.g., 5), and saves it.\n - Builds a CheckoutLineInfo using the existing pattern in the file.\n - Asserts that variant_discounted_price equals Money(price_override, checkout_line.currency) even when the channel listing has a non-None discounted_price.\n\n3) Add GraphQL mutation test to cover voucher calculation with override\n- File: saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py\n- Update imports to include DiscountValueType from the discount module because the test configures a percentage voucher.\n- Add a new test named test_add_promo_code_with_price_override_set which:\n - Sets checkout_line.price_override to a Decimal value and saves it.\n - Configures a voucher of type SPECIFIC_PRODUCT with discount_value_type set to PERCENTAGE and a 10% channel discount, and associates it with the product on the checkout line.\n - Calculates expected_discount as 10% of the price_override multiplied by the line quantity and expected_subtotal as price_override * quantity - expected_discount.\n - Calls the checkoutAddPromoCode mutation and asserts no errors, that voucherCode is set, discount amount equals expected_discount, and subtotal gross amount equals expected_subtotal.\n\nBehavioral expectations:\n- When price_override is set on a checkout line, it must be the source of truth for unit price used in discount/voucher computations and subtotals, regardless of any variant/channel listing discounted price.\n- GraphQL mutation checkoutAddPromoCode should reflect the discount and subtotal computed from the overridden price.\n\nDo not modify other files or pricing flows beyond the described precedence change and tests.",
557
- "prompt": "Ensure that a manually overridden checkout line price is respected across pricing and discount flows. Specifically, make the checkout line's effective unit price come from the price override when it is set, even if a channel listing discounted price is available. Update the logic that provides the line's discounted unit price to honor this precedence, and add tests that verify both the fetch-layer unit price and the GraphQL checkoutAddPromoCode mutation compute discounts and subtotals based on the override.",
558
- "supplementalFiles": [
559
- "saleor/checkout/base_calculations.py",
560
- "saleor/checkout/calculations.py",
561
- "saleor/checkout/models.py",
562
- "saleor/graphql/checkout/mutations/checkout_add_promo_code.py",
563
- "saleor/discount/utils/checkout.py"
564
- ],
565
- "fileDiffs": [
566
- {
567
- "path": "saleor/checkout/fetch.py",
568
- "status": "modified",
569
- "diff": "Index: saleor/checkout/fetch.py\n===================================================================\n--- saleor/checkout/fetch.py\t836d01d (parent)\n+++ saleor/checkout/fetch.py\t4a044c6 (commit)\n@@ -72,10 +72,17 @@\n If listing is present return the discounted price from the listing,\n if listing is not present, calculate current unit price based on\n `undiscounted_unit_price` and catalogue promotion discounts.\n \"\"\"\n+\n+ # if price_override is set, it takes precedence over any other price for\n+ # further calculations\n+ if self.line.price_override is not None:\n+ return Money(self.line.price_override, self.line.currency)\n+\n if self.channel_listing and self.channel_listing.discounted_price is not None:\n return self.channel_listing.discounted_price\n+\n catalogue_discounts = self.get_catalogue_discounts()\n total_price = self.undiscounted_unit_price * self.line.quantity\n for discount in catalogue_discounts:\n total_price -= discount.amount\n"
570
- },
571
- {
572
- "path": "saleor/checkout/tests/test_fetch.py",
573
- "status": "modified",
574
- "diff": "Index: saleor/checkout/tests/test_fetch.py\n===================================================================\n--- saleor/checkout/tests/test_fetch.py\t836d01d (parent)\n+++ saleor/checkout/tests/test_fetch.py\t4a044c6 (commit)\n@@ -1,5 +1,8 @@\n+from decimal import Decimal\n+\n import pytest\n+from prices import Money\n \n from ...product.models import ProductChannelListing, ProductVariantChannelListing\n from ..fetch import CheckoutLineInfo, fetch_checkout_lines\n \n@@ -216,8 +219,47 @@\n checkout_line_info.variant_discounted_price == expected_discounted_variant_price\n )\n \n \n+def test_checkout_line_info_variant_discounted_price_with_price_override(\n+ checkout_with_item_on_promotion,\n+):\n+ # given\n+ checkout_line = checkout_with_item_on_promotion.lines.first()\n+ channel = checkout_with_item_on_promotion.channel\n+ variant = checkout_line.variant\n+ variant_channel_listing = variant.channel_listings.get(channel_id=channel.id)\n+ product = variant.product\n+ product_type = product.product_type\n+ discounts = checkout_line.discounts.all()\n+ checkout_line.price_override = Decimal(5)\n+ checkout_line.save(update_fields=[\"price_override\"])\n+\n+ expected_discounted_variant_price = checkout_line.price_override\n+ assert variant_channel_listing.discounted_price != variant_channel_listing.price\n+\n+ # when\n+ checkout_line_info = CheckoutLineInfo(\n+ line=checkout_line,\n+ variant=variant,\n+ channel_listing=variant_channel_listing,\n+ product=product,\n+ product_type=product_type,\n+ collections=[],\n+ tax_class=product.tax_class or product_type.tax_class,\n+ discounts=discounts,\n+ rules_info=[],\n+ channel=channel,\n+ voucher=None,\n+ voucher_code=None,\n+ )\n+\n+ # then\n+ assert checkout_line_info.variant_discounted_price == Money(\n+ expected_discounted_variant_price, checkout_line.currency\n+ )\n+\n+\n def test_fetch_checkout_lines_info(checkout_with_item_on_promotion):\n # given\n lines = list(checkout_with_item_on_promotion.lines.all())\n \n"
575
- },
576
- {
577
- "path": "saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py",
578
- "status": "modified",
579
- "diff": "Index: saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py\n===================================================================\n--- saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py\t836d01d (parent)\n+++ saleor/graphql/checkout/tests/mutations/test_checkout_add_promo_code.py\t4a044c6 (commit)\n@@ -17,9 +17,9 @@\n add_variant_to_checkout,\n assign_external_shipping_to_checkout,\n )\n from .....core.models import EventDelivery\n-from .....discount import VoucherType\n+from .....discount import DiscountValueType, VoucherType\n from .....plugins.manager import get_plugins_manager\n from .....product.models import (\n Collection,\n ProductChannelListing,\n@@ -1456,4 +1456,43 @@\n assert filter_shipping_call.kwargs[\"timeout\"] == settings.WEBHOOK_SYNC_TIMEOUT\n \n tax_delivery = tax_delivery_call.args[0]\n assert tax_delivery.webhook_id == tax_webhook.id\n+\n+\n+def test_add_promo_code_with_price_override_set(\n+ user_api_client, checkout_with_item, voucher\n+):\n+ # given\n+ checkout_line = checkout_with_item.lines.first()\n+ price_override = Decimal(\"5.00\")\n+ checkout_line.price_override = price_override\n+ checkout_line.save(update_fields=[\"price_override\"])\n+ product = checkout_line.variant.product\n+\n+ voucher.type = VoucherType.SPECIFIC_PRODUCT\n+ voucher.discount_value_type = DiscountValueType.PERCENTAGE\n+ cl = voucher.channel_listings.get(channel=checkout_with_item.channel)\n+ cl.discount_value = Decimal(\"10.00\") # 10% discount\n+ cl.save(update_fields=[\"discount_value\"])\n+ voucher.save(update_fields=[\"type\", \"discount_value_type\"])\n+ voucher.products.add(product)\n+\n+ # total expected discount is 10% of price override\n+ expected_discount = price_override * Decimal(\"0.10\") * checkout_line.quantity\n+ expected_subtotal = price_override * checkout_line.quantity - expected_discount\n+\n+ variables = {\n+ \"id\": to_global_id_or_none(checkout_with_item),\n+ \"promoCode\": voucher.code,\n+ }\n+\n+ # when\n+ response = user_api_client.post_graphql(MUTATION_CHECKOUT_ADD_PROMO_CODE, variables)\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"checkoutAddPromoCode\"]\n+\n+ # then\n+ assert not data[\"errors\"]\n+ assert data[\"checkout\"][\"voucherCode\"] == voucher.code\n+ assert data[\"checkout\"][\"discount\"][\"amount\"] == expected_discount\n+ assert data[\"checkout\"][\"subtotalPrice\"][\"gross\"][\"amount\"] == expected_subtotal\n"
580
- }
581
- ]
582
- },
583
- {
584
- "id": "add-reference-filters",
585
- "sha": "836d01d8429ff250a78abbc5439743c28bc1f772",
586
- "parentSha": "f3c1a9678d36c934c9295ce557c409cb1279c79f",
587
- "spec": "Implement reference-based attribute filtering for pages with containsAny/containsAll semantics and update schema, validations, and fixtures.\n\nRequirements\n1) GraphQL input types\n- Add a new input type ContainsFilterInput with two optional fields: containsAny: [String!] and containsAll: [String!]. Describe them as: containsAny = field contains at least one of the specified values, containsAll = contains all of the specified values.\n- Add a new input type ReferenceAttributeWhereInput exposing four optional fields (all of type ContainsFilterInput):\n - referencedIds: filter by referenced objects using global Relay IDs for Page, Product, and ProductVariant nodes.\n - pageSlugs: filter by referenced Page slugs.\n - productSlugs: filter by referenced Product slugs.\n - productVariantSkus: filter by referenced ProductVariant SKUs.\n- Extend AttributeValuePageInput to include reference: ReferenceAttributeWhereInput (optional), with description \"Filter by reference attribute value.\".\n- Ensure these types appear in the schema and are referenced by PageWhereInput -> attributes[].value.reference.\n\n2) Filtering behavior\n- In page filtering (PageWhere/filters), extend the attribute value filtering pipeline to handle AttributeInputType.REFERENCE. When an attribute with input_type=REFERENCE is filtered via attributes[].value.reference, it must support the following keys (independently or in combination): referencedIds, pageSlugs, productSlugs, productVariantSkus.\n- For each key, support both containsAny and containsAll operators, mutually exclusive per key:\n - containsAny: return pages having at least one attribute value referencing any of the provided identifiers.\n - containsAll: return pages having attribute values referencing all provided identifiers (logical AND across the values).\n- Implement the above without requiring the attribute to be unique; pages can have multiple values. Use efficient subqueries (Exists/OuterRef) against AttributeValue and AssignedPageAttributeValue to express both operators.\n- Identifier resolution:\n - referencedIds: accept a list of global Relay IDs (Page, Product, ProductVariant). Resolve to underlying models and IDs; combine filters for different entity types with OR for containsAny and AND for containsAll.\n - pageSlugs: resolve to Page by slug.\n - productSlugs: resolve to Product by slug.\n - productVariantSkus: resolve to ProductVariant by sku.\n- Scope by attribute when attr_id is provided: only consider AttributeValue rows tied to that attribute. Otherwise, accept any reference values of the given type present on the page.\n\n3) Reusable filtering helpers\n- Introduce reusable helper functions in GraphQL attribute filtering module to build Q expressions using Exists/OuterRef, parameterized by:\n - db connection name, assigned attribute model (AssignedPageAttributeValue), assigned id field (page_id), AttributeValue reference field (reference_page_id/reference_product_id/reference_variant_id), and identifier field used to match the referenced model (slug/id/sku).\n- Provide separate helpers for the three reference types (pages, products, variants) and one for global object IDs that handles mixed types, returning a combined Q expression that can be AND/OR composed as per containsAll/containsAny rules.\n\n4) Input validation\n- Extend page attribute where input validation to handle reference values:\n - Reject null or empty reference input (the value for reference must not be null or empty).\n - For each sub-key (referencedIds/pageSlugs/productSlugs/productVariantSkus), disallow providing both containsAll and containsAny simultaneously. Raise a GraphQLError indicating the offending fields.\n - Disallow empty lists or null for containsAll/containsAny. Raise a GraphQLError indicating offending fields and that values cannot be null or empty.\n - If a value.reference filter is used for a non-REFERENCE attribute, raise validation error for incorrect input type.\n\n5) Imports and consistency\n- Update imports for product/page models with explicit modules (e.g., from ...product import models as product_models, from ...page import models as page_models).\n- Adjust GraphQL core type imports to use ..core.types.base.BaseInputObjectType and ..core.types.common.NonNullList to match current code organization.\n\n6) Tests\n- Add end-to-end GraphQL tests under saleor/graphql/page/tests/queries/ to cover:\n - containsAny and containsAll for pageSlugs, productSlugs, productVariantSkus on a REFERENCE attribute assigned to PageType, verifying counts and that pages with both references satisfy containsAll and any one satisfies containsAny.\n - containsAny and containsAll for referencedIds using mixed relay IDs for Page/Product/ProductVariant.\n - Negative validation scenarios: null reference, empty input objects, empty lists for containsAny/containsAll, both operators provided for the same key, use of reference filter on a non-reference attribute.\n- Update an existing test that expects page count from filtering by empty slugs list to reflect the new page fixtures (see below).\n\n7) Fixtures\n- Update page fixtures to create four published pages (add two more to prior two) so baseline page queries without filters return four items. Ensure tests referencing this fixture expect 4 instead of 2 in the relevant case.\n\nAcceptance criteria\n- Schema contains ContainsFilterInput and ReferenceAttributeWhereInput; AttributeValuePageInput includes reference field.\n- Querying pages with attribute value reference filters supports both containsAny and containsAll for the four supported sub-fields and returns expected results.\n- Input validation errors are raised with clear messages according to rules above.\n- All updated and new tests pass, including the page fixture count adjustment.",
588
- "prompt": "Extend page GraphQL filtering to support reference-type attributes with flexible contains-any/all semantics. Add a reusable input that can express containsAny/containsAll, expose a \"reference\" filter field under attribute values in PageWhereInput, and allow filtering by referenced page slugs, product slugs, variant SKUs, and global Relay IDs. Ensure input validation prevents empty or conflicting operators and that filtering is efficient and correctly scoped to the attribute. Update the schema, page fixtures, and tests so that pages can be filtered using these new reference options and all tests pass.",
589
- "supplementalFiles": [
590
- "saleor/attribute/models.py",
591
- "saleor/page/models.py",
592
- "saleor/product/models.py",
593
- "saleor/graphql/utils/filters.py",
594
- "saleor/graphql/core/filters/__init__.py",
595
- "saleor/graphql/attribute/types.py"
596
- ],
597
- "fileDiffs": [
598
- {
599
- "path": "saleor/graphql/attribute/filters.py",
600
- "status": "modified",
601
- "diff": "Index: saleor/graphql/attribute/filters.py\n===================================================================\n--- saleor/graphql/attribute/filters.py\tf3c1a96 (parent)\n+++ saleor/graphql/attribute/filters.py\t836d01d (commit)\n@@ -1,13 +1,16 @@\n+from typing import Literal, TypedDict\n+\n import django_filters\n import graphene\n-from django.db.models import Q\n+from django.db.models import Exists, OuterRef, Q, QuerySet\n \n from ...attribute import AttributeInputType\n-from ...attribute.models import Attribute, AttributeValue\n+from ...attribute.models import AssignedPageAttributeValue, Attribute, AttributeValue\n from ...channel.models import Channel\n+from ...page import models as page_models\n from ...permission.utils import has_one_of_permissions\n-from ...product import models\n+from ...product import models as product_models\n from ...product.models import ALL_PRODUCTS_PERMISSIONS\n from ..channel.filters import get_channel_slug_from_filter_data\n from ..core.doc_category import DOC_CATEGORY_ATTRIBUTES\n from ..core.enums import MeasurementUnitsEnum\n@@ -30,12 +33,10 @@\n FilterInputDescriptions,\n StringFilterInput,\n WhereInputObjectType,\n )\n-from ..core.types import (\n- BaseInputObjectType,\n- NonNullList,\n-)\n+from ..core.types.base import BaseInputObjectType\n+from ..core.types.common import NonNullList\n from ..core.utils import from_global_id_or_error\n from ..utils import get_user_or_app_from_context\n from ..utils.filters import filter_by_ids, filter_slug_list, filter_where_by_value_field\n from .enums import AttributeEntityTypeEnum, AttributeInputTypeEnum, AttributeTypeEnum\n@@ -48,15 +49,17 @@\n channel = None\n if channel_slug is not None:\n channel = Channel.objects.using(qs.db).filter(slug=str(channel_slug)).first()\n limited_channel_access = False if channel_slug is None else True\n- product_qs = models.Product.objects.using(qs.db).visible_to_user(\n+ product_qs = product_models.Product.objects.using(qs.db).visible_to_user(\n requestor, channel, limited_channel_access\n )\n \n if field == \"in_category\":\n _type, category_id = from_global_id_or_error(value, \"Category\")\n- category = models.Category.objects.using(qs.db).filter(pk=category_id).first()\n+ category = (\n+ product_models.Category.objects.using(qs.db).filter(pk=category_id).first()\n+ )\n \n if category is None:\n return qs.none()\n \n@@ -329,4 +332,353 @@\n class Meta:\n filterset_class = AttributeValueWhere\n description = \"Where filtering options for attribute values.\"\n doc_category = DOC_CATEGORY_ATTRIBUTES\n+\n+\n+CONTAINS_TYPING = dict[Literal[\"contains_any\", \"contains_all\"], list[str]]\n+\n+\n+class SharedContainsFilterParams(TypedDict):\n+ attr_id: int | None\n+ db_connection_name: str\n+ assigned_attr_model: type[AssignedPageAttributeValue]\n+ assigned_id_field_name: Literal[\"page_id\"]\n+ identifier_field_name: Literal[\"slug\", \"id\", \"sku\"]\n+\n+\n+def filter_by_contains_referenced_object_ids(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ \"\"\"Build a filter expression for objects referencing other entities by global IDs.\n+\n+ Returns a Q expression to filter objects based on their references\n+ to other entities (like: variants, products, pages), identified by\n+ global IDs.\n+\n+ - If `contains_all` is provided, only objects that reference all of the\n+ specified global IDs will match.\n+ - If `contains_any` is provided, objects that reference at least one of\n+ the specified global IDs will match.\n+ \"\"\"\n+\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ variant_ids = set()\n+ product_ids = set()\n+ page_ids = set()\n+\n+ for obj_id in contains_any or contains_all or []:\n+ type_, id_ = graphene.Node.from_global_id(obj_id)\n+ if type_ == \"Page\":\n+ page_ids.add(id_)\n+ elif type_ == \"Product\":\n+ product_ids.add(id_)\n+ elif type_ == \"ProductVariant\":\n+ variant_ids.add(id_)\n+\n+ expression = Q()\n+ shared_filter_params: SharedContainsFilterParams = {\n+ \"attr_id\": attr_id,\n+ \"db_connection_name\": db_connection_name,\n+ \"assigned_attr_model\": assigned_attr_model,\n+ \"assigned_id_field_name\": assigned_id_field_name,\n+ \"identifier_field_name\": \"id\",\n+ }\n+ if contains_all:\n+ if page_ids:\n+ expression &= _filter_contains_all_condition(\n+ contains_all=list(page_ids),\n+ referenced_model=page_models.Page,\n+ attr_value_reference_field_name=\"reference_page_id\",\n+ **shared_filter_params,\n+ )\n+ if product_ids:\n+ expression &= _filter_contains_all_condition(\n+ contains_all=list(product_ids),\n+ referenced_model=product_models.Product,\n+ attr_value_reference_field_name=\"reference_product_id\",\n+ **shared_filter_params,\n+ )\n+ if variant_ids:\n+ expression &= _filter_contains_all_condition(\n+ contains_all=list(variant_ids),\n+ referenced_model=product_models.ProductVariant,\n+ attr_value_reference_field_name=\"reference_variant_id\",\n+ **shared_filter_params,\n+ )\n+ return expression\n+\n+ if contains_any:\n+ if page_ids:\n+ expression |= _filter_contains_any_condition(\n+ contains_any=list(page_ids),\n+ referenced_model=page_models.Page,\n+ attr_value_reference_field_name=\"reference_page_id\",\n+ **shared_filter_params,\n+ )\n+\n+ if product_ids:\n+ expression |= _filter_contains_any_condition(\n+ contains_any=list(product_ids),\n+ referenced_model=product_models.Product,\n+ attr_value_reference_field_name=\"reference_product_id\",\n+ **shared_filter_params,\n+ )\n+\n+ if variant_ids:\n+ expression |= _filter_contains_any_condition(\n+ contains_any=list(variant_ids),\n+ referenced_model=product_models.ProductVariant,\n+ attr_value_reference_field_name=\"reference_variant_id\",\n+ **shared_filter_params,\n+ )\n+ return expression\n+\n+\n+def _filter_contains_single_expression(\n+ attr_id: int | None,\n+ db_connection_name: str,\n+ reference_objs: QuerySet[\n+ page_models.Page | product_models.Product | product_models.ProductVariant\n+ ],\n+ attr_value_reference_field_name: Literal[\n+ \"reference_page_id\", \"reference_product_id\", \"reference_variant_id\"\n+ ],\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ single_reference_qs = AttributeValue.objects.using(db_connection_name).filter(\n+ Exists(reference_objs.filter(id=OuterRef(attr_value_reference_field_name))),\n+ )\n+ if attr_id:\n+ attr_query = Attribute.objects.using(db_connection_name).filter(id=attr_id)\n+ single_reference_qs = single_reference_qs.filter(\n+ Exists(attr_query.filter(id=OuterRef(\"attribute_id\"))),\n+ )\n+ assigned_attr_value = assigned_attr_model.objects.using(db_connection_name).filter(\n+ Exists(single_reference_qs.filter(id=OuterRef(\"value_id\"))),\n+ **{str(assigned_id_field_name): OuterRef(\"id\")},\n+ )\n+ return Q(Exists(assigned_attr_value))\n+\n+\n+def _filter_contains_all_condition(\n+ attr_id: int | None,\n+ db_connection_name: str,\n+ contains_all: list[str],\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+ identifier_field_name: Literal[\"slug\", \"id\", \"sku\"],\n+ referenced_model: type[\n+ page_models.Page | product_models.Product | product_models.ProductVariant\n+ ],\n+ attr_value_reference_field_name: Literal[\n+ \"reference_page_id\", \"reference_product_id\", \"reference_variant_id\"\n+ ],\n+):\n+ \"\"\"Build a filter expression that ensures all specified references are present.\n+\n+ Constructs a Q expression that checks for references to all entities from\n+ `referenced_model`, matched using the provided identifiers in `contains_all`.\n+\n+ For each identifier, it resolves the corresponding object using\n+ `identifier_field_name` and adds a subquery to verify the presence\n+ of that reference. The subqueries are combined using logical AND.\n+ \"\"\"\n+\n+ identifiers = contains_all\n+ expression = Q()\n+\n+ for identifier in identifiers:\n+ reference_obj = referenced_model.objects.using(db_connection_name).filter(\n+ **{str(identifier_field_name): identifier}\n+ )\n+ expression &= _filter_contains_single_expression(\n+ attr_id,\n+ db_connection_name,\n+ reference_obj,\n+ attr_value_reference_field_name,\n+ assigned_attr_model,\n+ assigned_id_field_name,\n+ )\n+ return expression\n+\n+\n+def _filter_contains_any_condition(\n+ attr_id: int | None,\n+ db_connection_name: str,\n+ contains_any: list[str],\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+ identifier_field_name: Literal[\"slug\", \"id\", \"sku\"],\n+ referenced_model: type[\n+ page_models.Page | product_models.Product | product_models.ProductVariant\n+ ],\n+ attr_value_reference_field_name: Literal[\n+ \"reference_page_id\", \"reference_product_id\", \"reference_variant_id\"\n+ ],\n+):\n+ \"\"\"Build a filter expression that ensures at least one specified reference is present.\n+\n+ Constructs a Q expression that checks for a reference to any entity from\n+ `referenced_model`, matched using the provided identifiers in `contains_any`.\n+\n+ All matching references are resolved using `identifier_field_name`,\n+ and passed as a single queryset to be checked in a single subquery.\n+\n+ \"\"\"\n+ identifiers = contains_any\n+ reference_objs = referenced_model.objects.using(db_connection_name).filter(\n+ **{f\"{identifier_field_name}__in\": identifiers}\n+ )\n+ return _filter_contains_single_expression(\n+ attr_id,\n+ db_connection_name,\n+ reference_objs,\n+ attr_value_reference_field_name,\n+ assigned_attr_model,\n+ assigned_id_field_name,\n+ )\n+\n+\n+def filter_by_contains_referenced_pages(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ \"\"\"Build a filter expression for referenced pages.\n+\n+ Returns a Q expression to filter objects based on their references\n+ to pages.\n+\n+ - If `contains_all` is provided, only objects that reference all of the\n+ specified pages will match.\n+ - If `contains_any` is provided, objects that reference at least one of\n+ the specified pages will match.\n+ \"\"\"\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ shared_filter_params: SharedContainsFilterParams = {\n+ \"attr_id\": attr_id,\n+ \"db_connection_name\": db_connection_name,\n+ \"assigned_attr_model\": assigned_attr_model,\n+ \"assigned_id_field_name\": assigned_id_field_name,\n+ \"identifier_field_name\": \"slug\",\n+ }\n+ if contains_all:\n+ return _filter_contains_all_condition(\n+ contains_all=contains_all,\n+ referenced_model=page_models.Page,\n+ attr_value_reference_field_name=\"reference_page_id\",\n+ **shared_filter_params,\n+ )\n+\n+ if contains_any:\n+ return _filter_contains_any_condition(\n+ contains_any=contains_any,\n+ referenced_model=page_models.Page,\n+ attr_value_reference_field_name=\"reference_page_id\",\n+ **shared_filter_params,\n+ )\n+ return Q()\n+\n+\n+def filter_by_contains_referenced_products(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ \"\"\"Build a filter expression for referenced products.\n+\n+ Returns a Q expression to filter objects based on their references\n+ to products.\n+\n+ - If `contains_all` is provided, only objects that reference all of the\n+ specified products will match.\n+ - If `contains_any` is provided, objects that reference at least one of\n+ the specified products will match.\n+ \"\"\"\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ shared_filter_params: SharedContainsFilterParams = {\n+ \"attr_id\": attr_id,\n+ \"db_connection_name\": db_connection_name,\n+ \"assigned_attr_model\": assigned_attr_model,\n+ \"assigned_id_field_name\": assigned_id_field_name,\n+ \"identifier_field_name\": \"slug\",\n+ }\n+\n+ if contains_all:\n+ return _filter_contains_all_condition(\n+ contains_all=contains_all,\n+ referenced_model=product_models.Product,\n+ attr_value_reference_field_name=\"reference_product_id\",\n+ **shared_filter_params,\n+ )\n+\n+ if contains_any:\n+ return _filter_contains_any_condition(\n+ contains_any=contains_any,\n+ referenced_model=product_models.Product,\n+ attr_value_reference_field_name=\"reference_product_id\",\n+ **shared_filter_params,\n+ )\n+ return Q()\n+\n+\n+def filter_by_contains_referenced_variants(\n+ attr_id: int | None,\n+ attr_value: CONTAINS_TYPING,\n+ db_connection_name: str,\n+ assigned_attr_model: type[AssignedPageAttributeValue],\n+ assigned_id_field_name: Literal[\"page_id\"],\n+):\n+ \"\"\"Build a filter expression for referenced product variants.\n+\n+ Returns a Q expression to filter objects based on their references\n+ to product variants.\n+\n+ - If `contains_all` is provided, only objects that reference all of the\n+ specified variants will match.\n+ - If `contains_any` is provided, objects that reference at least one of\n+ the specified variants will match.\n+ \"\"\"\n+\n+ contains_all = attr_value.get(\"contains_all\")\n+ contains_any = attr_value.get(\"contains_any\")\n+\n+ shared_filter_params: SharedContainsFilterParams = {\n+ \"attr_id\": attr_id,\n+ \"db_connection_name\": db_connection_name,\n+ \"assigned_attr_model\": assigned_attr_model,\n+ \"assigned_id_field_name\": assigned_id_field_name,\n+ \"identifier_field_name\": \"sku\",\n+ }\n+\n+ if contains_all:\n+ return _filter_contains_all_condition(\n+ contains_all=contains_all,\n+ referenced_model=product_models.ProductVariant,\n+ attr_value_reference_field_name=\"reference_variant_id\",\n+ **shared_filter_params,\n+ )\n+\n+ if contains_any:\n+ return _filter_contains_any_condition(\n+ contains_any=contains_any,\n+ referenced_model=product_models.ProductVariant,\n+ attr_value_reference_field_name=\"reference_variant_id\",\n+ **shared_filter_params,\n+ )\n+ return Q()\n"
602
- },
603
- {
604
- "path": "saleor/graphql/core/filters/where_input.py",
605
- "status": "modified",
606
- "diff": "Index: saleor/graphql/core/filters/where_input.py\n===================================================================\n--- saleor/graphql/core/filters/where_input.py\tf3c1a96 (parent)\n+++ saleor/graphql/core/filters/where_input.py\t836d01d (commit)\n@@ -55,8 +55,10 @@\n EQ = \"The value equal to.\"\n ONE_OF = \"The value included in.\"\n NOT_ONE_OF = \"The value not included in.\"\n RANGE = \"The value in range.\"\n+ CONTAINS_ALL = \"The field contains all of the specified values.\"\n+ CONTAINS_ANY = \"The field contains at least one of the specified values.\"\n \n \n class StringFilterInput(graphene.InputObjectType):\n eq = graphene.String(description=FilterInputDescriptions.EQ, required=False)\n@@ -181,4 +183,22 @@\n Matches objects where the metadata key \"color\" is set to either \"blue\" or \"green\".\n - `{key: \"status\", value: {eq: \"active\"}}`\n Matches objects where the metadata key \"status\" is set to \"active\".\n \"\"\"\n+\n+\n+class ContainsFilterInput(graphene.InputObjectType):\n+ contains_any = NonNullList(\n+ graphene.String,\n+ description=FilterInputDescriptions.CONTAINS_ANY,\n+ required=False,\n+ )\n+ contains_all = NonNullList(\n+ graphene.String,\n+ description=FilterInputDescriptions.CONTAINS_ALL,\n+ required=False,\n+ )\n+\n+ class Meta:\n+ description = (\n+ \"Define the filtering options for fields that can contain multiple values.\"\n+ )\n"
607
- },
608
- {
609
- "path": "saleor/graphql/page/filters.py",
610
- "status": "modified",
611
- "diff": "Index: saleor/graphql/page/filters.py\n===================================================================\n--- saleor/graphql/page/filters.py\tf3c1a96 (parent)\n+++ saleor/graphql/page/filters.py\t836d01d (commit)\n@@ -1,4 +1,6 @@\n+from typing import Literal\n+\n import django_filters\n import graphene\n from django.db.models import Exists, FloatField, OuterRef, Q\n from django.db.models.functions import Cast\n@@ -6,8 +8,15 @@\n \n from ...attribute import AttributeInputType\n from ...attribute.models import AssignedPageAttributeValue, Attribute, AttributeValue\n from ...page import models\n+from ..attribute.filters import (\n+ CONTAINS_TYPING,\n+ filter_by_contains_referenced_object_ids,\n+ filter_by_contains_referenced_pages,\n+ filter_by_contains_referenced_products,\n+ filter_by_contains_referenced_variants,\n+)\n from ..core.context import ChannelQsContext\n from ..core.doc_category import DOC_CATEGORY_PAGES\n from ..core.filters import (\n FilterInputObjectType,\n@@ -21,8 +30,9 @@\n MetadataWhereBase,\n OperationObjectTypeWhereFilter,\n )\n from ..core.filters.where_input import (\n+ ContainsFilterInput,\n DecimalFilterInput,\n GlobalIDFilterInput,\n StringFilterInput,\n WhereInputObjectType,\n@@ -211,18 +221,130 @@\n elif attr.input_type == AttributeInputType.DATE_TIME:\n attr_filter_expression &= filter_by_date_time_attribute(\n attr.id, attr_value[\"date_time\"], qs.db\n )\n+ elif attr.input_type == AttributeInputType.REFERENCE:\n+ attr_filter_expression &= filter_pages_by_reference_attributes(\n+ attr.id, attr_value[\"reference\"], qs.db\n+ )\n if attr_filter_expression != Q():\n return qs.filter(attr_filter_expression)\n return qs.none()\n \n \n+def filter_pages_by_reference_attributes(\n+ attr_id: int | None,\n+ attr_value: dict[\n+ Literal[\n+ \"referenced_ids\", \"page_slugs\", \"product_slugs\", \"product_variant_skus\"\n+ ],\n+ CONTAINS_TYPING,\n+ ],\n+ db_connection_name: str,\n+):\n+ filter_expression = Q()\n+\n+ if \"referenced_ids\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_object_ids(\n+ attr_id,\n+ attr_value[\"referenced_ids\"],\n+ db_connection_name,\n+ assigned_attr_model=AssignedPageAttributeValue,\n+ assigned_id_field_name=\"page_id\",\n+ )\n+ if \"page_slugs\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_pages(\n+ attr_id,\n+ attr_value[\"page_slugs\"],\n+ db_connection_name,\n+ assigned_attr_model=AssignedPageAttributeValue,\n+ assigned_id_field_name=\"page_id\",\n+ )\n+ if \"product_slugs\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_products(\n+ attr_id,\n+ attr_value[\"product_slugs\"],\n+ db_connection_name,\n+ assigned_attr_model=AssignedPageAttributeValue,\n+ assigned_id_field_name=\"page_id\",\n+ )\n+ if \"product_variant_skus\" in attr_value:\n+ filter_expression &= filter_by_contains_referenced_variants(\n+ attr_id,\n+ attr_value[\"product_variant_skus\"],\n+ db_connection_name,\n+ assigned_attr_model=AssignedPageAttributeValue,\n+ assigned_id_field_name=\"page_id\",\n+ )\n+ return filter_expression\n+\n+\n+def validate_attribute_value_reference_input(\n+ values: list[\n+ dict[\n+ Literal[\n+ \"referenced_ids\", \"page_slugs\", \"product_slugs\", \"product_variant_skus\"\n+ ],\n+ CONTAINS_TYPING,\n+ ]\n+ | None\n+ ],\n+):\n+ \"\"\"Validate the input for reference attributes.\n+\n+ This function checks if the input for reference attributes is valid.\n+ It raises a GraphQLError if the input is invalid.\n+ \"\"\"\n+ duplicated_error = []\n+ empty_input_value_error = set()\n+ for value in values:\n+ if not value:\n+ raise GraphQLError(\n+ message=\"Invalid input for reference attributes. \"\n+ \"Provided 'value' cannot be null or empty.\"\n+ )\n+ for key in value:\n+ single_key_value = value[key]\n+ if (\n+ \"contains_all\" in single_key_value\n+ and \"contains_any\" in single_key_value\n+ ):\n+ duplicated_error.append(key)\n+ continue\n+ if (\n+ \"contains_all\" in single_key_value\n+ and not single_key_value[\"contains_all\"]\n+ ):\n+ empty_input_value_error.add(key)\n+ continue\n+ if (\n+ \"contains_any\" in single_key_value\n+ and not single_key_value[\"contains_any\"]\n+ ):\n+ empty_input_value_error.add(key)\n+\n+ if empty_input_value_error:\n+ raise GraphQLError(\n+ message=(\n+ f\"Invalid input for reference attributes. For fields: {', '.join(empty_input_value_error)}. \"\n+ f\"Provided values cannot be null or empty.\"\n+ )\n+ )\n+ if duplicated_error:\n+ raise GraphQLError(\n+ message=(\n+ f\"Invalid input for reference attributes. For fields: {', '.join(duplicated_error)}. \"\n+ \"Cannot provide both 'containsAll' and 'containsAny' for the same reference filter.\"\n+ )\n+ )\n+\n+\n def validate_attribute_value_input(attributes: list[dict], db_connection_name: str):\n slug_list = [attr[\"slug\"] for attr in attributes]\n value_as_empty_list = []\n value_more_than_one_list = []\n invalid_input_type_list = []\n+ reference_value_list = []\n if len(slug_list) != len(set(slug_list)):\n raise GraphQLError(\n message=\"Duplicated attribute slugs in attribute 'where' input are not allowed.\"\n )\n@@ -244,8 +366,10 @@\n type_specific_value_list[attr[\"slug\"]] = value_key\n if value[value_key] is None:\n value_as_empty_list.append(attr[\"slug\"])\n continue\n+ if value_key == \"reference\":\n+ reference_value_list.append(value[\"reference\"])\n \n if type_specific_value_list:\n attribute_input_type_map = Attribute.objects.using(db_connection_name).in_bulk(\n type_specific_value_list.keys(),\n@@ -264,9 +388,13 @@\n if \"date_time\" == value_key and input_type != AttributeInputType.DATE_TIME:\n invalid_input_type_list.append(attr_slug)\n if \"boolean\" == value_key and input_type != AttributeInputType.BOOLEAN:\n invalid_input_type_list.append(attr_slug)\n+ if \"reference\" == value_key and input_type != AttributeInputType.REFERENCE:\n+ invalid_input_type_list.append(attr_slug)\n \n+ validate_attribute_value_reference_input(reference_value_list)\n+\n if value_as_empty_list:\n raise GraphQLError(\n message=(\n f\"Incorrect input for attributes with slugs: {','.join(value_as_empty_list)}. \"\n@@ -288,8 +416,28 @@\n )\n )\n \n \n+class ReferenceAttributeWhereInput(BaseInputObjectType):\n+ referenced_ids = ContainsFilterInput(\n+ description=\"Returns objects with a reference pointing to an object identified by the given ID.\",\n+ )\n+ page_slugs = ContainsFilterInput(\n+ description=\"Returns objects with a reference pointing to a page identified by the given slug.\",\n+ )\n+ product_slugs = ContainsFilterInput(\n+ description=(\n+ \"Returns objects with a reference pointing to a product identified by the given slug.\"\n+ )\n+ )\n+ product_variant_skus = ContainsFilterInput(\n+ description=(\n+ \"Returns objects with a reference pointing \"\n+ \"to a product variant identified by the given sku.\"\n+ )\n+ )\n+\n+\n class AttributeValuePageInput(BaseInputObjectType):\n slug = StringFilterInput(\n description=\"Filter by slug assigned to AttributeValue.\",\n )\n@@ -311,8 +459,12 @@\n boolean = graphene.Boolean(\n required=False,\n description=\"Filter by boolean value for attributes of boolean type.\",\n )\n+ reference = ReferenceAttributeWhereInput(\n+ required=False,\n+ description=(\"Filter by reference attribute value.\"),\n+ )\n \n \n class AttributePageWhereInput(BaseInputObjectType):\n slug = graphene.String(description=\"Filter by attribute slug.\", required=True)\n"
612
- },
613
- {
614
- "path": "saleor/graphql/page/tests/queries/test_pages.py",
615
- "status": "modified",
616
- "diff": "Index: saleor/graphql/page/tests/queries/test_pages.py\n===================================================================\n--- saleor/graphql/page/tests/queries/test_pages.py\tf3c1a96 (parent)\n+++ saleor/graphql/page/tests/queries/test_pages.py\t836d01d (commit)\n@@ -140,9 +140,9 @@\n (\"filter_by\", \"pages_count\"),\n [\n ({\"slugs\": [\"test-url-1\"]}, 1),\n ({\"slugs\": [\"test-url-1\", \"test-url-2\"]}, 2),\n- ({\"slugs\": []}, 2),\n+ ({\"slugs\": []}, 4),\n ],\n )\n def test_pages_with_filtering(filter_by, pages_count, staff_api_client, page_list):\n # given\n"
617
- },
618
- {
619
- "path": "saleor/graphql/page/tests/queries/test_pages_with_where.py",
620
- "status": "modified",
621
- "diff": "Index: saleor/graphql/page/tests/queries/test_pages_with_where.py\n===================================================================\n--- saleor/graphql/page/tests/queries/test_pages_with_where.py\tf3c1a96 (parent)\n+++ saleor/graphql/page/tests/queries/test_pages_with_where.py\t836d01d (commit)\n@@ -3,10 +3,12 @@\n import graphene\n import pytest\n \n from .....attribute import AttributeInputType\n+from .....attribute.models import AttributeValue\n from .....attribute.utils import associate_attribute_values_to_instance\n from .....page.models import Page, PageType\n+from ....core.utils import to_global_id_or_none\n from ....tests.utils import get_graphql_content\n \n QUERY_PAGES_WITH_WHERE = \"\"\"\n query ($where: PageWhereInput) {\n@@ -600,8 +602,640 @@\n )\n \n \n @pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 2), (\"containsAll\", 1)]\n+)\n+def test_pages_query_with_attribute_value_reference_to_pages(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ page_type_page_reference_attribute,\n+):\n+ # given\n+ page_type.page_attributes.add(page_type_page_reference_attribute)\n+\n+ reference_page_1_slug = \"referenced-page-1\"\n+ reference_page_2_slug = \"referenced-page-2\"\n+ referenced_page_1, referenced_page_2 = Page.objects.bulk_create(\n+ [\n+ Page(\n+ title=\"Referenced Page 1\",\n+ slug=reference_page_1_slug,\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ Page(\n+ title=\"Referenced Page 2\",\n+ slug=reference_page_2_slug,\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ ]\n+ )\n+\n+ attribute_value_1, attribute_value_2 = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=page_type_page_reference_attribute,\n+ name=f\"Page {referenced_page_1.pk}\",\n+ slug=f\"page-{referenced_page_1.pk}\",\n+ reference_page=referenced_page_1,\n+ ),\n+ AttributeValue(\n+ attribute=page_type_page_reference_attribute,\n+ name=f\"Page {referenced_page_2.pk}\",\n+ slug=f\"page-{referenced_page_2.pk}\",\n+ reference_page=referenced_page_2,\n+ ),\n+ ]\n+ )\n+ page_with_both_references = page_list[0]\n+ associate_attribute_values_to_instance(\n+ page_with_both_references,\n+ {page_type_page_reference_attribute.pk: [attribute_value_1, attribute_value_2]},\n+ )\n+\n+ page_with_single_reference = page_list[1]\n+ associate_attribute_values_to_instance(\n+ page_with_single_reference,\n+ {page_type_page_reference_attribute.pk: [attribute_value_2]},\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": \"page-reference\",\n+ \"value\": {\n+ \"reference\": {\n+ \"pageSlugs\": {\n+ filter_type: [\n+ reference_page_1_slug,\n+ reference_page_2_slug,\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 2), (\"containsAll\", 1)]\n+)\n+def test_pages_query_with_attribute_value_reference_to_products(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ page_type_product_reference_attribute,\n+ product_list,\n+):\n+ # given\n+ page_type.page_attributes.add(page_type_product_reference_attribute)\n+\n+ first_product = product_list[0]\n+ second_product = product_list[1]\n+\n+ attribute_value_1, attribute_value_2 = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=page_type_product_reference_attribute,\n+ name=f\"Product {first_product.pk}\",\n+ slug=f\"product-{first_product.pk}\",\n+ reference_product=first_product,\n+ ),\n+ AttributeValue(\n+ attribute=page_type_product_reference_attribute,\n+ name=f\"Product {second_product.pk}\",\n+ slug=f\"product-{second_product.pk}\",\n+ reference_product=second_product,\n+ ),\n+ ]\n+ )\n+\n+ page_with_both_references = page_list[0]\n+ associate_attribute_values_to_instance(\n+ page_with_both_references,\n+ {\n+ page_type_product_reference_attribute.pk: [\n+ attribute_value_1,\n+ attribute_value_2,\n+ ]\n+ },\n+ )\n+\n+ page_with_single_reference = page_list[1]\n+ associate_attribute_values_to_instance(\n+ page_with_single_reference,\n+ {page_type_product_reference_attribute.pk: [attribute_value_2]},\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": \"product-reference\",\n+ \"value\": {\n+ \"reference\": {\n+ \"productSlugs\": {\n+ filter_type: [first_product.slug, second_product.slug]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == expected_count\n+ assert pages_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"Page\", page_list[0].pk\n+ )\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 2), (\"containsAll\", 1)]\n+)\n+def test_pages_query_with_attribute_value_reference_to_product_variants(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ page_type_variant_reference_attribute,\n+ product_variant_list,\n+):\n+ # given\n+ page_type.page_attributes.add(page_type_variant_reference_attribute)\n+\n+ first_variant_sku = \"test-variant-1\"\n+ second_variant_sku = \"test-variant-2\"\n+\n+ first_variant = product_variant_list[0]\n+ first_variant.sku = first_variant_sku\n+ first_variant.save()\n+\n+ second_variant = product_variant_list[1]\n+ second_variant.sku = second_variant_sku\n+ second_variant.save()\n+\n+ attribute_value_1, attribute_value_2 = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=page_type_variant_reference_attribute,\n+ name=f\"Variant {first_variant.pk}\",\n+ slug=f\"variant-{first_variant.pk}\",\n+ reference_variant=first_variant,\n+ ),\n+ AttributeValue(\n+ attribute=page_type_variant_reference_attribute,\n+ name=f\"Variant {second_variant.pk}\",\n+ slug=f\"variant-{second_variant.pk}\",\n+ reference_variant=second_variant,\n+ ),\n+ ]\n+ )\n+\n+ page_with_both_references = page_list[0]\n+ associate_attribute_values_to_instance(\n+ page_with_both_references,\n+ {\n+ page_type_variant_reference_attribute.pk: [\n+ attribute_value_1,\n+ attribute_value_2,\n+ ]\n+ },\n+ )\n+\n+ page_with_single_reference = page_list[1]\n+ associate_attribute_values_to_instance(\n+ page_with_single_reference,\n+ {page_type_variant_reference_attribute.pk: [attribute_value_2]},\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": \"variant-reference\",\n+ \"value\": {\n+ \"reference\": {\n+ \"productVariantSkus\": {\n+ filter_type: [\n+ first_variant_sku,\n+ second_variant_sku,\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == expected_count\n+ assert pages_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"Page\", page_list[0].pk\n+ )\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 3), (\"containsAll\", 2)]\n+)\n+def test_pages_query_with_attribute_value_referenced_page_ids(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ page_type_page_reference_attribute,\n+):\n+ # given\n+ page_type.page_attributes.add(\n+ page_type_page_reference_attribute,\n+ )\n+\n+ referenced_first_page, referenced_second_page, referenced_third_page = (\n+ Page.objects.bulk_create(\n+ [\n+ Page(\n+ title=\"Referenced Page\",\n+ slug=\"referenced-page\",\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ Page(\n+ title=\"Referenced Page\",\n+ slug=\"referenced-page2\",\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ Page(\n+ title=\"Referenced Page\",\n+ slug=\"referenced-page3\",\n+ page_type=page_type,\n+ is_published=True,\n+ ),\n+ ]\n+ )\n+ )\n+\n+ first_attr_value, second_attr_value, third_attr_value = (\n+ AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=page_type_page_reference_attribute,\n+ name=f\"Page {referenced_first_page.pk}\",\n+ slug=f\"page-{referenced_first_page.pk}\",\n+ reference_page=referenced_first_page,\n+ ),\n+ AttributeValue(\n+ attribute=page_type_page_reference_attribute,\n+ name=f\"Page {referenced_second_page.pk}\",\n+ slug=f\"page-{referenced_second_page.pk}\",\n+ reference_page=referenced_second_page,\n+ ),\n+ AttributeValue(\n+ attribute=page_type_page_reference_attribute,\n+ name=f\"Page {referenced_third_page.pk}\",\n+ slug=f\"page-{referenced_third_page.pk}\",\n+ reference_page=referenced_third_page,\n+ ),\n+ ]\n+ )\n+ )\n+ fist_page_with_all_ids = page_list[0]\n+ second_page_with_all_ids = page_list[1]\n+ page_with_single_id = page_list[2]\n+ associate_attribute_values_to_instance(\n+ fist_page_with_all_ids,\n+ {\n+ page_type_page_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ second_page_with_all_ids,\n+ {\n+ page_type_page_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page_with_single_id,\n+ {page_type_page_reference_attribute.pk: [first_attr_value]},\n+ )\n+\n+ referenced_first_global_id = to_global_id_or_none(referenced_first_page)\n+ referenced_second_global_id = to_global_id_or_none(referenced_second_page)\n+ referenced_third_global_id = to_global_id_or_none(referenced_third_page)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": page_type_page_reference_attribute.slug,\n+ \"value\": {\n+ \"reference\": {\n+ \"referencedIds\": {\n+ filter_type: [\n+ referenced_first_global_id,\n+ referenced_second_global_id,\n+ referenced_third_global_id,\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(page_list) > len(pages_nodes)\n+ assert len(pages_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 3), (\"containsAll\", 2)]\n+)\n+def test_pages_query_with_attribute_value_referenced_variant_ids(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ page_type_variant_reference_attribute,\n+ product_variant_list,\n+):\n+ # given\n+ page_type.page_attributes.add(\n+ page_type_variant_reference_attribute,\n+ )\n+\n+ first_variant = product_variant_list[0]\n+ second_variant = product_variant_list[1]\n+ third_variant = product_variant_list[2]\n+\n+ first_attr_value, second_attr_value, third_attr_value = (\n+ AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=page_type_variant_reference_attribute,\n+ name=f\"Variant {first_variant.pk}\",\n+ slug=f\"variant-{first_variant.pk}\",\n+ reference_variant=first_variant,\n+ ),\n+ AttributeValue(\n+ attribute=page_type_variant_reference_attribute,\n+ name=f\"Variant {second_variant.pk}\",\n+ slug=f\"variant-{second_variant.pk}\",\n+ reference_variant=second_variant,\n+ ),\n+ AttributeValue(\n+ attribute=page_type_variant_reference_attribute,\n+ name=f\"Variant {third_variant.pk}\",\n+ slug=f\"variant-{third_variant.pk}\",\n+ reference_variant=third_variant,\n+ ),\n+ ]\n+ )\n+ )\n+ fist_page_with_all_ids = page_list[0]\n+ second_page_with_all_ids = page_list[1]\n+ page_with_single_id = page_list[2]\n+ associate_attribute_values_to_instance(\n+ fist_page_with_all_ids,\n+ {\n+ page_type_variant_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ second_page_with_all_ids,\n+ {\n+ page_type_variant_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page_with_single_id,\n+ {page_type_variant_reference_attribute.pk: [first_attr_value]},\n+ )\n+ referenced_first_global_id = to_global_id_or_none(first_variant)\n+ referenced_second_global_id = to_global_id_or_none(second_variant)\n+ referenced_third_global_id = to_global_id_or_none(third_variant)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": page_type_variant_reference_attribute.slug,\n+ \"value\": {\n+ \"reference\": {\n+ \"referencedIds\": {\n+ filter_type: [\n+ referenced_first_global_id,\n+ referenced_second_global_id,\n+ referenced_third_global_id,\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(page_list) > len(pages_nodes)\n+ assert len(pages_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"filter_type\", \"expected_count\"), [(\"containsAny\", 3), (\"containsAll\", 2)]\n+)\n+def test_pages_query_with_attribute_value_referenced_product_ids(\n+ filter_type,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ page_type_product_reference_attribute,\n+ product_list,\n+):\n+ # given\n+ page_type.page_attributes.add(\n+ page_type_product_reference_attribute,\n+ )\n+ first_product = product_list[0]\n+ second_product = product_list[1]\n+ third_product = product_list[2]\n+\n+ first_attr_value, second_attr_value, third_attr_value = (\n+ AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(\n+ attribute=page_type_product_reference_attribute,\n+ name=f\"Product {first_product.pk}\",\n+ slug=f\"Product-{first_product.pk}\",\n+ reference_product=first_product,\n+ ),\n+ AttributeValue(\n+ attribute=page_type_product_reference_attribute,\n+ name=f\"Product {second_product.pk}\",\n+ slug=f\"product-{second_product.pk}\",\n+ reference_product=second_product,\n+ ),\n+ AttributeValue(\n+ attribute=page_type_product_reference_attribute,\n+ name=f\"Product {third_product.pk}\",\n+ slug=f\"Product-{third_product.pk}\",\n+ reference_product=third_product,\n+ ),\n+ ]\n+ )\n+ )\n+ fist_page_with_all_ids = page_list[0]\n+ second_page_with_all_ids = page_list[1]\n+ page_with_single_id = page_list[2]\n+ associate_attribute_values_to_instance(\n+ fist_page_with_all_ids,\n+ {\n+ page_type_product_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ second_page_with_all_ids,\n+ {\n+ page_type_product_reference_attribute.pk: [\n+ first_attr_value,\n+ second_attr_value,\n+ third_attr_value,\n+ ],\n+ },\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page_with_single_id,\n+ {\n+ page_type_product_reference_attribute.pk: [\n+ first_attr_value,\n+ ],\n+ },\n+ )\n+ referenced_first_global_id = to_global_id_or_none(first_product)\n+ referenced_second_global_id = to_global_id_or_none(second_product)\n+ referenced_third_global_id = to_global_id_or_none(third_product)\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": page_type_product_reference_attribute.slug,\n+ \"value\": {\n+ \"reference\": {\n+ \"referencedIds\": {\n+ filter_type: [\n+ referenced_first_global_id,\n+ referenced_second_global_id,\n+ referenced_third_global_id,\n+ ]\n+ }\n+ }\n+ },\n+ },\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(page_list) > len(pages_nodes)\n+ assert len(pages_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n \"attribute_filter\",\n [\n # When input receives None\n [{\"slug\": \"page-size\"}, {\"slug\": \"page-size\"}],\n@@ -658,8 +1292,160 @@\n [{\"slug\": \"date_time\", \"value\": {\"name\": None}}],\n [{\"slug\": \"date_time\", \"value\": {\"slug\": None}}],\n # Date time can't be used with non date time fields\n [{\"slug\": \"date_time\", \"value\": {\"numeric\": {\"eq\": 1.2}}}],\n+ # Reference attribute\n+ [\n+ {\n+ \"slug\": \"date_time\",\n+ \"value\": {\n+ \"reference\": {\n+ \"pageSlugs\": {\n+ \"containsAll\": [\n+ \"about\",\n+ ]\n+ }\n+ }\n+ },\n+ }\n+ ],\n+ [{\"slug\": \"reference-product\", \"value\": {}}],\n+ [{\"slug\": \"reference-product\", \"value\": {\"reference\": {}}}],\n+ [{\"slug\": \"reference-product\", \"value\": {\"reference\": None}}],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"referencedIds\": {\"containsAll\": []}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"pageSlugs\": {\"containsAll\": []}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"productSlugs\": {\"containsAll\": []}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"productVariantSkus\": {\"containsAll\": []}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"pageSlugs\": {\"containsAny\": []}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"productSlugs\": {\"containsAny\": []}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"productVariantSkus\": {\"containsAny\": []}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"referencedIds\": {\"containsAny\": []}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\n+ \"reference\": {\"pageSlugs\": {\"containsAny\": [], \"containsAll\": []}}\n+ },\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\n+ \"reference\": {\n+ \"productSlugs\": {\"containsAny\": [], \"containsAll\": []}\n+ }\n+ },\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\n+ \"reference\": {\n+ \"productVariantSkus\": {\"containsAny\": [], \"containsAll\": []}\n+ }\n+ },\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\n+ \"reference\": {\n+ \"referencedIds\": {\"containsAny\": [], \"containsAll\": []}\n+ }\n+ },\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"referencedIds\": {\"containsAll\": None}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"pageSlugs\": {\"containsAll\": None}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"productSlugs\": {\"containsAll\": None}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"productVariantSkus\": {\"containsAll\": None}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"pageSlugs\": {\"containsAny\": None}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"productSlugs\": {\"containsAny\": None}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"productVariantSkus\": {\"containsAny\": None}}},\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"reference-product\",\n+ \"value\": {\"reference\": {\"referencedIds\": {\"containsAny\": None}}},\n+ }\n+ ],\n ],\n )\n def test_pages_query_failed_filter_validation(\n attribute_filter,\n@@ -671,15 +1457,22 @@\n boolean_attribute,\n numeric_attribute_without_unit,\n date_attribute,\n date_time_attribute,\n+ page_type_product_reference_attribute,\n ):\n # given\n boolean_attribute.type = \"PAGE_TYPE\"\n boolean_attribute.save()\n numeric_attribute_without_unit.type = \"PAGE_TYPE\"\n numeric_attribute_without_unit.save()\n \n+ page_type_product_reference_attribute.slug = \"reference-product\"\n+ page_type_product_reference_attribute.save()\n+\n+ page_type.page_attributes.add(\n+ page_type_product_reference_attribute,\n+ )\n page_type.page_attributes.add(size_page_attribute)\n page_type.page_attributes.add(tag_page_attribute)\n page_type.page_attributes.add(boolean_attribute)\n page_type.page_attributes.add(numeric_attribute_without_unit)\n"
622
- },
623
- {
624
- "path": "saleor/graphql/schema.graphql",
625
- "status": "modified",
626
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\tf3c1a96 (parent)\n+++ saleor/graphql/schema.graphql\t836d01d (commit)\n@@ -13033,10 +13033,46 @@\n dateTime: DateTimeRangeInput\n \n \"\"\"Filter by boolean value for attributes of boolean type.\"\"\"\n boolean: Boolean\n+\n+ \"\"\"Filter by reference attribute value.\"\"\"\n+ reference: ReferenceAttributeWhereInput\n }\n \n+input ReferenceAttributeWhereInput {\n+ \"\"\"\n+ Returns objects with a reference pointing to an object identified by the given ID.\n+ \"\"\"\n+ referencedIds: ContainsFilterInput\n+\n+ \"\"\"\n+ Returns objects with a reference pointing to a page identified by the given slug.\n+ \"\"\"\n+ pageSlugs: ContainsFilterInput\n+\n+ \"\"\"\n+ Returns objects with a reference pointing to a product identified by the given slug.\n+ \"\"\"\n+ productSlugs: ContainsFilterInput\n+\n+ \"\"\"\n+ Returns objects with a reference pointing to a product variant identified by the given sku.\n+ \"\"\"\n+ productVariantSkus: ContainsFilterInput\n+}\n+\n+\"\"\"\n+Define the filtering options for fields that can contain multiple values.\n+\"\"\"\n+input ContainsFilterInput {\n+ \"\"\"The field contains at least one of the specified values.\"\"\"\n+ containsAny: [String!]\n+\n+ \"\"\"The field contains all of the specified values.\"\"\"\n+ containsAll: [String!]\n+}\n+\n type PageTypeCountableConnection @doc(category: \"Pages\") {\n \"\"\"Pagination data for this connection.\"\"\"\n pageInfo: PageInfo!\n edges: [PageTypeCountableEdge!]!\n"
627
- },
628
- {
629
- "path": "saleor/page/tests/fixtures/page.py",
630
- "status": "modified",
631
- "diff": "Index: saleor/page/tests/fixtures/page.py\n===================================================================\n--- saleor/page/tests/fixtures/page.py\tf3c1a96 (parent)\n+++ saleor/page/tests/fixtures/page.py\t836d01d (commit)\n@@ -62,9 +62,25 @@\n \"content\": dummy_editorjs(\"Test content.\"),\n \"is_published\": True,\n \"page_type\": page_type,\n }\n- pages = Page.objects.bulk_create([Page(**data_1), Page(**data_2)])\n+ data_3 = {\n+ \"slug\": \"test3\",\n+ \"title\": \"Test page3\",\n+ \"content\": dummy_editorjs(\"Test content.\"),\n+ \"is_published\": True,\n+ \"page_type\": page_type,\n+ }\n+ data_4 = {\n+ \"slug\": \"test4\",\n+ \"title\": \"Test page4\",\n+ \"content\": dummy_editorjs(\"Test content.\"),\n+ \"is_published\": True,\n+ \"page_type\": page_type,\n+ }\n+ pages = Page.objects.bulk_create(\n+ [Page(**data_1), Page(**data_2), Page(**data_3), Page(**data_4)]\n+ )\n return pages\n \n \n @pytest.fixture\n"
632
- }
633
- ]
634
- },
635
- {
636
- "id": "add-adyen-fields",
637
- "sha": "da430e432703295dae09966d92988f1aeb1a03d2",
638
- "parentSha": "41b751454f72f34ed1f0095305c65700502a053c",
639
- "spec": "Implement dedicated Adyen-specific fields and wire them through the payment flow, deprecating reliance on Transaction.gateway_response for post-processing.\n\nScope:\n- Add two new nullable TextFields to the Transaction model for Adyen: legacy_adyen_plugin_result_code and legacy_adyen_plugin_payment_method. Include a schema migration to add these fields. Add comments in the model indicating their temporary/legacy nature and the motivation (decoupling from gateway_response).\n- Extend the GatewayResponse dataclass to include matching optional fields (legacy_adyen_plugin_result_code, legacy_adyen_plugin_payment_method). Document these as temporary and keep raw_response marked as deprecated for Adyen.\n- When persisting a new Transaction from a GatewayResponse (in the payment utils), copy the two new dataclass fields onto the Transaction instance in addition to existing fields (error, customer_id, action_required_data, gateway_response, etc.).\n- Update the Adyen gateway plugin to:\n - Import logging and define a module-level logger.\n - Populate the new dataclass fields for all methods that produce a GatewayResponse based on Adyen API responses or notifications, by extracting and normalizing (strip, lower) resultCode and paymentMethod from the Adyen payload. Keep raw_response population with an inline deprecation comment.\n - In confirm_payment, prefer reading the normalized values from Transaction.legacy_adyen_plugin_result_code and Transaction.legacy_adyen_plugin_payment_method to determine TransactionKind. If neither field is present, log a warning with the exact message: \"Reading deprecated raw_response from Adyen plugin.\" and then fall back to reading transaction.gateway_response[\"resultCode\"] and [\"paymentMethod\"] (strip, lower). Preserve existing logic for PENDING_STATUSES, AUTH_STATUS/iDEAL capture, and defaulting.\n - In flows that create a confirmation/callback response without a raw gateway payload (e.g., token-based confirmation), pass raw_response as an empty object with an inline deprecation comment.\n- Update Adyen webhook handlers so that whenever they create a GatewayResponse for a new transaction, they also set the two new fields using the normalized values from the notification payload.\n- Adjust tests that construct GatewayResponse in the Adyen plugin tests to include the new fields (use values derived from the test payload), ensuring assertions still pass and behavior is unchanged aside from the additional writes. Keep raw_response usage, but tests should not rely on it for confirm_payment logic when the new fields are present.\n\nAcceptance/observable outcomes:\n- Database has two new nullable TextFields on Transaction for Adyen. Migration applies cleanly and is ordered after the current latest payment migration.\n- Adyen-originated Transactions persist legacy_adyen_plugin_result_code and legacy_adyen_plugin_payment_method alongside existing data.\n- confirm_payment uses the new Transaction fields if present; if absent, it emits a single warning with the specified message and falls back to gateway_response keys.\n- Existing behavior for computing TransactionKind remains consistent (pending mapping, iDEAL special case, etc.).\n- All updated integrations (plugin methods and webhooks) continue to include raw_response but mark it as deprecated in code comments.\n- Updated tests compile and pass, now supplying the new fields when constructing GatewayResponse instances for Adyen plugin flows.",
640
- "prompt": "Introduce dedicated, temporary fields to carry Adyen-specific result code and payment method through the payment layer so the system no longer relies on reading these from the stored raw gateway response. Extend the response dataclass to include them, persist them in transactions, and have the Adyen plugin and webhooks populate them from Adyen payloads. Update the confirmation path to prefer these fields when computing transaction kind, falling back to the old raw response with a warning if they are absent. Keep raw response writes for now but mark them as deprecated in code comments. Update affected tests to include the new fields.",
641
- "supplementalFiles": [
642
- "saleor/payment/gateway.py",
643
- "saleor/webhook/transport/payment.py"
644
- ],
645
- "fileDiffs": [
646
- {
647
- "path": "saleor/payment/gateways/adyen/plugin.py",
648
- "status": "modified",
649
- "diff": "Index: saleor/payment/gateways/adyen/plugin.py\n===================================================================\n--- saleor/payment/gateways/adyen/plugin.py\t41b7514 (parent)\n+++ saleor/payment/gateways/adyen/plugin.py\tda430e4 (commit)\n@@ -1,5 +1,6 @@\n import json\n+import logging\n from typing import Optional\n from urllib.parse import urlencode, urljoin\n \n from django.contrib.auth.hashers import make_password\n@@ -48,9 +49,11 @@\n GATEWAY_NAME = \"Adyen\"\n WEBHOOK_PATH = \"/webhooks\"\n ADDITIONAL_ACTION_PATH = \"/additional-actions\"\n \n+logger = logging.getLogger(__name__)\n \n+\n class AdyenGatewayPlugin(BasePlugin):\n PLUGIN_ID = \"mirumee.payments.adyen\"\n PLUGIN_NAME = GATEWAY_NAME\n CONFIGURATION_PER_CHANNEL = True\n@@ -464,12 +467,19 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=error_message,\n+ # @deprecated\n raw_response=result.message,\n action_required_data=action,\n payment_method_info=payment_method_info,\n psp_reference=psp_reference,\n+ legacy_adyen_plugin_payment_method=result.message.get(\"paymentMethod\", \"\")\n+ .strip()\n+ .lower(),\n+ legacy_adyen_plugin_result_code=result.message.get(\"resultCode\", \"\")\n+ .strip()\n+ .lower(),\n )\n \n @classmethod\n def _update_config_items(\n@@ -541,11 +551,18 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=result.message.get(\"refusalReason\"),\n+ # @deprecated\n raw_response=result.message,\n psp_reference=result.message.get(\"pspReference\", \"\"),\n payment_method_info=payment_method_info,\n+ legacy_adyen_plugin_payment_method=result.message.get(\"paymentMethod\", \"\")\n+ .strip()\n+ .lower(),\n+ legacy_adyen_plugin_result_code=result.message.get(\"resultCode\", \"\")\n+ .strip()\n+ .lower(),\n )\n \n def confirm_payment(\n self, payment_information: \"PaymentData\", previous_value\n@@ -594,12 +611,29 @@\n # We don't have async notification for this payment so we try to proceed\n # standard flow for confirming an additional action\n return self._process_additional_action(payment_information, kind)\n \n- result_code = transaction.gateway_response.get(\"resultCode\", \"\").strip().lower()\n- payment_method = (\n- transaction.gateway_response.get(\"paymentMethod\", \"\").strip().lower()\n- )\n+ result_code_temporary_field = transaction.legacy_adyen_plugin_result_code\n+ payment_method_temporary_field = transaction.legacy_adyen_plugin_payment_method\n+\n+ if (not result_code_temporary_field) and (not payment_method_temporary_field):\n+ # Track legacy reads, so we keep grace period in case of enqueued messages\n+ logger.warning(\"Reading deprecated raw_response from Adyen plugin.\")\n+\n+ if result_code_temporary_field:\n+ result_code = result_code_temporary_field\n+ else:\n+ result_code = (\n+ transaction.gateway_response.get(\"resultCode\", \"\").strip().lower()\n+ )\n+\n+ if payment_method_temporary_field:\n+ payment_method = payment_method_temporary_field\n+ else:\n+ payment_method = (\n+ transaction.gateway_response.get(\"paymentMethod\", \"\").strip().lower()\n+ )\n+\n if result_code and result_code in PENDING_STATUSES:\n kind = TransactionKind.PENDING\n elif result_code == AUTH_STATUS and payment_method == \"ideal\":\n kind = TransactionKind.CAPTURE\n@@ -637,8 +671,9 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=token,\n error=None,\n+ # @deprecated\n raw_response={},\n transaction_already_processed=bool(transaction_already_processed),\n psp_reference=token,\n )\n@@ -705,10 +740,17 @@\n amount=amount,\n currency=currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=\"\",\n+ # @deprecated\n raw_response=result.message,\n psp_reference=result.message.get(\"pspReference\", \"\"),\n+ legacy_adyen_plugin_payment_method=result.message.get(\"paymentMethod\", \"\")\n+ .strip()\n+ .lower(),\n+ legacy_adyen_plugin_result_code=result.message.get(\"resultCode\", \"\")\n+ .strip()\n+ .lower(),\n )\n \n def capture_payment(\n self, payment_information: \"PaymentData\", previous_value\n@@ -735,11 +777,18 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=\"\",\n+ # @deprecated\n raw_response=result.message,\n payment_method_info=payment_method_info,\n psp_reference=result.message.get(\"pspReference\", \"\"),\n+ legacy_adyen_plugin_payment_method=result.message.get(\"paymentMethod\", \"\")\n+ .strip()\n+ .lower(),\n+ legacy_adyen_plugin_result_code=result.message.get(\"resultCode\", \"\")\n+ .strip()\n+ .lower(),\n )\n \n def void_payment(\n self, payment_information: \"PaymentData\", previous_value\n@@ -766,10 +815,17 @@\n amount=payment_information.amount,\n currency=payment_information.currency,\n transaction_id=result.message.get(\"pspReference\", \"\"),\n error=\"\",\n+ # @deprecated\n raw_response=result.message,\n psp_reference=result.message.get(\"pspReference\", \"\"),\n+ legacy_adyen_plugin_payment_method=result.message.get(\"paymentMethod\", \"\")\n+ .strip()\n+ .lower(),\n+ legacy_adyen_plugin_result_code=result.message.get(\"resultCode\", \"\")\n+ .strip()\n+ .lower(),\n )\n \n @classmethod\n def validate_plugin_configuration(\n"
650
- },
651
- {
652
- "path": "saleor/payment/gateways/adyen/tests/test_plugin.py",
653
- "status": "modified",
654
- "diff": "Index: saleor/payment/gateways/adyen/tests/test_plugin.py\n===================================================================\n--- saleor/payment/gateways/adyen/tests/test_plugin.py\t41b7514 (parent)\n+++ saleor/payment/gateways/adyen/tests/test_plugin.py\tda430e4 (commit)\n@@ -46,8 +46,10 @@\n error=None,\n raw_response=expected_message,\n psp_reference=\"ref-id\",\n payment_method_info=PaymentMethodInfo(),\n+ legacy_adyen_plugin_result_code=expected_message.get(\"resultCode\"),\n+ legacy_adyen_plugin_payment_method=\"\",\n )\n mocked_api_call.assert_called_with(\n dummy_payment_data.data, plugin.adyen.checkout.payments_details\n )\n"
655
- },
656
- {
657
- "path": "saleor/payment/gateways/adyen/webhooks.py",
658
- "status": "modified",
659
- "diff": "Index: saleor/payment/gateways/adyen/webhooks.py\n===================================================================\n--- saleor/payment/gateways/adyen/webhooks.py\t41b7514 (parent)\n+++ saleor/payment/gateways/adyen/webhooks.py\tda430e4 (commit)\n@@ -152,8 +152,14 @@\n currency=currency,\n error=\"\",\n raw_response=notification,\n psp_reference=transaction_id,\n+ legacy_adyen_plugin_payment_method=notification.get(\"paymentMethod\", \"\")\n+ .strip()\n+ .lower(),\n+ legacy_adyen_plugin_result_code=notification.get(\"resultCode\", \"\")\n+ .strip()\n+ .lower(),\n )\n return create_transaction(\n payment,\n kind=kind,\n@@ -1167,8 +1173,14 @@\n error=error_message,\n raw_response=response.message,\n action_required_data=response.message.get(\"action\"),\n psp_reference=response.message.get(\"pspReference\", \"\"),\n+ legacy_adyen_plugin_payment_method=response.message.get(\"paymentMethod\", \"\")\n+ .strip()\n+ .lower(),\n+ legacy_adyen_plugin_result_code=response.message.get(\"resultCode\", \"\")\n+ .strip()\n+ .lower(),\n )\n \n create_transaction(\n payment=payment,\n"
660
- },
661
- {
662
- "path": "saleor/payment/interface.py",
663
- "status": "modified",
664
- "diff": "Index: saleor/payment/interface.py\n===================================================================\n--- saleor/payment/interface.py\t41b7514 (parent)\n+++ saleor/payment/interface.py\tda430e4 (commit)\n@@ -315,16 +315,22 @@\n transaction_id: str\n error: str | None\n customer_id: str | None = None\n payment_method_info: PaymentMethodInfo | None = None\n+ # @deprecated\n raw_response: dict[str, str] | None = None\n action_required_data: JSONType | None = None\n # Some gateway can process transaction asynchronously. This value define if we\n # should create new transaction based on this response\n transaction_already_processed: bool = False\n psp_reference: str | None = None\n \n+ # Temporary pass Adyen-plugin-specific data to model, so we can drop raw_response\n+ # After the plugin is gone, this should be removed\n+ legacy_adyen_plugin_result_code: str | None = None\n+ legacy_adyen_plugin_payment_method: str | None = None\n \n+\n @dataclass\n class AddressData:\n first_name: str\n last_name: str\n"
665
- },
666
- {
667
- "path": "saleor/payment/migrations/0064_transaction_legacy_adyen_plugin_payment_method_and_more.py",
668
- "status": "added",
669
- "diff": "Index: saleor/payment/migrations/0064_transaction_legacy_adyen_plugin_payment_method_and_more.py\n===================================================================\n--- saleor/payment/migrations/0064_transaction_legacy_adyen_plugin_payment_method_and_more.py\t41b7514 (parent)\n+++ saleor/payment/migrations/0064_transaction_legacy_adyen_plugin_payment_method_and_more.py\tda430e4 (commit)\n@@ -1,1 +1,22 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-07-08 08:49\n+\n+from django.db import migrations, models\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"payment\", \"0063_transactionitem_payment_method_type_ids_and_more\"),\n+ ]\n+\n+ operations = [\n+ migrations.AddField(\n+ model_name=\"transaction\",\n+ name=\"legacy_adyen_plugin_payment_method\",\n+ field=models.TextField(null=True),\n+ ),\n+ migrations.AddField(\n+ model_name=\"transaction\",\n+ name=\"legacy_adyen_plugin_result_code\",\n+ field=models.TextField(null=True),\n+ ),\n+ ]\n"
670
- },
671
- {
672
- "path": "saleor/payment/models.py",
673
- "status": "modified",
674
- "diff": "Index: saleor/payment/models.py\n===================================================================\n--- saleor/payment/models.py\t41b7514 (parent)\n+++ saleor/payment/models.py\tda430e4 (commit)\n@@ -466,11 +466,24 @@\n default=Decimal(\"0.0\"),\n )\n error = models.TextField(null=True)\n customer_id = models.CharField(max_length=256, null=True)\n+ # @deprecated\n gateway_response = JSONField(encoder=DjangoJSONEncoder)\n already_processed = models.BooleanField(default=False)\n \n+ \"\"\"\n+ Legacy fields that allow Adyen plugin to work until it's removed.\n+\n+ Previously Adyen plugin was using gateway_response which holds entire response for every Payment plugin.\n+ Adyen plugin is the only plugin using this field, it has access to result_code and payment_method.\n+\n+ To remove gateway_response we introduce two legacy fields that Adyen can write to and gateway_response can be removed.\n+ Once plugin is removed, these fields should be removed from the model.\n+ \"\"\"\n+ legacy_adyen_plugin_result_code = models.TextField(null=True)\n+ legacy_adyen_plugin_payment_method = models.TextField(null=True)\n+\n class Meta:\n ordering = (\"pk\",)\n indexes = [\n GinIndex(\n"
675
- },
676
- {
677
- "path": "saleor/payment/utils.py",
678
- "status": "modified",
679
- "diff": "Index: saleor/payment/utils.py\n===================================================================\n--- saleor/payment/utils.py\t41b7514 (parent)\n+++ saleor/payment/utils.py\tda430e4 (commit)\n@@ -487,8 +487,10 @@\n error=gateway_response.error,\n customer_id=gateway_response.customer_id,\n gateway_response=gateway_response.raw_response or {},\n action_required_data=gateway_response.action_required_data or {},\n+ legacy_adyen_plugin_result_code=gateway_response.legacy_adyen_plugin_result_code,\n+ legacy_adyen_plugin_payment_method=gateway_response.legacy_adyen_plugin_payment_method,\n )\n return txn\n \n \n"
680
- }
681
- ]
682
- },
683
- {
684
- "id": "extend-category-filter",
685
- "sha": "1088948818eea49b9b23bf58a8b9dfd711bab426",
686
- "parentSha": "550fb2283ec99bb059546db9b66a8e26b33a044b",
687
- "spec": "Implement subcategory-inclusive product filtering for GraphQL where filters.\n\nScope:\n- GraphQL product where filters (saleor/graphql/product/filters.py)\n- GraphQL tests for products where query (saleor/graphql/product/tests/queries/test_products_query_with_where.py)\n- Changelog entry (CHANGELOG.md)\n\nRequirements:\n1) Add a helper that filters by categories and their subcategories for where filters.\n- In saleor/graphql/product/filters.py, introduce a function named where_filter_by_categories(qs, value) that:\n - Accepts a queryset and a where filter value object that may contain eq or one_of fields with Category global IDs.\n - Returns qs.none() when value is empty or no valid IDs are provided.\n - Uses existing global ID resolution helper to convert provided Category global IDs into primary keys.\n - Uses the existing category-tree-based product filtering helper (filter_products_by_categories) to filter products by the resolved category primary keys, including subcategories.\n\n2) Replace the existing ProductWhere.filter_category implementation to use the new helper.\n- In saleor/graphql/product/filters.py, within the ProductWhere class, update the filter_category static method to delegate to where_filter_by_categories, replacing any behavior that only matched exact Category IDs.\n- Preserve the where filter structure and expected input shape (the category field supports operations like eq and one_of via OperationObjectTypeWhereFilter and StringFilterInput/ID filter input).\n\n3) Add a test case to validate subcategory filtering via where.\n- In saleor/graphql/product/tests/queries/test_products_query_with_where.py, add a test named test_product_filter_by_subcategories that:\n - Creates a parent category and assigns two subcategories to it.\n - Assigns two products to the respective subcategories.\n - Queries products with where.category.eq set to the parent's Category global ID and a channel provided.\n - Asserts that exactly the two products from the subcategories are returned.\n\n4) Document the behavior in the changelog.\n- In CHANGELOG.md, under the relevant section describing GraphQL changes, add a bullet stating that filtering products by category now includes subcategories to clarify the new behavior.\n\nBehavioral outcome:\n- The products GraphQL where filter category field will include products assigned to the specified category and to any of its descendant subcategories when filtering by a parent category.",
688
- "prompt": "Add support for including subcategories when filtering products by category in the GraphQL products where filter. Update the category where filter so that providing a parent category returns products from that category and all its descendant subcategories. Add a test that sets up a parent category with two subcategories, assigns products to the subcategories, queries with the parent category, and expects both products to be returned. Document this behavior in the changelog.",
689
- "supplementalFiles": [
690
- "saleor/product/models.py",
691
- "saleor/graphql/product/schema.py",
692
- "saleor/graphql/core/filters/where_filters.py",
693
- "saleor/graphql/utils/__init__.py"
694
- ],
695
- "fileDiffs": [
696
- {
697
- "path": "CHANGELOG.md",
698
- "status": "modified",
699
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\t550fb22 (parent)\n+++ CHANGELOG.md\t1088948 (commit)\n@@ -81,8 +81,9 @@\n - `pageType.availableAttributes`\n - Extend `AttributeEntityType` with `CATEGORY` and `COLLECTION`. You can now assign category and collection as a attribute reference.\n - Attribute values now expose the `referencedObject`, allowing for easier access to the linked entity.\n - You can now filter and search attribute choices using the new `where` and `search` fields on the `attribute.choices` query.\n+- Filtering products by `category` now also includes subcategories. The filter will return products that belong to the specified categories as well as their subcategories.\n - Deprecated `Transaction.gatewayResponse` field. Please migrate to Transaction API and Apps.\n - Extend the `Attribute` type with a `values` field, allowing you to retrieve all values assigned to a specific attribute.\n \n ### Webhooks\n"
700
- },
701
- {
702
- "path": "saleor/graphql/product/filters.py",
703
- "status": "modified",
704
- "diff": "Index: saleor/graphql/product/filters.py\n===================================================================\n--- saleor/graphql/product/filters.py\t550fb22 (parent)\n+++ saleor/graphql/product/filters.py\t1088948 (commit)\n@@ -1106,8 +1106,24 @@\n return qs.none()\n return filter_where_range_field_with_conditions(qs, \"updated_at\", value)\n \n \n+def where_filter_by_categories(qs, value):\n+ \"\"\"Filter products by categories and subcategories of provided categories.\"\"\"\n+ if not value:\n+ return qs.none()\n+ eq = value.get(\"eq\")\n+ one_of = value.get(\"one_of\")\n+ pks = None\n+ if eq and isinstance(eq, str):\n+ _, pks = resolve_global_ids_to_primary_keys([eq], \"Category\", True)\n+ if one_of:\n+ _, pks = resolve_global_ids_to_primary_keys(one_of, \"Category\", True)\n+ if pks:\n+ return filter_products_by_categories(qs, pks)\n+ return qs.none()\n+\n+\n class ProductWhere(MetadataWhereFilterBase):\n ids = GlobalIDMultipleChoiceWhereFilter(method=filter_by_ids(\"Product\"))\n name = OperationObjectTypeWhereFilter(\n input_class=StringFilterInput,\n@@ -1214,9 +1230,9 @@\n return filter_where_by_id_field(qs, \"product_type\", value, \"ProductType\")\n \n @staticmethod\n def filter_category(qs, _, value):\n- return filter_where_by_id_field(qs, \"category\", value, \"Category\")\n+ return where_filter_by_categories(qs, value)\n \n @staticmethod\n def filter_collection(qs, _, value):\n collection_products_qs = CollectionProduct.objects.using(qs.db).filter()\n"
705
- },
706
- {
707
- "path": "saleor/graphql/product/tests/queries/test_products_query_with_where.py",
708
- "status": "modified",
709
- "diff": "Index: saleor/graphql/product/tests/queries/test_products_query_with_where.py\n===================================================================\n--- saleor/graphql/product/tests/queries/test_products_query_with_where.py\t550fb22 (parent)\n+++ saleor/graphql/product/tests/queries/test_products_query_with_where.py\t1088948 (commit)\n@@ -246,8 +246,46 @@\n product_list[1].slug,\n }\n \n \n+def test_product_filter_by_subcategories(\n+ api_client, product_list, channel_USD, category_list\n+):\n+ # given\n+ subcategory_1 = category_list[0]\n+ subcategory_2 = category_list[1]\n+ parent_category = category_list[2]\n+\n+ subcategory_1.parent = parent_category\n+ subcategory_2.parent = parent_category\n+ subcategory_1.save()\n+ subcategory_2.save()\n+\n+ product_list[0].category = subcategory_1\n+ product_list[1].category = subcategory_2\n+ Product.objects.bulk_update(product_list, [\"category\"])\n+\n+ category_id = graphene.Node.to_global_id(\"Category\", parent_category.pk)\n+\n+ variables = {\n+ \"channel\": channel_USD.slug,\n+ \"where\": {\"category\": {\"eq\": category_id}},\n+ }\n+\n+ # when\n+ response = api_client.post_graphql(PRODUCTS_WHERE_QUERY, variables)\n+\n+ # then\n+ data = get_graphql_content(response)\n+ products = data[\"data\"][\"products\"][\"edges\"]\n+ assert len(products) == 2\n+ returned_slugs = {node[\"node\"][\"slug\"] for node in products}\n+ assert returned_slugs == {\n+ product_list[0].slug,\n+ product_list[1].slug,\n+ }\n+\n+\n def test_product_filter_by_category(\n api_client, product_list, channel_USD, category_list\n ):\n # given\n"
710
- }
711
- ]
712
- },
713
- {
714
- "id": "add-page-channel",
715
- "sha": "e848f046c912cf0b4ecb2b311af6ab9ed62368cd",
716
- "parentSha": "c54df414ac3641e4f1412e8aeb0a7783c194a985",
717
- "spec": "Implement channel support for the Page GraphQL queries and propagate channel context through resolvers and filters, mirroring the existing product/collection pattern.\n\nScope and required changes:\n\n1) Core typing enhancements\n- File: saleor/graphql/core/context.py\n - Update imports to include Generic and Model from typing and django.db.models.\n - Introduce a type variable M bound to Model.\n - Update ChannelQsContext to be Generic[M] and store a typed QuerySet[M]. Keep channel_slug: str | None.\n\n2) Page filters to be channel-aware via ChannelQsContext\n- File: saleor/graphql/page/filters.py\n - Import ChannelQsContext from ..core.context.\n - Change search_pages signature to accept and return a ChannelQsContext instead of a raw QuerySet.\n - When a search value is provided, update channel_qs.qs with the filtered QuerySet and return the same ChannelQsContext instance. If no value, return the input ChannelQsContext unchanged.\n\n3) Page resolvers return ChannelQsContext and handle non-existing channel\n- File: saleor/graphql/page/resolvers.py\n - Import Channel, ChannelQsContext.\n - Update resolve_pages signature to accept optional channel_slug: str | None and channel: Channel | None and return ChannelQsContext[models.Page].\n - If a channel slug is provided but resolved channel is None, return ChannelQsContext(qs=models.Page.objects.none(), channel_slug=channel_slug).\n - Otherwise, build a Page queryset using get_database_connection_name(info.context) and visible_to_user(requestor), and return ChannelQsContext(qs=page_qs, channel_slug=channel_slug).\n\n4) Page schema: add channel args and propagate slug via ChannelContext/ChannelQsContext\n- File: saleor/graphql/page/schema.py\n - Import ChannelBySlugLoader; remove ChannelQsContext direct import here and use it via resolvers.\n - Add channel: String argument to the page and pages fields with description noting Added in 3.22.\n - Resolve page:\n - If channel arg provided, load channel via ChannelBySlugLoader(info.context). If provided slug does not resolve to a channel, return null.\n - Resolve the page (by id/slug/slugLanguageCode) and wrap it in ChannelContext with channel_slug set to the provided channel slug (or None when not provided).\n - Resolve pages:\n - If channel slug provided, load channel via ChannelBySlugLoader.\n - Call resolve_pages(info, channel_slug=channel, channel=loaded_channel) to get ChannelQsContext.\n - Apply search filtering by passing the ChannelQsContext into search_pages and receiving a ChannelQsContext back.\n - Pass the ChannelQsContext into filter_connection_queryset, then into create_connection_slice, preserving channel context.\n - When channel slug is provided but not found, the connection should be empty (edges length 0).\n\n5) Schema SDL updates\n- File: saleor/graphql/schema.graphql\n - Add the channel: String argument to the page and pages fields with the appropriate description and Added in Saleor 3.22 note.\n\n6) Translations schema integration\n- File: saleor/graphql/translations/schema.py\n - For TranslatableKinds.PAGE in resolve_translations, adjust to use resolve_pages(info).qs (unwrap the ChannelQsContext to a queryset) so existing code paths that expect a QuerySet continue to work.\n\n7) Tests: page queries and pricing propagation to referenced objects\n- Files: saleor/graphql/page/tests/queries/test_page.py, saleor/graphql/page/tests/queries/test_pages.py, saleor/graphql/product/tests/test_attributes.py\n - Add tests that:\n - Verify page query exposes a channel arg and that when provided with a valid channel slug, referencedObject for attribute values includes pricing (indicating channel_slug was propagated).\n - Validate variant and product referenced objects under attributes include pricing fields when channel is provided.\n - Verify that providing a non-existing channel slug to page returns null for single page and an empty connection for pages.\n - Extend existing attribute tests for product and variant to assert that pricing is present when a channel is passed (ensuring channel context flows to referencedObject).\n\nBehavioral expectations:\n- page(id/slug, channel) returns a ChannelContext-wrapped page node; when channel slug does not resolve, returns null.\n- pages(first, channel, ...) returns a connection over a ChannelQsContext; when channel slug does not resolve, returns an empty list of edges.\n- search_pages operates on and returns ChannelQsContext to preserve channel slug through filters.\n- referencedObject for attribute values on pages, when channel is provided, includes pricing for Product and ProductVariant referenced types, confirming channel context propagation.\n- SDL contains the new channel argument for page and pages fields, with correct descriptions.\n\nNon-goals:\n- Do not modify how channel context is used in product/collection beyond reusing established patterns.\n- Do not change pricing computation logic; only ensure channel slug reaches the appropriate resolvers/dataloaders.\n",
718
- "prompt": "Add channel-awareness to the Page GraphQL queries. Introduce an optional channel argument on both the single page and pages fields and ensure that when provided, the channel slug is propagated down to all nested resolvers so channel-specific data like pricing is available on referenced objects. Follow the pattern used by product and collection queries: return a channel-aware queryset wrapper from the pages resolver, wrap single page results in a channel-aware context, and keep the channel slug intact through filtering and pagination. If a non-existent channel is requested, the single page query should return null and the list query should return an empty connection. Update the schema SDL and translations to reflect and consume the new channel-aware APIs, and add tests that verify referenced attribute objects (products/variants) expose pricing when channel is set.",
719
- "supplementalFiles": [
720
- "saleor/graphql/core/connection.py",
721
- "saleor/graphql/attribute/types.py",
722
- "saleor/graphql/product/schema.py",
723
- "saleor/graphql/page/types.py"
724
- ],
725
- "fileDiffs": [
726
- {
727
- "path": "saleor/graphql/core/context.py",
728
- "status": "modified",
729
- "diff": "Index: saleor/graphql/core/context.py\n===================================================================\n--- saleor/graphql/core/context.py\tc54df41 (parent)\n+++ saleor/graphql/core/context.py\te848f04 (commit)\n@@ -1,10 +1,10 @@\n import datetime\n from dataclasses import dataclass\n-from typing import TYPE_CHECKING, Any, TypeVar\n+from typing import TYPE_CHECKING, Any, Generic, TypeVar\n \n from django.conf import settings\n-from django.db.models import QuerySet\n+from django.db.models import Model, QuerySet\n from django.http import HttpRequest\n from django.utils.functional import empty\n \n if TYPE_CHECKING:\n@@ -88,8 +88,11 @@\n class ChannelContext(BaseContext[N]):\n channel_slug: str | None\n \n \n+M = TypeVar(\"M\", bound=Model)\n+\n+\n @dataclass\n-class ChannelQsContext:\n- qs: QuerySet\n+class ChannelQsContext(Generic[M]):\n+ qs: QuerySet[M]\n channel_slug: str | None\n"
730
- },
731
- {
732
- "path": "saleor/graphql/page/filters.py",
733
- "status": "modified",
734
- "diff": "Index: saleor/graphql/page/filters.py\n===================================================================\n--- saleor/graphql/page/filters.py\tc54df41 (parent)\n+++ saleor/graphql/page/filters.py\te848f04 (commit)\n@@ -6,8 +6,9 @@\n \n from ...attribute import AttributeInputType\n from ...attribute.models import AssignedPageAttributeValue, Attribute, AttributeValue\n from ...page import models\n+from ..core.context import ChannelQsContext\n from ..core.doc_category import DOC_CATEGORY_PAGES\n from ..core.filters import (\n FilterInputObjectType,\n GlobalIDMultipleChoiceFilter,\n@@ -40,16 +41,17 @@\n )\n from .types import Page, PageType\n \n \n-def search_pages(qs, value):\n+def search_pages(channel_qs: ChannelQsContext, value):\n if not value:\n- return qs\n- return qs.filter(\n+ return channel_qs\n+ channel_qs.qs = channel_qs.qs.filter(\n Q(title__trigram_similar=value)\n | Q(slug__trigram_similar=value)\n | Q(content__icontains=value)\n )\n+ return channel_qs\n \n \n def filter_page_page_types(qs, _, value):\n if not value:\n"
735
- },
736
- {
737
- "path": "saleor/graphql/page/resolvers.py",
738
- "status": "modified",
739
- "diff": "Index: saleor/graphql/page/resolvers.py\n===================================================================\n--- saleor/graphql/page/resolvers.py\tc54df41 (parent)\n+++ saleor/graphql/page/resolvers.py\te848f04 (commit)\n@@ -1,6 +1,7 @@\n+from ...channel.models import Channel\n from ...page import models\n-from ..core.context import get_database_connection_name\n+from ..core.context import ChannelQsContext, get_database_connection_name\n from ..core.utils import from_global_id_or_error\n from ..core.validators import validate_one_of_args_is_in_query\n from ..utils import get_user_or_app_from_context\n from .types import Page\n@@ -38,13 +39,20 @@\n )\n return page\n \n \n-def resolve_pages(info):\n+def resolve_pages(\n+ info, channel_slug: str | None = None, channel: Channel | None = None\n+) -> ChannelQsContext[models.Page]:\n requestor = get_user_or_app_from_context(info.context)\n- return models.Page.objects.using(\n- get_database_connection_name(info.context)\n- ).visible_to_user(requestor)\n+ if channel is None and channel_slug is not None:\n+ # If channel is provided but not found, return None\n+ page_qs = models.Page.objects.none()\n+ else:\n+ page_qs = models.Page.objects.using(\n+ get_database_connection_name(info.context)\n+ ).visible_to_user(requestor)\n+ return ChannelQsContext(qs=page_qs, channel_slug=channel_slug)\n \n \n def resolve_page_type(info, id):\n return (\n"
740
- },
741
- {
742
- "path": "saleor/graphql/page/schema.py",
743
- "status": "modified",
744
- "diff": "Index: saleor/graphql/page/schema.py\n===================================================================\n--- saleor/graphql/page/schema.py\tc54df41 (parent)\n+++ saleor/graphql/page/schema.py\te848f04 (commit)\n@@ -1,9 +1,10 @@\n import graphene\n \n+from ..channel.dataloaders import ChannelBySlugLoader\n from ..core import ResolveInfo\n from ..core.connection import create_connection_slice, filter_connection_queryset\n-from ..core.context import ChannelContext, ChannelQsContext\n+from ..core.context import ChannelContext\n from ..core.descriptions import ADDED_IN_321, ADDED_IN_322, DEPRECATED_IN_3X_INPUT\n from ..core.doc_category import DOC_CATEGORY_PAGES\n from ..core.enums import LanguageCodeEnum\n from ..core.fields import BaseField, FilterConnectionField\n@@ -42,8 +43,12 @@\n LanguageCodeEnum,\n description=\"Language code of the page slug, omit to use primary slug.\"\n + ADDED_IN_321,\n ),\n+ channel=graphene.String(\n+ description=\"Slug of a channel for which the data should be returned.\"\n+ + ADDED_IN_322\n+ ),\n description=\"Look up a page by ID or slug.\",\n doc_category=DOC_CATEGORY_PAGES,\n )\n pages = FilterConnectionField(\n@@ -60,8 +65,12 @@\n ),\n where=PageWhereInput(\n description=\"Where filtering options for pages.\" + ADDED_IN_322\n ),\n+ channel=graphene.String(\n+ description=\"Slug of a channel for which the data should be returned.\"\n+ + ADDED_IN_322\n+ ),\n description=\"List of the shop's pages.\",\n doc_category=DOC_CATEGORY_PAGES,\n )\n page_type = BaseField(\n@@ -81,27 +90,46 @@\n )\n \n @staticmethod\n def resolve_page(\n- _root, info: ResolveInfo, *, id=None, slug=None, slug_language_code=None\n+ _root,\n+ info: ResolveInfo,\n+ *,\n+ id=None,\n+ slug=None,\n+ slug_language_code=None,\n+ channel=None,\n ):\n- page = resolve_page(info, id, slug, slug_language_code)\n- if not page:\n- return None\n- return ChannelContext(page, channel_slug=None)\n+ def _resolve_page(channel_instance):\n+ if channel is not None and channel_instance is None:\n+ # If channel is provided but not found, return None\n+ return None\n+ page = resolve_page(info, id, slug, slug_language_code)\n+ if page is None:\n+ return None\n+ return ChannelContext(page, channel_slug=channel)\n \n+ if channel:\n+ return ChannelBySlugLoader(info.context).load(channel).then(_resolve_page)\n+ return _resolve_page(channel_instance=None)\n+\n @staticmethod\n- def resolve_pages(_root, info: ResolveInfo, **kwargs):\n- qs = resolve_pages(info)\n- search = kwargs.get(\"search\") or kwargs.get(\"filter\", {}).get(\"search\")\n- if search:\n- qs = search_pages(qs, search)\n- qs = ChannelQsContext(qs, channel_slug=None)\n- qs = filter_connection_queryset(\n- qs, kwargs, allow_replica=info.context.allow_replica\n- )\n- return create_connection_slice(qs, info, kwargs, PageCountableConnection)\n+ def resolve_pages(_root, info: ResolveInfo, *, channel=None, **kwargs):\n+ def _resolve_pages(channel_instance):\n+ qs = resolve_pages(info, channel_slug=channel, channel=channel_instance)\n+ search = kwargs.get(\"search\") or kwargs.get(\"filter\", {}).get(\"search\")\n+ if search:\n+ qs = search_pages(qs, search)\n \n+ qs = filter_connection_queryset(\n+ qs, kwargs, allow_replica=info.context.allow_replica\n+ )\n+ return create_connection_slice(qs, info, kwargs, PageCountableConnection)\n+\n+ if channel:\n+ return ChannelBySlugLoader(info.context).load(channel).then(_resolve_pages)\n+ return _resolve_pages(channel_instance=None)\n+\n @staticmethod\n def resolve_page_type(_root, info: ResolveInfo, *, id):\n _, id = from_global_id_or_error(id, PageType)\n return resolve_page_type(info, id)\n"
745
- },
746
- {
747
- "path": "saleor/graphql/page/tests/queries/test_page.py",
748
- "status": "modified",
749
- "diff": "Index: saleor/graphql/page/tests/queries/test_page.py\n===================================================================\n--- saleor/graphql/page/tests/queries/test_page.py\tc54df41 (parent)\n+++ saleor/graphql/page/tests/queries/test_page.py\te848f04 (commit)\n@@ -17,8 +17,9 @@\n page(id: $id, slug: $slug, slugLanguageCode: $slugLanguageCode) {\n id\n title\n slug\n+ created\n pageType {\n id\n }\n content\n@@ -786,4 +787,233 @@\n assert value[\"referencedObject\"][\"__typename\"] == \"Product\"\n assert value[\"referencedObject\"][\"id\"] == graphene.Node.to_global_id(\n \"Product\", product.id\n )\n+\n+\n+QUERY_PAGE_WITH_ATTRIBUTE_AND_CHANNEL = \"\"\"\n+query Page($id: ID!, $channel: String) {\n+ page(id: $id, channel: $channel) {\n+ attributes {\n+ attribute {\n+ id\n+ slug\n+ }\n+ values {\n+ reference\n+ referencedObject {\n+ __typename\n+ ... on Page {\n+ id\n+ }\n+ ... on ProductVariant {\n+ id\n+ pricing {\n+ onSale\n+ }\n+ }\n+ ... on Product {\n+ id\n+ created\n+ pricing {\n+ onSale\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\"\"\"\n+\n+\n+def test_page_attribute_with_referenced_page_object_and_channel_slug(\n+ staff_api_client,\n+ page_type_page_reference_attribute,\n+ permission_manage_pages,\n+ page,\n+ channel_USD,\n+):\n+ # given\n+ staff_api_client.user.user_permissions.add(permission_manage_pages)\n+\n+ # Create a second page to reference\n+ referenced_page = Page.objects.create(\n+ title=\"Referenced Page\",\n+ slug=\"referenced-page\",\n+ page_type=page.page_type,\n+ is_published=True,\n+ )\n+\n+ page_type = page.page_type\n+ page_type.page_attributes.all().delete()\n+ page_type.page_attributes.add(page_type_page_reference_attribute)\n+\n+ attribute_value = AttributeValue.objects.create(\n+ attribute=page_type_page_reference_attribute,\n+ name=f\"Page {referenced_page.pk}\",\n+ slug=f\"page-{referenced_page.pk}\",\n+ reference_page=referenced_page,\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page, {page_type_page_reference_attribute.pk: [attribute_value]}\n+ )\n+\n+ query = QUERY_PAGE_WITH_ATTRIBUTE_AND_CHANNEL\n+\n+ # when\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Page\", page.pk),\n+ \"channel\": channel_USD.slug,\n+ }\n+ response = staff_api_client.post_graphql(query, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ page_data = content[\"data\"][\"page\"]\n+ page_attributes = page_data[\"attributes\"]\n+ assert len(page_attributes) == 1\n+\n+ attribute_with_reference = page_attributes[0]\n+ assert attribute_with_reference[\"attribute\"][\"slug\"] == \"page-reference\"\n+\n+ assert len(attribute_with_reference[\"values\"]) == 1\n+ value = attribute_with_reference[\"values\"][0]\n+ assert value[\"reference\"] == graphene.Node.to_global_id(\"Page\", referenced_page.id)\n+ assert value[\"referencedObject\"][\"__typename\"] == \"Page\"\n+ assert value[\"referencedObject\"][\"id\"] == graphene.Node.to_global_id(\n+ \"Page\", referenced_page.id\n+ )\n+\n+\n+def test_page_attribute_with_referenced_product_variant_object_and_channel_slug(\n+ staff_api_client,\n+ page_type_variant_reference_attribute,\n+ permission_manage_pages,\n+ page,\n+ product,\n+ channel_USD,\n+):\n+ # given\n+ staff_api_client.user.user_permissions.add(permission_manage_pages)\n+\n+ product_variant = product.variants.first()\n+ page_type = page.page_type\n+ page_type.page_attributes.all().delete()\n+ page_type.page_attributes.add(page_type_variant_reference_attribute)\n+\n+ attribute_value = AttributeValue.objects.create(\n+ attribute=page_type_variant_reference_attribute,\n+ name=f\"Variant {product_variant.pk}\",\n+ slug=f\"variant-{product_variant.pk}\",\n+ reference_variant=product_variant,\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page,\n+ {page_type_variant_reference_attribute.pk: [attribute_value]},\n+ )\n+\n+ query = QUERY_PAGE_WITH_ATTRIBUTE_AND_CHANNEL\n+\n+ # when\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Page\", page.pk),\n+ \"channel\": channel_USD.slug,\n+ }\n+ response = staff_api_client.post_graphql(query, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ page_data = content[\"data\"][\"page\"]\n+ page_attributes = page_data[\"attributes\"]\n+ assert len(page_attributes) == 1\n+\n+ attribute_with_reference = page_attributes[0]\n+ assert attribute_with_reference[\"attribute\"][\"slug\"] == \"variant-reference\"\n+\n+ assert len(attribute_with_reference[\"values\"]) == 1\n+ value = attribute_with_reference[\"values\"][0]\n+ assert value[\"reference\"] == graphene.Node.to_global_id(\n+ \"ProductVariant\", product_variant.id\n+ )\n+ assert value[\"referencedObject\"][\"__typename\"] == \"ProductVariant\"\n+ # having pricing object means that we passed channel_slug to the variant\n+ assert value[\"referencedObject\"][\"pricing\"]\n+\n+\n+def test_page_attribute_with_referenced_product_object_and_channel_slug(\n+ staff_api_client,\n+ page_type_product_reference_attribute,\n+ permission_manage_pages,\n+ page,\n+ product,\n+ channel_USD,\n+):\n+ # given\n+ staff_api_client.user.user_permissions.add(permission_manage_pages)\n+\n+ page_type = page.page_type\n+ page_type.page_attributes.all().delete()\n+ page_type.page_attributes.add(page_type_product_reference_attribute)\n+\n+ attribute_value = AttributeValue.objects.create(\n+ attribute=page_type_product_reference_attribute,\n+ name=f\"Product {product.pk}\",\n+ slug=f\"product-{product.pk}\",\n+ reference_product=product,\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page,\n+ {page_type_product_reference_attribute.pk: [attribute_value]},\n+ )\n+\n+ query = QUERY_PAGE_WITH_ATTRIBUTE_AND_CHANNEL\n+\n+ # when\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Page\", page.pk),\n+ \"channel\": channel_USD.slug,\n+ }\n+ response = staff_api_client.post_graphql(query, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ page_data = content[\"data\"][\"page\"]\n+ page_attributes = page_data[\"attributes\"]\n+ assert len(page_attributes) == 1\n+\n+ attribute_with_reference = page_attributes[0]\n+ assert attribute_with_reference[\"attribute\"][\"slug\"] == \"product-reference\"\n+\n+ assert len(attribute_with_reference[\"values\"]) == 1\n+ value = attribute_with_reference[\"values\"][0]\n+ assert value[\"reference\"] == graphene.Node.to_global_id(\"Product\", product.id)\n+ assert value[\"referencedObject\"][\"__typename\"] == \"Product\"\n+ # having pricing object means that we passed channel_slug to the product\n+ assert value[\"referencedObject\"][\"pricing\"]\n+\n+\n+def test_page_channel_not_found(staff_api_client, page, permission_manage_pages):\n+ # given\n+ staff_api_client.user.user_permissions.add(permission_manage_pages)\n+\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Page\", page.pk),\n+ \"channel\": \"not-existing-channel\",\n+ }\n+ query = \"\"\"\n+ query Page($id: ID!, $channel: String) {\n+ page(id: $id, channel: $channel) {\n+ id\n+ }\n+ }\n+ \"\"\"\n+\n+ # when\n+ response = staff_api_client.post_graphql(query, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"page\"] is None\n"
750
- },
751
- {
752
- "path": "saleor/graphql/page/tests/queries/test_pages.py",
753
- "status": "modified",
754
- "diff": "Index: saleor/graphql/page/tests/queries/test_pages.py\n===================================================================\n--- saleor/graphql/page/tests/queries/test_pages.py\tc54df41 (parent)\n+++ saleor/graphql/page/tests/queries/test_pages.py\te848f04 (commit)\n@@ -2,8 +2,10 @@\n import pytest\n from django.utils import timezone\n from freezegun import freeze_time\n \n+from .....attribute.models.base import AttributeValue\n+from .....attribute.utils import associate_attribute_values_to_instance\n from .....page.models import Page\n from .....tests.utils import dummy_editorjs\n from ....tests.utils import get_graphql_content\n \n@@ -368,4 +370,189 @@\n # then\n content = get_graphql_content(response)\n data = content[\"data\"][\"pages\"][\"edges\"]\n assert len(data) == page_count - 1\n+\n+\n+PAGES_QUERY_WITH_ATTRIBUTE_AND_CHANNEL = \"\"\"\n+ query ($channel: String) {\n+ pages(first: 10, channel: $channel) {\n+ edges {\n+ node {\n+ id\n+ title\n+ slug\n+ pageType {\n+ id\n+ }\n+ content\n+ contentJson\n+ attributes {\n+ attribute {\n+ slug\n+ }\n+ values {\n+ id\n+ slug\n+ reference\n+ referencedObject {\n+ __typename\n+ ... on Page {\n+ id\n+ }\n+ ... on ProductVariant {\n+ id\n+ pricing {\n+ onSale\n+ }\n+ }\n+ ... on Product {\n+ id\n+ created\n+ pricing {\n+ onSale\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+\"\"\"\n+\n+\n+def test_pages_attribute_with_referenced_product_variant_object_and_channel_slug(\n+ staff_api_client,\n+ page_type_variant_reference_attribute,\n+ permission_manage_pages,\n+ page,\n+ product,\n+ channel_USD,\n+):\n+ # given\n+ staff_api_client.user.user_permissions.add(permission_manage_pages)\n+\n+ product_variant = product.variants.first()\n+ page_type = page.page_type\n+ page_type.page_attributes.all().delete()\n+ page_type.page_attributes.add(page_type_variant_reference_attribute)\n+\n+ attribute_value = AttributeValue.objects.create(\n+ attribute=page_type_variant_reference_attribute,\n+ name=f\"Variant {product_variant.pk}\",\n+ slug=f\"variant-{product_variant.pk}\",\n+ reference_variant=product_variant,\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page,\n+ {page_type_variant_reference_attribute.pk: [attribute_value]},\n+ )\n+\n+ query = PAGES_QUERY_WITH_ATTRIBUTE_AND_CHANNEL\n+\n+ # when\n+ variables = {\n+ \"channel\": channel_USD.slug,\n+ }\n+ response = staff_api_client.post_graphql(query, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_data = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_data) == 1\n+ page_data = pages_data[0][\"node\"]\n+ page_attributes = page_data[\"attributes\"]\n+ assert len(page_attributes) == 1\n+\n+ attribute_with_reference = page_attributes[0]\n+ assert attribute_with_reference[\"attribute\"][\"slug\"] == \"variant-reference\"\n+\n+ assert len(attribute_with_reference[\"values\"]) == 1\n+ value = attribute_with_reference[\"values\"][0]\n+ assert value[\"reference\"] == graphene.Node.to_global_id(\n+ \"ProductVariant\", product_variant.id\n+ )\n+ assert value[\"referencedObject\"][\"__typename\"] == \"ProductVariant\"\n+ # having pricing object means that we passed channel_slug to the variant\n+ assert value[\"referencedObject\"][\"pricing\"]\n+\n+\n+def test_pages_attribute_with_referenced_product_object_and_channel_slug(\n+ staff_api_client,\n+ page_type_product_reference_attribute,\n+ permission_manage_pages,\n+ page,\n+ product,\n+ channel_USD,\n+):\n+ # given\n+ staff_api_client.user.user_permissions.add(permission_manage_pages)\n+\n+ page_type = page.page_type\n+ page_type.page_attributes.all().delete()\n+ page_type.page_attributes.add(page_type_product_reference_attribute)\n+\n+ attribute_value = AttributeValue.objects.create(\n+ attribute=page_type_product_reference_attribute,\n+ name=f\"Product {product.pk}\",\n+ slug=f\"product-{product.pk}\",\n+ reference_product=product,\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page,\n+ {page_type_product_reference_attribute.pk: [attribute_value]},\n+ )\n+\n+ query = PAGES_QUERY_WITH_ATTRIBUTE_AND_CHANNEL\n+\n+ # when\n+ variables = {\n+ \"channel\": channel_USD.slug,\n+ }\n+ response = staff_api_client.post_graphql(query, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_data = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_data) == 1\n+ page_data = pages_data[0][\"node\"]\n+ page_attributes = page_data[\"attributes\"]\n+ assert len(page_attributes) == 1\n+\n+ attribute_with_reference = page_attributes[0]\n+ assert attribute_with_reference[\"attribute\"][\"slug\"] == \"product-reference\"\n+\n+ assert len(attribute_with_reference[\"values\"]) == 1\n+ value = attribute_with_reference[\"values\"][0]\n+ assert value[\"reference\"] == graphene.Node.to_global_id(\"Product\", product.id)\n+ assert value[\"referencedObject\"][\"__typename\"] == \"Product\"\n+ # having pricing object means that we passed channel_slug to the product\n+ assert value[\"referencedObject\"][\"pricing\"]\n+\n+\n+def test_pages_attribute_with_incorrect_channel_slug(\n+ staff_api_client,\n+ page_type_variant_reference_attribute,\n+ permission_manage_pages,\n+ page,\n+ product,\n+ channel_USD,\n+):\n+ # given\n+ staff_api_client.user.user_permissions.add(permission_manage_pages)\n+\n+ query = PAGES_QUERY_WITH_ATTRIBUTE_AND_CHANNEL\n+\n+ # when\n+ variables = {\n+ \"channel\": \"non-existing-channel-slug\",\n+ }\n+ response = staff_api_client.post_graphql(query, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_data = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_data) == 0\n"
755
- },
756
- {
757
- "path": "saleor/graphql/product/tests/test_attributes.py",
758
- "status": "modified",
759
- "diff": "Index: saleor/graphql/product/tests/test_attributes.py\n===================================================================\n--- saleor/graphql/product/tests/test_attributes.py\tc54df41 (parent)\n+++ saleor/graphql/product/tests/test_attributes.py\te848f04 (commit)\n@@ -45,12 +45,18 @@\n }\n ... on ProductVariant {\n id\n created\n+ pricing {\n+ onSale\n+ }\n }\n ... on Product {\n id\n created\n+ pricing {\n+ onSale\n+ }\n }\n }\n }\n }\n@@ -71,12 +77,18 @@\n }\n ... on ProductVariant {\n id\n created\n+ pricing {\n+ onSale\n+ }\n }\n ... on Product {\n id\n created\n+ pricing {\n+ onSale\n+ }\n }\n }\n }\n }\n@@ -1903,8 +1915,10 @@\n assert value[\"referencedObject\"][\"__typename\"] == \"ProductVariant\"\n assert value[\"referencedObject\"][\"id\"] == graphene.Node.to_global_id(\n \"ProductVariant\", product_variant.id\n )\n+ # having pricing object means that we passed channel_slug to the variant\n+ assert value[\"referencedObject\"][\"pricing\"]\n \n \n def test_product_attribute_with_referenced_product_object(\n staff_api_client,\n@@ -1953,8 +1967,10 @@\n assert value[\"referencedObject\"][\"__typename\"] == \"Product\"\n assert value[\"referencedObject\"][\"id\"] == graphene.Node.to_global_id(\n \"Product\", product.id\n )\n+ # having pricing object means that we passed channel_slug to the product\n+ assert value[\"referencedObject\"][\"pricing\"]\n \n \n def test_product_variant_attribute_with_referenced_page_object(\n staff_api_client,\n@@ -2060,8 +2076,10 @@\n assert value[\"referencedObject\"][\"__typename\"] == \"ProductVariant\"\n assert value[\"referencedObject\"][\"id\"] == graphene.Node.to_global_id(\n \"ProductVariant\", product_variant.id\n )\n+ # having pricing object means that we passed channel_slug to the variant\n+ assert value[\"referencedObject\"][\"pricing\"]\n \n \n def test_product_variant_attribute_with_referenced_product_object(\n staff_api_client,\n@@ -2112,4 +2130,6 @@\n assert value[\"referencedObject\"][\"__typename\"] == \"Product\"\n assert value[\"referencedObject\"][\"id\"] == graphene.Node.to_global_id(\n \"Product\", product.id\n )\n+ # having pricing object means that we passed channel_slug to the product\n+ assert value[\"referencedObject\"][\"pricing\"]\n"
760
- },
761
- {
762
- "path": "saleor/graphql/schema.graphql",
763
- "status": "modified",
764
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\tc54df41 (parent)\n+++ saleor/graphql/schema.graphql\te848f04 (commit)\n@@ -683,8 +683,15 @@\n \n Added in Saleor 3.21.\n \"\"\"\n slugLanguageCode: LanguageCodeEnum\n+\n+ \"\"\"\n+ Slug of a channel for which the data should be returned.\n+ \n+ Added in Saleor 3.22.\n+ \"\"\"\n+ channel: String\n ): Page @doc(category: \"Pages\")\n \n \"\"\"List of the shop's pages.\"\"\"\n pages(\n@@ -707,8 +714,15 @@\n Added in Saleor 3.22.\n \"\"\"\n where: PageWhereInput\n \n+ \"\"\"\n+ Slug of a channel for which the data should be returned.\n+ \n+ Added in Saleor 3.22.\n+ \"\"\"\n+ channel: String\n+\n \"\"\"Return the elements in the list that come before the specified cursor.\"\"\"\n before: String\n \n \"\"\"Return the elements in the list that come after the specified cursor.\"\"\"\n"
765
- },
766
- {
767
- "path": "saleor/graphql/translations/schema.py",
768
- "status": "modified",
769
- "diff": "Index: saleor/graphql/translations/schema.py\n===================================================================\n--- saleor/graphql/translations/schema.py\tc54df41 (parent)\n+++ saleor/graphql/translations/schema.py\te848f04 (commit)\n@@ -119,9 +119,9 @@\n qs = resolve_collections(info)\n elif kind == TranslatableKinds.CATEGORY:\n qs = resolve_categories(info)\n elif kind == TranslatableKinds.PAGE:\n- qs = resolve_pages(info)\n+ qs = resolve_pages(info).qs\n elif kind == TranslatableKinds.SHIPPING_METHOD:\n qs = resolve_shipping_methods(info)\n elif kind == TranslatableKinds.VOUCHER:\n qs = resolve_vouchers(info)\n"
770
- }
771
- ]
772
- },
773
- {
774
- "id": "trace-dataloader-span",
775
- "sha": "8d0cb16c5c084ff8def4d4218a67e3986514a342",
776
- "parentSha": "9e1838c9eb0b35bf4024c178b06482347a266f6b",
777
- "spec": "Implement full-lifecycle tracing for GraphQL DataLoader batch loads and report the number of objects returned.\n\nRequired changes:\n\n1) Add a new telemetry attribute constant\n- File: saleor/core/telemetry/saleor_attributes.py\n- Add a constant named GRAPHQL_RESOLVER_ROW_COUNT with the exact value \"graphql.resolver.row_count\" in the GraphQL section alongside existing GraphQL attributes. Do not remove existing constants.\n\n2) Extend DataLoader batch span to cover async resolution and report row count\n- File: saleor/graphql/core/dataloaders.py\n- In DataLoader.batch_load_fn:\n - Start a span for the batch load that does not auto-end when exiting the context so its lifetime can include async resolution.\n - Preserve setting OPERATION_NAME to \"dataloader.batch_load\".\n - Execute the batch load within allow_writer_in_context(self.context).\n - If the batch result is a list (synchronous result): set the GRAPHQL_RESOLVER_ROW_COUNT attribute to the number of returned items, end the span explicitly, and return a resolved Promise with the list.\n - If the batch result is a Promise: attach fulfillment and rejection handlers. On fulfillment, set GRAPHQL_RESOLVER_ROW_COUNT to the number of returned items and end the span before returning the list. On rejection, end the span and re-raise the error to propagate failure. Ensure the function returns the Promise with these handlers attached.\n\nNotes and constraints:\n- Do not change DataLoader public API, generics, or the use of allow_writer_in_context.\n- Avoid altering other telemetry attributes or GraphQL tracing behavior.\n- The new attribute should be reported for every dataloader batch load regardless of success (end the span on both success and error).\n",
778
- "prompt": "Instrument GraphQL dataloaders so that each batch load is traced for the full duration, including asynchronous resolution, and expose an attribute with the number of items returned. Add the missing telemetry attribute for this row count. Ensure the span ends on both success and failure and that existing tracing attributes and behavior remain unchanged.",
779
- "supplementalFiles": [
780
- "saleor/graphql/core/tracing.py",
781
- "saleor/graphql/views.py",
782
- "saleor/core/telemetry/utils.py",
783
- "saleor/asgi/telemetry.py",
784
- "saleor/core/tracing.py"
785
- ],
786
- "fileDiffs": [
787
- {
788
- "path": "saleor/core/telemetry/saleor_attributes.py",
789
- "status": "modified",
790
- "diff": "Index: saleor/core/telemetry/saleor_attributes.py\n===================================================================\n--- saleor/core/telemetry/saleor_attributes.py\t9e1838c (parent)\n+++ saleor/core/telemetry/saleor_attributes.py\t8d0cb16 (commit)\n@@ -7,12 +7,13 @@\n OPERATION_NAME: Final = \"operation.name\"\n \n # GraphQL\n GRAPHQL_DOCUMENT_FINGERPRINT: Final = \"graphql.document_fingerprint\"\n-GRAPHQL_OPERATION_IDENTIFIER: Final = \"graphql.operation.identifier\"\n+GRAPHQL_FIELD_NAME: Final = \"graphql.field_name\"\n GRAPHQL_OPERATION_COST: Final = \"graphql.operation.cost\"\n+GRAPHQL_OPERATION_IDENTIFIER: Final = \"graphql.operation.identifier\"\n GRAPHQL_PARENT_TYPE: Final = \"graphql.parent_type\"\n-GRAPHQL_FIELD_NAME: Final = \"graphql.field_name\"\n+GRAPHQL_RESOLVER_ROW_COUNT: Final = \"graphql.resolver.row_count\"\n \n # Http\n SALEOR_SOURCE_SERVICE_NAME: Final = \"saleor.source.service.name\"\n \n"
791
- },
792
- {
793
- "path": "saleor/graphql/core/dataloaders.py",
794
- "status": "modified",
795
- "diff": "Index: saleor/graphql/core/dataloaders.py\n===================================================================\n--- saleor/graphql/core/dataloaders.py\t9e1838c (parent)\n+++ saleor/graphql/core/dataloaders.py\t8d0cb16 (commit)\n@@ -41,20 +41,38 @@\n \n def batch_load_fn( # pylint: disable=method-hidden\n self, keys: Iterable[K]\n ) -> Promise[list[R]]:\n- with tracer.start_as_current_span(self.__class__.__name__) as span:\n+ with tracer.start_as_current_span(\n+ self.__class__.__name__, end_on_exit=False\n+ ) as span:\n span.set_attribute(\n saleor_attributes.OPERATION_NAME, \"dataloader.batch_load\"\n )\n \n with allow_writer_in_context(self.context):\n results = self.batch_load(keys)\n \n if not isinstance(results, Promise):\n+ span.set_attribute(\n+ saleor_attributes.GRAPHQL_RESOLVER_ROW_COUNT, len(results)\n+ )\n+ span.end()\n return Promise.resolve(results)\n- return results\n \n+ def did_fulfill(results: list[R]) -> list[R]:\n+ span.set_attribute(\n+ saleor_attributes.GRAPHQL_RESOLVER_ROW_COUNT, len(results)\n+ )\n+ span.end()\n+ return results\n+\n+ def did_reject(error: Exception) -> list[R]:\n+ span.end()\n+ raise error\n+\n+ return results.then(did_fulfill, did_reject)\n+\n def batch_load(self, keys: Iterable[K]) -> Promise[list[R]] | list[R]:\n raise NotImplementedError()\n \n \n"
796
- }
797
- ]
798
- },
799
- {
800
- "id": "fix-giftcard-total",
801
- "sha": "9e1838c9eb0b35bf4024c178b06482347a266f6b",
802
- "parentSha": "421f362a87a4f45e62c4ad63ccbd737fd54ceb31",
803
- "spec": "Implement corrected gift card application logic and add a data migration to fix historical negative net totals for orders using gift cards, along with corresponding tests and a fixture.\n\n1) Update checkout total calculation with gift cards\n- File: saleor/checkout/calculations.py\n- Function: calculate_checkout_total_with_gift_cards(...)\n- Behavior to implement:\n - Compute the base total via checkout_total(...), without altering that function.\n - If the returned total equals zero_taxed_money(currency), return it unchanged.\n - Otherwise, compute the gross-to-net ratio as gross_percentage = total.gross / total.net.\n - Subtract the checkout's total gift card balance from the total.gross value only, using checkout_info.checkout.get_total_gift_cards_balance(database_connection_name).\n - Floor total.gross at zero using zero_money(currency) so gross never goes below zero.\n - Recompute total.net proportionally: total.net = quantize_price(total.gross / gross_percentage, currency).\n - Return the adjusted TaxedMoney total.\n- Notes:\n - Use the existing zero_money, zero_taxed_money, and quantize_price helpers.\n - Do not allow negative gross or net values. Net is derived from the gross via the original ratio.\n\n2) Add data migration to fix historical orders with negative net totals\n- File: saleor/order/migrations/0197_fix_negative_total_net_for_orders_using_gift_cards.py\n- Migration: RunPython forward-only migration that:\n - Iterates through orders in batches (size 500), ordered by primary key, with SELECT FOR UPDATE inside a transaction.\n - Filters for orders with total_net_amount < 0 and with at least one related gift card (exclude orders where gift_cards is None/empty).\n - Sets total_net_amount to Decimal(\"0.00\") for the batch.\n - Continues until no more rows match.\n- Dependencies: (\"order\", \"0196_alter_fulfillment_shipping_refund_amount_and_more\")\n- Reverse code: no-op.\n\n3) Add merge migrations for migration graph resolution\n- File: saleor/order/migrations/0205_merge_20250703_1438.py\n - Dependencies: (\"order\", \"0197_fix_negative_total_net_for_orders_using_gift_cards\"), (\"order\", \"0204_set_order_lines_count\")\n - No operations.\n- File: saleor/order/migrations/0216_merge_20250704_1033.py\n - Dependencies: (\"order\", \"0205_merge_20250703_1438\"), (\"order\", \"0215_merge_20250623_0624\")\n - No operations.\n\n4) Add an order fixture for gift card association\n- File: saleor/order/tests/fixtures/order.py\n- Add a new pytest fixture named order_with_gift_card that takes an existing order and gift_card, adds the gift card to the order (order.gift_cards.add(gift_card)), and returns the order.\n\n5) Add tests verifying the new gift card total behavior\n- File: saleor/tax/tests/test_checkout_calculations.py\n- Add a new parameterized test (test_calculate_checkout_total_with_gift_cards) that:\n - Patches saleor.checkout.calculations.checkout_total to return specific TaxedMoney values (net and gross) across cases, including zero totals and non-zero tax rates.\n - Sets the gift card current_balance_amount on the gift_card used by checkout.\n - Invokes calculations.calculate_checkout_total_with_gift_cards(manager, checkout_info, lines, address) and asserts net and gross equal expected values for each case.\n- Adjust imports accordingly: import unittest.mock as mock and import ...checkout as calculations for calling the function.\n- Test scenarios include:\n - Zero totals with zero and non-zero gift card balances (expect zero total).\n - Equal net and gross totals with partial and full gift card coverage (zero tax rate behavior).\n - Gross > net (positive tax rate), partial and full gift card coverage, verifying proportional net adjustment (e.g., 12 gross on 10 net with 10 gift card => 2 gross, 1.67 net).\n\nAcceptance criteria\n- calculate_checkout_total_with_gift_cards subtracts gift card value from gross, never produces negative gross, and recomputes net proportionally, returning zero totals unmodified.\n- The new migration updates any existing orders with negative total_net_amount and at least one gift card to have total_net_amount = 0 in batches.\n- New tests for gift card total calculation pass, and existing tests remain unaffected.\n- The new fixture order_with_gift_card is available for tests that need an order linked to a gift card.",
804
- "prompt": "Update checkout total calculation to apply gift cards correctly and prevent negative net totals. Specifically, when computing a checkout total with gift cards applied, subtract the gift card value from the gross amount, floor the gross at zero, and recompute the net proportionally using the original gross-to-net ratio, rounded to currency precision. If the total is already zero, return it unchanged.\n\nAdd a data migration that fixes existing orders which have a negative net total and at least one gift card applied by setting their total_net_amount to zero in batches within transactions. Provide necessary merge migrations to resolve dependencies.\n\nAdd a test that mocks the base checkout total and verifies the updated behavior across zero and non-zero tax cases and varying gift card balances, and include a fixture that attaches a gift card to an order for test setup.",
805
- "supplementalFiles": [
806
- "saleor/checkout/models.py",
807
- "saleor/core/prices.py",
808
- "saleor/core/taxes.py",
809
- "saleor/checkout/fetch.py",
810
- "saleor/giftcard/models.py",
811
- "saleor/order/models.py"
812
- ],
813
- "fileDiffs": [
814
- {
815
- "path": "saleor/checkout/calculations.py",
816
- "status": "modified",
817
- "diff": "Index: saleor/checkout/calculations.py\n===================================================================\n--- saleor/checkout/calculations.py\t421f362 (parent)\n+++ saleor/checkout/calculations.py\t9e1838c (commit)\n@@ -145,13 +145,30 @@\n database_connection_name=database_connection_name,\n pregenerated_subscription_payloads=pregenerated_subscription_payloads,\n force_update=force_update,\n allow_sync_webhooks=allow_sync_webhooks,\n- ) - checkout_info.checkout.get_total_gift_cards_balance(database_connection_name)\n+ )\n \n- return max(total, zero_taxed_money(total.currency))\n+ if total == zero_taxed_money(total.currency):\n+ return total\n \n+ # Calculate how many percent of total net value the total gross value is.\n+ gross_percentage = total.gross / total.net\n \n+ # Subtract gift cards value from total gross value.\n+ total.gross -= checkout_info.checkout.get_total_gift_cards_balance(\n+ database_connection_name\n+ )\n+\n+ # Gross value cannot be below zero.\n+ total.gross = max(total.gross, zero_money(total.currency))\n+\n+ # Adjusted total net value is proportional to potentially reduced total gross value.\n+ total.net = quantize_price(total.gross / gross_percentage, total.currency)\n+\n+ return total\n+\n+\n def checkout_total(\n *,\n manager: \"PluginsManager\",\n checkout_info: \"CheckoutInfo\",\n"
818
- },
819
- {
820
- "path": "saleor/order/migrations/0197_fix_negative_total_net_for_orders_using_gift_cards.py",
821
- "status": "added",
822
- "diff": "Index: saleor/order/migrations/0197_fix_negative_total_net_for_orders_using_gift_cards.py\n===================================================================\n--- saleor/order/migrations/0197_fix_negative_total_net_for_orders_using_gift_cards.py\t421f362 (parent)\n+++ saleor/order/migrations/0197_fix_negative_total_net_for_orders_using_gift_cards.py\t9e1838c (commit)\n@@ -1,1 +1,51 @@\n-[NEW FILE]\n\\n+# Generated by Django 4.2.15 on 2025-07-02 11:39\n+\n+import uuid\n+from decimal import Decimal\n+\n+from django.db import migrations, transaction\n+\n+\n+def fix_negative_total_net_for_orders_using_gift_cards(apps, _schema_editor):\n+ Order = apps.get_model(\"order\", \"Order\")\n+ # No memory usage tests were conducted here.\n+ # It's assumed that loading 500 identifiers to memory is not straining the memory\n+ # usage.\n+\n+ BATCH_SIZE = 500\n+ start_pk = uuid.UUID(\"00000000-0000-0000-0000-000000000000\")\n+ while True:\n+ with transaction.atomic():\n+ # Following select query has been tested on database with 4.2m actual orders, it took ~5s.\n+ order_pks = list(\n+ Order.objects.filter(\n+ pk__gt=start_pk,\n+ total_net_amount__lt=Decimal(\"0.00\"),\n+ )\n+ .exclude(gift_cards=None)\n+ .order_by(\"pk\")\n+ .select_for_update()\n+ .values_list(\"pk\", flat=True)[:BATCH_SIZE]\n+ )\n+\n+ if not order_pks:\n+ break\n+\n+ Order.objects.filter(\n+ pk__in=order_pks,\n+ ).update(total_net_amount=Decimal(\"0.00\"))\n+\n+ start_pk = order_pks[-1]\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"order\", \"0196_alter_fulfillment_shipping_refund_amount_and_more\"),\n+ ]\n+\n+ operations = [\n+ migrations.RunPython(\n+ fix_negative_total_net_for_orders_using_gift_cards,\n+ reverse_code=migrations.RunPython.noop,\n+ ),\n+ ]\n"
823
- },
824
- {
825
- "path": "saleor/order/migrations/0205_merge_20250703_1438.py",
826
- "status": "added",
827
- "diff": "Index: saleor/order/migrations/0205_merge_20250703_1438.py\n===================================================================\n--- saleor/order/migrations/0205_merge_20250703_1438.py\t421f362 (parent)\n+++ saleor/order/migrations/0205_merge_20250703_1438.py\t9e1838c (commit)\n@@ -1,1 +1,12 @@\n-[NEW FILE]\n\\n+# Generated by Django 4.2.20 on 2025-07-03 14:38\n+\n+from django.db import migrations\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"order\", \"0197_fix_negative_total_net_for_orders_using_gift_cards\"),\n+ (\"order\", \"0204_set_order_lines_count\"),\n+ ]\n+\n+ operations = []\n"
828
- },
829
- {
830
- "path": "saleor/order/migrations/0216_merge_20250704_1033.py",
831
- "status": "added",
832
- "diff": "Index: saleor/order/migrations/0216_merge_20250704_1033.py\n===================================================================\n--- saleor/order/migrations/0216_merge_20250704_1033.py\t421f362 (parent)\n+++ saleor/order/migrations/0216_merge_20250704_1033.py\t9e1838c (commit)\n@@ -1,1 +1,12 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-07-04 10:33\n+\n+from django.db import migrations\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"order\", \"0205_merge_20250703_1438\"),\n+ (\"order\", \"0215_merge_20250623_0624\"),\n+ ]\n+\n+ operations = []\n"
833
- },
834
- {
835
- "path": "saleor/order/tests/fixtures/order.py",
836
- "status": "modified",
837
- "diff": "Index: saleor/order/tests/fixtures/order.py\n===================================================================\n--- saleor/order/tests/fixtures/order.py\t421f362 (parent)\n+++ saleor/order/tests/fixtures/order.py\t9e1838c (commit)\n@@ -231,8 +231,14 @@\n return order_generator()\n \n \n @pytest.fixture\n+def order_with_gift_card(order, gift_card):\n+ order.gift_cards.add(gift_card)\n+ return order\n+\n+\n+@pytest.fixture\n def order_unconfirmed(order):\n order.status = OrderStatus.UNCONFIRMED\n order.save(update_fields=[\"status\"])\n return order\n"
838
- },
839
- {
840
- "path": "saleor/tax/tests/test_checkout_calculations.py",
841
- "status": "modified",
842
- "diff": "Index: saleor/tax/tests/test_checkout_calculations.py\n===================================================================\n--- saleor/tax/tests/test_checkout_calculations.py\t421f362 (parent)\n+++ saleor/tax/tests/test_checkout_calculations.py\t9e1838c (commit)\n@@ -1,9 +1,11 @@\n from decimal import Decimal\n+from unittest import mock\n \n import pytest\n from prices import Money, TaxedMoney\n \n+from ...checkout import calculations\n from ...checkout.fetch import fetch_checkout_info, fetch_checkout_lines\n from ...checkout.utils import add_variant_to_checkout\n from ...core.prices import quantize_price\n from ...core.taxes import zero_taxed_money\n@@ -33,8 +35,66 @@\n \n \n @pytest.mark.parametrize(\n (\n+ \"checkout_total_net\",\n+ \"checkout_total_gross\",\n+ \"gift_card_balance\",\n+ \"expected_total_net\",\n+ \"expected_total_gross\",\n+ ),\n+ [\n+ (\"0.00\", \"0.00\", \"0.00\", \"0.00\", \"0.00\"),\n+ (\"0.00\", \"0.00\", \"10.00\", \"0.00\", \"0.00\"),\n+ (\"10.00\", \"10.00\", \"5.00\", \"5.00\", \"5.00\"), # tax rate = 0%\n+ (\"10.00\", \"10.00\", \"10.00\", \"0.00\", \"0.00\"), # tax rate = 0%\n+ (\"10.00\", \"12.00\", \"10.00\", \"1.67\", \"2.00\"), # tax rate = 20%\n+ (\"10.00\", \"12.00\", \"12.00\", \"0.00\", \"0.00\"), # tax rate = 20%\n+ (\"30.00\", \"36.00\", \"12.00\", \"20\", \"24.00\"), # tax rate = 20%\n+ ],\n+)\n+@mock.patch(\"saleor.checkout.calculations.checkout_total\")\n+def test_calculate_checkout_total_with_gift_cards(\n+ checkout_total_mock,\n+ checkout_total_net,\n+ checkout_total_gross,\n+ gift_card_balance,\n+ expected_total_net,\n+ expected_total_gross,\n+ gift_card,\n+ checkout_with_gift_card,\n+ address,\n+):\n+ assert not gift_card.last_used_on\n+ gift_card.current_balance_amount = Decimal(gift_card_balance)\n+ gift_card.save(update_fields=[\"current_balance_amount\"])\n+\n+ checkout = checkout_with_gift_card\n+ checkout.metadata_storage.store_value_in_metadata(items={\"accepted\": \"true\"})\n+ checkout.metadata_storage.store_value_in_private_metadata(\n+ items={\"accepted\": \"false\"}\n+ )\n+ checkout.save()\n+ checkout.metadata_storage.save()\n+\n+ manager = get_plugins_manager(allow_replica=False)\n+ lines, _ = fetch_checkout_lines(checkout)\n+ checkout_info = fetch_checkout_info(checkout, lines, manager)\n+\n+ checkout_total_mock.return_value = TaxedMoney(\n+ net=Money(checkout_total_net, \"USD\"), gross=Money(checkout_total_gross, \"USD\")\n+ )\n+\n+ total = calculations.calculate_checkout_total_with_gift_cards(\n+ manager, checkout_info, lines, address\n+ )\n+\n+ assert total.net == Money(expected_total_net, \"USD\")\n+ assert total.gross == Money(expected_total_gross, \"USD\")\n+\n+\n+@pytest.mark.parametrize(\n+ (\n \"expected_net\",\n \"expected_gross\",\n \"expected_tax_rate\",\n \"voucher_amount\",\n"
843
- }
844
- ]
845
- },
846
- {
847
- "id": "attribute-choices-where",
848
- "sha": "421f362a87a4f45e62c4ad63ccbd737fd54ceb31",
849
- "parentSha": "e60ad19f0c9be6899bad2c581db7b894fc0e5651",
850
- "spec": "Implement where and search filtering for attribute choices in the GraphQL API.\n\nScope and behavior:\n- Add a new where-based filter input for attribute values and expose it on the Attribute.choices field.\n- Add a search argument to Attribute.choices that filters by name or slug using case-insensitive matching.\n- Maintain backward compatibility by keeping the legacy filter argument but deprecating it with a clear message.\n- Ensure query execution uses existing connection filtering utilities and channel context consistently with the rest of the API.\n\nChanges to make:\n1) saleor/graphql/attribute/filters.py\n- Introduce a reusable helper that filters AttributeValue querysets by name or slug, case-insensitively (ilike). Use it for the legacy search filter and for the new choices search.\n • Define a function that takes a queryset and a string and returns qs filtered by name__ilike OR slug__ilike.\n • Update AttributeValueFilter.filter_search to delegate to this helper.\n- Define a WhereFilterSet for attribute values:\n • Create AttributeValueWhere extending the base where filter class.\n • Include ids using GlobalIDMultipleChoiceWhereFilter with method constructed via filter_by_ids(\"AttributeValue\").\n • Include name and slug using OperationObjectTypeWhereFilter with StringFilterInput and filter_where_by_value_field for each corresponding field.\n- Define a WhereInputObjectType for attribute values:\n • Create AttributeValueWhereInput bound to AttributeValueWhere, described for the attributes domain.\n\n2) saleor/graphql/attribute/types.py\n- Update the Attribute.choices field definition:\n • Keep the existing filter argument but update its description to mark it deprecated and advise using the where filter instead (use the same deprecation constant used elsewhere in the codebase).\n • Add a new where argument of type AttributeValueWhereInput with a description noting its addition.\n • Add a new search argument (String) that searches choices by name or slug (case-insensitive), with an \"Added in\" note consistent with the codebase.\n- Update the resolver for choices:\n • When search is provided, apply the attribute value search helper before building the ChannelQsContext and before invoking the connection filtering.\n • Continue to pass the queryset through filter_connection_queryset with the received kwargs to apply sort/filter/where as appropriate.\n\n3) saleor/graphql/schema.graphql\n- Update the Attribute.choices field schema:\n • Mark filter: AttributeValueFilterInput as deprecated with reason \"Use where filter instead.\".\n • Add where: AttributeValueWhereInput with an appropriate docstring noting its addition in the correct version.\n • Add search: String with an appropriate docstring noting its addition in the correct version.\n- Add the new input type AttributeValueWhereInput including:\n • ids: [ID!]\n • name: StringFilterInput\n • slug: StringFilterInput\n • AND: [AttributeValueWhereInput!]\n • OR: [AttributeValueWhereInput!]\n\n4) Tests (GraphQL query tests under attribute queries)\n- Add tests for attribute choices filtering and searching to validate behavior:\n • Filter by choices ids: provide attribute id and where.ids (global IDs) and assert only matching choices are returned.\n • Filter by choices slug: provide where.slug with eq and oneOf cases; cover None and empty list behaviors returning empty results.\n • Filter by choices name: provide where.name similar to slug cases; include None/empty list cases returning empty results.\n • Search choices: pass the search string to the choices field and ensure case-insensitive matches across name/slug, returning the expected subset.\n\n5) CHANGELOG.md\n- Add an entry under GraphQL noting that attribute choices can now be filtered and searched using the new where and search fields on the attribute.choices query.\n\nConstraints and notes:\n- Use existing core where filtering utilities: WhereFilterSet, WhereInputObjectType, OperationObjectTypeWhereFilter, GlobalIDMultipleChoiceWhereFilter, filter_where_by_value_field, and filter_by_ids to ensure consistency.\n- Ensure the new where input supports AND and OR composition as per existing WhereInputObjectType behavior.\n- Preserve existing functionality and pagination via filter_connection_queryset and ChannelQsContext.\n- Follow existing documentation patterns using deprecation/version-added constants for GraphQL descriptions.\n",
851
- "prompt": "Enhance the GraphQL API for attributes so that attribute choices can be filtered with a structured where input and searched by a free-text string.\n\n- Add a new where input for attribute values (ids, name, slug with AND/OR composition) and expose it on Attribute.choices.\n- Add a search argument on Attribute.choices that matches name or slug case-insensitively.\n- Keep the old filter argument but mark it deprecated with a clear message directing users to the where filter.\n- Update the choices resolver to apply search first, then pass the queryset through the connection filtering utilities to honor sort/filter/where/pagination.\n- Update the schema documentation accordingly and add tests that verify where filtering by ids/name/slug and the search behavior.\n- Update the changelog to announce the new where and search options for attribute choices.",
852
- "supplementalFiles": [
853
- "saleor/graphql/core/filters/where_filters.py",
854
- "saleor/graphql/core/filters/where_input.py",
855
- "saleor/graphql/core/filters/shared_filters.py",
856
- "saleor/graphql/context.py",
857
- "saleor/graphql/core/connection.py",
858
- "saleor/graphql/product/filters.py",
859
- "saleor/graphql/utils/filters.py"
860
- ],
861
- "fileDiffs": [
862
- {
863
- "path": "CHANGELOG.md",
864
- "status": "modified",
865
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\te60ad19 (parent)\n+++ CHANGELOG.md\t421f362 (commit)\n@@ -80,10 +80,10 @@\n - `collection.products`\n - `pageType.availableAttributes`\n - Extend `AttributeEntityType` with `CATEGORY` and `COLLECTION`. You can now assign category and collection as a attribute reference.\n - Attribute values now expose the `referencedObject`, allowing for easier access to the linked entity.\n+- You can now filter and search attribute choices using the new `where` and `search` fields on the `attribute.choices` query.\n \n-\n ### Webhooks\n - Transaction webhooks responsible for processing payments can now return payment method details`, which will be associated with the corresponding transaction. See [docs](https://docs.saleor.io/developer/extending/webhooks/synchronous-events/transaction#response-4) to learn more.\n \n ### Other changes\n"
866
- },
867
- {
868
- "path": "saleor/graphql/attribute/filters.py",
869
- "status": "modified",
870
- "diff": "Index: saleor/graphql/attribute/filters.py\n===================================================================\n--- saleor/graphql/attribute/filters.py\te60ad19 (parent)\n+++ saleor/graphql/attribute/filters.py\t421f362 (commit)\n@@ -23,8 +23,9 @@\n ListObjectTypeFilter,\n MetadataFilterBase,\n MetadataWhereFilterBase,\n OperationObjectTypeWhereFilter,\n+ WhereFilterSet,\n )\n from ..core.filters.where_input import (\n FilterInputDescriptions,\n StringFilterInput,\n@@ -91,8 +92,13 @@\n return qs\n return qs.filter(type=value)\n \n \n+def search_attribute_values(qs, value):\n+ name_slug_qs = Q(name__ilike=value) | Q(slug__ilike=value)\n+ return qs.filter(name_slug_qs)\n+\n+\n class AttributeValueFilter(django_filters.FilterSet):\n search = django_filters.CharFilter(method=\"filter_search\")\n ids = GlobalIDMultipleChoiceFilter(field_name=\"id\")\n slugs = ListObjectTypeFilter(input_class=graphene.String, method=filter_slug_list)\n@@ -102,15 +108,14 @@\n fields = [\"search\"]\n \n @classmethod\n def filter_search(cls, queryset, _name, value):\n+ \"\"\"Filter attribute values by name or slug.\"\"\"\n if not value:\n return queryset\n- name_slug_qs = Q(name__ilike=value) | Q(slug__ilike=value)\n+ return search_attribute_values(queryset, value)\n \n- return queryset.filter(name_slug_qs)\n \n-\n class AttributeFilter(MetadataFilterBase):\n search = django_filters.CharFilter(method=filter_attribute_search)\n ids = GlobalIDMultipleChoiceFilter(field_name=\"id\")\n type = EnumFilter(input_class=AttributeTypeEnum, method=filter_by_attribute_type)\n@@ -295,4 +300,33 @@\n class Meta:\n filterset_class = AttributeWhere\n description = \"Where filtering options.\"\n doc_category = DOC_CATEGORY_ATTRIBUTES\n+\n+\n+class AttributeValueWhere(WhereFilterSet):\n+ ids = GlobalIDMultipleChoiceWhereFilter(method=filter_by_ids(\"AttributeValue\"))\n+ name = OperationObjectTypeWhereFilter(\n+ input_class=StringFilterInput, method=\"filter_by_name\"\n+ )\n+ slug = OperationObjectTypeWhereFilter(\n+ input_class=StringFilterInput, method=\"filter_by_slug\"\n+ )\n+\n+ class Meta:\n+ model = AttributeValue\n+ fields = []\n+\n+ @staticmethod\n+ def filter_by_name(qs, name, value):\n+ return filter_where_by_value_field(qs, \"name\", value)\n+\n+ @staticmethod\n+ def filter_by_slug(qs, name, value):\n+ return filter_where_by_value_field(qs, \"slug\", value)\n+\n+\n+class AttributeValueWhereInput(WhereInputObjectType):\n+ class Meta:\n+ filterset_class = AttributeValueWhere\n+ description = \"Where filtering options for attribute values.\"\n+ doc_category = DOC_CATEGORY_ATTRIBUTES\n"
871
- },
872
- {
873
- "path": "saleor/graphql/attribute/tests/queries/test_attribute_where.py",
874
- "status": "modified",
875
- "diff": "Index: saleor/graphql/attribute/tests/queries/test_attribute_where.py\n===================================================================\n--- saleor/graphql/attribute/tests/queries/test_attribute_where.py\te60ad19 (parent)\n+++ saleor/graphql/attribute/tests/queries/test_attribute_where.py\t421f362 (commit)\n@@ -3,9 +3,9 @@\n import graphene\n import pytest\n \n from .....attribute import AttributeInputType, AttributeType\n-from .....attribute.models import Attribute\n+from .....attribute.models import Attribute, AttributeValue\n from .....attribute.utils import associate_attribute_values_to_instance\n from .....core.units import MeasurementUnits\n from .....product import ProductTypeKind\n from .....product.models import ProductType\n@@ -1831,4 +1831,168 @@\n nodes = data[\"data\"][\"attributes\"][\"edges\"]\n assert len(nodes) == len(indexes)\n returned_attrs = {node[\"node\"][\"slug\"] for node in nodes}\n assert returned_attrs == {attributes[index].slug for index in indexes}\n+\n+\n+ATTRIBUTE_VALUES_FILTER_QUERY = \"\"\"\n+query($id: ID!, $where: AttributeValueWhereInput, $search: String) {\n+ attribute(id: $id) {\n+ name\n+ slug\n+ choices(first: 10, where: $where, search: $search) {\n+ edges {\n+ node {\n+ name\n+ slug\n+ }\n+ }\n+ }\n+ }\n+}\n+\"\"\"\n+\n+\n+def test_attributes_filter_by_choices_ids(api_client, color_attribute):\n+ # given\n+ choices = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(slug=\"choice-1\", name=\"Choice 1\", attribute=color_attribute),\n+ AttributeValue(slug=\"choice-2\", name=\"Choice 2\", attribute=color_attribute),\n+ AttributeValue(slug=\"choice-3\", name=\"Choice 3\", attribute=color_attribute),\n+ ]\n+ )\n+ lookup_values = [choices[0], choices[2]]\n+ value_ids = [\n+ graphene.Node.to_global_id(\"AttributeValue\", value.pk)\n+ for value in lookup_values\n+ ]\n+\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Attribute\", color_attribute.pk),\n+ \"where\": {\"ids\": value_ids},\n+ }\n+\n+ # when\n+ response = api_client.post_graphql(ATTRIBUTE_VALUES_FILTER_QUERY, variables)\n+\n+ # then\n+ data = get_graphql_content(response)\n+ choices = data[\"data\"][\"attribute\"][\"choices\"][\"edges\"]\n+ assert len(choices) == len(lookup_values)\n+ returned_choices = {node[\"node\"][\"slug\"] for node in choices}\n+ assert returned_choices == {value.slug for value in lookup_values}\n+\n+\n+@pytest.mark.parametrize(\n+ (\"where\", \"indexes\"),\n+ [\n+ ({\"eq\": \"choice-1\"}, [0]),\n+ ({\"eq\": \"non-existent-choice\"}, []),\n+ ({\"oneOf\": [\"choice-1\", \"choice-2\"]}, [0, 1]),\n+ ({\"oneOf\": [\"non-existent-choice\"]}, []),\n+ ({\"oneOf\": []}, []),\n+ ({\"oneOf\": None}, []),\n+ ({\"eq\": None}, []),\n+ ],\n+)\n+def test_attributes_filter_by_choices_slug(where, indexes, api_client, color_attribute):\n+ # given\n+ values = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(slug=\"choice-1\", name=\"Choice 1\", attribute=color_attribute),\n+ AttributeValue(slug=\"choice-2\", name=\"Choice 2\", attribute=color_attribute),\n+ AttributeValue(slug=\"choice-3\", name=\"Choice 3\", attribute=color_attribute),\n+ ]\n+ )\n+\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Attribute\", color_attribute.pk),\n+ \"where\": {\"slug\": where},\n+ }\n+\n+ # when\n+ response = api_client.post_graphql(ATTRIBUTE_VALUES_FILTER_QUERY, variables)\n+\n+ # then\n+ data = get_graphql_content(response)\n+ choices = data[\"data\"][\"attribute\"][\"choices\"][\"edges\"]\n+ assert len(choices) == len(indexes)\n+ returned_choices = {node[\"node\"][\"slug\"] for node in choices}\n+ assert returned_choices == {values[index].slug for index in indexes}\n+\n+\n+@pytest.mark.parametrize(\n+ (\"where\", \"indexes\"),\n+ [\n+ ({\"eq\": \"Choice 1\"}, [0]),\n+ ({\"eq\": \"Non-existent Choice\"}, []),\n+ ({\"oneOf\": [\"Choice 1\", \"Choice 2\"]}, [0, 1]),\n+ ({\"oneOf\": [\"Non-existent Choice\"]}, []),\n+ ({\"oneOf\": []}, []),\n+ ({\"oneOf\": None}, []),\n+ ({\"eq\": None}, []),\n+ ],\n+)\n+def test_attributes_filter_by_choices_name(where, indexes, api_client, color_attribute):\n+ # given\n+ values = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(slug=\"choice-1\", name=\"Choice 1\", attribute=color_attribute),\n+ AttributeValue(slug=\"choice-2\", name=\"Choice 2\", attribute=color_attribute),\n+ AttributeValue(slug=\"choice-3\", name=\"Choice 3\", attribute=color_attribute),\n+ ]\n+ )\n+\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Attribute\", color_attribute.pk),\n+ \"where\": {\"name\": where},\n+ }\n+\n+ # when\n+ response = api_client.post_graphql(ATTRIBUTE_VALUES_FILTER_QUERY, variables)\n+\n+ # then\n+ data = get_graphql_content(response)\n+ choices = data[\"data\"][\"attribute\"][\"choices\"][\"edges\"]\n+ assert len(choices) == len(indexes)\n+ returned_choices = {node[\"node\"][\"name\"] for node in choices}\n+ assert returned_choices == {values[index].name for index in indexes}\n+\n+\n+@pytest.mark.parametrize(\n+ (\"search\", \"indexes\"),\n+ [\n+ (\"choice\", [0, 1, 2]),\n+ (\"Choice\", [0, 1, 2]),\n+ (\"choice-1\", [0]),\n+ (\"Choice 2\", [1]),\n+ (\"Choice 3\", [2]),\n+ (\"Non-existent\", []),\n+ ],\n+)\n+def test_attributes_filter_by_choices_search(\n+ search, indexes, api_client, color_attribute\n+):\n+ # given\n+ values = AttributeValue.objects.bulk_create(\n+ [\n+ AttributeValue(slug=\"choice-1\", name=\"Choice 1\", attribute=color_attribute),\n+ AttributeValue(slug=\"choice-2\", name=\"Choice 2\", attribute=color_attribute),\n+ AttributeValue(slug=\"choice-3\", name=\"Choice 3\", attribute=color_attribute),\n+ ]\n+ )\n+\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Attribute\", color_attribute.pk),\n+ \"search\": search,\n+ }\n+\n+ # when\n+ response = api_client.post_graphql(ATTRIBUTE_VALUES_FILTER_QUERY, variables)\n+\n+ # then\n+ data = get_graphql_content(response)\n+ choices = data[\"data\"][\"attribute\"][\"choices\"][\"edges\"]\n+ assert len(choices) == len(indexes)\n+ returned_choices = {node[\"node\"][\"name\"] for node in choices}\n+ assert returned_choices == {values[index].name for index in indexes}\n"
876
- },
877
- {
878
- "path": "saleor/graphql/attribute/types.py",
879
- "status": "modified",
880
- "diff": "Index: saleor/graphql/attribute/types.py\n===================================================================\n--- saleor/graphql/attribute/types.py\te60ad19 (parent)\n+++ saleor/graphql/attribute/types.py\t421f362 (commit)\n@@ -45,9 +45,13 @@\n from ..translations.types import AttributeTranslation, AttributeValueTranslation\n from .dataloaders import AttributesByAttributeId\n from .descriptions import AttributeDescriptions, AttributeValueDescriptions\n from .enums import AttributeEntityTypeEnum, AttributeInputTypeEnum, AttributeTypeEnum\n-from .filters import AttributeValueFilterInput\n+from .filters import (\n+ AttributeValueFilterInput,\n+ AttributeValueWhereInput,\n+ search_attribute_values,\n+)\n from .sorters import AttributeChoicesSortingInput\n from .utils import AttributeAssignmentMixin\n \n \n@@ -240,10 +244,17 @@\n choices = FilterConnectionField(\n AttributeValueCountableConnection,\n sort_by=AttributeChoicesSortingInput(description=\"Sort attribute choices.\"),\n filter=AttributeValueFilterInput(\n- description=\"Filtering options for attribute choices.\"\n+ description=(\n+ f\"Filtering options for attribute choices. {DEPRECATED_IN_3X_INPUT} \"\n+ \"Use where filter instead.\"\n+ ),\n ),\n+ where=AttributeValueWhereInput(\n+ description=\"Where filtering options for attribute choices.\" + ADDED_IN_322\n+ ),\n+ search=graphene.String(description=\"Search attribute choices.\" + ADDED_IN_322),\n description=AttributeDescriptions.VALUES,\n )\n \n value_required = graphene.Boolean(\n@@ -355,8 +366,11 @@\n qs = attr.values.using(get_database_connection_name(info.context)).all()\n else:\n qs = models.AttributeValue.objects.none()\n \n+ if search := kwargs.pop(\"search\", None):\n+ qs = search_attribute_values(qs, search)\n+\n channel_context_qs = ChannelQsContext(qs=qs, channel_slug=root.channel_slug)\n channel_context_qs = filter_connection_queryset(\n channel_context_qs, kwargs, allow_replica=info.context.allow_replica\n )\n"
881
- },
882
- {
883
- "path": "saleor/graphql/schema.graphql",
884
- "status": "modified",
885
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\te60ad19 (parent)\n+++ saleor/graphql/schema.graphql\t421f362 (commit)\n@@ -6044,10 +6044,24 @@\n \"\"\"Sort attribute choices.\"\"\"\n sortBy: AttributeChoicesSortingInput\n \n \"\"\"Filtering options for attribute choices.\"\"\"\n- filter: AttributeValueFilterInput\n+ filter: AttributeValueFilterInput @deprecated(reason: \"Use where filter instead.\")\n \n+ \"\"\"\n+ Where filtering options for attribute choices.\n+ \n+ Added in Saleor 3.22.\n+ \"\"\"\n+ where: AttributeValueWhereInput\n+\n+ \"\"\"\n+ Search attribute choices.\n+ \n+ Added in Saleor 3.22.\n+ \"\"\"\n+ search: String\n+\n \"\"\"Return the elements in the list that come before the specified cursor.\"\"\"\n before: String\n \n \"\"\"Return the elements in the list that come after the specified cursor.\"\"\"\n@@ -7428,8 +7442,21 @@\n ids: [ID!]\n slugs: [String!]\n }\n \n+\"\"\"Where filtering options for attribute values.\"\"\"\n+input AttributeValueWhereInput @doc(category: \"Attributes\") {\n+ ids: [ID!]\n+ name: StringFilterInput\n+ slug: StringFilterInput\n+\n+ \"\"\"List of conditions that must be met.\"\"\"\n+ AND: [AttributeValueWhereInput!]\n+\n+ \"\"\"A list of conditions of which at least one must be met.\"\"\"\n+ OR: [AttributeValueWhereInput!]\n+}\n+\n type ProductTypeCountableConnection @doc(category: \"Products\") {\n \"\"\"Pagination data for this connection.\"\"\"\n pageInfo: PageInfo!\n edges: [ProductTypeCountableEdge!]!\n"
886
- }
887
- ]
888
- },
889
- {
890
- "id": "channelize-page-type",
891
- "sha": "bf1860ea03ab90f96ad0def1aca7611b3b72dbf7",
892
- "parentSha": "94492e45d9d2ae7c074ffe4ff8e6346d22edb442",
893
- "spec": "Implement channel context support for Page across GraphQL schema, resolvers, and mutations.\n\nScope and required changes:\n\n1) saleor/graphql/page/types.py\n- Change Page base class from ModelObjectType[models.Page] to ChannelContextType[models.Page].\n- Set Meta.default_resolver to ChannelContextType.resolver_with_context.\n- Update translation field to use resolver=ChannelContextType.resolve_translation.\n- Update all field resolvers to accept root: ChannelContext[models.Page] and use root.node instead of the raw model instance. This includes: resolve_publication_date, resolve_created, resolve_page_type, resolve_content_json.\n- Update attributes resolvers:\n - resolve_attributes: when mapping SelectedAttribute, wrap both attribute and each value in ChannelContext with channel_slug=root.channel_slug; load data using page = root.node and use page.id in dataloaders; return lists of SelectedAttribute with channel-wrapped nodes.\n - resolve_attribute(slug): same as above for a single attribute; load using page.id; wrap attribute and values with ChannelContext(channel_slug=root.channel_slug).\n\n2) saleor/graphql/page/schema.py\n- Import and use ChannelContext and ChannelQsContext.\n- In resolve_page, wrap the returned page in ChannelContext(page, channel_slug=None); return None if page is not found.\n- In resolve_pages, wrap queryset with ChannelQsContext(qs, channel_slug=None) before filtering/slicing.\n\n3) saleor/graphql/page/mutations/page_create.py\n- Import ChannelContext.\n- Override success_response to call super().success_response(instance), set response.page = ChannelContext(instance, channel_slug=None), and return response.\n\n4) saleor/graphql/page/mutations/page_update.py\n- Import ChannelContext.\n- Override success_response analogous to PageCreate; set response.page = ChannelContext(instance, channel_slug=None).\n\n5) saleor/graphql/page/mutations/page_delete.py\n- Import ChannelContext.\n- After calling the delete mutation logic, set response.page = ChannelContext(page, channel_slug=None) before returning.\n\n6) saleor/graphql/page/mutations/page_reorder_attribute_values.py\n- Return PageReorderAttributeValues(page=ChannelContext(page, channel_slug=None)) instead of the bare page object.\n\n7) saleor/graphql/menu/types.py\n- In MenuItem.resolve_page, preserve existing permission check. Replace the previous inline lambda with a helper to return ChannelContext(node=page, channel_slug=root.channel_slug) only if the requester has full access or the page is visible; otherwise return None. Ensure the returned object is channel-wrapped when available.\n\n8) saleor/graphql/attribute/types.py\n- In AttributeValue.resolve_referenced_object, for AttributeEntityType.PAGE, return PageByIdLoader(...).load(reference_pk).then(wrap_with_channel_context), where wrap_with_channel_context wraps node in ChannelContext(node=_object, channel_slug=root.channel_slug). Do not return a bare page instance.\n\n9) saleor/graphql/meta/mutations/base.py\n- Add import: from ....page import models as page_models.\n- In BaseMetadataMutation.success_response(), extend the use_channel_context isinstance union to include page_models.Page so that Page results are wrapped in ChannelContext(node=instance, channel_slug=None).\n\n10) saleor/graphql/translations/types.py\n- In PageTranslatableContent.resolve_page, wrap the looked-up page in ChannelContext(page, channel_slug=None) if found; return None if not.\n\n11) saleor/graphql/webhook/subscription_types.py\n- In PageBase.resolve_page, return ChannelContext(page, channel_slug=None) instead of returning the bare page.\n\nBehavioral expectations:\n- All GraphQL fields returning a Page node should return ChannelContext-wrapped nodes.\n- All Page connections (pages field) should be based on ChannelQsContext so edges’ nodes are channel-wrapped by the connection helper.\n- Resolvers inside Page type must use root.node and propagate root.channel_slug when wrapping nested attribute/value nodes.\n- Mutations that return a page field return ChannelContext-wrapped pages.\n- Metadata mutations treat Page as a channel-context type and wrap it accordingly.\n- MenuItem’s page field respects visibility and returns channel-wrapped pages or None consistently with permissions.\n- Attribute value referenced_object for PAGE returns a channel-wrapped Page.\n\nDo not modify any unrelated files. Ensure imports for ChannelContext, ChannelContextType, and ChannelQsContext are added where used.",
894
- "prompt": "Make the Page GraphQL type channel-aware and ensure all Page-related resolvers and mutations return channel-scoped objects.\n\nSpecifically:\n- Update the Page type to the same channel-aware base used by other multi-channel types and adjust its resolvers to use an unwrapped node and propagate channel slug to nested fields.\n- Ensure single Page queries return channel-wrapped nodes and Page list queries work with channel-wrapped querysets so connections wrap edges with channel context.\n- Adjust page mutations to return channel-wrapped Page instances in their responses.\n- Make attribute value references to Page, menu item page resolution, translations, and webhooks return channel-wrapped pages.\n- Include Page in the set of models that the metadata mutation base auto-wraps in channel context.\n\nUse existing Product/Collection implementations as a guide for wrapping nodes and querysets. Keep permission and visibility checks unchanged, only change the wrapping and resolver structure.",
895
- "supplementalFiles": [
896
- "saleor/graphql/core/context.py",
897
- "saleor/graphql/core/types/context.py",
898
- "saleor/graphql/core/connection.py",
899
- "saleor/graphql/product/types/collections.py",
900
- "saleor/graphql/product/schema.py",
901
- "saleor/graphql/page/resolvers.py"
902
- ],
903
- "fileDiffs": [
904
- {
905
- "path": "saleor/graphql/attribute/types.py",
906
- "status": "modified",
907
- "diff": "Index: saleor/graphql/attribute/types.py\n===================================================================\n--- saleor/graphql/attribute/types.py\t94492e4 (parent)\n+++ saleor/graphql/attribute/types.py\tbf1860e (commit)\n@@ -136,9 +136,13 @@\n .load(reference_pk)\n .then(wrap_with_channel_context)\n )\n if attribute.entity_type == AttributeEntityType.PAGE:\n- return PageByIdLoader(info.context).load(reference_pk)\n+ return (\n+ PageByIdLoader(info.context)\n+ .load(reference_pk)\n+ .then(wrap_with_channel_context)\n+ )\n return None\n \n return (\n AttributesByAttributeId(info.context)\n"
908
- },
909
- {
910
- "path": "saleor/graphql/menu/types.py",
911
- "status": "modified",
912
- "diff": "Index: saleor/graphql/menu/types.py\n===================================================================\n--- saleor/graphql/menu/types.py\t94492e4 (parent)\n+++ saleor/graphql/menu/types.py\tbf1860e (commit)\n@@ -223,16 +223,18 @@\n requestor\n and requestor.is_active\n and requestor.has_perm(PagePermissions.MANAGE_PAGES)\n )\n+\n+ def resolve_page_with_channel(page):\n+ if requestor_has_access_to_all or page.is_visible:\n+ return ChannelContext(node=page, channel_slug=root.channel_slug)\n+ return None\n+\n return (\n PageByIdLoader(info.context)\n .load(root.node.page_id)\n- .then(\n- lambda page: (\n- page if requestor_has_access_to_all or page.is_visible else None\n- )\n- )\n+ .then(resolve_page_with_channel)\n )\n return None\n \n \n"
913
- },
914
- {
915
- "path": "saleor/graphql/meta/mutations/base.py",
916
- "status": "modified",
917
- "diff": "Index: saleor/graphql/meta/mutations/base.py\n===================================================================\n--- saleor/graphql/meta/mutations/base.py\t94492e4 (parent)\n+++ saleor/graphql/meta/mutations/base.py\tbf1860e (commit)\n@@ -11,8 +11,9 @@\n from ....discount import models as discount_models\n from ....discount.models import Promotion\n from ....menu import models as menu_models\n from ....order import models as order_models\n+from ....page import models as page_models\n from ....product import models as product_models\n from ....shipping import models as shipping_models\n from ...core import ResolveInfo\n from ...core.context import BaseContext, ChannelContext, SyncWebhookControlContext\n@@ -258,9 +259,10 @@\n | product_models.ProductVariant\n | shipping_models.ShippingMethod\n | shipping_models.ShippingZone\n | attribute_models.Attribute\n- | attribute_models.AttributeValue,\n+ | attribute_models.AttributeValue\n+ | page_models.Page,\n )\n \n use_channel_context = use_channel_context or (\n # For old sales migrated into promotions\n"
918
- },
919
- {
920
- "path": "saleor/graphql/page/mutations/page_create.py",
921
- "status": "modified",
922
- "diff": "Index: saleor/graphql/page/mutations/page_create.py\n===================================================================\n--- saleor/graphql/page/mutations/page_create.py\t94492e4 (parent)\n+++ saleor/graphql/page/mutations/page_create.py\tbf1860e (commit)\n@@ -9,8 +9,9 @@\n from ....permission.enums import PagePermissions\n from ...attribute.types import AttributeValueInput\n from ...attribute.utils import PageAttributeAssignmentMixin\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.descriptions import DEPRECATED_IN_3X_INPUT, RICH_CONTENT\n from ...core.doc_category import DOC_CATEGORY_PAGES\n from ...core.fields import JSONString\n from ...core.mutations import DeprecatedModelMutation\n@@ -133,4 +134,10 @@\n def save(cls, info: ResolveInfo, instance, cleaned_input):\n super().save(info, instance, cleaned_input)\n manager = get_plugin_manager_promise(info.context).get()\n cls.call_event(manager.page_created, instance)\n+\n+ @classmethod\n+ def success_response(cls, instance):\n+ response = super().success_response(instance)\n+ response.page = ChannelContext(instance, channel_slug=None)\n+ return response\n"
923
- },
924
- {
925
- "path": "saleor/graphql/page/mutations/page_delete.py",
926
- "status": "modified",
927
- "diff": "Index: saleor/graphql/page/mutations/page_delete.py\n===================================================================\n--- saleor/graphql/page/mutations/page_delete.py\t94492e4 (parent)\n+++ saleor/graphql/page/mutations/page_delete.py\tbf1860e (commit)\n@@ -6,8 +6,9 @@\n from ....core.tracing import traced_atomic_transaction\n from ....page import models\n from ....permission.enums import PagePermissions\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.mutations import ModelDeleteMutation\n from ...core.types import PageError\n from ...plugins.dataloaders import get_plugin_manager_promise\n from ..types import Page\n@@ -34,8 +35,9 @@\n cls.delete_assigned_attribute_values(page)\n response = super().perform_mutation(_root, info, **data)\n page.page_type = page_type\n cls.call_event(manager.page_deleted, page)\n+ response.page = ChannelContext(page, channel_slug=None)\n return response\n \n @staticmethod\n def delete_assigned_attribute_values(instance):\n"
928
- },
929
- {
930
- "path": "saleor/graphql/page/mutations/page_reorder_attribute_values.py",
931
- "status": "modified",
932
- "diff": "Index: saleor/graphql/page/mutations/page_reorder_attribute_values.py\n===================================================================\n--- saleor/graphql/page/mutations/page_reorder_attribute_values.py\t94492e4 (parent)\n+++ saleor/graphql/page/mutations/page_reorder_attribute_values.py\tbf1860e (commit)\n@@ -7,8 +7,9 @@\n from ....permission.enums import PagePermissions\n from ...attribute.mutations import BaseReorderAttributeValuesMutation\n from ...attribute.types import Attribute\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.doc_category import DOC_CATEGORY_PAGES\n from ...core.inputs import ReorderInput\n from ...core.types import NonNullList, PageError\n from ...core.utils.reordering import perform_reordering\n@@ -43,9 +44,9 @@\n @classmethod\n def perform_mutation(cls, _root, _info: ResolveInfo, /, **data):\n page_id = data[\"page_id\"]\n page = cls.perform(page_id, \"page\", data, \"attributevalues\", PageErrorCode)\n- return PageReorderAttributeValues(page=page)\n+ return PageReorderAttributeValues(page=ChannelContext(page, channel_slug=None))\n \n @classmethod\n def perform(\n cls,\n"
933
- },
934
- {
935
- "path": "saleor/graphql/page/mutations/page_update.py",
936
- "status": "modified",
937
- "diff": "Index: saleor/graphql/page/mutations/page_update.py\n===================================================================\n--- saleor/graphql/page/mutations/page_update.py\t94492e4 (parent)\n+++ saleor/graphql/page/mutations/page_update.py\tbf1860e (commit)\n@@ -3,8 +3,9 @@\n from ....page import models\n from ....permission.enums import PagePermissions\n from ...attribute.utils import PageAttributeAssignmentMixin\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.types import PageError\n from ...plugins.dataloaders import get_plugin_manager_promise\n from ..types import Page\n from .page_create import PageCreate, PageInput\n@@ -37,4 +38,10 @@\n def save(cls, info: ResolveInfo, instance, cleaned_input):\n super(PageCreate, cls).save(info, instance, cleaned_input)\n manager = get_plugin_manager_promise(info.context).get()\n cls.call_event(manager.page_updated, instance)\n+\n+ @classmethod\n+ def success_response(cls, instance):\n+ response = super().success_response(instance)\n+ response.page = ChannelContext(instance, channel_slug=None)\n+ return response\n"
938
- },
939
- {
940
- "path": "saleor/graphql/page/schema.py",
941
- "status": "modified",
942
- "diff": "Index: saleor/graphql/page/schema.py\n===================================================================\n--- saleor/graphql/page/schema.py\t94492e4 (parent)\n+++ saleor/graphql/page/schema.py\tbf1860e (commit)\n@@ -1,8 +1,9 @@\n import graphene\n \n from ..core import ResolveInfo\n from ..core.connection import create_connection_slice, filter_connection_queryset\n+from ..core.context import ChannelContext, ChannelQsContext\n from ..core.descriptions import ADDED_IN_321, ADDED_IN_322, DEPRECATED_IN_3X_INPUT\n from ..core.doc_category import DOC_CATEGORY_PAGES\n from ..core.enums import LanguageCodeEnum\n from ..core.fields import BaseField, FilterConnectionField\n@@ -82,16 +83,20 @@\n @staticmethod\n def resolve_page(\n _root, info: ResolveInfo, *, id=None, slug=None, slug_language_code=None\n ):\n- return resolve_page(info, id, slug, slug_language_code)\n+ page = resolve_page(info, id, slug, slug_language_code)\n+ if not page:\n+ return None\n+ return ChannelContext(page, channel_slug=None)\n \n @staticmethod\n def resolve_pages(_root, info: ResolveInfo, **kwargs):\n qs = resolve_pages(info)\n search = kwargs.get(\"search\") or kwargs.get(\"filter\", {}).get(\"search\")\n if search:\n qs = search_pages(qs, search)\n+ qs = ChannelQsContext(qs, channel_slug=None)\n qs = filter_connection_queryset(\n qs, kwargs, allow_replica=info.context.allow_replica\n )\n return create_connection_slice(qs, info, kwargs, PageCountableConnection)\n"
943
- },
944
- {
945
- "path": "saleor/graphql/page/types.py",
946
- "status": "modified",
947
- "diff": "Index: saleor/graphql/page/types.py\n===================================================================\n--- saleor/graphql/page/types.py\t94492e4 (parent)\n+++ saleor/graphql/page/types.py\tbf1860e (commit)\n@@ -25,8 +25,9 @@\n from ..core.federation import federated_entity, resolve_federation_references\n from ..core.fields import FilterConnectionField, JSONString, PermissionsField\n from ..core.scalars import Date, DateTime\n from ..core.types import ModelObjectType, NonNullList\n+from ..core.types.context import ChannelContextType\n from ..meta.types import ObjectWithMetadata\n from ..translations.fields import TranslationField\n from ..translations.types import PageTranslation\n from ..utils import get_user_or_app_from_context\n@@ -145,9 +146,9 @@\n doc_category = DOC_CATEGORY_PAGES\n node = PageType\n \n \n-class Page(ModelObjectType[models.Page]):\n+class Page(ChannelContextType[models.Page]):\n id = graphene.GlobalID(required=True, description=\"ID of the page.\")\n seo_title = graphene.String(description=\"Title of the page for SEO.\")\n seo_description = graphene.String(description=\"Description of the page for SEO.\")\n title = graphene.String(required=True, description=\"Title of the page.\")\n@@ -170,9 +171,13 @@\n description=\"Content of the page.\" + RICH_CONTENT,\n deprecation_reason=\"Use the `content` field instead.\",\n required=True,\n )\n- translation = TranslationField(PageTranslation, type_name=\"page\")\n+ translation = TranslationField(\n+ PageTranslation,\n+ type_name=\"page\",\n+ resolver=ChannelContextType.resolve_translation,\n+ )\n attribute = graphene.Field(\n SelectedAttribute,\n slug=graphene.Argument(\n graphene.String,\n@@ -187,44 +192,48 @@\n description=\"List of attributes assigned to this page.\",\n )\n \n class Meta:\n+ default_resolver = ChannelContextType.resolver_with_context\n description = (\n \"A static page that can be manually added by a shop operator through the \"\n \"dashboard.\"\n )\n interfaces = [graphene.relay.Node, ObjectWithMetadata]\n model = models.Page\n \n @staticmethod\n- def resolve_publication_date(root: models.Page, _info: ResolveInfo):\n- return root.published_at\n+ def resolve_publication_date(root: ChannelContext[models.Page], _info: ResolveInfo):\n+ return root.node.published_at\n \n @staticmethod\n- def resolve_created(root: models.Page, _info: ResolveInfo):\n- return root.created_at\n+ def resolve_created(root: ChannelContext[models.Page], _info: ResolveInfo):\n+ return root.node.created_at\n \n @staticmethod\n- def resolve_page_type(root: models.Page, info: ResolveInfo):\n- return PageTypeByIdLoader(info.context).load(root.page_type_id)\n+ def resolve_page_type(root: ChannelContext[models.Page], info: ResolveInfo):\n+ return PageTypeByIdLoader(info.context).load(root.node.page_type_id)\n \n @staticmethod\n- def resolve_content_json(root: models.Page, _info: ResolveInfo):\n- content = root.content\n+ def resolve_content_json(root: ChannelContext[models.Page], _info: ResolveInfo):\n+ content = root.node.content\n return content if content is not None else {}\n \n @staticmethod\n- def resolve_attributes(root: models.Page, info: ResolveInfo):\n+ def resolve_attributes(root: ChannelContext[models.Page], info: ResolveInfo):\n+ page = root.node\n+\n def wrap_with_channel_context(\n attributes: list[dict[str, list]] | None,\n ) -> list[SelectedAttribute] | None:\n if attributes is None:\n return None\n return [\n SelectedAttribute(\n- attribute=ChannelContext(attribute[\"attribute\"], None),\n+ attribute=ChannelContext(attribute[\"attribute\"], root.channel_slug),\n values=[\n- ChannelContext(value, None) for value in attribute[\"values\"]\n+ ChannelContext(value, root.channel_slug)\n+ for value in attribute[\"values\"]\n ],\n )\n for attribute in attributes\n ]\n@@ -236,28 +245,35 @@\n and requestor.has_perm(PagePermissions.MANAGE_PAGES)\n ):\n return (\n SelectedAttributesAllByPageIdLoader(info.context)\n- .load(root.id)\n+ .load(page.id)\n .then(wrap_with_channel_context)\n )\n return (\n SelectedAttributesVisibleInStorefrontPageIdLoader(info.context)\n- .load(root.id)\n+ .load(page.id)\n .then(wrap_with_channel_context)\n )\n \n @staticmethod\n- def resolve_attribute(root: models.Page, info: ResolveInfo, slug: str):\n+ def resolve_attribute(\n+ root: ChannelContext[models.Page], info: ResolveInfo, slug: str\n+ ):\n+ page = root.node\n+\n def wrap_with_channel_context(\n attribute_data: dict[str, dict | list[dict]] | None,\n ) -> SelectedAttribute | None:\n if attribute_data is None:\n return None\n return SelectedAttribute(\n- attribute=ChannelContext(attribute_data[\"attribute\"], None),\n+ attribute=ChannelContext(\n+ attribute_data[\"attribute\"], root.channel_slug\n+ ),\n values=[\n- ChannelContext(value, None) for value in attribute_data[\"values\"]\n+ ChannelContext(value, root.channel_slug)\n+ for value in attribute_data[\"values\"]\n ],\n )\n \n requestor = get_user_or_app_from_context(info.context)\n@@ -267,14 +283,14 @@\n and requestor.has_perm(PagePermissions.MANAGE_PAGES)\n ):\n return (\n SelectedAttributeAllByPageIdAttributeSlugLoader(info.context)\n- .load((root.id, slug))\n+ .load((page.id, slug))\n .then(wrap_with_channel_context)\n )\n return (\n SelectedAttributeVisibleInStorefrontPageIdAttributeSlugLoader(info.context)\n- .load((root.id, slug))\n+ .load((page.id, slug))\n .then(wrap_with_channel_context)\n )\n \n \n"
948
- },
949
- {
950
- "path": "saleor/graphql/translations/types.py",
951
- "status": "modified",
952
- "diff": "Index: saleor/graphql/translations/types.py\n===================================================================\n--- saleor/graphql/translations/types.py\t94492e4 (parent)\n+++ saleor/graphql/translations/types.py\tbf1860e (commit)\n@@ -655,14 +655,17 @@\n )\n \n @staticmethod\n def resolve_page(root: page_models.Page, info):\n- return (\n+ page = (\n page_models.Page.objects.using(get_database_connection_name(info.context))\n .visible_to_user(info.context.user)\n .filter(pk=root.id)\n .first()\n )\n+ if not page:\n+ return None\n+ return ChannelContext(page, channel_slug=None)\n \n @staticmethod\n def resolve_content_json(root: page_models.Page, _info):\n content = root.content\n"
953
- },
954
- {
955
- "path": "saleor/graphql/webhook/subscription_types.py",
956
- "status": "modified",
957
- "diff": "Index: saleor/graphql/webhook/subscription_types.py\n===================================================================\n--- saleor/graphql/webhook/subscription_types.py\t94492e4 (parent)\n+++ saleor/graphql/webhook/subscription_types.py\tbf1860e (commit)\n@@ -1529,9 +1529,9 @@\n \n @staticmethod\n def resolve_page(root, _info: ResolveInfo):\n _, page = root\n- return page\n+ return ChannelContext(page, channel_slug=None)\n \n \n class PageCreated(SubscriptionObjectType, PageBase):\n class Meta:\n"
958
- }
959
- ]
960
- },
961
- {
962
- "id": "channelize-attributes",
963
- "sha": "94492e45d9d2ae7c074ffe4ff8e6346d22edb442",
964
- "parentSha": "5d692d80e2c822729fe66dc4d16b21eb8c183297",
965
- "spec": "Implement channel awareness for attribute-related GraphQL operations by consistently using ChannelContext and ChannelQsContext wrappers and updating types/resolvers accordingly.\n\nRequirements\n1) Core context/types updates\n- Update generic typing for ChannelContext to be based on the model type variable used across GraphQL types, and adjust ChannelContextTypeForObjectType and ChannelContextType so resolvers can accept ChannelContext roots. Ensure resolver_with_context unwraps root.node and resolve_translation works with ChannelContext. Confirm ChannelContextType resolves IDs from root.node.pk and preserves Graphene is_type_of behavior.\n\n2) Attribute GraphQL types\n- AttributeValue type: convert to a ChannelContext-aware type. Set default_resolver to the ChannelContext-aware resolver. Update resolvers (referenced_object, input_type, file, reference, date_time, date) to accept ChannelContext[AttributeValue] and operate on root.node; where other objects are returned (products, variants, pages, collections), return them wrapped in ChannelContext with the same channel_slug as the attribute value. Ensure TranslationField for AttributeValue uses ChannelContextType.resolve_translation.\n- Attribute type: convert to a ChannelContext-aware type. Set default_resolver to the ChannelContext-aware resolver. Update translation field to use ChannelContextType.resolve_translation. Update resolvers to accept ChannelContext[Attribute] and use root.node to return values for: value_required, visible_in_storefront, filterable_in_storefront, filterable_in_dashboard, storefront_search_position, available_in_grid, and with_choices. For choices, fetch values normally, then wrap the queryset in ChannelQsContext with the current channel before filtering/slicing; pass the ChannelQsContext into filter_connection_queryset and create_connection_slice so nodes are wrapped automatically. For product_types and product_variant_types connections, continue to return regular querysets.\n- SelectedAttribute type: change to a ChannelContextTypeForObjectType so its attribute and values fields can be ChannelContext-wrapped.\n\n3) Attribute schema and queries\n- For attribute list queries, wrap the queryset in ChannelQsContext with channel_slug=None before passing through filter_connection_queryset and create_connection_slice.\n- For single attribute retrieval (by id/slug/externalReference), if the attribute exists, return ChannelContext(node=attribute, channel_slug=None), otherwise None.\n\n4) Attribute mutations\n- attribute_create and attribute_update: return attribute as ChannelContext(instance, None).\n- attribute_delete: override success_response to set response.attribute to ChannelContext(instance, None).\n- attribute_reorder_values: return ChannelContext(attribute, None) in the response.\n- attribute_value_create: return attribute and attributeValue as ChannelContext-wrapped instances.\n- attribute_value_update/delete: in success_response, set response.attribute and response.attributeValue as ChannelContext-wrapped instances.\n- attribute_bulk_create and attribute_bulk_update: in get_results, when not rejecting everything, wrap each created/updated instance in ChannelContext(instance, None) before building the result items.\n\n5) Page GraphQL types\n- PageType.resolve_attributes: after loading attributes, wrap each Attribute in ChannelContext(attribute, None). For available_attributes connection queries, wrap the queryset in ChannelQsContext(channel_slug=None) before filtering/slicing.\n- Page.resolve_attributes: map loaded selected attributes to SelectedAttribute where attribute and each value are wrapped in ChannelContext(..., None); preserve None if loader returns None.\n- Page.resolve_attribute(slug): load the attribute/value pair and return a SelectedAttribute with ChannelContext-wrapped attribute and values; return None when loader returns None.\n\n6) Product GraphQL types\n- For ProductVariant.selectedAttributes: convert loader results into a list of SelectedAttribute where attribute and values are wrapped in ChannelContext using the root.channel_slug; when filtering by VariantAttributeScope (ALL/VARIANT_SELECTION/NOT_VARIANT_SELECTION), ensure returned structures are SelectedAttribute items with ChannelContext wrapping applied based on the scope.\n- For Product.resolve_attribute(slug): convert loaded attribute data into a SelectedAttribute with attribute and values wrapped in ChannelContext using root.channel_slug; return None if not present.\n- For Product.resolve_attributes: map selected attributes to list[SelectedAttribute] with ChannelContext-wrapped attribute and values using root.channel_slug, handling manage-products vs storefront loaders accordingly.\n- For ProductType.resolve_product_attributes: return list of Attributes wrapped with ChannelContext(None) after unpacking from annotated tuples produced by the loader.\n- For ProductType.resolve_variant_attributes and resolve_assigned_variant_attributes: ensure returned attributes (and variant-selection annotated structures) include Attribute items wrapped in ChannelContext(None), and when returning annotated dicts for assigned variant attributes include attribute as ChannelContext(attr, None) with variant_selection preserved.\n- For attributes connection queries in ProductType (available_attributes etc.), wrap queryset in ChannelQsContext(channel_slug=None) prior to filter/slice so nodes come back wrapped.\n\n7) Decorators and metadata mutations\n- Update check_attribute_required_permissions decorator to expect a ChannelContext[Attribute] root and unwrap the attribute from root.node before checking permissions based on Attribute.type.\n- Update meta mutations base so that when handling metadata for attribute_models.Attribute or attribute_models.AttributeValue instances, use_channel_context is considered True (alongside existing cases). This ensures metadata operations are channel-aware for those models when needed.\n\n8) Translations\n- attribute_translate mutation: after calling super, set response.attribute = ChannelContext(response.attribute, None) before returning.\n- attribute_value_translate mutation: after calling super, set response.attributeValue = ChannelContext(response.attributeValue, None) before returning.\n- Translation GraphQL types: when resolving fields that return Attribute or AttributeValue, return ChannelContext(node=root, channel_slug=None) instead of raw model instances. Ensure attribute id fields still resolve via to_global_id as before.\n\n9) Webhook subscription types\n- For AttributeBase.resolve_attribute and AttributeValueBase.resolve_attribute_value, return the corresponding objects wrapped in ChannelContext(..., None) so subscribers receive channel-aware payloads.\n\n10) Tests\n- Where attribute queries are used in tests, adjust selection sets to include translations and additional fields that are now available and ensure test assertions account for ChannelContext-wrapped fields being resolved correctly (e.g., choices edges’ node fields still resolved as before). No change in the external schema shape other than introducing channel context under the hood.\n\nAcceptance\n- All attribute-related queries and mutations should return ChannelContext-wrapped nodes where appropriate, preserving backward-compatible field access in GraphQL.\n- Connections created from ChannelQsContext should yield edges whose nodes are ChannelContext-wrapped.\n- Permission checks for attribute fields work when resolvers receive ChannelContext roots.\n- Translations and subscriptions return ChannelContext-wrapped Attribute and AttributeValue nodes.\n- Metadata operations recognize Attribute and AttributeValue as needing ChannelContext when applicable.\n",
966
- "prompt": "Make attribute-related GraphQL types and operations channel-aware. Ensure attribute, attribute value, and selected attribute representations returned by queries, mutations, and subscriptions are wrapped in a channel context, and that attribute querysets are wrapped similarly for connections. Update types to support ChannelContext roots, modify resolvers to unwrap and rewrap as needed, adjust permission checks to accept channel-aware roots, and ensure translations and metadata integrations also return channel-aware nodes. Preserve the external GraphQL shape while internally propagating the channel context.",
967
- "supplementalFiles": ["saleor/graphql/core/connection.py"],
968
- "fileDiffs": [
969
- {
970
- "path": "saleor/graphql/attribute/mutations/attribute_bulk_create.py",
971
- "status": "modified",
972
- "diff": "Index: saleor/graphql/attribute/mutations/attribute_bulk_create.py\n===================================================================\n--- saleor/graphql/attribute/mutations/attribute_bulk_create.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/mutations/attribute_bulk_create.py\t94492e4 (commit)\n@@ -13,8 +13,9 @@\n from ....permission.enums import PageTypePermissions, ProductTypePermissions\n from ....webhook.event_types import WebhookEventAsyncType\n from ....webhook.utils import get_webhooks_for_event\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.doc_category import DOC_CATEGORY_ATTRIBUTES\n from ...core.enums import ErrorPolicyEnum\n from ...core.mutations import BaseMutation, DeprecatedModelMutation\n from ...core.types import AttributeBulkCreateError, BaseObjectType, NonNullList\n@@ -196,15 +197,23 @@\n \n def get_results(\n instances_data_with_errors_list: list[dict], reject_everything: bool = False\n ) -> list[AttributeBulkCreateResult]:\n- return [\n- AttributeBulkCreateResult(\n- attribute=None if reject_everything else data.get(\"instance\"),\n- errors=data.get(\"errors\"),\n+ results = []\n+ for data in instances_data_with_errors_list:\n+ if reject_everything:\n+ attribute = None\n+ else:\n+ attribute = data.get(\"instance\")\n+ if attribute:\n+ attribute = ChannelContext(attribute, None)\n+ results.append(\n+ AttributeBulkCreateResult(\n+ attribute=attribute,\n+ errors=data.get(\"errors\"),\n+ )\n )\n- for data in instances_data_with_errors_list\n- ]\n+ return results\n \n \n class AttributeBulkCreate(BaseMutation):\n count = graphene.Int(\n"
973
- },
974
- {
975
- "path": "saleor/graphql/attribute/mutations/attribute_bulk_update.py",
976
- "status": "modified",
977
- "diff": "Index: saleor/graphql/attribute/mutations/attribute_bulk_update.py\n===================================================================\n--- saleor/graphql/attribute/mutations/attribute_bulk_update.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/mutations/attribute_bulk_update.py\t94492e4 (commit)\n@@ -13,8 +13,9 @@\n from ....core.tracing import traced_atomic_transaction\n from ....permission.enums import PageTypePermissions, ProductTypePermissions\n from ....webhook.utils import get_webhooks_for_event\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.doc_category import DOC_CATEGORY_ATTRIBUTES\n from ...core.enums import ErrorPolicyEnum\n from ...core.mutations import BaseMutation, DeprecatedModelMutation\n from ...core.types import (\n@@ -51,15 +52,23 @@\n \n def get_results(\n instances_data_with_errors_list: list[dict], reject_everything: bool = False\n ) -> list[AttributeBulkUpdateResult]:\n- return [\n- AttributeBulkUpdateResult(\n- attribute=None if reject_everything else data.get(\"instance\"),\n- errors=data.get(\"errors\"),\n+ results = []\n+ for data in instances_data_with_errors_list:\n+ if reject_everything:\n+ attribute = None\n+ else:\n+ attribute = data.get(\"instance\")\n+ if attribute:\n+ attribute = ChannelContext(attribute, None)\n+ results.append(\n+ AttributeBulkUpdateResult(\n+ attribute=attribute,\n+ errors=data.get(\"errors\"),\n+ )\n )\n- for data in instances_data_with_errors_list\n- ]\n+ return results\n \n \n class AttributeBulkUpdateInput(BaseInputObjectType):\n id = graphene.ID(description=\"ID of an attribute to update.\", required=False)\n"
978
- },
979
- {
980
- "path": "saleor/graphql/attribute/mutations/attribute_create.py",
981
- "status": "modified",
982
- "diff": "Index: saleor/graphql/attribute/mutations/attribute_create.py\n===================================================================\n--- saleor/graphql/attribute/mutations/attribute_create.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/mutations/attribute_create.py\t94492e4 (commit)\n@@ -7,8 +7,9 @@\n from ....core.exceptions import PermissionDenied\n from ....permission.enums import PageTypePermissions, ProductTypePermissions\n from ....webhook.event_types import WebhookEventAsyncType\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.descriptions import DEPRECATED_IN_3X_INPUT\n from ...core.doc_category import DOC_CATEGORY_ATTRIBUTES\n from ...core.enums import MeasurementUnitsEnum\n from ...core.fields import JSONString\n@@ -173,9 +174,9 @@\n instance.save()\n cls._save_m2m(info, instance, cleaned_input)\n cls.post_save_action(info, instance, cleaned_input)\n # Return the attribute that was created\n- return AttributeCreate(attribute=instance)\n+ return AttributeCreate(attribute=ChannelContext(instance, None))\n \n @classmethod\n def post_save_action(cls, info: ResolveInfo, instance, cleaned_input):\n manager = get_plugin_manager_promise(info.context).get()\n"
983
- },
984
- {
985
- "path": "saleor/graphql/attribute/mutations/attribute_delete.py",
986
- "status": "modified",
987
- "diff": "Index: saleor/graphql/attribute/mutations/attribute_delete.py\n===================================================================\n--- saleor/graphql/attribute/mutations/attribute_delete.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/mutations/attribute_delete.py\t94492e4 (commit)\n@@ -3,8 +3,9 @@\n from ....attribute import models as models\n from ....permission.enums import ProductTypePermissions\n from ....webhook.event_types import WebhookEventAsyncType\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.mutations import ModelDeleteMutation, ModelWithExtRefMutation\n from ...core.types import AttributeError\n from ...core.utils import WebhookEventInfo\n from ...plugins.dataloaders import get_plugin_manager_promise\n@@ -33,7 +34,13 @@\n ),\n ]\n \n @classmethod\n+ def success_response(cls, instance):\n+ response = super().success_response(instance)\n+ response.attribute = ChannelContext(instance, None)\n+ return response\n+\n+ @classmethod\n def post_save_action(cls, info: ResolveInfo, instance, cleaned_input):\n manager = get_plugin_manager_promise(info.context).get()\n cls.call_event(manager.attribute_deleted, instance)\n"
988
- },
989
- {
990
- "path": "saleor/graphql/attribute/mutations/attribute_reorder_values.py",
991
- "status": "modified",
992
- "diff": "Index: saleor/graphql/attribute/mutations/attribute_reorder_values.py\n===================================================================\n--- saleor/graphql/attribute/mutations/attribute_reorder_values.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/mutations/attribute_reorder_values.py\t94492e4 (commit)\n@@ -6,8 +6,9 @@\n from ....core.tracing import traced_atomic_transaction\n from ....permission.enums import ProductTypePermissions\n from ....webhook.event_types import WebhookEventAsyncType\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.doc_category import DOC_CATEGORY_ATTRIBUTES\n from ...core.inputs import ReorderInput\n from ...core.mutations import BaseMutation\n from ...core.types import AttributeError, NonNullList\n@@ -99,5 +100,5 @@\n for value in events_list:\n cls.call_event(manager.attribute_value_updated, value)\n cls.call_event(manager.attribute_updated, attribute)\n \n- return AttributeReorderValues(attribute=attribute)\n+ return AttributeReorderValues(attribute=ChannelContext(attribute, None))\n"
993
- },
994
- {
995
- "path": "saleor/graphql/attribute/mutations/attribute_update.py",
996
- "status": "modified",
997
- "diff": "Index: saleor/graphql/attribute/mutations/attribute_update.py\n===================================================================\n--- saleor/graphql/attribute/mutations/attribute_update.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/mutations/attribute_update.py\t94492e4 (commit)\n@@ -5,8 +5,9 @@\n from ....attribute.error_codes import AttributeErrorCode\n from ....permission.enums import ProductTypePermissions\n from ....webhook.event_types import WebhookEventAsyncType\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.descriptions import DEPRECATED_IN_3X_INPUT\n from ...core.doc_category import DOC_CATEGORY_ATTRIBUTES\n from ...core.enums import MeasurementUnitsEnum\n from ...core.mutations import ModelWithExtRefMutation\n@@ -151,9 +152,9 @@\n cls._save_m2m(info, instance, cleaned_input)\n cls.post_save_action(info, instance, cleaned_input)\n \n # Return the attribute that was created\n- return AttributeUpdate(attribute=instance)\n+ return AttributeUpdate(attribute=ChannelContext(instance, None))\n \n @classmethod\n def post_save_action(cls, info: ResolveInfo, instance, cleaned_input):\n manager = get_plugin_manager_promise(info.context).get()\n"
998
- },
999
- {
1000
- "path": "saleor/graphql/attribute/mutations/attribute_value_create.py",
1001
- "status": "modified",
1002
- "diff": "Index: saleor/graphql/attribute/mutations/attribute_value_create.py\n===================================================================\n--- saleor/graphql/attribute/mutations/attribute_value_create.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/mutations/attribute_value_create.py\t94492e4 (commit)\n@@ -7,8 +7,9 @@\n from ....core.utils import generate_unique_slug\n from ....permission.enums import ProductPermissions\n from ....webhook.event_types import WebhookEventAsyncType\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.mutations import DeprecatedModelMutation\n from ...core.types import AttributeError\n from ...core.utils import WebhookEventInfo\n from ...plugins.dataloaders import get_plugin_manager_promise\n@@ -102,9 +103,12 @@\n \n instance.save()\n cls._save_m2m(info, instance, cleaned_input)\n cls.post_save_action(info, instance, cleaned_input)\n- return AttributeValueCreate(attribute=attribute, attributeValue=instance)\n+ return AttributeValueCreate(\n+ attribute=ChannelContext(attribute, None),\n+ attributeValue=ChannelContext(instance, None),\n+ )\n \n @classmethod\n def post_save_action(cls, info: ResolveInfo, instance, cleaned_input):\n manager = get_plugin_manager_promise(info.context).get()\n"
1003
- },
1004
- {
1005
- "path": "saleor/graphql/attribute/mutations/attribute_value_delete.py",
1006
- "status": "modified",
1007
- "diff": "Index: saleor/graphql/attribute/mutations/attribute_value_delete.py\n===================================================================\n--- saleor/graphql/attribute/mutations/attribute_value_delete.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/mutations/attribute_value_delete.py\t94492e4 (commit)\n@@ -5,8 +5,9 @@\n from ....permission.enums import ProductTypePermissions\n from ....product import models as product_models\n from ....webhook.event_types import WebhookEventAsyncType\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.mutations import ModelDeleteMutation, ModelWithExtRefMutation\n from ...core.types import AttributeError\n from ...core.utils import WebhookEventInfo\n from ...plugins.dataloaders import get_plugin_manager_promise\n@@ -71,6 +72,7 @@\n \n @classmethod\n def success_response(cls, instance):\n response = super().success_response(instance)\n- response.attribute = instance.attribute\n+ response.attribute = ChannelContext(instance.attribute, None)\n+ response.attributeValue = ChannelContext(instance, None)\n return response\n"
1008
- },
1009
- {
1010
- "path": "saleor/graphql/attribute/mutations/attribute_value_update.py",
1011
- "status": "modified",
1012
- "diff": "Index: saleor/graphql/attribute/mutations/attribute_value_update.py\n===================================================================\n--- saleor/graphql/attribute/mutations/attribute_value_update.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/mutations/attribute_value_update.py\t94492e4 (commit)\n@@ -6,8 +6,9 @@\n from ....permission.enums import ProductTypePermissions\n from ....product import models as product_models\n from ....webhook.event_types import WebhookEventAsyncType\n from ...core import ResolveInfo\n+from ...core.context import ChannelContext\n from ...core.mutations import ModelWithExtRefMutation\n from ...core.types import AttributeError\n from ...core.utils import WebhookEventInfo\n from ...plugins.dataloaders import get_plugin_manager_promise\n@@ -86,9 +87,10 @@\n \n @classmethod\n def success_response(cls, instance):\n response = super().success_response(instance)\n- response.attribute = instance.attribute\n+ response.attribute = ChannelContext(instance.attribute, None)\n+ response.attributeValue = ChannelContext(instance, None)\n return response\n \n @classmethod\n def post_save_action(cls, info: ResolveInfo, instance, cleaned_input):\n"
1013
- },
1014
- {
1015
- "path": "saleor/graphql/attribute/schema.py",
1016
- "status": "modified",
1017
- "diff": "Index: saleor/graphql/attribute/schema.py\n===================================================================\n--- saleor/graphql/attribute/schema.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/schema.py\t94492e4 (commit)\n@@ -2,8 +2,9 @@\n \n from ...attribute import models\n from ..core import ResolveInfo\n from ..core.connection import create_connection_slice, filter_connection_queryset\n+from ..core.context import ChannelContext, ChannelQsContext\n from ..core.descriptions import DEPRECATED_IN_3X_INPUT\n from ..core.doc_category import DOC_CATEGORY_ATTRIBUTES\n from ..core.fields import BaseField, FilterConnectionField\n from ..core.utils.resolvers import resolve_by_global_id_slug_or_ext_ref\n@@ -68,16 +69,20 @@\n qs, kwargs, info.context, allow_replica=info.context.allow_replica\n )\n if search:\n qs = filter_attribute_search(qs, None, search)\n+ qs = ChannelQsContext(qs=qs, channel_slug=None)\n return create_connection_slice(qs, info, kwargs, AttributeCountableConnection)\n \n def resolve_attribute(\n self, info: ResolveInfo, *, id=None, slug=None, external_reference=None\n ):\n- return resolve_by_global_id_slug_or_ext_ref(\n+ attribute = resolve_by_global_id_slug_or_ext_ref(\n info, models.Attribute, id, slug, external_reference\n )\n+ if attribute:\n+ return ChannelContext(node=attribute, channel_slug=None)\n+ return None\n \n \n class AttributeMutations(graphene.ObjectType):\n # attribute mutations\n"
1018
- },
1019
- {
1020
- "path": "saleor/graphql/attribute/tests/queries/test_attribute_query.py",
1021
- "status": "modified",
1022
- "diff": "Index: saleor/graphql/attribute/tests/queries/test_attribute_query.py\n===================================================================\n--- saleor/graphql/attribute/tests/queries/test_attribute_query.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/tests/queries/test_attribute_query.py\t94492e4 (commit)\n@@ -63,38 +63,58 @@\n )\n \n \n QUERY_ATTRIBUTE = \"\"\"\n- query($id: ID!, $query: String) {\n- attribute(id: $id) {\n- id\n- slug\n- name\n- inputType\n- entityType\n- type\n- unit\n- choices(first: 10, filter: {search: $query}) {\n- edges {\n- node {\n- slug\n- inputType\n- value\n- file {\n- url\n- contentType\n- }\n- }\n- }\n- }\n- valueRequired\n- visibleInStorefront\n- filterableInStorefront\n- filterableInDashboard\n- availableInGrid\n- storefrontSearchPosition\n+query ($id: ID!, $query: String) {\n+ attribute(id: $id) {\n+ id\n+ slug\n+ name\n+ inputType\n+ entityType\n+ type\n+ unit\n+ choices(first: 10, filter: {search: $query}) {\n+ edges {\n+ node {\n+ slug\n+ inputType\n+ value\n+ file {\n+ url\n+ contentType\n+ }\n }\n+ }\n }\n+ valueRequired\n+ visibleInStorefront\n+ filterableInStorefront\n+ filterableInDashboard\n+ availableInGrid\n+ storefrontSearchPosition\n+ translation(languageCode: PL) {\n+ id\n+ name\n+ }\n+ withChoices\n+ productTypes(first: 1) {\n+ edges {\n+ node {\n+ id\n+ }\n+ }\n+ }\n+ productVariantTypes(first: 1) {\n+ edges {\n+ node {\n+ id\n+ }\n+ }\n+ }\n+ externalReference\n+ }\n+}\n \"\"\"\n \n \n def test_get_single_product_attribute_by_staff(\n@@ -416,11 +436,13 @@\n \"node\": {\n \"slug\": value.slug,\n \"value\": value.value,\n \"inputType\": value.input_type.upper(),\n- \"file\": {\"url\": value.file_url, \"contentType\": value.content_type}\n- if value.file_url\n- else None,\n+ \"file\": (\n+ {\"url\": value.file_url, \"contentType\": value.content_type}\n+ if value.file_url\n+ else None\n+ ),\n }\n }\n attribute_value_data.append(data)\n \n@@ -438,12 +460,32 @@\n slug\n choices(first: 10) {\n edges {\n node {\n+ id\n+ name\n+ slug\n+ inputType\n+ value\n+ file {\n+ url\n+ contentType\n+ }\n+ translation(languageCode: PL) {\n id\n name\n- slug\n+ translatableContent {\n+ id\n+ }\n }\n+ reference\n+ richText\n+ plainText\n+ boolean\n+ date\n+ dateTime\n+ externalReference\n+ }\n }\n }\n }\n }\n"
1023
- },
1024
- {
1025
- "path": "saleor/graphql/attribute/types.py",
1026
- "status": "modified",
1027
- "diff": "Index: saleor/graphql/attribute/types.py\n===================================================================\n--- saleor/graphql/attribute/types.py\t5d692d8 (parent)\n+++ saleor/graphql/attribute/types.py\t94492e4 (commit)\n@@ -12,9 +12,13 @@\n CountableConnection,\n create_connection_slice,\n filter_connection_queryset,\n )\n-from ..core.context import ChannelContext, get_database_connection_name\n+from ..core.context import (\n+ ChannelContext,\n+ ChannelQsContext,\n+ get_database_connection_name,\n+)\n from ..core.descriptions import (\n ADDED_IN_322,\n DEFAULT_DEPRECATION_REASON,\n DEPRECATED_IN_3X_INPUT,\n@@ -29,11 +33,11 @@\n DateRangeInput,\n DateTimeRangeInput,\n File,\n IntRangeInput,\n- ModelObjectType,\n NonNullList,\n )\n+from ..core.types.context import ChannelContextType, ChannelContextTypeForObjectType\n from ..decorators import check_attribute_required_permissions\n from ..meta.types import ObjectWithMetadata\n from ..page.dataloaders import PageByIdLoader\n from ..product.dataloaders.products import ProductByIdLoader, ProductVariantByIdLoader\n@@ -58,15 +62,17 @@\n return None\n return reference_pk\n \n \n-class AttributeValue(ModelObjectType[models.AttributeValue]):\n+class AttributeValue(ChannelContextType[models.AttributeValue]):\n id = graphene.GlobalID(required=True, description=\"The ID of the attribute value.\")\n name = graphene.String(description=AttributeValueDescriptions.NAME)\n slug = graphene.String(description=AttributeValueDescriptions.SLUG)\n value = graphene.String(description=AttributeValueDescriptions.VALUE)\n translation = TranslationField(\n- AttributeValueTranslation, type_name=\"attribute value\"\n+ AttributeValueTranslation,\n+ type_name=\"attribute value\",\n+ resolver=ChannelContextType.resolve_translation,\n )\n input_type = AttributeInputTypeEnum(description=AttributeDescriptions.INPUT_TYPE)\n reference = graphene.ID(description=\"The ID of the referenced object.\")\n referenced_object = graphene.Field(\n@@ -95,24 +101,29 @@\n required=False,\n )\n \n class Meta:\n+ default_resolver = ChannelContextType.resolver_with_context\n description = \"Represents a value of an attribute.\"\n interfaces = [graphene.relay.Node]\n model = models.AttributeValue\n \n @staticmethod\n- def resolve_referenced_object(root: models.AttributeValue, info: ResolveInfo):\n+ def resolve_referenced_object(\n+ root: ChannelContext[models.AttributeValue], info: ResolveInfo\n+ ):\n+ attr_value = root.node\n+\n def prepare_referenced_object(attribute):\n if not attribute:\n return None\n- reference_pk = get_reference_pk(attribute, root)\n+ reference_pk = get_reference_pk(attribute, attr_value)\n \n if reference_pk is None:\n return None\n \n def wrap_with_channel_context(_object):\n- return ChannelContext(node=_object, channel_slug=None)\n+ return ChannelContext(node=_object, channel_slug=root.channel_slug)\n \n if attribute.entity_type == AttributeEntityType.PRODUCT:\n return (\n ProductByIdLoader(info.context)\n@@ -130,63 +141,78 @@\n return None\n \n return (\n AttributesByAttributeId(info.context)\n- .load(root.attribute_id)\n+ .load(attr_value.attribute_id)\n .then(prepare_referenced_object)\n )\n \n- @staticmethod\n- def resolve_input_type(root: models.AttributeValue, info: ResolveInfo):\n+ def resolve_input_type(\n+ root: ChannelContext[models.AttributeValue], info: ResolveInfo\n+ ):\n+ attr_value = root.node\n return (\n AttributesByAttributeId(info.context)\n- .load(root.attribute_id)\n+ .load(attr_value.attribute_id)\n .then(lambda attribute: attribute.input_type)\n )\n \n @staticmethod\n- def resolve_file(root: models.AttributeValue, _info: ResolveInfo) -> None | File:\n- if not root.file_url:\n+ def resolve_file(\n+ root: ChannelContext[models.AttributeValue], _info: ResolveInfo\n+ ) -> None | File:\n+ attr_value = root.node\n+ if not attr_value.file_url:\n return None\n- return File(url=root.file_url, content_type=root.content_type)\n+ return File(url=attr_value.file_url, content_type=attr_value.content_type)\n \n @staticmethod\n- def resolve_reference(root: models.AttributeValue, info: ResolveInfo):\n+ def resolve_reference(\n+ root: ChannelContext[models.AttributeValue], info: ResolveInfo\n+ ):\n+ attr_value = root.node\n+\n def prepare_reference(attribute) -> None | str:\n- reference_pk = get_reference_pk(attribute, root)\n+ reference_pk = get_reference_pk(attribute, attr_value)\n if reference_pk is None:\n return None\n return graphene.Node.to_global_id(attribute.entity_type, reference_pk)\n \n return (\n AttributesByAttributeId(info.context)\n- .load(root.attribute_id)\n+ .load(attr_value.attribute_id)\n .then(prepare_reference)\n )\n \n @staticmethod\n- def resolve_date_time(root: models.AttributeValue, info: ResolveInfo):\n+ def resolve_date_time(\n+ root: ChannelContext[models.AttributeValue], info: ResolveInfo\n+ ):\n+ attr_value = root.node\n+\n def _resolve_date(attribute):\n if attribute.input_type == AttributeInputType.DATE_TIME:\n- return root.date_time\n+ return attr_value.date_time\n return None\n \n return (\n AttributesByAttributeId(info.context)\n- .load(root.attribute_id)\n+ .load(attr_value.attribute_id)\n .then(_resolve_date)\n )\n \n @staticmethod\n- def resolve_date(root: models.AttributeValue, info: ResolveInfo):\n+ def resolve_date(root: ChannelContext[models.AttributeValue], info: ResolveInfo):\n+ attr_value = root.node\n+\n def _resolve_date(attribute):\n if attribute.input_type == AttributeInputType.DATE:\n- return root.date_time\n+ return attr_value.date_time\n return None\n \n return (\n AttributesByAttributeId(info.context)\n- .load(root.attribute_id)\n+ .load(attr_value.attribute_id)\n .then(_resolve_date)\n )\n \n \n@@ -195,9 +221,9 @@\n doc_category = DOC_CATEGORY_ATTRIBUTES\n node = AttributeValue\n \n \n-class Attribute(ModelObjectType[models.Attribute]):\n+class Attribute(ChannelContextType[models.Attribute]):\n id = graphene.GlobalID(required=True, description=\"The ID of the attribute.\")\n input_type = AttributeInputTypeEnum(description=AttributeDescriptions.INPUT_TYPE)\n entity_type = AttributeEntityTypeEnum(\n description=AttributeDescriptions.ENTITY_TYPE, required=False\n@@ -278,9 +304,13 @@\n ),\n required=True,\n deprecation_reason=DEFAULT_DEPRECATION_REASON,\n )\n- translation = TranslationField(AttributeTranslation, type_name=\"attribute\")\n+ translation = TranslationField(\n+ AttributeTranslation,\n+ type_name=\"attribute\",\n+ resolver=ChannelContextType.resolve_translation,\n+ )\n with_choices = graphene.Boolean(\n description=AttributeDescriptions.WITH_CHOICES, required=True\n )\n product_types = ConnectionField(\n@@ -303,77 +333,100 @@\n required=False,\n )\n \n class Meta:\n+ default_resolver = ChannelContextType.resolver_with_context\n description = (\n \"Custom attribute of a product. Attributes can be assigned to products and \"\n \"variants at the product type level.\"\n )\n interfaces = [graphene.relay.Node, ObjectWithMetadata]\n model = models.Attribute\n \n @staticmethod\n- def resolve_choices(root: models.Attribute, info: ResolveInfo, **kwargs):\n- if root.input_type in AttributeInputType.TYPES_WITH_CHOICES:\n- qs = root.values.using(get_database_connection_name(info.context)).all()\n+ def resolve_choices(\n+ root: ChannelContext[models.Attribute], info: ResolveInfo, **kwargs\n+ ):\n+ attr = root.node\n+ if attr.input_type in AttributeInputType.TYPES_WITH_CHOICES:\n+ qs = attr.values.using(get_database_connection_name(info.context)).all()\n else:\n qs = models.AttributeValue.objects.none()\n \n- qs = filter_connection_queryset(\n- qs, kwargs, allow_replica=info.context.allow_replica\n+ channel_context_qs = ChannelQsContext(qs=qs, channel_slug=root.channel_slug)\n+ channel_context_qs = filter_connection_queryset(\n+ channel_context_qs, kwargs, allow_replica=info.context.allow_replica\n )\n return create_connection_slice(\n- qs, info, kwargs, AttributeValueCountableConnection\n+ channel_context_qs, info, kwargs, AttributeValueCountableConnection\n )\n \n @staticmethod\n @check_attribute_required_permissions()\n- def resolve_value_required(root: models.Attribute, _info: ResolveInfo):\n- return root.value_required\n+ def resolve_value_required(\n+ root: ChannelContext[models.Attribute], _info: ResolveInfo\n+ ):\n+ return root.node.value_required\n \n @staticmethod\n @check_attribute_required_permissions()\n- def resolve_visible_in_storefront(root: models.Attribute, _info: ResolveInfo):\n- return root.visible_in_storefront\n+ def resolve_visible_in_storefront(\n+ root: ChannelContext[models.Attribute], _info: ResolveInfo\n+ ):\n+ return root.node.visible_in_storefront\n \n @staticmethod\n @check_attribute_required_permissions()\n- def resolve_filterable_in_storefront(root: models.Attribute, _info: ResolveInfo):\n- return root.filterable_in_storefront\n+ def resolve_filterable_in_storefront(\n+ root: ChannelContext[models.Attribute], _info: ResolveInfo\n+ ):\n+ return root.node.filterable_in_storefront\n \n @staticmethod\n @check_attribute_required_permissions()\n- def resolve_filterable_in_dashboard(root: models.Attribute, _info: ResolveInfo):\n- return root.filterable_in_dashboard\n+ def resolve_filterable_in_dashboard(\n+ root: ChannelContext[models.Attribute], _info: ResolveInfo\n+ ):\n+ return root.node.filterable_in_dashboard\n \n @staticmethod\n @check_attribute_required_permissions()\n- def resolve_storefront_search_position(root: models.Attribute, _info: ResolveInfo):\n- return root.storefront_search_position\n+ def resolve_storefront_search_position(\n+ root: ChannelContext[models.Attribute], _info: ResolveInfo\n+ ):\n+ return root.node.storefront_search_position\n \n @staticmethod\n @check_attribute_required_permissions()\n- def resolve_available_in_grid(root: models.Attribute, _info: ResolveInfo):\n- return root.available_in_grid\n+ def resolve_available_in_grid(\n+ root: ChannelContext[models.Attribute], _info: ResolveInfo\n+ ):\n+ return root.node.available_in_grid\n \n @staticmethod\n- def resolve_with_choices(root: models.Attribute, _info: ResolveInfo):\n- return root.input_type in AttributeInputType.TYPES_WITH_CHOICES\n+ def resolve_with_choices(\n+ root: ChannelContext[models.Attribute], _info: ResolveInfo\n+ ):\n+ return root.node.input_type in AttributeInputType.TYPES_WITH_CHOICES\n \n @staticmethod\n- def resolve_product_types(root: models.Attribute, info: ResolveInfo, **kwargs):\n+ def resolve_product_types(\n+ root: ChannelContext[models.Attribute], info: ResolveInfo, **kwargs\n+ ):\n from ..product.types import ProductTypeCountableConnection\n \n- qs = root.product_types.using(get_database_connection_name(info.context)).all()\n+ qs = root.node.product_types.using(\n+ get_database_connection_name(info.context)\n+ ).all()\n return create_connection_slice(qs, info, kwargs, ProductTypeCountableConnection)\n \n @staticmethod\n def resolve_product_variant_types(\n- root: models.Attribute, info: ResolveInfo, **kwargs\n+ root: ChannelContext[models.Attribute], info: ResolveInfo, **kwargs\n ):\n from ..product.types import ProductTypeCountableConnection\n \n- qs = root.product_variant_types.using(\n+ qs = root.node.product_variant_types.using(\n get_database_connection_name(info.context)\n ).all()\n return create_connection_slice(qs, info, kwargs, ProductTypeCountableConnection)\n \n@@ -404,9 +457,9 @@\n )\n doc_category = DOC_CATEGORY_ATTRIBUTES\n \n \n-class SelectedAttribute(BaseObjectType):\n+class SelectedAttribute(ChannelContextTypeForObjectType):\n attribute = graphene.Field(\n Attribute,\n default_value=None,\n description=AttributeDescriptions.NAME,\n"
1028
- },
1029
- {
1030
- "path": "saleor/graphql/core/context.py",
1031
- "status": "modified",
1032
- "diff": "Index: saleor/graphql/core/context.py\n===================================================================\n--- saleor/graphql/core/context.py\t5d692d8 (parent)\n+++ saleor/graphql/core/context.py\t94492e4 (commit)\n@@ -3,9 +3,8 @@\n from typing import TYPE_CHECKING, Any, Generic, TypeVar\n \n from django.conf import settings\n from django.db.models import QuerySet\n-from django.db.models.base import Model\n from django.http import HttpRequest\n from django.utils.functional import empty\n \n if TYPE_CHECKING:\n@@ -84,13 +83,10 @@\n self.node = node\n self.allow_sync_webhooks = allow_sync_webhooks\n \n \n-C = TypeVar(\"C\", bound=Model)\n-\n-\n @dataclass\n-class ChannelContext(BaseContext[C]):\n+class ChannelContext(BaseContext[N]):\n channel_slug: str | None\n \n \n @dataclass\n"
1033
- },
1034
- {
1035
- "path": "saleor/graphql/core/types/context.py",
1036
- "status": "modified",
1037
- "diff": "Index: saleor/graphql/core/types/context.py\n===================================================================\n--- saleor/graphql/core/types/context.py\t5d692d8 (parent)\n+++ saleor/graphql/core/types/context.py\t94492e4 (commit)\n@@ -1,48 +1,52 @@\n-from typing import TypeVar, cast\n+from typing import Generic, TypeVar, cast\n \n from django.db.models import Model\n from graphene.types.resolver import get_default_resolver\n \n from ...translations.resolvers import resolve_translation\n from .. import ResolveInfo\n from ..context import ChannelContext\n+from .base import BaseObjectType\n from .model import ModelObjectType\n \n-T = TypeVar(\"T\", bound=Model)\n+N = TypeVar(\"N\", bound=Model)\n \n \n-class ChannelContextTypeForObjectType(ModelObjectType[T]):\n+class ChannelContextTypeForObjectType(Generic[N], BaseObjectType):\n \"\"\"A Graphene type that supports resolvers' root as ChannelContext objects.\"\"\"\n \n class Meta:\n abstract = True\n \n @staticmethod\n def resolver_with_context(\n- attname, default_value, root: ChannelContext, info: ResolveInfo, **args\n+ attname, default_value, root: ChannelContext[N], info: ResolveInfo, **args\n ):\n resolver = get_default_resolver()\n return resolver(attname, default_value, root.node, info, **args)\n \n @staticmethod\n- def resolve_id(root: ChannelContext[T], _info: ResolveInfo):\n- return root.node.pk\n-\n- @staticmethod\n def resolve_translation(\n- root: ChannelContext[T], info: ResolveInfo, *, language_code\n+ root: ChannelContext[N], info: ResolveInfo, *, language_code\n ):\n # Resolver for TranslationField; needs to be manually specified.\n return resolve_translation(root.node, info, language_code=language_code)\n \n \n-class ChannelContextType(ChannelContextTypeForObjectType[T]):\n+T = TypeVar(\"T\", bound=Model)\n+\n+\n+class ChannelContextType(ChannelContextTypeForObjectType[T], ModelObjectType[T]):\n \"\"\"A Graphene type that supports resolvers' root as ChannelContext objects.\"\"\"\n \n class Meta:\n abstract = True\n \n+ @staticmethod\n+ def resolve_id(root: ChannelContext[T], _info: ResolveInfo):\n+ return root.node.pk\n+\n @classmethod\n def is_type_of(cls, root: ChannelContext[T] | T, _info: ResolveInfo) -> bool:\n # Unwrap node from ChannelContext if it didn't happen already\n if isinstance(root, ChannelContext):\n@@ -54,6 +58,5 @@\n if cls._meta.model._meta.proxy:\n model = root._meta.model\n else:\n model = cast(type[Model], root._meta.model._meta.concrete_model)\n-\n return model == cls._meta.model\n"
1038
- },
1039
- {
1040
- "path": "saleor/graphql/decorators.py",
1041
- "status": "modified",
1042
- "diff": "Index: saleor/graphql/decorators.py\n===================================================================\n--- saleor/graphql/decorators.py\t5d692d8 (parent)\n+++ saleor/graphql/decorators.py\t94492e4 (commit)\n@@ -4,8 +4,9 @@\n \n from graphene import ResolveInfo\n \n from ..attribute import AttributeType\n+from ..attribute.models import Attribute\n from ..core.exceptions import PermissionDenied\n from ..permission.auth_filters import is_app, is_staff_user\n from ..permission.enums import (\n BasePermissionEnum,\n@@ -18,8 +19,9 @@\n has_one_of_permissions,\n one_of_permissions_or_auth_filter_required,\n )\n from ..permission.utils import permission_required as core_permission_required\n+from .core.context import ChannelContext\n from .utils import get_user_or_app_from_context\n \n \n def context(f):\n@@ -118,9 +120,10 @@\n As an attribute can belong to the product or to the page,\n different permissions need to be checked.\n \"\"\"\n \n- def check_perms(context, attribute):\n+ def check_perms(context, root: ChannelContext[Attribute]):\n+ attribute = root.node\n requestor = get_user_or_app_from_context(context)\n permissions: list[BasePermissionEnum]\n if attribute.type == AttributeType.PAGE_TYPE:\n permissions = [\n"
1043
- },
1044
- {
1045
- "path": "saleor/graphql/meta/mutations/base.py",
1046
- "status": "modified",
1047
- "diff": "Index: saleor/graphql/meta/mutations/base.py\n===================================================================\n--- saleor/graphql/meta/mutations/base.py\t5d692d8 (parent)\n+++ saleor/graphql/meta/mutations/base.py\t94492e4 (commit)\n@@ -1,8 +1,9 @@\n import graphene\n from django.core.exceptions import ValidationError\n from graphql.error.base import GraphQLError\n \n+from ....attribute import models as attribute_models\n from ....checkout import models as checkout_models\n from ....core import models\n from ....core.db.connection import allow_writer\n from ....core.error_codes import MetadataErrorCode\n@@ -255,10 +256,13 @@\n | product_models.Collection\n | product_models.Product\n | product_models.ProductVariant\n | shipping_models.ShippingMethod\n- | shipping_models.ShippingZone,\n+ | shipping_models.ShippingZone\n+ | attribute_models.Attribute\n+ | attribute_models.AttributeValue,\n )\n+\n use_channel_context = use_channel_context or (\n # For old sales migrated into promotions\n isinstance(instance, Promotion) and instance.old_sale_id\n )\n"
1048
- },
1049
- {
1050
- "path": "saleor/graphql/page/types.py",
1051
- "status": "modified",
1052
- "diff": "Index: saleor/graphql/page/types.py\n===================================================================\n--- saleor/graphql/page/types.py\t5d692d8 (parent)\n+++ saleor/graphql/page/types.py\t94492e4 (commit)\n@@ -14,9 +14,13 @@\n CountableConnection,\n create_connection_slice,\n filter_connection_queryset,\n )\n-from ..core.context import get_database_connection_name\n+from ..core.context import (\n+ ChannelContext,\n+ ChannelQsContext,\n+ get_database_connection_name,\n+)\n from ..core.descriptions import DEPRECATED_IN_3X_INPUT, RICH_CONTENT\n from ..core.doc_category import DOC_CATEGORY_PAGES\n from ..core.federation import federated_entity, resolve_federation_references\n from ..core.fields import FilterConnectionField, JSONString, PermissionsField\n@@ -84,17 +88,26 @@\n return models.PageType\n \n @staticmethod\n def resolve_attributes(root: models.PageType, info: ResolveInfo):\n+ def wrap_with_channel_context(attributes):\n+ return [ChannelContext(attribute, None) for attribute in attributes]\n+\n requestor = get_user_or_app_from_context(info.context)\n if (\n requestor\n and requestor.is_active\n and requestor.has_perm(PagePermissions.MANAGE_PAGES)\n ):\n- return PageAttributesAllByPageTypeIdLoader(info.context).load(root.pk)\n- return PageAttributesVisibleInStorefrontByPageTypeIdLoader(info.context).load(\n- root.pk\n+ return (\n+ PageAttributesAllByPageTypeIdLoader(info.context)\n+ .load(root.pk)\n+ .then(wrap_with_channel_context)\n+ )\n+ return (\n+ PageAttributesVisibleInStorefrontByPageTypeIdLoader(info.context)\n+ .load(root.pk)\n+ .then(wrap_with_channel_context)\n )\n \n @staticmethod\n def resolve_available_attributes(\n@@ -107,8 +120,9 @@\n qs, kwargs, info.context, allow_replica=info.context.allow_replica\n )\n if search:\n qs = filter_attribute_search(qs, None, search)\n+ qs = ChannelQsContext(qs=qs, channel_slug=None)\n return create_connection_slice(qs, info, kwargs, AttributeCountableConnection)\n \n @staticmethod\n def resolve_has_pages(root: models.PageType, info: ResolveInfo):\n@@ -199,33 +213,70 @@\n return content if content is not None else {}\n \n @staticmethod\n def resolve_attributes(root: models.Page, info: ResolveInfo):\n+ def wrap_with_channel_context(\n+ attributes: list[dict[str, list]] | None,\n+ ) -> list[SelectedAttribute] | None:\n+ if attributes is None:\n+ return None\n+ return [\n+ SelectedAttribute(\n+ attribute=ChannelContext(attribute[\"attribute\"], None),\n+ values=[\n+ ChannelContext(value, None) for value in attribute[\"values\"]\n+ ],\n+ )\n+ for attribute in attributes\n+ ]\n+\n requestor = get_user_or_app_from_context(info.context)\n if (\n requestor\n and requestor.is_active\n and requestor.has_perm(PagePermissions.MANAGE_PAGES)\n ):\n- return SelectedAttributesAllByPageIdLoader(info.context).load(root.id)\n- return SelectedAttributesVisibleInStorefrontPageIdLoader(info.context).load(\n- root.id\n+ return (\n+ SelectedAttributesAllByPageIdLoader(info.context)\n+ .load(root.id)\n+ .then(wrap_with_channel_context)\n+ )\n+ return (\n+ SelectedAttributesVisibleInStorefrontPageIdLoader(info.context)\n+ .load(root.id)\n+ .then(wrap_with_channel_context)\n )\n \n @staticmethod\n def resolve_attribute(root: models.Page, info: ResolveInfo, slug: str):\n+ def wrap_with_channel_context(\n+ attribute_data: dict[str, dict | list[dict]] | None,\n+ ) -> SelectedAttribute | None:\n+ if attribute_data is None:\n+ return None\n+ return SelectedAttribute(\n+ attribute=ChannelContext(attribute_data[\"attribute\"], None),\n+ values=[\n+ ChannelContext(value, None) for value in attribute_data[\"values\"]\n+ ],\n+ )\n+\n requestor = get_user_or_app_from_context(info.context)\n if (\n requestor\n and requestor.is_active\n and requestor.has_perm(PagePermissions.MANAGE_PAGES)\n ):\n- return SelectedAttributeAllByPageIdAttributeSlugLoader(info.context).load(\n- (root.id, slug)\n+ return (\n+ SelectedAttributeAllByPageIdAttributeSlugLoader(info.context)\n+ .load((root.id, slug))\n+ .then(wrap_with_channel_context)\n )\n- return SelectedAttributeVisibleInStorefrontPageIdAttributeSlugLoader(\n- info.context\n- ).load((root.id, slug))\n+ return (\n+ SelectedAttributeVisibleInStorefrontPageIdAttributeSlugLoader(info.context)\n+ .load((root.id, slug))\n+ .then(wrap_with_channel_context)\n+ )\n \n \n class PageCountableConnection(CountableConnection):\n class Meta:\n"
1053
- },
1054
- {
1055
- "path": "saleor/graphql/product/types/products.py",
1056
- "status": "modified",
1057
- "diff": "Index: saleor/graphql/product/types/products.py\n===================================================================\n--- saleor/graphql/product/types/products.py\t5d692d8 (parent)\n+++ saleor/graphql/product/types/products.py\t94492e4 (commit)\n@@ -1,8 +1,9 @@\n import sys\n from collections import defaultdict\n from dataclasses import asdict\n from decimal import Decimal\n+from typing import cast\n \n import graphene\n from graphene import relay\n from promise import Promise\n@@ -583,29 +584,57 @@\n root: ChannelContext[models.ProductVariant],\n info,\n variant_selection: str | None = None,\n ):\n- def apply_variant_selection_filter(selected_attributes):\n+ def apply_variant_selection_filter(\n+ selected_attributes,\n+ ) -> list[SelectedAttribute]:\n if not variant_selection or variant_selection == VariantAttributeScope.ALL:\n- return selected_attributes\n+ return [\n+ SelectedAttribute(\n+ attribute=ChannelContext(\n+ selected_att[\"attribute\"], root.channel_slug\n+ ),\n+ values=[\n+ ChannelContext(value, root.channel_slug)\n+ for value in selected_att[\"values\"]\n+ ],\n+ )\n+ for selected_att in selected_attributes\n+ ]\n attributes = [\n (selected_att[\"attribute\"], selected_att[\"variant_selection\"])\n for selected_att in selected_attributes\n ]\n+ attributes = cast(list[tuple[attribute_models.Attribute, bool]], attributes)\n variant_selection_attrs = [\n attr for attr, _ in get_variant_selection_attributes(attributes)\n ]\n \n if variant_selection == VariantAttributeScope.VARIANT_SELECTION:\n- return [\n- selected_attribute\n- for selected_attribute in selected_attributes\n- if selected_attribute[\"attribute\"] in variant_selection_attrs\n+ attributes_to_return = [\n+ selected_att\n+ for selected_att in selected_attributes\n+ if selected_att[\"attribute\"] in variant_selection_attrs\n ]\n+ else:\n+ attributes_to_return = [\n+ selected_att\n+ for selected_att in selected_attributes\n+ if selected_att[\"attribute\"] not in variant_selection_attrs\n+ ]\n+\n return [\n- selected_attribute\n- for selected_attribute in selected_attributes\n- if selected_attribute[\"attribute\"] not in variant_selection_attrs\n+ SelectedAttribute(\n+ attribute=ChannelContext(\n+ selected_att[\"attribute\"], root.channel_slug\n+ ),\n+ values=[\n+ ChannelContext(value, root.channel_slug)\n+ for value in selected_att[\"values\"]\n+ ],\n+ )\n+ for selected_att in attributes_to_return\n ]\n \n return (\n SelectedAttributesByProductVariantIdLoader(info.context)\n@@ -1302,15 +1331,36 @@\n \n @staticmethod\n def resolve_attribute(root: ChannelContext[models.Product], info, slug):\n def get_selected_attribute_by_slug(\n- attributes: list[SelectedAttribute],\n+ attributes: (\n+ list[\n+ dict[\n+ str,\n+ attribute_models.Attribute\n+ | list[attribute_models.AttributeValue],\n+ ]\n+ ]\n+ | None\n+ ),\n ) -> SelectedAttribute | None:\n- return next(\n- (atr for atr in attributes if atr[\"attribute\"].slug == slug),\n- None,\n- )\n+ if attributes is None:\n+ return None\n \n+ for atr in attributes:\n+ attribute = atr[\"attribute\"]\n+ attribute = cast(attribute_models.Attribute, attribute)\n+ if attribute.slug == slug:\n+ values = atr[\"values\"]\n+ values = cast(list[attribute_models.AttributeValue], values)\n+ return SelectedAttribute(\n+ attribute=ChannelContext(attribute, root.channel_slug),\n+ values=[\n+ ChannelContext(value, root.channel_slug) for value in values\n+ ],\n+ )\n+ return None\n+\n requestor = get_user_or_app_from_context(info.context)\n if (\n requestor\n and requestor.is_active\n@@ -1328,20 +1378,55 @@\n )\n \n @staticmethod\n def resolve_attributes(root: ChannelContext[models.Product], info):\n+ def wrap_with_channel_context(\n+ attributes: (\n+ list[\n+ dict[\n+ str,\n+ attribute_models.Attribute\n+ | list[attribute_models.AttributeValue],\n+ ]\n+ ]\n+ | None\n+ ),\n+ ) -> list[SelectedAttribute] | None:\n+ if attributes is None:\n+ return None\n+\n+ response = []\n+ for attr_data in attributes:\n+ attribute = attr_data[\"attribute\"]\n+ attribute = cast(attribute_models.Attribute, attribute)\n+ values = attr_data[\"values\"]\n+ values = cast(list[attribute_models.AttributeValue], values)\n+ response.append(\n+ SelectedAttribute(\n+ attribute=ChannelContext(attribute, root.channel_slug),\n+ values=[\n+ ChannelContext(value, root.channel_slug) for value in values\n+ ],\n+ )\n+ )\n+ return response\n+\n requestor = get_user_or_app_from_context(info.context)\n if (\n requestor\n and requestor.is_active\n and requestor.has_perm(ProductPermissions.MANAGE_PRODUCTS)\n ):\n- return SelectedAttributesAllByProductIdLoader(info.context).load(\n- root.node.id\n+ return (\n+ SelectedAttributesAllByProductIdLoader(info.context)\n+ .load(root.node.id)\n+ .then(wrap_with_channel_context)\n )\n- return SelectedAttributesVisibleInStorefrontByProductIdLoader(\n- info.context\n- ).load(root.node.id)\n+ return (\n+ SelectedAttributesVisibleInStorefrontByProductIdLoader(info.context)\n+ .load(root.node.id)\n+ .then(wrap_with_channel_context)\n+ )\n \n @staticmethod\n def resolve_media_by_id(root: ChannelContext[models.Product], info, *, id):\n _type, pk = from_global_id_or_error(id, ProductMedia)\n@@ -1786,9 +1871,9 @@\n \n @staticmethod\n def resolve_product_attributes(root: models.ProductType, info):\n def unpack_attributes(attributes):\n- return [attr for attr, *_ in attributes]\n+ return [ChannelContext(attr, None) for attr, *_ in attributes]\n \n requestor = get_user_or_app_from_context(info.context)\n if (\n requestor\n@@ -1814,14 +1899,16 @@\n variant_selection: str | None = None,\n ):\n def apply_variant_selection_filter(attributes):\n if not variant_selection or variant_selection == VariantAttributeScope.ALL:\n- return [attr for attr, *_ in attributes]\n+ return [ChannelContext(attr, None) for attr, *_ in attributes]\n variant_selection_attrs = get_variant_selection_attributes(attributes)\n if variant_selection == VariantAttributeScope.VARIANT_SELECTION:\n- return [attr for attr, *_ in variant_selection_attrs]\n+ return [\n+ ChannelContext(attr, None) for attr, *_ in variant_selection_attrs\n+ ]\n return [\n- attr\n+ ChannelContext(attr, None)\n for attr, variant_selection in attributes\n if (attr, variant_selection) not in variant_selection_attrs\n ]\n \n@@ -1851,19 +1938,28 @@\n ):\n def apply_variant_selection_filter(attributes):\n if not variant_selection or variant_selection == VariantAttributeScope.ALL:\n return [\n- {\"attribute\": attr, \"variant_selection\": variant_selection}\n+ {\n+ \"attribute\": ChannelContext(attr, None),\n+ \"variant_selection\": variant_selection,\n+ }\n for attr, variant_selection in attributes\n ]\n variant_selection_attrs = get_variant_selection_attributes(attributes)\n if variant_selection == VariantAttributeScope.VARIANT_SELECTION:\n return [\n- {\"attribute\": attr, \"variant_selection\": variant_selection}\n+ {\n+ \"attribute\": ChannelContext(attr, None),\n+ \"variant_selection\": variant_selection,\n+ }\n for attr, variant_selection in variant_selection_attrs\n ]\n return [\n- {\"attribute\": attr, \"variant_selection\": variant_selection}\n+ {\n+ \"attribute\": ChannelContext(attr, None),\n+ \"variant_selection\": variant_selection,\n+ }\n for attr, variant_selection in attributes\n if (attr, variant_selection) not in variant_selection_attrs\n ]\n \n@@ -1921,8 +2017,9 @@\n qs, kwargs, info.context, allow_replica=info.context.allow_replica\n )\n if search:\n qs = filter_attribute_search(qs, None, search)\n+ qs = ChannelQsContext(qs=qs, channel_slug=None)\n return create_connection_slice(qs, info, kwargs, AttributeCountableConnection)\n \n @staticmethod\n def resolve_weight(root: models.ProductType, _info):\n"
1058
- },
1059
- {
1060
- "path": "saleor/graphql/translations/mutations/attribute_translate.py",
1061
- "status": "modified",
1062
- "diff": "Index: saleor/graphql/translations/mutations/attribute_translate.py\n===================================================================\n--- saleor/graphql/translations/mutations/attribute_translate.py\t5d692d8 (parent)\n+++ saleor/graphql/translations/mutations/attribute_translate.py\t94492e4 (commit)\n@@ -2,8 +2,9 @@\n \n from ....attribute import models as attribute_models\n from ....permission.enums import SitePermissions\n from ...attribute.types import Attribute\n+from ...core.context import ChannelContext\n from ...core.enums import LanguageCodeEnum\n from ...core.types import TranslationError\n from .utils import BaseTranslateMutation, NameTranslationInput\n \n@@ -28,4 +29,10 @@\n object_type = Attribute\n error_type_class = TranslationError\n error_type_field = \"translation_errors\"\n permissions = (SitePermissions.MANAGE_TRANSLATIONS,)\n+\n+ @classmethod\n+ def perform_mutation(cls, *args, **kwargs):\n+ response = super().perform_mutation(*args, **kwargs)\n+ response.attribute = ChannelContext(response.attribute, None)\n+ return response\n"
1063
- },
1064
- {
1065
- "path": "saleor/graphql/translations/mutations/attribute_value_translate.py",
1066
- "status": "modified",
1067
- "diff": "Index: saleor/graphql/translations/mutations/attribute_value_translate.py\n===================================================================\n--- saleor/graphql/translations/mutations/attribute_value_translate.py\t5d692d8 (parent)\n+++ saleor/graphql/translations/mutations/attribute_value_translate.py\t94492e4 (commit)\n@@ -5,8 +5,9 @@\n from ....attribute import models as attribute_models\n from ....core.utils.editorjs import clean_editor_js\n from ....permission.enums import SitePermissions\n from ...attribute.types import AttributeValue\n+from ...core.context import ChannelContext\n from ...core.descriptions import RICH_CONTENT\n from ...core.enums import LanguageCodeEnum\n from ...core.fields import JSONString\n from ...core.types import TranslationError\n@@ -49,4 +50,10 @@\n )\n elif instance.attribute.input_type == AttributeInputType.PLAIN_TEXT:\n input_data[\"name\"] = truncatechars(input_data[\"plain_text\"], 250)\n return input_data\n+\n+ @classmethod\n+ def perform_mutation(cls, *args, **kwargs):\n+ response = super().perform_mutation(*args, **kwargs)\n+ response.attributeValue = ChannelContext(response.attributeValue, None)\n+ return response\n"
1068
- },
1069
- {
1070
- "path": "saleor/graphql/translations/types.py",
1071
- "status": "modified",
1072
- "diff": "Index: saleor/graphql/translations/types.py\n===================================================================\n--- saleor/graphql/translations/types.py\t5d692d8 (parent)\n+++ saleor/graphql/translations/types.py\t94492e4 (commit)\n@@ -20,13 +20,9 @@\n from ...shipping import models as shipping_models\n from ...site import models as site_models\n from ..attribute.dataloaders import AttributesByAttributeId, AttributeValueByIdLoader\n from ..core.context import ChannelContext, get_database_connection_name\n-from ..core.descriptions import (\n- ADDED_IN_321,\n- DEPRECATED_IN_3X_TYPE,\n- RICH_CONTENT,\n-)\n+from ..core.descriptions import ADDED_IN_321, DEPRECATED_IN_3X_TYPE, RICH_CONTENT\n from ..core.enums import LanguageCodeEnum\n from ..core.fields import JSONString, PermissionsField\n from ..core.tracing import traced_resolver\n from ..core.types import LanguageDisplay, ModelObjectType, NonNullList\n@@ -172,9 +168,9 @@\n )\n \n @staticmethod\n def resolve_attribute(root: attribute_models.Attribute, _info):\n- return root\n+ return ChannelContext(node=root, channel_slug=None)\n \n @staticmethod\n def resolve_attribute_id(root: attribute_models.Attribute, _info):\n return graphene.Node.to_global_id(\"Attribute\", root.id)\n@@ -218,9 +214,9 @@\n )\n \n @staticmethod\n def resolve_attribute_value(root: attribute_models.AttributeValue, _info):\n- return root\n+ return ChannelContext(node=root, channel_slug=None)\n \n @staticmethod\n def resolve_attribute(root: attribute_models.AttributeValue, info):\n return AttributesByAttributeId(info.context).load(root.attribute_id)\n"
1073
- },
1074
- {
1075
- "path": "saleor/graphql/webhook/subscription_types.py",
1076
- "status": "modified",
1077
- "diff": "Index: saleor/graphql/webhook/subscription_types.py\n===================================================================\n--- saleor/graphql/webhook/subscription_types.py\t5d692d8 (parent)\n+++ saleor/graphql/webhook/subscription_types.py\t94492e4 (commit)\n@@ -359,9 +359,9 @@\n \n @staticmethod\n def resolve_attribute(root, _info: ResolveInfo):\n _, attribute = root\n- return attribute\n+ return ChannelContext(attribute, None)\n \n \n class AttributeCreated(SubscriptionObjectType, AttributeBase):\n class Meta:\n@@ -394,10 +394,10 @@\n )\n \n @staticmethod\n def resolve_attribute_value(root, _info: ResolveInfo):\n- _, attribute = root\n- return attribute\n+ _, attribute_value = root\n+ return ChannelContext(attribute_value, None)\n \n \n class AttributeValueCreated(SubscriptionObjectType, AttributeValueBase):\n class Meta:\n"
1078
- }
1079
- ]
1080
- },
1081
- {
1082
- "id": "extend-variant-filters",
1083
- "sha": "5d692d80e2c822729fe66dc4d16b21eb8c183297",
1084
- "parentSha": "6ac60cb86e1968a8398d70460a6b7bc0f3959871",
1085
- "spec": "Implement extended product variant filtering and search in GraphQL and add a supporting database index.\n\nScope:\n1) GraphQL where-filters for ProductVariant\n- In saleor/graphql/product/filters.py:\n - Import filter_where_by_range_field from saleor/graphql/utils/filters.\n - In ProductVariantWhere, add fields:\n - sku: ObjectTypeWhereFilter using StringFilterInput and method filter_product_sku. Help text: “Filter by product SKU.”\n - updated_at: ObjectTypeWhereFilter using DateTimeRangeInput and method filter_updated_at. Help text: “Filter by when was the most recent update.”\n - Implement static filter_product_sku to delegate to filter_where_by_value_field(qs, \"sku\", value).\n - Implement static filter_updated_at to delegate to filter_where_by_range_field(qs, \"updated_at\", value).\n\n2) GraphQL schema and resolver updates\n- In saleor/graphql/product/schema.py:\n - Add necessary imports: Exists, OuterRef, models (product), ADDED_IN_322, get_database_connection_name.\n - Products query minor description spacing fix for deprecation text.\n - For productVariants field:\n - Update filter argument description to include deprecation messaging (Use `where` instead) consistent with other fields.\n - Add a new search: String argument, with description including the ADDED_IN_322 label.\n - In the resolver, if search is provided:\n - Build a product queryset using search_products(models.Product.objects.using(get_database_connection_name(info.context)), search).\n - Narrow the variant queryset by product_id using Exists(products.filter(id=OuterRef(\"product_id\"))).\n - Wrap the narrowed queryset back into ChannelQsContext preserving the channel_slug.\n - Continue with filter_connection_queryset and pagination as currently used.\n\n- In saleor/graphql/product/types/categories.py and saleor/graphql/product/types/products.py:\n - Update product lists’ filter descriptions to include deprecation text (Use `where` filter instead) with correct f-string/spacing, consistent with DEPRECATED_IN_3X_INPUT.\n\n- In saleor/graphql/schema.graphql:\n - For Query.productVariants and Product.productVariants:\n - Mark filter: ProductVariantFilterInput as deprecated with reason “Use `where` filter instead.”\n - Add search: String with docstring that includes “Added in Saleor 3.22.”\n - In Category.products:\n - Mark filter: ProductFilterInput as deprecated similarly.\n - In input ProductVariantWhereInput:\n - Add sku: StringFilterInput with description “Filter by product SKU.”\n - Add updatedAt: DateTimeRangeInput with description “Filter by when was the most recent update.”\n\n3) Tests\n- Add/adjust tests to cover the new where fields and search:\n - saleor/graphql/product/tests/queries/test_product_query.py:\n - Add QUERY_FETCH_PRODUCT_VARIANTS and test_query_product_variants_with_where that fetches product.productVariants with where { sku: { eq: value } } and asserts only matching variant is returned.\n - saleor/graphql/product/tests/queries/test_product_variants_query.py:\n - Add tests parametrized to verify searching variants by variant name and SKU, and by parent product name. Use update_products_search_vector to refresh the index before search.\n - saleor/graphql/product/tests/queries/test_product_variants_query_with_where.py:\n - Add parametrized tests for filtering by updatedAt with various gte/lte combinations using freezegun to manipulate updated_at, and for sku using eq and oneOf cases. Ensure channel filtering matches expectations.\n - saleor/graphql/product/tests/test_variant_with_filtering.py:\n - Ensure update_products_search_vector is invoked for products in fixtures so search reflects recent changes.\n\n4) Database index and model\n- In saleor/product/models.py (ProductVariant Meta):\n - Add a GinIndex named \"variant_gin\" on fields [\"name\", \"sku\"] with opclasses [\"gin_trgm_ops\", \"gin_trgm_ops\"]. Ensure GinIndex is imported from django.contrib.postgres.indexes.\n- Create a migration saleor/product/migrations/0201_productvariant_variant_gin.py:\n - atomic = False.\n - Dependency on (\"product\", \"0200_merge_20250527_1210\").\n - Use AddIndexConcurrently to add GinIndex as above for model_name=\"productvariant\".\n\n5) Documentation/Changelog\n- In CHANGELOG.md, under deprecations of filter argument for queries, include productVariants in the list.\n\nAcceptance criteria:\n- productVariants supports where filtering on sku (eq, oneOf) and updatedAt (gte, lte) and returns empty results for None/empty inputs as per utilities’ behavior.\n- productVariants accepts a new search argument. When provided, only variants whose parent products match product search are returned.\n- Legacy filter argument on productVariants and Category.products is marked deprecated in schema (directive present), and descriptions in schema/types reflect deprecation messaging.\n- GIN index is present on ProductVariant(name, sku) and migration is created with concurrent index addition.\n- All new tests pass, and existing tests remain green.\n",
1086
- "prompt": "Enhance product variant querying:\n- Add where-based filters to product variants for SKU equality/list and updatedAt ranges.\n- Introduce a search argument on the productVariants query that limits variants to those whose parent products match the platform’s product search, and deprecate the legacy filter argument in favor of where.\n- Update types and schema docs/deprecations consistently for product and category product lists.\n- Add a Postgres GIN trigram index on ProductVariant name and SKU to support efficient searching.\n- Provide comprehensive tests for searching and the new where filters, including updatedAt range scenarios and SKU eq/oneOf cases.\n- Update the changelog to mention the deprecation.\nEnsure the resolver integrates search by narrowing the variant queryset using product search results and preserves channel-aware behavior and pagination.",
1087
- "supplementalFiles": [
1088
- "saleor/graphql/core/filters/where_filters.py",
1089
- "saleor/graphql/core/filters/where_input.py",
1090
- "saleor/graphql/utils/filters.py",
1091
- "saleor/graphql/core/connection.py",
1092
- "saleor/product/search.py",
1093
- "saleor/graphql/product/resolvers.py"
1094
- ],
1095
- "fileDiffs": [
1096
- {
1097
- "path": "CHANGELOG.md",
1098
- "status": "modified",
1099
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\t6ac60cb (parent)\n+++ CHANGELOG.md\t5d692d8 (commit)\n@@ -71,8 +71,9 @@\n The `filter` argument has been deprecated in the following queries:\n - `attributes`\n - `customers`\n - `products`\n+ - `productVariants`\n - `orders`\n - `draftOrders`\n - `productType.availableAttributes`\n - `category.products`\n"
1100
- },
1101
- {
1102
- "path": "saleor/graphql/product/filters.py",
1103
- "status": "modified",
1104
- "diff": "Index: saleor/graphql/product/filters.py\n===================================================================\n--- saleor/graphql/product/filters.py\t6ac60cb (parent)\n+++ saleor/graphql/product/filters.py\t5d692d8 (commit)\n@@ -73,8 +73,9 @@\n filter_range_field,\n filter_slug_list,\n filter_where_by_id_field,\n filter_where_by_numeric_field,\n+ filter_where_by_range_field,\n filter_where_by_value_field,\n filter_where_range_field_with_conditions,\n )\n from ..warehouse import types as warehouse_types\n@@ -1336,14 +1337,32 @@\n \n \n class ProductVariantWhere(MetadataWhereFilterBase):\n ids = GlobalIDMultipleChoiceWhereFilter(method=filter_by_ids(\"ProductVariant\"))\n+ sku = ObjectTypeWhereFilter(\n+ input_class=StringFilterInput,\n+ method=\"filter_product_sku\",\n+ help_text=\"Filter by product SKU.\",\n+ )\n+ updated_at = ObjectTypeWhereFilter(\n+ input_class=DateTimeRangeInput,\n+ method=\"filter_updated_at\",\n+ help_text=\"Filter by when was the most recent update.\",\n+ )\n \n class Meta:\n model = ProductVariant\n fields = []\n \n+ @staticmethod\n+ def filter_product_sku(qs, _, value):\n+ return filter_where_by_value_field(qs, \"sku\", value)\n \n+ @staticmethod\n+ def filter_updated_at(qs, _, value):\n+ return filter_where_by_range_field(qs, \"updated_at\", value)\n+\n+\n class CollectionFilter(MetadataFilterBase):\n published = EnumFilter(\n input_class=CollectionPublished, method=\"filter_is_published\"\n )\n"
1105
- },
1106
- {
1107
- "path": "saleor/graphql/product/schema.py",
1108
- "status": "modified",
1109
- "diff": "Index: saleor/graphql/product/schema.py\n===================================================================\n--- saleor/graphql/product/schema.py\t6ac60cb (parent)\n+++ saleor/graphql/product/schema.py\t5d692d8 (commit)\n@@ -1,9 +1,11 @@\n import graphene\n+from django.db.models import Exists, OuterRef\n from promise import Promise\n \n from ...permission.enums import ProductPermissions\n from ...permission.utils import has_one_of_permissions\n+from ...product import models\n from ...product.models import ALL_PRODUCTS_PERMISSIONS\n from ...product.search import search_products\n from ..channel.dataloaders import ChannelBySlugLoader\n from ..channel.utils import get_default_channel_slug_or_graphql_error\n@@ -11,8 +13,9 @@\n from ..core.connection import create_connection_slice, filter_connection_queryset\n from ..core.context import ChannelContext, ChannelQsContext\n from ..core.descriptions import (\n ADDED_IN_321,\n+ ADDED_IN_322,\n DEFAULT_DEPRECATION_REASON,\n DEPRECATED_IN_3X_INPUT,\n )\n from ..core.doc_category import DOC_CATEGORY_PRODUCTS\n@@ -26,8 +29,9 @@\n from ..core.tracing import traced_resolver\n from ..core.types import NonNullList\n from ..core.utils import from_global_id_or_error\n from ..core.validators import validate_one_of_args_is_in_query\n+from ..shop.resolvers import get_database_connection_name\n from ..translations.mutations import (\n CategoryTranslate,\n CollectionTranslate,\n ProductBulkTranslate,\n@@ -263,10 +267,10 @@\n products = FilterConnectionField(\n ProductCountableConnection,\n filter=ProductFilterInput(\n description=(\n- f\"Filtering options for products. {DEPRECATED_IN_3X_INPUT} \"\n- \"Use `where` filter instead.\"\n+ f\"Filtering options for products. {DEPRECATED_IN_3X_INPUT}\"\n+ \" Use `where` filter instead.\"\n )\n ),\n where=ProductWhereInput(description=\"Where filtering options for products.\"),\n sort_by=ProductOrder(description=\"Sort products.\"),\n@@ -328,13 +332,17 @@\n channel=graphene.String(\n description=\"Slug of a channel for which the data should be returned.\"\n ),\n filter=ProductVariantFilterInput(\n- description=\"Filtering options for product variant.\"\n+ description=(\n+ f\"Filtering options for product variants. {DEPRECATED_IN_3X_INPUT}\"\n+ \" Use `where` filter instead.\"\n+ )\n ),\n where=ProductVariantWhereInput(\n description=\"Where filtering options for product variants.\"\n ),\n+ search=graphene.String(description=\"Search product variants.\" + ADDED_IN_322),\n sort_by=ProductVariantSortingInput(description=\"Sort products variants.\"),\n description=(\n \"List of product variants. Requires one of the following permissions to \"\n \"include the unpublished items: \"\n@@ -626,16 +634,29 @@\n channel = get_default_channel_slug_or_graphql_error(\n allow_replica=info.context.allow_replica\n )\n \n+ search = kwargs.get(\"search\")\n+\n def _resolve_product_variants(channel_obj):\n qs = resolve_product_variants(\n info,\n ids=ids,\n channel=channel_obj,\n limited_channel_access=limited_channel_access,\n requestor=requestor,\n )\n+ if search:\n+ products = search_products(\n+ models.Product.objects.using(\n+ get_database_connection_name(info.context)\n+ ),\n+ search,\n+ )\n+ variant_qs = qs.qs.filter(\n+ Exists(products.filter(id=OuterRef(\"product_id\")))\n+ )\n+ qs = ChannelQsContext(qs=variant_qs, channel_slug=qs.channel_slug)\n kwargs[\"channel\"] = qs.channel_slug\n qs = filter_connection_queryset(\n qs, kwargs, allow_replica=info.context.allow_replica\n )\n"
1110
- },
1111
- {
1112
- "path": "saleor/graphql/product/tests/queries/test_product_query.py",
1113
- "status": "modified",
1114
- "diff": "Index: saleor/graphql/product/tests/queries/test_product_query.py\n===================================================================\n--- saleor/graphql/product/tests/queries/test_product_query.py\t6ac60cb (parent)\n+++ saleor/graphql/product/tests/queries/test_product_query.py\t5d692d8 (commit)\n@@ -2993,4 +2993,47 @@\n data = content[\"data\"]\n assert data[\"product\"]\n assert data[\"product\"][\"id\"]\n assert data[\"product\"][\"taxClass\"][\"id\"]\n+\n+\n+QUERY_FETCH_PRODUCT_VARIANTS = \"\"\"\n+ query ($id: ID!, $channel: String, $where: ProductVariantWhereInput) {\n+ product(id: $id, channel: $channel) {\n+ id\n+ productVariants(first: 10, where: $where) {\n+ edges {\n+ node {\n+ id\n+ name\n+ sku\n+ }\n+ }\n+ }\n+ }\n+ }\n+\"\"\"\n+\n+\n+def test_query_product_variants_with_where(\n+ user_api_client, product_variant_list, channel_USD\n+):\n+ # given\n+ product = product_variant_list[0].product\n+ sku_value = product_variant_list[0].sku\n+ product_id = graphene.Node.to_global_id(\"Product\", product.id)\n+\n+ variables = {\n+ \"id\": product_id,\n+ \"channel\": channel_USD.slug,\n+ \"where\": {\"sku\": {\"eq\": sku_value}},\n+ }\n+\n+ # when\n+ response = user_api_client.post_graphql(QUERY_FETCH_PRODUCT_VARIANTS, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ variants = content[\"data\"][\"product\"][\"productVariants\"][\"edges\"]\n+\n+ assert len(variants) == 1\n+ assert variants[0][\"node\"][\"sku\"] == sku_value\n"
1115
- },
1116
- {
1117
- "path": "saleor/graphql/product/tests/queries/test_product_variants_query.py",
1118
- "status": "modified",
1119
- "diff": "Index: saleor/graphql/product/tests/queries/test_product_variants_query.py\n===================================================================\n--- saleor/graphql/product/tests/queries/test_product_variants_query.py\t6ac60cb (parent)\n+++ saleor/graphql/product/tests/queries/test_product_variants_query.py\t5d692d8 (commit)\n@@ -1,7 +1,9 @@\n import graphene\n+import pytest\n \n from .....product.models import Product, ProductVariant\n+from .....product.search import update_products_search_vector\n from ....tests.utils import get_graphql_content, get_graphql_content_from_response\n \n \n def _fetch_all_variants(client, variables=None, permissions=None):\n@@ -268,4 +270,76 @@\n permissions=[permission_manage_products],\n )\n \n assert data[\"totalCount\"] == product_count\n+\n+\n+QUERY_SEARCH_PRODUCT_VARIANTS = \"\"\"\n+ query searchProductVariants($search: String, $channel: String) {\n+ productVariants(search: $search, first: 10, channel: $channel) {\n+ edges {\n+ node {\n+ id\n+ name\n+ sku\n+ }\n+ }\n+ }\n+ }\n+\"\"\"\n+\n+\n+@pytest.mark.parametrize(\n+ (\"search_query\", \"expected_indexes\"),\n+ [(\"VariantName\", [0, 1, 2]), (\"SKU1\", [1]), (\"SKU2\", [2]), (\"Invalid\", [])],\n+)\n+def test_search_product_variants_by_variant_name_and_sku(\n+ search_query, expected_indexes, user_api_client, product_list, channel_USD\n+):\n+ # given\n+ variants_list = list(ProductVariant.objects.all())\n+ for index, variant in enumerate(variants_list):\n+ variant.sku = f\"SKU{index}\"\n+ variant.name = \"VariantName\"\n+ variant.save()\n+\n+ update_products_search_vector(Product.objects.values_list(\"id\", flat=True))\n+\n+ # when\n+ variables = {\"search\": search_query, \"channel\": channel_USD.slug}\n+ response = user_api_client.post_graphql(QUERY_SEARCH_PRODUCT_VARIANTS, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ variants = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(variants) == len(expected_indexes)\n+ skus = {node[\"node\"][\"sku\"] for node in variants}\n+ assert skus == {variants_list[index].sku for index in expected_indexes}\n+\n+\n+@pytest.mark.parametrize(\n+ (\"search_query\", \"expected_indexes\"),\n+ [(\"ProductName\", [0, 1]), (\"anotherName\", [2]), (\"Invalid\", [])],\n+)\n+def test_search_products_by_product_name(\n+ search_query, expected_indexes, user_api_client, product_list, channel_USD\n+):\n+ # given\n+ variants_list = sorted(ProductVariant.objects.all(), key=lambda x: x.product_id)\n+\n+ for product in product_list[:2]:\n+ product.name = \"ProductName\"\n+ product_list[2].name = \"anotherName\"\n+ Product.objects.bulk_update(product_list, [\"name\"])\n+\n+ update_products_search_vector(Product.objects.values_list(\"id\", flat=True))\n+\n+ # when\n+ variables = {\"search\": search_query, \"channel\": channel_USD.slug}\n+ response = user_api_client.post_graphql(QUERY_SEARCH_PRODUCT_VARIANTS, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ products = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(products) == len(expected_indexes)\n+ skus = {node[\"node\"][\"sku\"] for node in products}\n+ assert skus == {variants_list[index].sku for index in expected_indexes}\n"
1120
- },
1121
- {
1122
- "path": "saleor/graphql/product/tests/queries/test_product_variants_query_with_where.py",
1123
- "status": "modified",
1124
- "diff": "Index: saleor/graphql/product/tests/queries/test_product_variants_query_with_where.py\n===================================================================\n--- saleor/graphql/product/tests/queries/test_product_variants_query_with_where.py\t6ac60cb (parent)\n+++ saleor/graphql/product/tests/queries/test_product_variants_query_with_where.py\t5d692d8 (commit)\n@@ -1,6 +1,12 @@\n+import datetime\n+\n import graphene\n+import pytest\n+from django.utils import timezone\n+from freezegun import freeze_time\n \n+from .....product.models import ProductVariant\n from ....tests.utils import get_graphql_content\n \n PRODUCT_VARIANTS_WHERE_QUERY = \"\"\"\n query($where: ProductVariantWhereInput!, $channel: String) {\n@@ -66,4 +72,103 @@\n # then\n data = get_graphql_content(response)\n variants = data[\"data\"][\"productVariants\"][\"edges\"]\n assert len(variants) == 0\n+\n+\n+@pytest.mark.parametrize(\n+ (\"where\", \"indexes\"),\n+ [\n+ (\n+ {\n+ \"gte\": (timezone.now() - datetime.timedelta(days=25)).isoformat(),\n+ \"lte\": (timezone.now() - datetime.timedelta(days=2)).isoformat(),\n+ },\n+ [0, 1],\n+ ),\n+ (\n+ {\n+ \"lte\": (timezone.now() - datetime.timedelta(days=25)).isoformat(),\n+ },\n+ [],\n+ ),\n+ (\n+ {\n+ \"lte\": (timezone.now() - datetime.timedelta(hours=10)).isoformat(),\n+ },\n+ [0, 1],\n+ ),\n+ (None, []),\n+ ({\"gte\": None}, []),\n+ ({\"lte\": None}, []),\n+ ({\"lte\": None, \"gte\": None}, []),\n+ ({}, []),\n+ ],\n+)\n+def test_product_variant_filter_by_updated_at(\n+ where,\n+ indexes,\n+ product_variant_list,\n+ api_client,\n+ channel_USD,\n+):\n+ # given\n+ with freeze_time((timezone.now() - datetime.timedelta(days=15)).isoformat()):\n+ product_variant_list[0].save(update_fields=[\"updated_at\"])\n+\n+ with freeze_time((timezone.now() - datetime.timedelta(days=3)).isoformat()):\n+ product_variant_list[1].save(update_fields=[\"updated_at\"])\n+\n+ # variant available only in channel PLN\n+ with freeze_time((timezone.now() - datetime.timedelta(days=1)).isoformat()):\n+ product_variant_list[2].save(update_fields=[\"updated_at\"])\n+\n+ variables = {\"channel\": channel_USD.slug, \"where\": {\"updatedAt\": where}}\n+\n+ # when\n+ response = api_client.post_graphql(PRODUCT_VARIANTS_WHERE_QUERY, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ variants = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(variants) == len(indexes)\n+ skus = {node[\"node\"][\"sku\"] for node in variants}\n+ assert skus == {product_variant_list[index].sku for index in indexes}\n+\n+\n+@pytest.mark.parametrize(\n+ (\"where\", \"indexes\"),\n+ [\n+ ({\"eq\": \"SKU1\"}, [0]),\n+ ({\"eq\": \"SKU2\"}, [1]),\n+ ({\"eq\": \"SKU_NON_EXISTENT\"}, []),\n+ ({\"oneOf\": [\"SKU1\", \"SKU2\"]}, [0, 1]),\n+ ({\"oneOf\": [\"SKU1\", \"SKU_NON_EXISTENT\"]}, [0]),\n+ ({\"oneOf\": []}, []),\n+ (None, []),\n+ ({}, []),\n+ ],\n+)\n+def test_product_variant_filter_by_sku(\n+ where,\n+ indexes,\n+ product_variant_list,\n+ api_client,\n+ channel_USD,\n+):\n+ # given\n+ product_variant_list[0].sku = \"SKU1\"\n+ product_variant_list[1].sku = \"SKU2\"\n+ product_variant_list[2].sku = \"SKU3\"\n+ ProductVariant.objects.bulk_update(product_variant_list, [\"sku\"])\n+\n+ variables = {\"channel\": channel_USD.slug, \"where\": {\"sku\": where}}\n+\n+ # when\n+ response = api_client.post_graphql(PRODUCT_VARIANTS_WHERE_QUERY, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ variants = content[\"data\"][\"productVariants\"][\"edges\"]\n+ assert len(variants) == len(indexes)\n+ skus = {node[\"node\"][\"sku\"] for node in variants}\n+ assert skus == {product_variant_list[index].sku for index in indexes}\n"
1125
- },
1126
- {
1127
- "path": "saleor/graphql/product/tests/test_variant_with_filtering.py",
1128
- "status": "modified",
1129
- "diff": "Index: saleor/graphql/product/tests/test_variant_with_filtering.py\n===================================================================\n--- saleor/graphql/product/tests/test_variant_with_filtering.py\t6ac60cb (parent)\n+++ saleor/graphql/product/tests/test_variant_with_filtering.py\t5d692d8 (commit)\n@@ -4,8 +4,9 @@\n from django.utils import timezone\n from freezegun import freeze_time\n \n from ....product.models import Product, ProductVariant\n+from ....product.search import update_products_search_vector\n from ...tests.utils import get_graphql_content\n \n QUERY_VARIANTS_FILTER = \"\"\"\n query variants($filter: ProductVariantFilterInput){\n@@ -114,8 +115,9 @@\n preorder_end_date=timezone.now() - datetime.timedelta(days=1),\n ),\n ]\n )\n+ update_products_search_vector([product.id for product in products])\n return products\n \n \n @pytest.mark.parametrize(\n"
1130
- },
1131
- {
1132
- "path": "saleor/graphql/product/types/categories.py",
1133
- "status": "modified",
1134
- "diff": "Index: saleor/graphql/product/types/categories.py\n===================================================================\n--- saleor/graphql/product/types/categories.py\t6ac60cb (parent)\n+++ saleor/graphql/product/types/categories.py\t5d692d8 (commit)\n@@ -18,9 +18,9 @@\n create_connection_slice,\n filter_connection_queryset,\n )\n from ...core.context import ChannelQsContext, get_database_connection_name\n-from ...core.descriptions import RICH_CONTENT\n+from ...core.descriptions import DEPRECATED_IN_3X_INPUT, RICH_CONTENT\n from ...core.doc_category import DOC_CATEGORY_PRODUCTS\n from ...core.federation import federated_entity, resolve_federation_references\n from ...core.fields import ConnectionField, FilterConnectionField, JSONString\n from ...core.scalars import DateTime\n@@ -64,9 +64,10 @@\n products = FilterConnectionField(\n ProductCountableConnection,\n filter=ProductFilterInput(\n description=(\n- \"Filtering options for products. {DEPRECATED_IN_3X_INPUT} Use `where` filter instead.\"\n+ f\"Filtering options for products. {DEPRECATED_IN_3X_INPUT} \"\n+ \"Use `where` filter instead.\"\n )\n ),\n where=ProductWhereInput(description=\"Where filtering options for products.\"),\n sort_by=ProductOrder(description=\"Sort products.\"),\n"
1135
- },
1136
- {
1137
- "path": "saleor/graphql/product/types/products.py",
1138
- "status": "modified",
1139
- "diff": "Index: saleor/graphql/product/types/products.py\n===================================================================\n--- saleor/graphql/product/types/products.py\t6ac60cb (parent)\n+++ saleor/graphql/product/types/products.py\t5d692d8 (commit)\n@@ -962,9 +962,12 @@\n )\n product_variants = FilterConnectionField(\n ProductVariantCountableConnection,\n filter=ProductVariantFilterInput(\n- description=\"Filtering options for product variant.\"\n+ description=(\n+ f\"Filtering options for product variant. {DEPRECATED_IN_3X_INPUT} \"\n+ \"Use `where` filter instead.\"\n+ )\n ),\n where=ProductVariantWhereInput(\n description=\"Where filtering options for product variants.\"\n ),\n"
1140
- },
1141
- {
1142
- "path": "saleor/graphql/schema.graphql",
1143
- "status": "modified",
1144
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\t6ac60cb (parent)\n+++ saleor/graphql/schema.graphql\t5d692d8 (commit)\n@@ -554,14 +554,21 @@\n \n \"\"\"Slug of a channel for which the data should be returned.\"\"\"\n channel: String\n \n- \"\"\"Filtering options for product variant.\"\"\"\n- filter: ProductVariantFilterInput\n+ \"\"\"Filtering options for product variants.\"\"\"\n+ filter: ProductVariantFilterInput @deprecated(reason: \"Use `where` filter instead.\")\n \n \"\"\"Where filtering options for product variants.\"\"\"\n where: ProductVariantWhereInput\n \n+ \"\"\"\n+ Search product variants.\n+ \n+ Added in Saleor 3.22.\n+ \"\"\"\n+ search: String\n+\n \"\"\"Sort products variants.\"\"\"\n sortBy: ProductVariantSortingInput\n \n \"\"\"Return the elements in the list that come before the specified cursor.\"\"\"\n@@ -5697,9 +5704,9 @@\n Added in Saleor 3.21.\n \"\"\"\n productVariants(\n \"\"\"Filtering options for product variant.\"\"\"\n- filter: ProductVariantFilterInput\n+ filter: ProductVariantFilterInput @deprecated(reason: \"Use `where` filter instead.\")\n \n \"\"\"Where filtering options for product variants.\"\"\"\n where: ProductVariantWhereInput\n \n@@ -7546,12 +7553,10 @@\n \"\"\"\n List of products in the category. Requires the following permissions to include the unpublished items: MANAGE_ORDERS, MANAGE_DISCOUNTS, MANAGE_PRODUCTS.\n \"\"\"\n products(\n- \"\"\"\n- Filtering options for products. {DEPRECATED_IN_3X_INPUT} Use `where` filter instead.\n- \"\"\"\n- filter: ProductFilterInput\n+ \"\"\"Filtering options for products.\"\"\"\n+ filter: ProductFilterInput @deprecated(reason: \"Use `where` filter instead.\")\n \n \"\"\"Where filtering options for products.\"\"\"\n where: ProductWhereInput\n \n@@ -8199,8 +8204,14 @@\n input ProductVariantWhereInput @doc(category: \"Products\") {\n metadata: [MetadataFilter!]\n ids: [ID!]\n \n+ \"\"\"Filter by product SKU.\"\"\"\n+ sku: StringFilterInput\n+\n+ \"\"\"Filter by when was the most recent update.\"\"\"\n+ updatedAt: DateTimeRangeInput\n+\n \"\"\"List of conditions that must be met.\"\"\"\n AND: [ProductVariantWhereInput!]\n \n \"\"\"A list of conditions of which at least one must be met.\"\"\"\n"
1145
- },
1146
- {
1147
- "path": "saleor/product/migrations/0201_productvariant_variant_gin.py",
1148
- "status": "added",
1149
- "diff": "Index: saleor/product/migrations/0201_productvariant_variant_gin.py\n===================================================================\n--- saleor/product/migrations/0201_productvariant_variant_gin.py\t6ac60cb (parent)\n+++ saleor/product/migrations/0201_productvariant_variant_gin.py\t5d692d8 (commit)\n@@ -1,1 +1,24 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-07-03 12:21\n+\n+from django.contrib.postgres.indexes import GinIndex\n+from django.contrib.postgres.operations import AddIndexConcurrently\n+from django.db import migrations\n+\n+\n+class Migration(migrations.Migration):\n+ atomic = False\n+\n+ dependencies = [\n+ (\"product\", \"0200_merge_20250527_1210\"),\n+ ]\n+\n+ operations = [\n+ AddIndexConcurrently(\n+ model_name=\"productvariant\",\n+ index=GinIndex(\n+ fields=[\"name\", \"sku\"],\n+ name=\"variant_gin\",\n+ opclasses=[\"gin_trgm_ops\", \"gin_trgm_ops\"],\n+ ),\n+ ),\n+ ]\n"
1150
- },
1151
- {
1152
- "path": "saleor/product/models.py",
1153
- "status": "modified",
1154
- "diff": "Index: saleor/product/models.py\n===================================================================\n--- saleor/product/models.py\t6ac60cb (parent)\n+++ saleor/product/models.py\t5d692d8 (commit)\n@@ -374,8 +374,16 @@\n \n class Meta(ModelWithMetadata.Meta):\n ordering = (\"sort_order\", \"sku\")\n app_label = \"product\"\n+ indexes = [\n+ *ModelWithMetadata.Meta.indexes,\n+ GinIndex(\n+ name=\"variant_gin\",\n+ fields=[\"name\", \"sku\"],\n+ opclasses=[\"gin_trgm_ops\"] * 2,\n+ ),\n+ ]\n \n def __str__(self) -> str:\n return self.name or self.sku or f\"ID:{self.pk}\"\n \n"
1155
- }
1156
- ]
1157
- },
1158
- {
1159
- "id": "fix-transaction-race",
1160
- "sha": "2864acf1fa4ec6792f11a6effc38e419837760d6",
1161
- "parentSha": "ea1dfd826418c4dae89aadad62d91138a367cc53",
1162
- "spec": "Implement concurrency-safe transaction creation and event handling in GraphQL payment mutations, and refactor order status logic to avoid race conditions.\n\nScope\n- Apply changes to the following files:\n - saleor/graphql/payment/mutations/transaction/transaction_create.py\n - saleor/graphql/payment/mutations/transaction/transaction_event_report.py\n - saleor/graphql/payment/mutations/transaction/transaction_update.py\n - saleor/order/utils.py\n - Update/add tests in saleor/graphql/payment/tests/mutations/test_transaction_create.py and saleor/graphql/payment/tests/mutations/test_transaction_event_report.py\n\nRequirements\n1) TransactionCreate mutation (saleor/graphql/payment/mutations/transaction/transaction_create.py)\n- Introduce two helper methods:\n a) process_order_with_transaction(transaction, manager, user, app, money_data)\n - Ensure this runs inside a traced atomic DB transaction and acquires row-level locks on both the target Order and the TransactionItem using get_order_and_transaction_item_locked_for_update(order_id: UUID, transaction_pk).\n - If money_data is provided, recalculate order amounts via updates_amounts_for_order(order, save=False) and collect update_fields: total_charged_amount, charge_status, total_authorized_amount, authorize_status.\n - If the channel has automatically_confirm_all_new_orders and the order status is UNCONFIRMED, refresh order status using a new utility refresh_order_status(order). If the status changes, include status in update_fields.\n - Save the order with update_fields + updated_at inside the transaction.\n - After the transaction block, update the order's search vector (update_order_search_vector(order)) and call order_transaction_updated(order_info, transaction_item, manager, user, app, with previous_* values set to Decimal(0)).\n b) process_order_or_checkout_with_transaction(transaction, manager, user, app, money_data)\n - For a transaction tied to a checkout (transaction.checkout_id) and money_data present: within a traced atomic transaction, lock checkout and transaction using get_checkout_and_transaction_item_locked_for_update and update the checkout amounts/status using transaction_amounts_for_checkout_updated_without_price_recalculation(transaction, locked_checkout, manager, user, app). If the checkout was deleted, set a flag to process the order path.\n - If the transaction has order_id or the checkout was deleted (and money_data present), delegate to process_order_with_transaction.\n- In perform_mutation, after creating manual adjustment events and recalculating transaction amounts, replace separate order/checkout update branches with a single call to process_order_or_checkout_with_transaction.\n- Add necessary imports: TYPE_CHECKING, UUID, traced_atomic_transaction, User (account.models), App, get_checkout_and_transaction_item_locked_for_update, get_order_and_transaction_item_locked_for_update, refresh_order_status, update_order_search_vector, and the non-recalculating checkout update function.\n- Use type annotations for PluginsManager under TYPE_CHECKING.\n\n2) TransactionEventReport mutation (saleor/graphql/payment/mutations/transaction/transaction_event_report.py)\n- When processing an order-bound transaction, cast transaction.order_id to UUID and lock Order and TransactionItem with get_order_and_transaction_item_locked_for_update(order_id: UUID, transaction_pk) inside traced_atomic_transaction.\n- Keep subsequent logic: updates_amounts_for_order(order), update_order_search_vector(order), fetch_order_info, and order_transaction_updated.\n- Update type hints to import User from .....account.models and use Optional[User] as User | None where applicable.\n\n3) TransactionUpdate mutation (saleor/graphql/payment/mutations/transaction/transaction_update.py)\n- Keep the legacy update_order helper for now (annotate with TODO to refactor to new process_* functions in a future task). This helper should:\n - If money_data is present, call updates_amounts_for_order(order, save=False) and update fields: total_authorized_amount, total_charged_amount, authorize_status, charge_status.\n - If the channel auto-confirms and order is UNCONFIRMED, call update_order_status(order).\n - If update_search_vector is true, call update_order_search_vector(order, save=False) and include search_vector in update fields.\n - Save order with collected update_fields + updated_at.\n\n4) Order utilities (saleor/order/utils.py)\n- Add a new function refresh_order_status(order: Order) -> bool that recalculates the order status using the most recent quantities (including returns/replacements) without saving. It returns True if the status changed, False otherwise. This function must be called within a transaction with the order locked by the caller.\n- Refactor update_order_status(order: Order) to:\n - Wrap in transaction.atomic and lock the order via select_for_update.\n - Call refresh_order_status on the locked order and, if status changed, save locked_order with status and updated_at.\n - Update the provided order object's status to match the locked order, ensuring the mutation response reflects the new status immediately.\n\n5) Tests\n- Update existing tests to use checkout_with_prices where applicable (instead of checkout_with_items) and adjust assertions to compare against python enum values from order module (OrderAuthorizeStatus, OrderChargeStatus) instead of GraphQL enum string values.\n- Add tests for lock acquisition in TransactionCreate mutation (wrapped with pytest.mark.django_db(transaction=True)):\n a) test_lock_order_during_updating_order_amounts: Patch get_order_and_transaction_item_locked_for_update with wraps= to assert it is called once with the order.pk and the created transaction pk when creating a charged transaction for an order.\n b) test_lock_checkout_during_updating_checkout_amounts: Patch get_checkout_and_transaction_item_locked_for_update with wraps= to assert it is called once with checkout.pk and the created transaction pk when creating a charged transaction for a checkout.\n- Add a race-condition test where checkout completion is triggered concurrently during transaction creation:\n - Use race_condition.RunBefore to run create_order_from_checkout before recalculate_transaction_amounts in TransactionCreate.\n - After the mutation, assert the created Order (linked by checkout_token) has status UNFULFILLED, charge_status NONE, and authorize_status FULL.\n\nBehavioral Outcomes\n- TransactionCreate safely updates checkout/order amounts and statuses under row locks and atomic transactions, avoiding race conditions.\n- Order status updates use refresh_order_status inside a locked transaction, preventing status overrides and ensuring consistency in API responses.\n- The checkout path avoids unnecessary price recalculation when only transaction amounts change, by using the non-recalculating update function.\n- Tests verify locking, status values, and race-condition handling.\n",
1163
- "prompt": "Implement concurrency-safe transaction handling in payment GraphQL mutations and refactor order status updates to avoid race conditions.\n\nGoals:\n- When creating or reporting transaction events, ensure updates to checkout and order amounts/statuses are performed under row-level locks and within atomic transactions.\n- Separate the logic for processing checkout-bound transactions from order-bound ones, and ensure the checkout path doesn’t trigger a full price recalculation when only transaction amounts change.\n- Factor out a utility that recalculates an order’s status without saving, and have the existing status update function use it inside a locked transaction so API responses reflect the current status.\n- Update tests to assert the locking functions are invoked and that a concurrent checkout completion won’t corrupt order/transaction states.\n\nWhat to deliver:\n- GraphQL transaction mutations that lock the relevant Order or Checkout and the TransactionItem during updates, and wrap these sections in an atomic transaction.\n- A new order utility to refresh status (without saving) and a refactored status updater that uses it under a lock.\n- Tests covering lock acquisition, updated status expectations, and a simulated race condition during transaction creation.",
1164
- "supplementalFiles": [
1165
- "saleor/payment/lock_objects.py",
1166
- "saleor/checkout/actions.py",
1167
- "saleor/core/tracing.py",
1168
- "saleor/payment/transaction_item_calculations.py",
1169
- "saleor/payment/utils.py",
1170
- "saleor/checkout/fetch.py",
1171
- "saleor/order/search.py",
1172
- "saleor/order/fetch.py",
1173
- "saleor/tests/race_condition.py",
1174
- "saleor/checkout/complete_checkout.py",
1175
- "saleor/order/actions.py"
1176
- ],
1177
- "fileDiffs": [
1178
- {
1179
- "path": "saleor/graphql/payment/mutations/transaction/transaction_create.py",
1180
- "status": "modified",
1181
- "diff": "Index: saleor/graphql/payment/mutations/transaction/transaction_create.py\n===================================================================\n--- saleor/graphql/payment/mutations/transaction/transaction_create.py\tea1dfd8 (parent)\n+++ saleor/graphql/payment/mutations/transaction/transaction_create.py\t2864acf (commit)\n@@ -1,27 +1,37 @@\n import uuid\n from decimal import Decimal\n-from typing import cast\n+from typing import TYPE_CHECKING, cast\n+from uuid import UUID\n \n import graphene\n from django.core.exceptions import ValidationError\n from django.core.validators import URLValidator\n from django.db.models import Model\n \n+from .....account.models import User\n+from .....app.models import App\n from .....checkout import models as checkout_models\n-from .....checkout.actions import transaction_amounts_for_checkout_updated\n+from .....checkout.actions import (\n+ transaction_amounts_for_checkout_updated_without_price_recalculation,\n+)\n from .....core.prices import quantize_price\n+from .....core.tracing import traced_atomic_transaction\n from .....order import OrderStatus\n from .....order import models as order_models\n from .....order.actions import order_transaction_updated\n from .....order.events import transaction_event as order_transaction_event\n from .....order.fetch import fetch_order_info\n from .....order.search import update_order_search_vector\n-from .....order.utils import update_order_status, updates_amounts_for_order\n+from .....order.utils import refresh_order_status, updates_amounts_for_order\n from .....payment import TransactionEventType\n from .....payment import models as payment_models\n from .....payment.error_codes import TransactionCreateErrorCode\n from .....payment.interface import PaymentMethodDetails\n+from .....payment.lock_objects import (\n+ get_checkout_and_transaction_item_locked_for_update,\n+ get_order_and_transaction_item_locked_for_update,\n+)\n from .....payment.transaction_item_calculations import recalculate_transaction_amounts\n from .....payment.utils import (\n create_manual_adjustment_events,\n truncate_transaction_event_message,\n@@ -46,9 +56,12 @@\n get_payment_method_details,\n validate_payment_method_details_input,\n )\n \n+if TYPE_CHECKING:\n+ from .....plugins.manager import PluginsManager\n \n+\n class TransactionCreateInput(BaseInputObjectType):\n name = graphene.String(description=\"Payment name of the transaction.\")\n message = graphene.String(description=\"The message of the transaction.\")\n \n@@ -330,42 +343,91 @@\n currency=transaction.currency,\n )\n \n @classmethod\n- def update_order(\n+ def process_order_with_transaction(\n cls,\n- order: order_models.Order,\n- money_data: dict,\n- update_search_vector: bool = True,\n- ) -> None:\n- update_fields = []\n- if money_data:\n- updates_amounts_for_order(order, save=False)\n- update_fields.extend(\n- [\n- \"total_authorized_amount\",\n- \"total_charged_amount\",\n- \"authorize_status\",\n- \"charge_status\",\n- ]\n+ transaction: payment_models.TransactionItem,\n+ manager: \"PluginsManager\",\n+ user: User | None,\n+ app: App | None,\n+ money_data: dict[str, Decimal],\n+ ):\n+ order = None\n+ # This is executed after we ensure that the transaction is not a checkout\n+ # transaction, so we can safely cast the order_id to UUID.\n+ order_id = cast(UUID, transaction.order_id)\n+ with traced_atomic_transaction():\n+ order, transaction = get_order_and_transaction_item_locked_for_update(\n+ order_id, transaction.pk\n )\n- if (\n- order.channel.automatically_confirm_all_new_orders\n- and order.status == OrderStatus.UNCONFIRMED\n- ):\n- update_order_status(order)\n+ update_fields = []\n+ if money_data:\n+ updates_amounts_for_order(order, save=False)\n+ update_fields.extend(\n+ [\n+ \"total_charged_amount\",\n+ \"charge_status\",\n+ \"total_authorized_amount\",\n+ \"authorize_status\",\n+ ]\n+ )\n+ if (\n+ order.channel.automatically_confirm_all_new_orders\n+ and order.status == OrderStatus.UNCONFIRMED\n+ ):\n+ status_updated = refresh_order_status(order)\n+ if status_updated:\n+ update_fields.append(\"status\")\n+ if update_fields:\n+ update_fields.append(\"updated_at\")\n+ order.save(update_fields=update_fields)\n \n- if update_search_vector:\n- update_order_search_vector(order, save=False)\n- update_fields.append(\n- \"search_vector\",\n- )\n+ update_order_search_vector(order)\n \n- if update_fields:\n- update_fields.append(\"updated_at\")\n- order.save(update_fields=update_fields)\n+ order_info = fetch_order_info(order)\n+ order_transaction_updated(\n+ order_info=order_info,\n+ transaction_item=transaction,\n+ manager=manager,\n+ user=user,\n+ app=app,\n+ previous_authorized_value=Decimal(0),\n+ previous_charged_value=Decimal(0),\n+ previous_refunded_value=Decimal(0),\n+ )\n \n @classmethod\n+ def process_order_or_checkout_with_transaction(\n+ cls,\n+ transaction: payment_models.TransactionItem,\n+ manager: \"PluginsManager\",\n+ user: User | None,\n+ app: App | None,\n+ money_data: dict[str, Decimal],\n+ ):\n+ checkout_deleted = False\n+ if transaction.checkout_id and money_data:\n+ with traced_atomic_transaction():\n+ locked_checkout, transaction = (\n+ get_checkout_and_transaction_item_locked_for_update(\n+ transaction.checkout_id, transaction.pk\n+ )\n+ )\n+ if transaction.checkout_id and locked_checkout:\n+ transaction_amounts_for_checkout_updated_without_price_recalculation(\n+ transaction, locked_checkout, manager, user, app\n+ )\n+ else:\n+ checkout_deleted = True\n+ # If the checkout was deleted, we still want to update the order associated with the transaction.\n+\n+ if (transaction.order_id or checkout_deleted) and money_data:\n+ cls.process_order_with_transaction(\n+ transaction, manager, user, app, money_data\n+ )\n+\n+ @classmethod\n def perform_mutation( # type: ignore[override]\n cls,\n _root,\n info: ResolveInfo,\n@@ -416,27 +478,15 @@\n create_manual_adjustment_events(\n transaction=new_transaction, money_data=money_data, user=user, app=app\n )\n recalculate_transaction_amounts(new_transaction)\n- if transaction_data.get(\"order_id\") and money_data:\n- order = cast(order_models.Order, new_transaction.order)\n- cls.update_order(order, money_data, update_search_vector=True)\n+ cls.process_order_or_checkout_with_transaction(\n+ new_transaction,\n+ manager,\n+ user,\n+ app,\n+ money_data,\n+ )\n \n- order_info = fetch_order_info(order)\n- order_transaction_updated(\n- order_info=order_info,\n- transaction_item=new_transaction,\n- manager=manager,\n- user=user,\n- app=app,\n- previous_authorized_value=Decimal(0),\n- previous_charged_value=Decimal(0),\n- previous_refunded_value=Decimal(0),\n- )\n- if transaction_data.get(\"checkout_id\") and money_data:\n- transaction_amounts_for_checkout_updated(\n- new_transaction, manager, user, app\n- )\n-\n if transaction_event:\n cls.create_transaction_event(transaction_event, new_transaction, user, app)\n return TransactionCreate(transaction=new_transaction)\n"
1182
- },
1183
- {
1184
- "path": "saleor/graphql/payment/mutations/transaction/transaction_event_report.py",
1185
- "status": "modified",
1186
- "diff": "Index: saleor/graphql/payment/mutations/transaction/transaction_event_report.py\n===================================================================\n--- saleor/graphql/payment/mutations/transaction/transaction_event_report.py\tea1dfd8 (parent)\n+++ saleor/graphql/payment/mutations/transaction/transaction_event_report.py\t2864acf (commit)\n@@ -1,11 +1,13 @@\n from decimal import Decimal\n from typing import TYPE_CHECKING, Optional, cast\n+from uuid import UUID as UUID_TYPE\n \n import graphene\n from django.core.exceptions import ValidationError\n from django.utils import timezone\n \n+from .....account.models import User\n from .....app.models import App\n from .....checkout.actions import (\n transaction_amounts_for_checkout_updated_without_price_recalculation,\n )\n@@ -64,9 +66,8 @@\n )\n from .utils import get_transaction_item\n \n if TYPE_CHECKING:\n- from .....accounts.models import User\n from .....plugins.manager import PluginsManager\n \n \n class TransactionEventReport(DeprecatedModelMutation):\n@@ -306,19 +307,22 @@\n def process_order_with_transaction(\n cls,\n transaction: payment_models.TransactionItem,\n manager: \"PluginsManager\",\n- user: Optional[\"User\"],\n+ user: User | None,\n app: App | None,\n previous_authorized_value: Decimal,\n previous_charged_value: Decimal,\n previous_refunded_value: Decimal,\n related_granted_refund: order_models.OrderGrantedRefund | None,\n ):\n- order = cast(order_models.Order, transaction.order)\n+ order = None\n+ # This is executed after we ensure that the transaction is not a checkout\n+ # transaction, so we can safely cast the order_id to UUID.\n+ order_id = cast(UUID_TYPE, transaction.order_id)\n with traced_atomic_transaction():\n order, transaction = get_order_and_transaction_item_locked_for_update(\n- order.pk, transaction.pk\n+ order_id, transaction.pk\n )\n updates_amounts_for_order(order)\n update_order_search_vector(order)\n order_info = fetch_order_info(order)\n@@ -339,9 +343,9 @@\n def process_order_or_checkout_with_transaction(\n cls,\n transaction: payment_models.TransactionItem,\n manager: \"PluginsManager\",\n- user: Optional[\"User\"],\n+ user: User | None,\n app: App | None,\n previous_authorized_value: Decimal,\n previous_charged_value: Decimal,\n previous_refunded_value: Decimal,\n"
1187
- },
1188
- {
1189
- "path": "saleor/graphql/payment/mutations/transaction/transaction_update.py",
1190
- "status": "modified",
1191
- "diff": "Index: saleor/graphql/payment/mutations/transaction/transaction_update.py\n===================================================================\n--- saleor/graphql/payment/mutations/transaction/transaction_update.py\tea1dfd8 (parent)\n+++ saleor/graphql/payment/mutations/transaction/transaction_update.py\t2864acf (commit)\n@@ -5,12 +5,18 @@\n \n from .....app.models import App\n from .....checkout.actions import transaction_amounts_for_checkout_updated\n from .....core.exceptions import PermissionDenied\n+from .....order import OrderStatus\n from .....order import models as order_models\n from .....order.actions import order_transaction_updated\n from .....order.events import transaction_event as order_transaction_event\n from .....order.fetch import fetch_order_info\n+from .....order.search import update_order_search_vector\n+from .....order.utils import (\n+ update_order_status,\n+ updates_amounts_for_order,\n+)\n from .....payment import models as payment_models\n from .....payment.error_codes import (\n TransactionCreateErrorCode,\n TransactionUpdateErrorCode,\n@@ -193,9 +199,45 @@\n if app and not transaction.user_id and not transaction_has_assigned_app:\n transaction_data[\"app\"] = app\n transaction_data[\"app_identifier\"] = app.identifier\n \n+ # TODO (ENG-295): Remove this method when this will be refactored to use\n+ # the new functions `process_order_or_checkout_with_transaction`.\n @classmethod\n+ def update_order(\n+ cls,\n+ order: order_models.Order,\n+ money_data: dict,\n+ update_search_vector: bool = True,\n+ ) -> None:\n+ update_fields = []\n+ if money_data:\n+ updates_amounts_for_order(order, save=False)\n+ update_fields.extend(\n+ [\n+ \"total_authorized_amount\",\n+ \"total_charged_amount\",\n+ \"authorize_status\",\n+ \"charge_status\",\n+ ]\n+ )\n+ if (\n+ order.channel.automatically_confirm_all_new_orders\n+ and order.status == OrderStatus.UNCONFIRMED\n+ ):\n+ update_order_status(order)\n+\n+ if update_search_vector:\n+ update_order_search_vector(order, save=False)\n+ update_fields.append(\n+ \"search_vector\",\n+ )\n+\n+ if update_fields:\n+ update_fields.append(\"updated_at\")\n+ order.save(update_fields=update_fields)\n+\n+ @classmethod\n def perform_mutation(\n cls,\n _root,\n info: ResolveInfo,\n"
1192
- },
1193
- {
1194
- "path": "saleor/graphql/payment/tests/mutations/test_transaction_create.py",
1195
- "status": "modified",
1196
- "diff": "Index: saleor/graphql/payment/tests/mutations/test_transaction_create.py\n===================================================================\n--- saleor/graphql/payment/tests/mutations/test_transaction_create.py\tea1dfd8 (parent)\n+++ saleor/graphql/payment/tests/mutations/test_transaction_create.py\t2864acf (commit)\n@@ -6,16 +6,22 @@\n from freezegun import freeze_time\n \n from .....checkout import CheckoutAuthorizeStatus, CheckoutChargeStatus\n from .....checkout.calculations import fetch_checkout_data\n+from .....checkout.complete_checkout import create_order_from_checkout\n from .....checkout.fetch import fetch_checkout_info, fetch_checkout_lines\n from .....checkout.models import Checkout\n from .....order import OrderAuthorizeStatus, OrderChargeStatus, OrderEvents, OrderStatus\n from .....order.models import Order\n from .....order.utils import update_order_authorize_data, update_order_charge_data\n from .....payment import PaymentMethodType, TransactionEventType\n from .....payment.error_codes import TransactionCreateErrorCode\n+from .....payment.lock_objects import (\n+ get_checkout_and_transaction_item_locked_for_update,\n+ get_order_and_transaction_item_locked_for_update,\n+)\n from .....payment.models import TransactionItem\n+from .....tests import race_condition\n from ....core.utils import to_global_id_or_none\n from ....tests.utils import assert_no_permission, get_graphql_content\n from ...enums import TransactionActionEnum, TransactionEventTypeEnum\n \n@@ -417,9 +423,9 @@\n assert order_with_lines.status == OrderStatus.DRAFT\n \n \n def test_transaction_create_for_checkout_by_app(\n- checkout_with_items, permission_manage_payments, app_api_client\n+ checkout_with_prices, permission_manage_payments, app_api_client, plugins_manager\n ):\n # given\n name = \"Credit Card\"\n psp_reference = \"PSP reference - 123\"\n@@ -432,9 +438,9 @@\n private_metadata = {\"key\": \"test-2\", \"value\": \"321\"}\n external_url = f\"http://{TEST_SERVER_DOMAIN}/external-url\"\n \n variables = {\n- \"id\": graphene.Node.to_global_id(\"Checkout\", checkout_with_items.pk),\n+ \"id\": graphene.Node.to_global_id(\"Checkout\", checkout_with_prices.pk),\n \"transaction\": {\n \"name\": name,\n \"pspReference\": psp_reference,\n \"availableActions\": available_actions,\n@@ -453,15 +459,15 @@\n MUTATION_TRANSACTION_CREATE, variables, permissions=[permission_manage_payments]\n )\n \n # then\n- checkout_with_items.refresh_from_db()\n- assert checkout_with_items.charge_status == CheckoutChargeStatus.NONE\n- assert checkout_with_items.authorize_status == CheckoutAuthorizeStatus.PARTIAL\n+ checkout_with_prices.refresh_from_db()\n+ assert checkout_with_prices.charge_status == CheckoutChargeStatus.NONE\n+ assert checkout_with_prices.authorize_status == CheckoutAuthorizeStatus.PARTIAL\n \n available_actions = list(set(available_actions))\n \n- transaction = checkout_with_items.payment_transactions.first()\n+ transaction = checkout_with_prices.payment_transactions.first()\n content = get_graphql_content(response)\n data = content[\"data\"][\"transactionCreate\"][\"transaction\"]\n assert data[\"actions\"] == available_actions\n assert data[\"pspReference\"] == psp_reference\n@@ -482,9 +488,9 @@\n assert transaction.user is None\n \n \n def test_transaction_create_for_checkout_by_app_metadata_null_value(\n- checkout_with_items, permission_manage_payments, app_api_client\n+ checkout_with_prices, permission_manage_payments, app_api_client\n ):\n # given\n name = \"Credit Card\"\n psp_reference = \"PSP reference - 123\"\n@@ -495,9 +501,9 @@\n authorized_value = Decimal(\"10\")\n external_url = f\"http://{TEST_SERVER_DOMAIN}/external-url\"\n \n variables = {\n- \"id\": graphene.Node.to_global_id(\"Checkout\", checkout_with_items.pk),\n+ \"id\": graphene.Node.to_global_id(\"Checkout\", checkout_with_prices.pk),\n \"transaction\": {\n \"name\": name,\n \"pspReference\": psp_reference,\n \"availableActions\": available_actions,\n@@ -516,15 +522,15 @@\n MUTATION_TRANSACTION_CREATE, variables, permissions=[permission_manage_payments]\n )\n \n # then\n- checkout_with_items.refresh_from_db()\n- assert checkout_with_items.charge_status == CheckoutChargeStatus.NONE\n- assert checkout_with_items.authorize_status == CheckoutAuthorizeStatus.PARTIAL\n+ checkout_with_prices.refresh_from_db()\n+ assert checkout_with_prices.charge_status == CheckoutChargeStatus.NONE\n+ assert checkout_with_prices.authorize_status == CheckoutAuthorizeStatus.PARTIAL\n \n available_actions = list(set(available_actions))\n \n- transaction = checkout_with_items.payment_transactions.first()\n+ transaction = checkout_with_prices.payment_transactions.first()\n content = get_graphql_content(response)\n data = content[\"data\"][\"transactionCreate\"][\"transaction\"]\n assert data[\"actions\"] == available_actions\n assert data[\"pspReference\"] == psp_reference\n@@ -1130,9 +1136,9 @@\n assert charged_value == transaction.charged_value\n \n \n def test_transaction_create_for_checkout_by_staff(\n- checkout_with_items, permission_manage_payments, staff_api_client\n+ checkout_with_prices, permission_manage_payments, staff_api_client\n ):\n # given\n name = \"Credit Card\"\n psp_reference = \"PSP reference - 123\"\n@@ -1144,9 +1150,9 @@\n metadata = {\"key\": \"test-1\", \"value\": \"123\"}\n private_metadata = {\"key\": \"test-2\", \"value\": \"321\"}\n \n variables = {\n- \"id\": graphene.Node.to_global_id(\"Checkout\", checkout_with_items.pk),\n+ \"id\": graphene.Node.to_global_id(\"Checkout\", checkout_with_prices.pk),\n \"transaction\": {\n \"name\": name,\n \"pspReference\": psp_reference,\n \"availableActions\": available_actions,\n@@ -1166,12 +1172,12 @@\n \n # then\n available_actions = list(set(available_actions))\n \n- checkout_with_items.refresh_from_db()\n- assert checkout_with_items.charge_status == CheckoutChargeStatus.NONE\n- assert checkout_with_items.authorize_status == CheckoutAuthorizeStatus.PARTIAL\n- transaction = checkout_with_items.payment_transactions.first()\n+ checkout_with_prices.refresh_from_db()\n+ assert checkout_with_prices.charge_status == CheckoutChargeStatus.NONE\n+ assert checkout_with_prices.authorize_status == CheckoutAuthorizeStatus.PARTIAL\n+ transaction = checkout_with_prices.payment_transactions.first()\n content = get_graphql_content(response)\n data = content[\"data\"][\"transactionCreate\"][\"transaction\"]\n assert data[\"actions\"] == available_actions\n \n@@ -2784,4 +2790,170 @@\n assert len(transaction_data[\"errors\"]) == 1\n error = transaction_data[\"errors\"][0]\n assert error[\"code\"] == \"INVALID\"\n assert error[\"field\"] == \"paymentMethodDetails\"\n+\n+\n+# Test wrapped by `transaction=True` to ensure that `selector_for_update` is called in a database transaction.\n+@pytest.mark.django_db(transaction=True)\n+@patch(\n+ \"saleor.graphql.payment.mutations.transaction.transaction_create.get_order_and_transaction_item_locked_for_update\",\n+ wraps=get_order_and_transaction_item_locked_for_update,\n+)\n+def test_lock_order_during_updating_order_amounts(\n+ mocked_get_order_and_transaction_item_locked_for_update,\n+ transaction_item_generator,\n+ app_api_client,\n+ permission_manage_payments,\n+ order_with_lines,\n+):\n+ # given\n+ order = order_with_lines\n+ charged_value = order.total.gross.amount\n+\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Order\", order.pk),\n+ \"transaction\": {\n+ \"name\": \"Credit Card\",\n+ \"pspReference\": \"PSP reference - 123\",\n+ \"availableActions\": [],\n+ \"amountCharged\": {\n+ \"amount\": charged_value,\n+ \"currency\": \"USD\",\n+ },\n+ },\n+ }\n+\n+ # when\n+ app_api_client.post_graphql(\n+ MUTATION_TRANSACTION_CREATE, variables, permissions=[permission_manage_payments]\n+ )\n+\n+ # then\n+ order.refresh_from_db()\n+ transaction_pk = order.payment_transactions.get().pk\n+ assert order.total_charged.amount == charged_value\n+ assert order.charge_status == OrderChargeStatus.FULL\n+ assert order.authorize_status == OrderAuthorizeStatus.FULL\n+ mocked_get_order_and_transaction_item_locked_for_update.assert_called_once_with(\n+ order.pk, transaction_pk\n+ )\n+\n+\n+# Test wrapped by `transaction=True` to ensure that `selector_for_update` is called in a database transaction.\n+@pytest.mark.django_db(transaction=True)\n+@patch(\n+ \"saleor.graphql.payment.mutations.transaction.transaction_create.get_checkout_and_transaction_item_locked_for_update\",\n+ wraps=get_checkout_and_transaction_item_locked_for_update,\n+)\n+def test_lock_checkout_during_updating_checkout_amounts(\n+ mocked_get_checkout_and_transaction_item_locked_for_update,\n+ app_api_client,\n+ permission_manage_payments,\n+ checkout_with_items,\n+ plugins_manager,\n+):\n+ # given\n+ name = \"Credit Card\"\n+ psp_reference = \"PSP reference - 123\"\n+ available_actions = [\n+ TransactionActionEnum.CHARGE.name,\n+ ]\n+ metadata = {\"key\": \"test-1\", \"value\": \"123\"}\n+ private_metadata = {\"key\": \"test-2\", \"value\": \"321\"}\n+\n+ checkout = checkout_with_items\n+ lines, _ = fetch_checkout_lines(checkout)\n+ checkout_info = fetch_checkout_info(checkout, lines, plugins_manager)\n+ checkout_info, _ = fetch_checkout_data(checkout_info, plugins_manager, lines)\n+\n+ assert checkout.channel.automatically_complete_fully_paid_checkouts is False\n+\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Checkout\", checkout.pk),\n+ \"transaction\": {\n+ \"name\": name,\n+ \"pspReference\": psp_reference,\n+ \"availableActions\": available_actions,\n+ \"amountCharged\": {\n+ \"amount\": checkout_info.checkout.total.gross.amount,\n+ \"currency\": \"USD\",\n+ },\n+ \"metadata\": [metadata],\n+ \"privateMetadata\": [private_metadata],\n+ },\n+ }\n+\n+ # when\n+ app_api_client.post_graphql(\n+ MUTATION_TRANSACTION_CREATE, variables, permissions=[permission_manage_payments]\n+ )\n+\n+ # then\n+ checkout.refresh_from_db()\n+ transaction_pk = checkout.payment_transactions.get().pk\n+ assert checkout.charge_status == CheckoutChargeStatus.FULL\n+ assert checkout.authorize_status == CheckoutAuthorizeStatus.FULL\n+ mocked_get_checkout_and_transaction_item_locked_for_update.assert_called_once_with(\n+ checkout.pk, transaction_pk\n+ )\n+\n+\n+def test_transaction_create_create_checkout_completed_race_condition(\n+ app_api_client,\n+ permission_manage_payments,\n+ checkout_with_prices,\n+ plugins_manager,\n+):\n+ # given\n+ checkout = checkout_with_prices\n+ lines, _ = fetch_checkout_lines(checkout)\n+ checkout_info = fetch_checkout_info(checkout, lines, plugins_manager)\n+ name = \"Credit Card\"\n+ psp_reference = \"PSP reference - 123\"\n+ available_actions = [\n+ TransactionActionEnum.CHARGE.name,\n+ TransactionActionEnum.CHARGE.name,\n+ ]\n+ authorized_value = Decimal(checkout_info.checkout.total.gross.amount)\n+ metadata = {\"key\": \"test-1\", \"value\": \"123\"}\n+ private_metadata = {\"key\": \"test-2\", \"value\": \"321\"}\n+ external_url = f\"http://{TEST_SERVER_DOMAIN}/external-url\"\n+\n+ variables = {\n+ \"id\": graphene.Node.to_global_id(\"Checkout\", checkout.pk),\n+ \"transaction\": {\n+ \"name\": name,\n+ \"pspReference\": psp_reference,\n+ \"availableActions\": available_actions,\n+ \"amountAuthorized\": {\n+ \"amount\": authorized_value,\n+ \"currency\": \"USD\",\n+ },\n+ \"metadata\": [metadata],\n+ \"privateMetadata\": [private_metadata],\n+ \"externalUrl\": external_url,\n+ },\n+ }\n+\n+ # when\n+ def complete_checkout(*args, **kwargs):\n+ create_order_from_checkout(\n+ checkout_info, plugins_manager, user=None, app=app_api_client.app\n+ )\n+\n+ with race_condition.RunBefore(\n+ \"saleor.graphql.payment.mutations.transaction.transaction_create.recalculate_transaction_amounts\",\n+ complete_checkout,\n+ ):\n+ app_api_client.post_graphql(\n+ MUTATION_TRANSACTION_CREATE,\n+ variables,\n+ permissions=[permission_manage_payments],\n+ )\n+\n+ # then\n+ order = Order.objects.get(checkout_token=checkout.pk)\n+\n+ assert order.status == OrderStatus.UNFULFILLED\n+ assert order.charge_status == OrderChargeStatus.NONE\n+ assert order.authorize_status == OrderAuthorizeStatus.FULL\n"
1197
- },
1198
- {
1199
- "path": "saleor/graphql/payment/tests/mutations/test_transaction_event_report.py",
1200
- "status": "modified",
1201
- "diff": "Index: saleor/graphql/payment/tests/mutations/test_transaction_event_report.py\n===================================================================\n--- saleor/graphql/payment/tests/mutations/test_transaction_event_report.py\tea1dfd8 (parent)\n+++ saleor/graphql/payment/tests/mutations/test_transaction_event_report.py\t2864acf (commit)\n@@ -12,9 +12,15 @@\n from .....checkout.calculations import fetch_checkout_data\n from .....checkout.complete_checkout import create_order_from_checkout\n from .....checkout.fetch import fetch_checkout_info, fetch_checkout_lines\n from .....checkout.models import Checkout\n-from .....order import OrderEvents, OrderGrantedRefundStatus, OrderStatus\n+from .....order import (\n+ OrderAuthorizeStatus,\n+ OrderChargeStatus,\n+ OrderEvents,\n+ OrderGrantedRefundStatus,\n+ OrderStatus,\n+)\n from .....order.models import Order\n from .....payment import OPTIONAL_AMOUNT_EVENTS, PaymentMethodType, TransactionEventType\n from .....payment.lock_objects import (\n get_checkout_and_transaction_item_locked_for_update,\n@@ -24,9 +30,8 @@\n from .....payment.transaction_item_calculations import recalculate_transaction_amounts\n from .....tests import race_condition\n from ....core.enums import TransactionEventReportErrorCode\n from ....core.utils import to_global_id_or_none\n-from ....order.enums import OrderAuthorizeStatusEnum, OrderChargeStatusEnum\n from ....tests.utils import assert_no_permission, get_graphql_content\n from ...enums import TransactionActionEnum, TransactionEventTypeEnum\n \n TEST_SERVER_DOMAIN = \"testserver.com\"\n@@ -1059,9 +1064,9 @@\n get_graphql_content(response)\n order.refresh_from_db()\n \n assert order.total_charged.amount == current_charged_value + amount\n- assert order.charge_status == OrderChargeStatusEnum.PARTIAL.value\n+ assert order.charge_status == OrderChargeStatus.PARTIAL\n \n \n def test_transaction_event_updates_order_total_authorized(\n app_api_client,\n@@ -1115,9 +1120,9 @@\n get_graphql_content(response)\n order.refresh_from_db()\n \n assert order.total_authorized.amount == order.total.gross.amount + amount\n- assert order.authorize_status == OrderAuthorizeStatusEnum.FULL.value\n+ assert order.authorize_status == OrderAuthorizeStatus.FULL\n \n \n def test_transaction_event_updates_search_vector(\n app_api_client,\n@@ -2175,9 +2180,9 @@\n get_graphql_content(response)\n order.refresh_from_db()\n \n assert order.status == excpected_order_status\n- assert order.charge_status == OrderChargeStatusEnum.FULL.value\n+ assert order.charge_status == OrderChargeStatus.FULL\n mock_order_fully_paid.assert_called_once_with(order, webhooks=set())\n mock_order_updated.assert_called_once_with(order, webhooks=set())\n mock_order_paid.assert_called_once_with(order, webhooks=set())\n \n@@ -2241,9 +2246,9 @@\n get_graphql_content(response)\n order.refresh_from_db()\n \n assert order.status == OrderStatus.DRAFT\n- assert order.charge_status == OrderChargeStatusEnum.FULL.value\n+ assert order.charge_status == OrderChargeStatus.FULL\n mock_order_fully_paid.assert_called_once_with(order, webhooks=set())\n mock_order_updated.assert_called_once_with(order, webhooks=set())\n mock_order_paid.assert_called_once_with(order, webhooks=set())\n \n@@ -2297,9 +2302,9 @@\n # then\n get_graphql_content(response)\n order.refresh_from_db()\n \n- assert order.charge_status == OrderChargeStatusEnum.PARTIAL.value\n+ assert order.charge_status == OrderChargeStatus.PARTIAL\n assert not mock_order_fully_paid.called\n mock_order_updated.assert_called_once_with(order, webhooks=set())\n \n \n@@ -2352,9 +2357,9 @@\n # then\n get_graphql_content(response)\n order.refresh_from_db()\n \n- assert order.authorize_status == OrderAuthorizeStatusEnum.PARTIAL.value\n+ assert order.authorize_status == OrderAuthorizeStatus.PARTIAL\n assert not mock_order_fully_paid.called\n mock_order_updated.assert_called_once_with(order, webhooks=set())\n \n \n@@ -2407,9 +2412,9 @@\n # then\n get_graphql_content(response)\n order.refresh_from_db()\n \n- assert order.authorize_status == OrderAuthorizeStatusEnum.FULL.value\n+ assert order.authorize_status == OrderAuthorizeStatus.FULL\n assert not mock_order_fully_paid.called\n mock_order_updated.assert_called_once_with(order, webhooks=set())\n \n \n@@ -3391,9 +3396,10 @@\n get_graphql_content(response)\n order.refresh_from_db()\n \n assert order.total_charged.amount == amount\n- assert order.charge_status == OrderChargeStatusEnum.FULL.value\n+ assert order.charge_status == OrderChargeStatus.FULL\n+ assert order.authorize_status == OrderAuthorizeStatus.FULL\n mocked_get_order_and_transaction_item_locked_for_update.assert_called_once_with(\n order.pk, transaction.pk\n )\n \n@@ -3529,9 +3535,9 @@\n get_graphql_content(response)\n order = Order.objects.get(checkout_token=checkout.pk)\n \n assert order.status == OrderStatus.UNFULFILLED\n- assert order.charge_status == OrderChargeStatusEnum.FULL.value\n+ assert order.charge_status == OrderChargeStatus.FULL\n assert order.total_charged.amount == checkout.total.gross.amount\n \n \n TRANSACTION_EVENT_REPORT_WITH_CARD_PAYMENT_METHOD_DETAILS_QUERY = (\n"
1202
- },
1203
- {
1204
- "path": "saleor/order/utils.py",
1205
- "status": "modified",
1206
- "diff": "Index: saleor/order/utils.py\n===================================================================\n--- saleor/order/utils.py\tea1dfd8 (parent)\n+++ saleor/order/utils.py\t2864acf (commit)\n@@ -182,38 +182,57 @@\n quantity_awaiting_approval,\n )\n \n \n+def refresh_order_status(order: Order):\n+ \"\"\"Refresh order status based on the most recent data.\n+\n+ This function recalculates the order status using the most up-to-date information\n+ about fulfillments, returns, and replacements. It should always be called within\n+ a transaction and with the order locked to ensure data consistency and prevent race conditions.\n+\n+ Returns\n+ bool: True if the order status was changed, False otherwise.\n+\n+ \"\"\"\n+ old_status = order.status\n+ # Calculate the quantities for the most recent data\n+ (\n+ total_quantity,\n+ quantity_fulfilled,\n+ quantity_returned,\n+ quantity_awaiting_approval,\n+ ) = _calculate_quantity_including_returns(order)\n+\n+ all_products_replaced = total_quantity == 0\n+ if all_products_replaced:\n+ return False\n+\n+ order.status = determine_order_status(\n+ total_quantity,\n+ quantity_fulfilled,\n+ quantity_returned,\n+ quantity_awaiting_approval,\n+ )\n+ return old_status != order.status\n+\n+\n def update_order_status(order: Order):\n \"\"\"Update order status depending on fulfillments.\"\"\"\n with transaction.atomic():\n # Add a transaction block to ensure that the order status won't be overridden by\n # another process.\n locked_order = Order.objects.select_for_update().get(pk=order.pk)\n- # Calculate the quantities for the most recent data\n- (\n- total_quantity,\n- quantity_fulfilled,\n- quantity_returned,\n- quantity_awaiting_approval,\n- ) = _calculate_quantity_including_returns(locked_order)\n \n- all_products_replaced = total_quantity == 0\n- if all_products_replaced:\n- return\n+ status_updated = refresh_order_status(locked_order)\n \n- status = determine_order_status(\n- total_quantity,\n- quantity_fulfilled,\n- quantity_returned,\n- quantity_awaiting_approval,\n- )\n-\n # we would like to update the status for the order provided as the argument\n # to ensure that the reference order has up to date status\n- if status != order.status:\n- order.status = status\n- order.save(update_fields=[\"status\", \"updated_at\"])\n+ if status_updated:\n+ # We need to update the order status in original order object to ensure that\n+ # the status is updated in the mutation response.\n+ order.status = locked_order.status\n+ locked_order.save(update_fields=[\"status\", \"updated_at\"])\n \n \n def determine_order_status(\n total_quantity: int,\n"
1207
- }
1208
- ]
1209
- },
1210
- {
1211
- "id": "extend-attribute-refs",
1212
- "sha": "223f354da4f2333c3e8302f69e6cbaf203b56ec0",
1213
- "parentSha": "01138c1e4e6fd014ee214cb048f03560a302df5e",
1214
- "spec": "Implement support for assigning Category and Collection as reference targets for attributes throughout the system (models, GraphQL, and CSV), mirroring existing support for Page, Product, and ProductVariant.\n\nScope and requirements:\n1) Attribute entity type enum\n- Extend the AttributeEntityType enumeration to include CATEGORY and COLLECTION.\n - File: saleor/attribute/__init__.py\n - Add constants CATEGORY = \"Category\" and COLLECTION = \"Collection\".\n - Add both values to CHOICES.\n\n2) AttributeValue model: new reference fields\n- Add two nullable, blank=True ForeignKey fields to AttributeValue:\n - reference_category: FK to product.Category, related_name=\"references\", on_delete=models.CASCADE.\n - reference_collection: FK to product.Collection, related_name=\"references\", on_delete=models.CASCADE.\n- Ensure Category and Collection are imported from saleor.product.models.\n - File: saleor/attribute/models/base.py\n - Import Category, Collection alongside existing Product, ProductType, ProductVariant imports.\n - Define new fields near existing reference_page/product/variant fields.\n\n3) Migration to persist model changes and enum choices\n- Create a new migration that:\n - Adds AttributeValue.reference_category and AttributeValue.reference_collection as specified.\n - Alters Attribute.entity_type choices to add CATEGORY and COLLECTION.\n - Depends on attribute app’s latest migration and product app migration matching the repo state.\n - File: saleor/attribute/migrations/0049_attributevalue_references_attr_entity_type.py\n\n4) GraphQL: attribute reference resolution\n- Extend GraphQL attribute resolution utilities’ entity type mapping to include Category and Collection so that reference values are created, validated, and resolved correctly.\n - File: saleor/graphql/attribute/utils.py\n - In the ENTITY_TYPE_MAPPING (or equivalent structure), add:\n - AttributeEntityType.CATEGORY -> (product_models.Category, display field \"name\", reference field key \"reference_category\").\n - AttributeEntityType.COLLECTION -> (product_models.Collection, display field \"name\", reference field key \"reference_collection\").\n\n5) GraphQL schema enum\n- Extend the AttributeEntityType enum in the GraphQL schema to expose CATEGORY and COLLECTION.\n - File: saleor/graphql/schema.graphql\n - Add CATEGORY and COLLECTION to enum AttributeEntityTypeEnum.\n\n6) CSV export: attribute references\n- Ensure CSV utilities can export new reference types by:\n - Adding reference_category and reference_collection to the field maps for attribute values in CSV aggregations for product and variant contexts.\n - File: saleor/csv/utils/__init__.py\n - Extend ATTRIBUTE_FIELDS maps for product and variant to include:\n - \"reference_category\": appropriate path (e.g., \"attributevalues__value__reference_category\" for product; \"values__reference_category\" for variant), consistent with existing patterns.\n - \"reference_collection\": appropriate path (e.g., \"attributevalues__value__reference_collection\" for product; \"values__reference_collection\" for variant).\n - Extending the dataclass/structure used during CSV export to carry these references and adjusting the function that converts an attribute reference into a string token.\n - File: saleor/csv/utils/products_data.py\n - Add fields reference_category: str | None and reference_collection: str | None to the attribute data structure (alongside reference_page/product/variant).\n - Update the logic that computes the exportable reference value (e.g., _get_reference_value) to handle the new fields. The selection order should check page, product, variant, category, collection and yield a formatted string like \"{entity_type}_{reference_id}\" when present; otherwise return None.\n\n7) Deletion semantics\n- Ensure deleting a Category or Collection removes referencing AttributeValue rows via database-level cascade as defined by on_delete=models.CASCADE in the new ForeignKeys.\n- No code changes beyond the FK definitions are required for this behavior; confirm that related_name=\"references\" is consistent with existing reference_* fields.\n\nBehavioral outcomes:\n- Admins can define attributes of input_type REFERENCE targeting CATEGORY or COLLECTION entity types, and assign such values to pages, products, and variants consistent with existing reference behaviors.\n- GraphQL create/update mutations that accept reference attributes should accept Category/Collection global IDs when the attribute’s entity_type is CATEGORY/COLLECTION; they should resolve names and slugs analogously to other reference types.\n- GraphQL queries for attributes return reference values resolved for categories and collections similar to pages/products/variants.\n- CSV export includes the new references and produces tokens in line with existing formatting (e.g., \"Category_123\", \"Collection_456\").\n- Deleting a Category or Collection deletes associated AttributeValue rows referencing them; any assignments to products/variants/pages that pointed at these values are implicitly removed accordingly.\n\nNon-goals:\n- No modifications to association utilities are required beyond consuming new AttributeValue rows with reference_category/reference_collection set.\n- No changes to permissions or business rules beyond handling new reference types.\n\nNotes:\n- Keep parity with existing reference_* fields for naming, indexing, and nullability.\n- Ensure import ordering and circular dependencies are avoided in models by importing Category and Collection alongside existing model imports as done for Product/Variant.\n- The current tests reference slug formatting \"{instance_pk}_{target_pk}\" for new reference values; ensure utility code that creates/returns slugs follows established patterns used for other reference types.",
1215
- "prompt": "Add support for using categories and collections as attribute reference targets across the platform. Extend the attribute entity type enum to include category and collection; add nullable foreign keys on attribute values for these targets; update GraphQL to resolve, validate, and expose these references; and update CSV export to include them. Follow the existing patterns used for page/product/variant references so that reference values are created and exported consistently, and ensure that removing a category or collection deletes any attribute values that reference it.",
1216
- "supplementalFiles": [
1217
- "saleor/attribute/utils.py",
1218
- "saleor/product/models.py"
1219
- ],
1220
- "fileDiffs": [
1221
- {
1222
- "path": "CHANGELOG.md",
1223
- "status": "modified",
1224
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\t01138c1 (parent)\n+++ CHANGELOG.md\t223f354 (commit)\n@@ -77,8 +77,9 @@\n - `productType.availableAttributes`\n - `category.products`\n - `collection.products`\n - `pageType.availableAttributes`\n+- Extend `AttributeEntityType` with `CATEGORY` and `COLLECTION`. You can now assign category and collection as a attribute reference.\n \n \n ### Webhooks\n - Transaction webhooks responsible for processing payments can now return payment method details`, which will be associated with the corresponding transaction. See [docs](https://docs.saleor.io/developer/extending/webhooks/synchronous-events/transaction#response-4) to learn more.\n"
1225
- },
1226
- {
1227
- "path": "saleor/attribute/__init__.py",
1228
- "status": "modified",
1229
- "diff": "Index: saleor/attribute/__init__.py\n===================================================================\n--- saleor/attribute/__init__.py\t01138c1 (parent)\n+++ saleor/attribute/__init__.py\t223f354 (commit)\n@@ -108,10 +108,14 @@\n \n PAGE = \"Page\"\n PRODUCT = \"Product\"\n PRODUCT_VARIANT = \"ProductVariant\"\n+ CATEGORY = \"Category\"\n+ COLLECTION = \"Collection\"\n \n CHOICES = [\n (PAGE, \"Page\"),\n (PRODUCT, \"Product\"),\n (PRODUCT_VARIANT, \"Product Variant\"),\n+ (CATEGORY, \"Category\"),\n+ (COLLECTION, \"Collection\"),\n ]\n"
1230
- },
1231
- {
1232
- "path": "saleor/attribute/migrations/0049_attributevalue_references_attr_entity_type.py",
1233
- "status": "added",
1234
- "diff": "Index: saleor/attribute/migrations/0049_attributevalue_references_attr_entity_type.py\n===================================================================\n--- saleor/attribute/migrations/0049_attributevalue_references_attr_entity_type.py\t01138c1 (parent)\n+++ saleor/attribute/migrations/0049_attributevalue_references_attr_entity_type.py\t223f354 (commit)\n@@ -1,1 +1,52 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-06-30 09:25\n+\n+import django.db.models.deletion\n+from django.db import migrations, models\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"attribute\", \"0048_alter_attribute_metadata_and_more\"),\n+ (\"product\", \"0200_merge_20250527_1210\"),\n+ ]\n+\n+ operations = [\n+ migrations.AddField(\n+ model_name=\"attributevalue\",\n+ name=\"reference_category\",\n+ field=models.ForeignKey(\n+ blank=True,\n+ null=True,\n+ on_delete=django.db.models.deletion.CASCADE,\n+ related_name=\"references\",\n+ to=\"product.category\",\n+ ),\n+ ),\n+ migrations.AddField(\n+ model_name=\"attributevalue\",\n+ name=\"reference_collection\",\n+ field=models.ForeignKey(\n+ blank=True,\n+ null=True,\n+ on_delete=django.db.models.deletion.CASCADE,\n+ related_name=\"references\",\n+ to=\"product.collection\",\n+ ),\n+ ),\n+ migrations.AlterField(\n+ model_name=\"attribute\",\n+ name=\"entity_type\",\n+ field=models.CharField(\n+ blank=True,\n+ choices=[\n+ (\"Page\", \"Page\"),\n+ (\"Product\", \"Product\"),\n+ (\"ProductVariant\", \"Product Variant\"),\n+ (\"Category\", \"Category\"),\n+ (\"Collection\", \"Collection\"),\n+ ],\n+ max_length=50,\n+ null=True,\n+ ),\n+ ),\n+ ]\n"
1235
- },
1236
- {
1237
- "path": "saleor/attribute/models/base.py",
1238
- "status": "modified",
1239
- "diff": "Index: saleor/attribute/models/base.py\n===================================================================\n--- saleor/attribute/models/base.py\t01138c1 (parent)\n+++ saleor/attribute/models/base.py\t223f354 (commit)\n@@ -11,9 +11,9 @@\n from ...core.utils.translations import Translation\n from ...page.models import Page, PageType\n from ...permission.enums import PageTypePermissions, ProductTypePermissions\n from ...permission.utils import has_one_of_permissions\n-from ...product.models import Product, ProductType, ProductVariant\n+from ...product.models import Category, Collection, Product, ProductType, ProductVariant\n from .. import AttributeEntityType, AttributeInputType, AttributeType\n \n if TYPE_CHECKING:\n from ...account.models import User\n@@ -371,11 +371,28 @@\n null=True,\n blank=True,\n )\n \n+ reference_collection = models.ForeignKey(\n+ Collection,\n+ related_name=\"references\",\n+ on_delete=models.CASCADE,\n+ null=True,\n+ blank=True,\n+ )\n+\n+ reference_category = models.ForeignKey(\n+ Category,\n+ related_name=\"references\",\n+ on_delete=models.CASCADE,\n+ null=True,\n+ blank=True,\n+ )\n+\n reference_page = models.ForeignKey(\n Page, related_name=\"references\", on_delete=models.CASCADE, null=True, blank=True\n )\n+\n sort_order = models.IntegerField(editable=False, db_index=True, null=True)\n \n objects = AttributeValueManager()\n \n"
1240
- },
1241
- {
1242
- "path": "saleor/attribute/tests/fixtures/attribute.py",
1243
- "status": "modified",
1244
- "diff": "Index: saleor/attribute/tests/fixtures/attribute.py\n===================================================================\n--- saleor/attribute/tests/fixtures/attribute.py\t01138c1 (parent)\n+++ saleor/attribute/tests/fixtures/attribute.py\t223f354 (commit)\n@@ -636,8 +636,52 @@\n )\n \n \n @pytest.fixture\n+def product_type_category_reference_attribute(db):\n+ return Attribute.objects.create(\n+ slug=\"category-reference\",\n+ name=\"Category reference\",\n+ type=AttributeType.PRODUCT_TYPE,\n+ input_type=AttributeInputType.REFERENCE,\n+ entity_type=AttributeEntityType.CATEGORY,\n+ )\n+\n+\n+@pytest.fixture\n+def page_type_category_reference_attribute(db):\n+ return Attribute.objects.create(\n+ slug=\"category-reference\",\n+ name=\"Category reference\",\n+ type=AttributeType.PAGE_TYPE,\n+ input_type=AttributeInputType.REFERENCE,\n+ entity_type=AttributeEntityType.CATEGORY,\n+ )\n+\n+\n+@pytest.fixture\n+def product_type_collection_reference_attribute(db):\n+ return Attribute.objects.create(\n+ slug=\"collection-reference\",\n+ name=\"Collection reference\",\n+ type=AttributeType.PRODUCT_TYPE,\n+ input_type=AttributeInputType.REFERENCE,\n+ entity_type=AttributeEntityType.COLLECTION,\n+ )\n+\n+\n+@pytest.fixture\n+def page_type_collection_reference_attribute(db):\n+ return Attribute.objects.create(\n+ slug=\"collection-reference\",\n+ name=\"Collection reference\",\n+ type=AttributeType.PAGE_TYPE,\n+ input_type=AttributeInputType.REFERENCE,\n+ entity_type=AttributeEntityType.COLLECTION,\n+ )\n+\n+\n+@pytest.fixture\n def size_page_attribute(db):\n attribute = Attribute.objects.create(\n slug=\"page-size\",\n name=\"Page size\",\n"
1245
- },
1246
- {
1247
- "path": "saleor/csv/tests/export/products_data/test_get_products_data.py",
1248
- "status": "modified",
1249
- "diff": "Index: saleor/csv/tests/export/products_data/test_get_products_data.py\n===================================================================\n--- saleor/csv/tests/export/products_data/test_get_products_data.py\t01138c1 (parent)\n+++ saleor/csv/tests/export/products_data/test_get_products_data.py\t223f354 (commit)\n@@ -250,8 +250,10 @@\n variant,\n product_type_page_reference_attribute,\n product_type_product_reference_attribute,\n product_type_variant_reference_attribute,\n+ product_type_category_reference_attribute,\n+ product_type_collection_reference_attribute,\n numeric_attribute,\n product_with_image,\n product_with_variant_with_two_attributes,\n rich_text_attribute,\n@@ -260,16 +262,20 @@\n date_attribute,\n date_time_attribute,\n variant_with_many_stocks,\n swatch_attribute,\n+ category,\n+ collection,\n ):\n # given\n product.variants.add(variant_with_many_stocks)\n product.product_type.variant_attributes.add(\n file_attribute,\n product_type_page_reference_attribute,\n product_type_product_reference_attribute,\n product_type_variant_reference_attribute,\n+ product_type_category_reference_attribute,\n+ product_type_collection_reference_attribute,\n numeric_attribute,\n rich_text_attribute,\n swatch_attribute,\n boolean_attribute,\n@@ -280,8 +286,10 @@\n file_attribute,\n product_type_page_reference_attribute,\n product_type_product_reference_attribute,\n product_type_variant_reference_attribute,\n+ product_type_category_reference_attribute,\n+ product_type_collection_reference_attribute,\n numeric_attribute,\n rich_text_attribute,\n swatch_attribute,\n boolean_attribute,\n@@ -395,8 +403,60 @@\n product,\n {product_type_variant_reference_attribute.pk: [product_variant_ref_value]},\n )\n \n+ # add category reference attribute\n+ product_category_ref_value = AttributeValue.objects.create(\n+ attribute=product_type_category_reference_attribute,\n+ reference_category=category,\n+ slug=f\"product_{product.pk}_category_{category.pk}\",\n+ name=category.name,\n+ )\n+ variant_category_ref_value = AttributeValue.objects.create(\n+ attribute=product_type_category_reference_attribute,\n+ reference_category=category,\n+ slug=f\"variant_{variant_with_many_stocks.pk}_category_{category.pk}\",\n+ name=category.name,\n+ )\n+ associate_attribute_values_to_instance(\n+ variant_with_many_stocks,\n+ {product_type_category_reference_attribute.pk: [variant_category_ref_value]},\n+ )\n+ associate_attribute_values_to_instance(\n+ product,\n+ {product_type_category_reference_attribute.pk: [product_category_ref_value]},\n+ )\n+\n+ # add collection reference attribute\n+ product_collection_ref_value = AttributeValue.objects.create(\n+ attribute=product_type_collection_reference_attribute,\n+ reference_collection=collection,\n+ slug=f\"product_{product.pk}_collection_{collection.pk}\",\n+ name=collection.name,\n+ )\n+ variant_collection_ref_value = AttributeValue.objects.create(\n+ attribute=product_type_collection_reference_attribute,\n+ reference_collection=collection,\n+ slug=f\"variant_{variant_with_many_stocks.pk}_collection_{collection.pk}\",\n+ name=collection.name,\n+ )\n+ associate_attribute_values_to_instance(\n+ variant_with_many_stocks,\n+ {\n+ product_type_collection_reference_attribute.pk: [\n+ variant_collection_ref_value\n+ ]\n+ },\n+ )\n+ associate_attribute_values_to_instance(\n+ product,\n+ {\n+ product_type_collection_reference_attribute.pk: [\n+ product_collection_ref_value\n+ ]\n+ },\n+ )\n+\n # add numeric attribute\n numeric_value_1 = numeric_attribute.values.first()\n numeric_value_2 = numeric_attribute.values.last()\n \n"
1250
- },
1251
- {
1252
- "path": "saleor/csv/tests/export/products_data/test_handle_relations_data.py",
1253
- "status": "modified",
1254
- "diff": "Index: saleor/csv/tests/export/products_data/test_handle_relations_data.py\n===================================================================\n--- saleor/csv/tests/export/products_data/test_handle_relations_data.py\t01138c1 (parent)\n+++ saleor/csv/tests/export/products_data/test_handle_relations_data.py\t223f354 (commit)\n@@ -83,16 +83,22 @@\n product_list,\n file_attribute,\n product_type_page_reference_attribute,\n product_type_product_reference_attribute,\n+ product_type_collection_reference_attribute,\n+ product_type_category_reference_attribute,\n page,\n+ collection,\n+ category,\n ):\n # given\n product = product_list[0]\n product.product_type.product_attributes.add(\n file_attribute,\n product_type_page_reference_attribute,\n product_type_product_reference_attribute,\n+ product_type_collection_reference_attribute,\n+ product_type_category_reference_attribute,\n )\n associate_attribute_values_to_instance(\n product,\n {file_attribute.id: [file_attribute.values.first()]},\n@@ -114,8 +120,24 @@\n associate_attribute_values_to_instance(\n product,\n {product_type_product_reference_attribute.id: [product_ref_value]},\n )\n+ collection_ref_value = AttributeValue.objects.create(\n+ attribute=product_type_collection_reference_attribute,\n+ reference_collection=collection,\n+ )\n+ associate_attribute_values_to_instance(\n+ product,\n+ {product_type_collection_reference_attribute.id: [collection_ref_value]},\n+ )\n+ category_ref_value = AttributeValue.objects.create(\n+ attribute=product_type_category_reference_attribute,\n+ reference_category=category,\n+ )\n+ associate_attribute_values_to_instance(\n+ product,\n+ {product_type_category_reference_attribute.id: [category_ref_value]},\n+ )\n \n qs = Product.objects.all()\n export_fields = {\"name\", \"description\"}\n attribute_ids = list(Attribute.objects.values_list(\"pk\", flat=True))\n"
1255
- },
1256
- {
1257
- "path": "saleor/csv/tests/export/products_data/test_prepare_headers.py",
1258
- "status": "modified",
1259
- "diff": "Index: saleor/csv/tests/export/products_data/test_prepare_headers.py\n===================================================================\n--- saleor/csv/tests/export/products_data/test_prepare_headers.py\t01138c1 (parent)\n+++ saleor/csv/tests/export/products_data/test_prepare_headers.py\t223f354 (commit)\n@@ -43,17 +43,24 @@\n assert file_headers == [\"id\"]\n \n \n def test_get_attributes_headers(\n- product_with_multiple_values_attributes, product_type_without_variant\n+ product_with_multiple_values_attributes,\n+ product_type_without_variant,\n+ product_type_category_reference_attribute,\n+ product_type_collection_reference_attribute,\n ):\n # given\n attribute_ids = Attribute.objects.values_list(\"id\", flat=True)\n export_info = {\"attributes\": attribute_ids}\n \n product_type = product_with_multiple_values_attributes.product_type\n product_attribute = product_type.product_attributes.first()\n- product_type_without_variant.product_attributes.add(product_attribute)\n+ product_type_without_variant.product_attributes.add(\n+ product_attribute,\n+ product_type_category_reference_attribute,\n+ product_type_collection_reference_attribute,\n+ )\n \n # when\n attributes_headers = get_attributes_headers(export_info)\n \n"
1260
- },
1261
- {
1262
- "path": "saleor/csv/utils/__init__.py",
1263
- "status": "modified",
1264
- "diff": "Index: saleor/csv/utils/__init__.py\n===================================================================\n--- saleor/csv/utils/__init__.py\t01138c1 (parent)\n+++ saleor/csv/utils/__init__.py\t223f354 (commit)\n@@ -41,8 +41,10 @@\n \"attribute_pk\": \"attributevalues__value__attribute__pk\",\n \"reference_page\": \"attributevalues__value__reference_page\",\n \"reference_product\": \"attributevalues__value__reference_product\",\n \"reference_variant\": \"attributevalues__value__reference_variant\",\n+ \"reference_category\": \"attributevalues__value__reference_category\",\n+ \"reference_collection\": \"attributevalues__value__reference_collection\",\n }\n \n PRODUCT_CHANNEL_LISTING_FIELDS = {\n \"channel_pk\": \"channel_id\",\n@@ -76,8 +78,10 @@\n \"attribute_pk\": \"assignment__attribute__pk\",\n \"reference_page\": \"values__reference_page\",\n \"reference_product\": \"values__reference_product\",\n \"reference_variant\": \"values__reference_variant\",\n+ \"reference_category\": \"values__reference_category\",\n+ \"reference_collection\": \"values__reference_collection\",\n }\n \n VARIANT_CHANNEL_LISTING_FIELDS = {\n \"channel_pk\": \"channel__pk\",\n"
1265
- },
1266
- {
1267
- "path": "saleor/csv/utils/products_data.py",
1268
- "status": "modified",
1269
- "diff": "Index: saleor/csv/utils/products_data.py\n===================================================================\n--- saleor/csv/utils/products_data.py\t01138c1 (parent)\n+++ saleor/csv/utils/products_data.py\t223f354 (commit)\n@@ -368,8 +368,10 @@\n date_time: str | None = None\n reference_page: str | None = None\n reference_product: str | None = None\n reference_variant: str | None = None\n+ reference_category: str | None = None\n+ reference_collection: str | None = None\n \n \n def handle_attribute_data(\n pk: int,\n@@ -513,17 +515,25 @@\n [\n attribute_data.reference_page,\n attribute_data.reference_product,\n attribute_data.reference_variant,\n+ attribute_data.reference_category,\n+ attribute_data.reference_collection,\n ]\n ):\n return None\n+\n if attribute_data.reference_page:\n reference_id = attribute_data.reference_page\n elif attribute_data.reference_product:\n reference_id = attribute_data.reference_product\n- else:\n+ elif attribute_data.reference_variant:\n reference_id = attribute_data.reference_variant\n+ elif attribute_data.reference_category:\n+ reference_id = attribute_data.reference_category\n+ else:\n+ reference_id = attribute_data.reference_collection\n+\n return f\"{attribute_data.entity_type}_{reference_id}\"\n \n \n def add_warehouse_info_to_data(\n"
1270
- },
1271
- {
1272
- "path": "saleor/graphql/attribute/utils.py",
1273
- "status": "modified",
1274
- "diff": "Index: saleor/graphql/attribute/utils.py\n===================================================================\n--- saleor/graphql/attribute/utils.py\t01138c1 (parent)\n+++ saleor/graphql/attribute/utils.py\t223f354 (commit)\n@@ -106,8 +106,14 @@\n ),\n AttributeEntityType.PRODUCT_VARIANT: EntityTypeData(\n product_models.ProductVariant, \"name\", \"reference_variant\"\n ),\n+ AttributeEntityType.CATEGORY: EntityTypeData(\n+ product_models.Category, \"name\", \"reference_category\"\n+ ),\n+ AttributeEntityType.COLLECTION: EntityTypeData(\n+ product_models.Collection, \"name\", \"reference_collection\"\n+ ),\n }\n \n @classmethod\n def _resolve_attribute_nodes(\n"
1275
- },
1276
- {
1277
- "path": "saleor/graphql/page/tests/deprecated/test_page_update.py",
1278
- "status": "modified",
1279
- "diff": "Index: saleor/graphql/page/tests/deprecated/test_page_update.py\n===================================================================\n--- saleor/graphql/page/tests/deprecated/test_page_update.py\t01138c1 (parent)\n+++ saleor/graphql/page/tests/deprecated/test_page_update.py\t223f354 (commit)\n@@ -91,9 +91,8 @@\n days=5\n )\n page_id = graphene.Node.to_global_id(\"Page\", page.id)\n \n- # test creating root page\n variables = {\n \"id\": page_id,\n \"input\": {\n \"publishedAt\": published_at,\n"
1280
- },
1281
- {
1282
- "path": "saleor/graphql/page/tests/mutations/test_page_create.py",
1283
- "status": "modified",
1284
- "diff": "Index: saleor/graphql/page/tests/mutations/test_page_create.py\n===================================================================\n--- saleor/graphql/page/tests/mutations/test_page_create.py\t01138c1 (parent)\n+++ saleor/graphql/page/tests/mutations/test_page_create.py\t223f354 (commit)\n@@ -1233,4 +1233,154 @@\n assert data[\"page\"][\"attributes\"][0] == expected_attr_data\n \n page_type_variant_reference_attribute.refresh_from_db()\n assert page_type_variant_reference_attribute.values.count() == values_count + 1\n+\n+\n+def test_create_page_with_category_reference_attribute(\n+ staff_api_client,\n+ permission_manage_pages,\n+ page_type,\n+ page_type_category_reference_attribute,\n+ category,\n+):\n+ # given\n+ page_slug = \"test-slug\"\n+ page_content = dummy_editorjs(\"test content\", True)\n+ page_title = \"test title\"\n+ page_is_published = True\n+ page_type = PageType.objects.create(\n+ name=\"Test page type 2\", slug=\"test-page-type-2\"\n+ )\n+ page_type_id = graphene.Node.to_global_id(\"PageType\", page_type.pk)\n+\n+ ref_attribute_id = graphene.Node.to_global_id(\n+ \"Attribute\", page_type_category_reference_attribute.pk\n+ )\n+ page_type.page_attributes.add(page_type_category_reference_attribute)\n+ reference = graphene.Node.to_global_id(\"Category\", category.pk)\n+\n+ values_count = page_type_category_reference_attribute.values.count()\n+\n+ variables = {\n+ \"input\": {\n+ \"title\": page_title,\n+ \"content\": page_content,\n+ \"isPublished\": page_is_published,\n+ \"slug\": page_slug,\n+ \"pageType\": page_type_id,\n+ \"attributes\": [{\"id\": ref_attribute_id, \"references\": [reference]}],\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ CREATE_PAGE_MUTATION, variables, permissions=[permission_manage_pages]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"pageCreate\"]\n+ errors = data[\"errors\"]\n+\n+ assert not errors\n+ assert data[\"page\"][\"title\"] == page_title\n+ assert data[\"page\"][\"content\"] == page_content\n+ assert data[\"page\"][\"slug\"] == page_slug\n+ assert data[\"page\"][\"isPublished\"] == page_is_published\n+ assert data[\"page\"][\"pageType\"][\"id\"] == page_type_id\n+ assert len(data[\"page\"][\"attributes\"]) == 1\n+ page_id = data[\"page\"][\"id\"]\n+ _, new_page_pk = graphene.Node.from_global_id(page_id)\n+ expected_attr_data = {\n+ \"attribute\": {\"slug\": page_type_category_reference_attribute.slug},\n+ \"values\": [\n+ {\n+ \"slug\": f\"{new_page_pk}_{category.pk}\",\n+ \"file\": None,\n+ \"name\": category.name,\n+ \"reference\": reference,\n+ \"plainText\": None,\n+ \"dateTime\": None,\n+ \"date\": None,\n+ }\n+ ],\n+ }\n+ assert data[\"page\"][\"attributes\"][0] == expected_attr_data\n+\n+ page_type_category_reference_attribute.refresh_from_db()\n+ assert page_type_category_reference_attribute.values.count() == values_count + 1\n+\n+\n+def test_create_page_with_collection_reference_attribute(\n+ staff_api_client,\n+ permission_manage_pages,\n+ page_type,\n+ page_type_collection_reference_attribute,\n+ collection,\n+):\n+ # given\n+ page_slug = \"test-slug\"\n+ page_content = dummy_editorjs(\"test content\", True)\n+ page_title = \"test title\"\n+ page_is_published = True\n+ page_type = PageType.objects.create(\n+ name=\"Test page type 2\", slug=\"test-page-type-2\"\n+ )\n+ page_type_id = graphene.Node.to_global_id(\"PageType\", page_type.pk)\n+\n+ ref_attribute_id = graphene.Node.to_global_id(\n+ \"Attribute\", page_type_collection_reference_attribute.pk\n+ )\n+ page_type.page_attributes.add(page_type_collection_reference_attribute)\n+ reference = graphene.Node.to_global_id(\"Collection\", collection.pk)\n+\n+ values_count = page_type_collection_reference_attribute.values.count()\n+\n+ variables = {\n+ \"input\": {\n+ \"title\": page_title,\n+ \"content\": page_content,\n+ \"isPublished\": page_is_published,\n+ \"slug\": page_slug,\n+ \"pageType\": page_type_id,\n+ \"attributes\": [{\"id\": ref_attribute_id, \"references\": [reference]}],\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ CREATE_PAGE_MUTATION, variables, permissions=[permission_manage_pages]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"pageCreate\"]\n+ errors = data[\"errors\"]\n+\n+ assert not errors\n+ assert data[\"page\"][\"title\"] == page_title\n+ assert data[\"page\"][\"content\"] == page_content\n+ assert data[\"page\"][\"slug\"] == page_slug\n+ assert data[\"page\"][\"isPublished\"] == page_is_published\n+ assert data[\"page\"][\"pageType\"][\"id\"] == page_type_id\n+ assert len(data[\"page\"][\"attributes\"]) == 1\n+ page_id = data[\"page\"][\"id\"]\n+ _, new_page_pk = graphene.Node.from_global_id(page_id)\n+ expected_attr_data = {\n+ \"attribute\": {\"slug\": page_type_collection_reference_attribute.slug},\n+ \"values\": [\n+ {\n+ \"slug\": f\"{new_page_pk}_{collection.pk}\",\n+ \"file\": None,\n+ \"name\": collection.name,\n+ \"reference\": reference,\n+ \"plainText\": None,\n+ \"dateTime\": None,\n+ \"date\": None,\n+ }\n+ ],\n+ }\n+ assert data[\"page\"][\"attributes\"][0] == expected_attr_data\n+\n+ page_type_collection_reference_attribute.refresh_from_db()\n+ assert page_type_collection_reference_attribute.values.count() == values_count + 1\n"
1285
- },
1286
- {
1287
- "path": "saleor/graphql/page/tests/mutations/test_page_update.py",
1288
- "status": "modified",
1289
- "diff": "Index: saleor/graphql/page/tests/mutations/test_page_update.py\n===================================================================\n--- saleor/graphql/page/tests/mutations/test_page_update.py\t01138c1 (parent)\n+++ saleor/graphql/page/tests/mutations/test_page_update.py\t223f354 (commit)\n@@ -928,8 +928,114 @@\n page_type_variant_reference_attribute.refresh_from_db()\n assert page_type_variant_reference_attribute.values.count() == values_count\n \n \n+def test_update_page_with_category_reference_attribute_new_value(\n+ staff_api_client,\n+ permission_manage_pages,\n+ page,\n+ page_type_category_reference_attribute,\n+ category,\n+):\n+ # given\n+ query = UPDATE_PAGE_MUTATION\n+\n+ page_type = page.page_type\n+ page_type.page_attributes.add(page_type_category_reference_attribute)\n+ values_count = page_type_category_reference_attribute.values.count()\n+ ref_attribute_id = graphene.Node.to_global_id(\n+ \"Attribute\", page_type_category_reference_attribute.pk\n+ )\n+ reference = graphene.Node.to_global_id(\"Category\", category.pk)\n+ page_id = graphene.Node.to_global_id(\"Page\", page.id)\n+\n+ variables = {\n+ \"id\": page_id,\n+ \"input\": {\"attributes\": [{\"id\": ref_attribute_id, \"references\": [reference]}]},\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_pages]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"pageUpdate\"]\n+\n+ assert not data[\"errors\"]\n+ assert data[\"page\"]\n+ updated_attribute = {\n+ \"attribute\": {\"slug\": page_type_category_reference_attribute.slug},\n+ \"values\": [\n+ {\n+ \"slug\": f\"{page.pk}_{category.pk}\",\n+ \"name\": category.name,\n+ \"file\": None,\n+ \"plainText\": None,\n+ \"reference\": reference,\n+ }\n+ ],\n+ }\n+ assert updated_attribute in data[\"page\"][\"attributes\"]\n+\n+ page_type_category_reference_attribute.refresh_from_db()\n+ assert page_type_category_reference_attribute.values.count() == values_count + 1\n+\n+\n+def test_update_page_with_collection_reference_attribute_new_value(\n+ staff_api_client,\n+ permission_manage_pages,\n+ page,\n+ page_type_collection_reference_attribute,\n+ collection,\n+):\n+ # given\n+ query = UPDATE_PAGE_MUTATION\n+\n+ page_type = page.page_type\n+ page_type.page_attributes.add(page_type_collection_reference_attribute)\n+ values_count = page_type_collection_reference_attribute.values.count()\n+ ref_attribute_id = graphene.Node.to_global_id(\n+ \"Attribute\", page_type_collection_reference_attribute.pk\n+ )\n+ reference = graphene.Node.to_global_id(\"Collection\", collection.pk)\n+ page_id = graphene.Node.to_global_id(\"Page\", page.id)\n+\n+ variables = {\n+ \"id\": page_id,\n+ \"input\": {\"attributes\": [{\"id\": ref_attribute_id, \"references\": [reference]}]},\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_pages]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"pageUpdate\"]\n+\n+ assert not data[\"errors\"]\n+ assert data[\"page\"]\n+ updated_attribute = {\n+ \"attribute\": {\"slug\": page_type_collection_reference_attribute.slug},\n+ \"values\": [\n+ {\n+ \"slug\": f\"{page.pk}_{collection.pk}\",\n+ \"name\": collection.name,\n+ \"file\": None,\n+ \"plainText\": None,\n+ \"reference\": reference,\n+ }\n+ ],\n+ }\n+ assert updated_attribute in data[\"page\"][\"attributes\"]\n+\n+ page_type_collection_reference_attribute.refresh_from_db()\n+ assert page_type_collection_reference_attribute.values.count() == values_count + 1\n+\n+\n @freeze_time(\"2020-03-18 12:00:00\")\n def test_public_page_sets_publication_date(\n staff_api_client, permission_manage_pages, page_type\n ):\n"
1290
- },
1291
- {
1292
- "path": "saleor/graphql/product/tests/mutations/test_category_delete.py",
1293
- "status": "modified",
1294
- "diff": "Index: saleor/graphql/product/tests/mutations/test_category_delete.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_category_delete.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_category_delete.py\t223f354 (commit)\n@@ -6,8 +6,10 @@\n from django.core.files import File\n from django.utils.functional import SimpleLazyObject\n from freezegun import freeze_time\n \n+from .....attribute.models import AttributeValue\n+from .....attribute.utils import associate_attribute_values_to_instance\n from .....core.utils.json_serializer import CustomJsonEncoder\n from .....discount.utils.promotion import get_active_catalogue_promotion_rules\n from .....product.models import Category, ProductChannelListing\n from .....thumbnail.models import Thumbnail\n@@ -207,4 +209,131 @@\n )\n for product_channel_listing in product_channel_listings:\n assert product_channel_listing.is_published is False\n assert not product_channel_listing.published_at\n+\n+\n+def test_category_delete_removes_reference_to_product(\n+ staff_api_client,\n+ category,\n+ product_type_product_reference_attribute,\n+ product_type,\n+ product,\n+ permission_manage_products,\n+):\n+ # given\n+ query = MUTATION_CATEGORY_DELETE\n+\n+ product_type.product_attributes.add(product_type_product_reference_attribute)\n+ attr_value = AttributeValue.objects.create(\n+ attribute=product_type_product_reference_attribute,\n+ name=category.name,\n+ slug=f\"{product.pk}_{category.pk}\",\n+ reference_category=category,\n+ )\n+ associate_attribute_values_to_instance(\n+ product, {product_type_product_reference_attribute.pk: [attr_value]}\n+ )\n+ reference_id = graphene.Node.to_global_id(\"Category\", category.pk)\n+\n+ variables = {\"id\": reference_id}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"categoryDelete\"]\n+\n+ with pytest.raises(attr_value._meta.model.DoesNotExist):\n+ attr_value.refresh_from_db()\n+ with pytest.raises(category._meta.model.DoesNotExist):\n+ category.refresh_from_db()\n+\n+ assert not data[\"errors\"]\n+\n+\n+def test_category_delete_removes_reference_to_product_variant(\n+ staff_api_client,\n+ category,\n+ product_type_product_reference_attribute,\n+ product_type,\n+ product_list,\n+ permission_manage_products,\n+):\n+ # given\n+ query = MUTATION_CATEGORY_DELETE\n+\n+ variant = product_list[0].variants.first()\n+ product_type.variant_attributes.set([product_type_product_reference_attribute])\n+ attr_value = AttributeValue.objects.create(\n+ attribute=product_type_product_reference_attribute,\n+ name=category.name,\n+ slug=f\"{variant.pk}_{category.pk}\",\n+ reference_category=category,\n+ )\n+ associate_attribute_values_to_instance(\n+ variant, {product_type_product_reference_attribute.pk: [attr_value]}\n+ )\n+ reference_id = graphene.Node.to_global_id(\"Category\", category.pk)\n+\n+ variables = {\"id\": reference_id}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"categoryDelete\"]\n+\n+ with pytest.raises(attr_value._meta.model.DoesNotExist):\n+ attr_value.refresh_from_db()\n+ with pytest.raises(category._meta.model.DoesNotExist):\n+ category.refresh_from_db()\n+\n+ assert not data[\"errors\"]\n+\n+\n+def test_category_delete_removes_reference_to_page(\n+ staff_api_client,\n+ category,\n+ page,\n+ page_type_product_reference_attribute,\n+ permission_manage_products,\n+):\n+ # given\n+ query = MUTATION_CATEGORY_DELETE\n+\n+ page_type = page.page_type\n+ page_type.page_attributes.add(page_type_product_reference_attribute)\n+ attr_value = AttributeValue.objects.create(\n+ attribute=page_type_product_reference_attribute,\n+ name=page.title,\n+ slug=f\"{page.pk}_{category.pk}\",\n+ reference_category=category,\n+ )\n+ associate_attribute_values_to_instance(\n+ page, {page_type_product_reference_attribute.pk: [attr_value]}\n+ )\n+ reference_id = graphene.Node.to_global_id(\"Category\", category.pk)\n+\n+ variables = {\"id\": reference_id}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"categoryDelete\"]\n+\n+ with pytest.raises(attr_value._meta.model.DoesNotExist):\n+ attr_value.refresh_from_db()\n+ with pytest.raises(category._meta.model.DoesNotExist):\n+ category.refresh_from_db()\n+\n+ assert not data[\"errors\"]\n"
1295
- },
1296
- {
1297
- "path": "saleor/graphql/product/tests/mutations/test_category_update.py",
1298
- "status": "modified",
1299
- "diff": "Index: saleor/graphql/product/tests/mutations/test_category_update.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_category_update.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_category_update.py\t223f354 (commit)\n@@ -7,9 +7,8 @@\n from django.utils import timezone\n from django.utils.functional import SimpleLazyObject\n from django.utils.text import slugify\n from freezegun import freeze_time\n-from graphql_relay import to_global_id\n \n from .....core.utils.json_serializer import CustomJsonEncoder\n from .....product.error_codes import ProductErrorCode\n from .....product.models import Category\n@@ -342,9 +341,9 @@\n \n variables = {\n \"name\": \"new-name\",\n \"slug\": \"new-slug\",\n- \"id\": to_global_id(\"Category\", category.id),\n+ \"id\": graphene.Node.to_global_id(\"Category\", category.id),\n \"backgroundImage\": image_name,\n \"backgroundImageAlt\": image_alt,\n \"isPublished\": True,\n }\n@@ -396,9 +395,9 @@\n \n variables = {\n \"name\": \"new-name\",\n \"slug\": \"new-slug\",\n- \"id\": to_global_id(\"Category\", category.id),\n+ \"id\": graphene.Node.to_global_id(\"Category\", category.id),\n \"backgroundImage\": image_name,\n \"backgroundImageAlt\": image_alt,\n \"isPublished\": True,\n }\n@@ -634,9 +633,9 @@\n }\n \"\"\"\n assert category_with_image.background_image\n variables = {\n- \"id\": to_global_id(\"Category\", category_with_image.id),\n+ \"id\": graphene.Node.to_global_id(\"Category\", category_with_image.id),\n \"backgroundImage\": None,\n }\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n"
1300
- },
1301
- {
1302
- "path": "saleor/graphql/product/tests/mutations/test_collection_add_products.py",
1303
- "status": "modified",
1304
- "diff": "Index: saleor/graphql/product/tests/mutations/test_collection_add_products.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_collection_add_products.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_collection_add_products.py\t223f354 (commit)\n@@ -1,7 +1,7 @@\n from unittest.mock import patch\n \n-from graphql_relay import to_global_id\n+import graphene\n \n from .....discount.utils.promotion import get_active_catalogue_promotion_rules\n from .....product.error_codes import CollectionErrorCode\n from ....tests.utils import (\n@@ -35,10 +35,12 @@\n ):\n # given\n query = COLLECTION_ADD_PRODUCTS_MUTATION\n \n- collection_id = to_global_id(\"Collection\", collection.id)\n- product_ids = [to_global_id(\"Product\", product.pk) for product in product_list]\n+ collection_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n+ product_ids = [\n+ graphene.Node.to_global_id(\"Product\", product.pk) for product in product_list\n+ ]\n products_before = collection.products.count()\n variables = {\"id\": collection_id, \"products\": product_ids}\n \n # when\n@@ -62,10 +64,12 @@\n product_list,\n permission_manage_products,\n ):\n query = COLLECTION_ADD_PRODUCTS_MUTATION\n- collection_id = to_global_id(\"Collection\", collection.id)\n- product_ids = [to_global_id(\"Product\", product.pk) for product in product_list]\n+ collection_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n+ product_ids = [\n+ graphene.Node.to_global_id(\"Product\", product.pk) for product in product_list\n+ ]\n products_before = collection.products.count()\n variables = {\"id\": collection_id, \"products\": product_ids}\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n@@ -79,10 +83,12 @@\n def test_add_products_to_collection_on_sale_trigger_discounted_price_recalculation(\n staff_api_client, collection, product_list, permission_manage_products\n ):\n query = COLLECTION_ADD_PRODUCTS_MUTATION\n- collection_id = to_global_id(\"Collection\", collection.id)\n- product_ids = [to_global_id(\"Product\", product.pk) for product in product_list]\n+ collection_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n+ product_ids = [\n+ graphene.Node.to_global_id(\"Product\", product.pk) for product in product_list\n+ ]\n products_before = collection.products.count()\n variables = {\"id\": collection_id, \"products\": product_ids}\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n@@ -96,10 +102,12 @@\n staff_api_client, collection, product_list, permission_manage_products\n ):\n query = COLLECTION_ADD_PRODUCTS_MUTATION\n product_list[0].variants.all().delete()\n- collection_id = to_global_id(\"Collection\", collection.id)\n- product_ids = [to_global_id(\"Product\", product.pk) for product in product_list]\n+ collection_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n+ product_ids = [\n+ graphene.Node.to_global_id(\"Product\", product.pk) for product in product_list\n+ ]\n variables = {\"id\": collection_id, \"products\": product_ids}\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n )\n"
1305
- },
1306
- {
1307
- "path": "saleor/graphql/product/tests/mutations/test_collection_create.py",
1308
- "status": "modified",
1309
- "diff": "Index: saleor/graphql/product/tests/mutations/test_collection_create.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_collection_create.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_collection_create.py\t223f354 (commit)\n@@ -1,9 +1,9 @@\n import os\n from unittest.mock import patch\n \n+import graphene\n import pytest\n-from graphql_relay import to_global_id\n \n from .....product.models import Collection\n from .....product.tests.utils import create_image\n from .....tests.utils import dummy_editorjs\n@@ -71,9 +71,11 @@\n ):\n # given\n staff_api_client.user.user_permissions.add(permission_manage_products)\n \n- product_ids = [to_global_id(\"Product\", product.pk) for product in product_list]\n+ product_ids = [\n+ graphene.Node.to_global_id(\"Product\", product.pk) for product in product_list\n+ ]\n image_file, image_name = create_image()\n image_alt = \"Alt text for an image.\"\n name = \"test-name\"\n slug = \"test-slug\"\n@@ -129,9 +131,11 @@\n permission_manage_products,\n ):\n query = CREATE_COLLECTION_MUTATION\n \n- product_ids = [to_global_id(\"Product\", product.pk) for product in product_list]\n+ product_ids = [\n+ graphene.Node.to_global_id(\"Product\", product.pk) for product in product_list\n+ ]\n name = \"test-name\"\n slug = \"test-slug\"\n description = dummy_editorjs(\"description\", True)\n variables = {\n"
1310
- },
1311
- {
1312
- "path": "saleor/graphql/product/tests/mutations/test_collection_delete.py",
1313
- "status": "modified",
1314
- "diff": "Index: saleor/graphql/product/tests/mutations/test_collection_delete.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_collection_delete.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_collection_delete.py\t223f354 (commit)\n@@ -1,10 +1,12 @@\n from unittest.mock import MagicMock, patch\n \n+import graphene\n import pytest\n from django.core.files import File\n-from graphql_relay import to_global_id\n \n+from .....attribute.models import AttributeValue\n+from .....attribute.utils import associate_attribute_values_to_instance\n from .....discount.utils.promotion import get_active_catalogue_promotion_rules\n from .....thumbnail.models import Thumbnail\n from ....tests.utils import (\n get_graphql_content,\n@@ -15,8 +17,13 @@\n collectionDelete(id: $id) {\n collection {\n name\n }\n+ errors {\n+ field\n+ message\n+ code\n+ }\n }\n }\n \"\"\"\n \n@@ -31,9 +38,9 @@\n ):\n # given\n query = DELETE_COLLECTION_MUTATION\n collection.products.set(product_list)\n- collection_id = to_global_id(\"Collection\", collection.id)\n+ collection_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n variables = {\"id\": collection_id}\n \n # when\n response = staff_api_client.post_graphql(\n@@ -68,9 +75,9 @@\n Thumbnail.objects.create(collection=collection, size=128, image=thumbnail_mock)\n Thumbnail.objects.create(collection=collection, size=200, image=thumbnail_mock)\n \n collection_id = collection.id\n- variables = {\"id\": to_global_id(\"Collection\", collection.id)}\n+ variables = {\"id\": graphene.Node.to_global_id(\"Collection\", collection.id)}\n \n # when\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n@@ -96,9 +103,9 @@\n permission_manage_products,\n ):\n query = DELETE_COLLECTION_MUTATION\n collection.products.add(*product_list)\n- collection_id = to_global_id(\"Collection\", collection.id)\n+ collection_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n variables = {\"id\": collection_id}\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n )\n@@ -107,4 +114,131 @@\n assert data[\"name\"] == collection.name\n with pytest.raises(collection._meta.model.DoesNotExist):\n collection.refresh_from_db()\n assert len(product_list) == product_updated_mock.call_count\n+\n+\n+def test_collection_delete_removes_reference_to_product(\n+ staff_api_client,\n+ collection,\n+ product_type_product_reference_attribute,\n+ product_type,\n+ product,\n+ permission_manage_products,\n+):\n+ # given\n+ query = DELETE_COLLECTION_MUTATION\n+\n+ product_type.product_attributes.add(product_type_product_reference_attribute)\n+ attr_value = AttributeValue.objects.create(\n+ attribute=product_type_product_reference_attribute,\n+ name=collection.name,\n+ slug=f\"{product.pk}_{collection.pk}\",\n+ reference_collection=collection,\n+ )\n+ associate_attribute_values_to_instance(\n+ product, {product_type_product_reference_attribute.pk: [attr_value]}\n+ )\n+ reference_id = graphene.Node.to_global_id(\"Collection\", collection.pk)\n+\n+ variables = {\"id\": reference_id}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"collectionDelete\"]\n+\n+ with pytest.raises(attr_value._meta.model.DoesNotExist):\n+ attr_value.refresh_from_db()\n+ with pytest.raises(collection._meta.model.DoesNotExist):\n+ collection.refresh_from_db()\n+\n+ assert not data[\"errors\"]\n+\n+\n+def test_collection_delete_removes_reference_to_product_variant(\n+ staff_api_client,\n+ collection,\n+ product_type_product_reference_attribute,\n+ product_type,\n+ product_list,\n+ permission_manage_products,\n+):\n+ # given\n+ query = DELETE_COLLECTION_MUTATION\n+\n+ variant = product_list[0].variants.first()\n+ product_type.variant_attributes.set([product_type_product_reference_attribute])\n+ attr_value = AttributeValue.objects.create(\n+ attribute=product_type_product_reference_attribute,\n+ name=collection.name,\n+ slug=f\"{variant.pk}_{collection.pk}\",\n+ reference_collection=collection,\n+ )\n+ associate_attribute_values_to_instance(\n+ variant, {product_type_product_reference_attribute.pk: [attr_value]}\n+ )\n+ reference_id = graphene.Node.to_global_id(\"Collection\", collection.pk)\n+\n+ variables = {\"id\": reference_id}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"collectionDelete\"]\n+\n+ with pytest.raises(attr_value._meta.model.DoesNotExist):\n+ attr_value.refresh_from_db()\n+ with pytest.raises(collection._meta.model.DoesNotExist):\n+ collection.refresh_from_db()\n+\n+ assert not data[\"errors\"]\n+\n+\n+def test_collection_delete_removes_reference_to_page(\n+ staff_api_client,\n+ collection,\n+ page,\n+ page_type_product_reference_attribute,\n+ permission_manage_products,\n+):\n+ # given\n+ query = DELETE_COLLECTION_MUTATION\n+\n+ page_type = page.page_type\n+ page_type.page_attributes.add(page_type_product_reference_attribute)\n+ attr_value = AttributeValue.objects.create(\n+ attribute=page_type_product_reference_attribute,\n+ name=page.title,\n+ slug=f\"{page.pk}_{collection.pk}\",\n+ reference_collection=collection,\n+ )\n+ associate_attribute_values_to_instance(\n+ page, {page_type_product_reference_attribute.pk: [attr_value]}\n+ )\n+ reference_id = graphene.Node.to_global_id(\"Collection\", collection.pk)\n+\n+ variables = {\"id\": reference_id}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"collectionDelete\"]\n+\n+ with pytest.raises(attr_value._meta.model.DoesNotExist):\n+ attr_value.refresh_from_db()\n+ with pytest.raises(collection._meta.model.DoesNotExist):\n+ collection.refresh_from_db()\n+\n+ assert not data[\"errors\"]\n"
1315
- },
1316
- {
1317
- "path": "saleor/graphql/product/tests/mutations/test_collection_remove_products.py",
1318
- "status": "modified",
1319
- "diff": "Index: saleor/graphql/product/tests/mutations/test_collection_remove_products.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_collection_remove_products.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_collection_remove_products.py\t223f354 (commit)\n@@ -1,7 +1,7 @@\n from unittest.mock import patch\n \n-from graphql_relay import to_global_id\n+import graphene\n \n from .....discount.utils.promotion import get_active_catalogue_promotion_rules\n from ....tests.utils import (\n get_graphql_content,\n@@ -29,10 +29,12 @@\n ):\n # given\n query = COLLECTION_REMOVE_PRODUCTS_MUTATION\n collection.products.add(*product_list)\n- collection_id = to_global_id(\"Collection\", collection.id)\n- product_ids = [to_global_id(\"Product\", product.pk) for product in product_list]\n+ collection_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n+ product_ids = [\n+ graphene.Node.to_global_id(\"Product\", product.pk) for product in product_list\n+ ]\n products_before = collection.products.count()\n variables = {\"id\": collection_id, \"products\": product_ids}\n \n # when\n@@ -57,10 +59,12 @@\n permission_manage_products,\n ):\n query = COLLECTION_REMOVE_PRODUCTS_MUTATION\n collection.products.add(*product_list)\n- collection_id = to_global_id(\"Collection\", collection.id)\n- product_ids = [to_global_id(\"Product\", product.pk) for product in product_list]\n+ collection_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n+ product_ids = [\n+ graphene.Node.to_global_id(\"Product\", product.pk) for product in product_list\n+ ]\n products_before = collection.products.count()\n variables = {\"id\": collection_id, \"products\": product_ids}\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n"
1320
- },
1321
- {
1322
- "path": "saleor/graphql/product/tests/mutations/test_collection_update.py",
1323
- "status": "modified",
1324
- "diff": "Index: saleor/graphql/product/tests/mutations/test_collection_update.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_collection_update.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_collection_update.py\t223f354 (commit)\n@@ -2,9 +2,8 @@\n \n import graphene\n import pytest\n from django.core.files import File\n-from graphql_relay import to_global_id\n \n from .....product.error_codes import ProductErrorCode\n from .....product.models import Collection\n from .....product.tests.utils import create_image, create_zip_file_with_image_ext\n@@ -69,9 +68,9 @@\n variables = {\n \"name\": name,\n \"slug\": slug,\n \"description\": description,\n- \"id\": to_global_id(\"Collection\", collection.id),\n+ \"id\": graphene.Node.to_global_id(\"Collection\", collection.id),\n \"metadata\": [{\"key\": metadata_key, \"value\": metadata_value}],\n \"privateMetadata\": [{\"key\": metadata_key, \"value\": metadata_value}],\n }\n \n@@ -133,9 +132,9 @@\n \n collection.products.set([product])\n \n variables = {\n- \"id\": to_global_id(\"Collection\", collection.id),\n+ \"id\": graphene.Node.to_global_id(\"Collection\", collection.id),\n \"metadata\": [{\"key\": metadata_key, \"value\": metadata_value}],\n }\n \n # when\n@@ -203,9 +202,9 @@\n \n variables = {\n \"name\": \"new-name\",\n \"slug\": \"new-slug\",\n- \"id\": to_global_id(\"Collection\", collection.id),\n+ \"id\": graphene.Node.to_global_id(\"Collection\", collection.id),\n \"backgroundImage\": image_name,\n \"backgroundImageAlt\": image_alt,\n }\n body = get_multipart_request_body(\n@@ -253,9 +252,9 @@\n \n variables = {\n \"name\": \"new-name\",\n \"slug\": \"new-slug\",\n- \"id\": to_global_id(\"Collection\", collection.id),\n+ \"id\": graphene.Node.to_global_id(\"Collection\", collection.id),\n \"backgroundImage\": image_name,\n \"backgroundImageAlt\": image_alt,\n }\n body = get_multipart_request_body(\n@@ -306,9 +305,9 @@\n \n variables = {\n \"name\": \"new-name\",\n \"slug\": \"new-slug\",\n- \"id\": to_global_id(\"Collection\", collection.id),\n+ \"id\": graphene.Node.to_global_id(\"Collection\", collection.id),\n \"backgroundImage\": image_name,\n \"backgroundImageAlt\": image_alt,\n }\n body = get_multipart_request_body(\n@@ -376,10 +375,10 @@\n old_slug = collection.slug\n \n assert old_slug != input_slug\n \n- node_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n- variables = {\"slug\": input_slug, \"id\": node_id}\n+ Node_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n+ variables = {\"slug\": input_slug, \"id\": Node_id}\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n )\n content = get_graphql_content(response)\n@@ -407,10 +406,10 @@\n second_collection.save()\n \n assert input_slug != collection.slug\n \n- node_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n- variables = {\"slug\": input_slug, \"id\": node_id}\n+ Node_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n+ variables = {\"slug\": input_slug, \"id\": Node_id}\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n )\n content = get_graphql_content(response)\n@@ -469,10 +468,10 @@\n \n assert input_slug != old_slug\n assert input_name != old_name\n \n- node_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n- variables = {\"slug\": input_slug, \"name\": input_name, \"id\": node_id}\n+ Node_id = graphene.Node.to_global_id(\"Collection\", collection.id)\n+ variables = {\"slug\": input_slug, \"name\": input_name, \"id\": Node_id}\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n )\n content = get_graphql_content(response)\n@@ -511,9 +510,9 @@\n }\n \"\"\"\n assert collection_with_image.background_image\n variables = {\n- \"id\": to_global_id(\"Collection\", collection_with_image.id),\n+ \"id\": graphene.Node.to_global_id(\"Collection\", collection_with_image.id),\n \"backgroundImage\": None,\n }\n response = staff_api_client.post_graphql(\n query, variables, permissions=[permission_manage_products]\n"
1325
- },
1326
- {
1327
- "path": "saleor/graphql/product/tests/mutations/test_product_create.py",
1328
- "status": "modified",
1329
- "diff": "Index: saleor/graphql/product/tests/mutations/test_product_create.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_product_create.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_product_create.py\t223f354 (commit)\n@@ -1755,8 +1755,159 @@\n product_type_variant_reference_attribute.refresh_from_db()\n assert product_type_variant_reference_attribute.values.count() == values_count + 1\n \n \n+def test_create_product_with_category_reference_attribute(\n+ staff_api_client,\n+ product_type,\n+ category,\n+ color_attribute,\n+ product_type_category_reference_attribute,\n+ permission_manage_products,\n+):\n+ # given\n+ query = CREATE_PRODUCT_MUTATION\n+\n+ values_count = product_type_category_reference_attribute.values.count()\n+ product_type_id = graphene.Node.to_global_id(\"ProductType\", product_type.pk)\n+ category_id = graphene.Node.to_global_id(\"Category\", category.pk)\n+ product_name = \"test name\"\n+ product_slug = \"product-test-slug\"\n+\n+ # Add second attribute\n+ product_type.product_attributes.add(product_type_category_reference_attribute)\n+ reference_attr_id = graphene.Node.to_global_id(\n+ \"Attribute\", product_type_category_reference_attribute.id\n+ )\n+ reference = graphene.Node.to_global_id(\"Category\", category.pk)\n+\n+ variables = {\n+ \"input\": {\n+ \"productType\": product_type_id,\n+ \"category\": category_id,\n+ \"name\": product_name,\n+ \"slug\": product_slug,\n+ \"attributes\": [{\"id\": reference_attr_id, \"references\": [reference]}],\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"productCreate\"]\n+ assert data[\"errors\"] == []\n+ assert data[\"product\"][\"name\"] == product_name\n+ assert data[\"product\"][\"slug\"] == product_slug\n+ assert data[\"product\"][\"productType\"][\"name\"] == product_type.name\n+ assert data[\"product\"][\"category\"][\"name\"] == category.name\n+ _, product_id = graphene.Node.from_global_id(data[\"product\"][\"id\"])\n+ expected_attributes_data = [\n+ {\"attribute\": {\"slug\": color_attribute.slug}, \"values\": []},\n+ {\n+ \"attribute\": {\"slug\": product_type_category_reference_attribute.slug},\n+ \"values\": [\n+ {\n+ \"slug\": f\"{product_id}_{category.id}\",\n+ \"name\": category.name,\n+ \"file\": None,\n+ \"richText\": None,\n+ \"plainText\": None,\n+ \"boolean\": None,\n+ \"date\": None,\n+ \"dateTime\": None,\n+ \"reference\": reference,\n+ }\n+ ],\n+ },\n+ ]\n+ for attr_data in data[\"product\"][\"attributes\"]:\n+ assert attr_data in expected_attributes_data\n+\n+ product_type_category_reference_attribute.refresh_from_db()\n+ assert product_type_category_reference_attribute.values.count() == values_count + 1\n+\n+\n+def test_create_product_with_collection_reference_attribute(\n+ staff_api_client,\n+ product_type,\n+ category,\n+ color_attribute,\n+ product_type_collection_reference_attribute,\n+ permission_manage_products,\n+ collection,\n+):\n+ # given\n+ query = CREATE_PRODUCT_MUTATION\n+\n+ values_count = product_type_collection_reference_attribute.values.count()\n+ product_type_id = graphene.Node.to_global_id(\"ProductType\", product_type.pk)\n+ category_id = graphene.Node.to_global_id(\"Category\", category.pk)\n+ product_name = \"test name\"\n+ product_slug = \"product-test-slug\"\n+\n+ # Add second attribute\n+ product_type.product_attributes.add(product_type_collection_reference_attribute)\n+ reference_attr_id = graphene.Node.to_global_id(\n+ \"Attribute\", product_type_collection_reference_attribute.id\n+ )\n+ reference = graphene.Node.to_global_id(\"Collection\", collection.pk)\n+\n+ variables = {\n+ \"input\": {\n+ \"productType\": product_type_id,\n+ \"category\": category_id,\n+ \"name\": product_name,\n+ \"slug\": product_slug,\n+ \"attributes\": [{\"id\": reference_attr_id, \"references\": [reference]}],\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"productCreate\"]\n+ assert data[\"errors\"] == []\n+ assert data[\"product\"][\"name\"] == product_name\n+ assert data[\"product\"][\"slug\"] == product_slug\n+ assert data[\"product\"][\"productType\"][\"name\"] == product_type.name\n+ assert data[\"product\"][\"category\"][\"name\"] == category.name\n+ _, product_id = graphene.Node.from_global_id(data[\"product\"][\"id\"])\n+ expected_attributes_data = [\n+ {\"attribute\": {\"slug\": color_attribute.slug}, \"values\": []},\n+ {\n+ \"attribute\": {\"slug\": product_type_collection_reference_attribute.slug},\n+ \"values\": [\n+ {\n+ \"slug\": f\"{product_id}_{collection.id}\",\n+ \"name\": collection.name,\n+ \"file\": None,\n+ \"richText\": None,\n+ \"plainText\": None,\n+ \"boolean\": None,\n+ \"date\": None,\n+ \"dateTime\": None,\n+ \"reference\": reference,\n+ }\n+ ],\n+ },\n+ ]\n+ for attr_data in data[\"product\"][\"attributes\"]:\n+ assert attr_data in expected_attributes_data\n+\n+ product_type_collection_reference_attribute.refresh_from_db()\n+ assert (\n+ product_type_collection_reference_attribute.values.count() == values_count + 1\n+ )\n+\n+\n def test_create_product_with_product_reference_attribute_values_saved_in_order(\n staff_api_client,\n product_type,\n category,\n"
1330
- },
1331
- {
1332
- "path": "saleor/graphql/product/tests/mutations/test_product_update.py",
1333
- "status": "modified",
1334
- "diff": "Index: saleor/graphql/product/tests/mutations/test_product_update.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_product_update.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_product_update.py\t223f354 (commit)\n@@ -1272,9 +1272,8 @@\n # given\n query = MUTATION_UPDATE_PRODUCT\n \n product_id = graphene.Node.to_global_id(\"Product\", product.pk)\n-\n attribute_id = graphene.Node.to_global_id(\n \"Attribute\", product_type_page_reference_attribute.pk\n )\n product_type.product_attributes.add(product_type_page_reference_attribute)\n@@ -1286,11 +1285,9 @@\n )\n associate_attribute_values_to_instance(\n product, {product_type_page_reference_attribute.pk: [attr_value]}\n )\n-\n values_count = product_type_page_reference_attribute.values.count()\n-\n reference = graphene.Node.to_global_id(\"Page\", page.pk)\n \n variables = {\n \"productId\": product_id,\n@@ -1348,11 +1345,9 @@\n query = MUTATION_UPDATE_PRODUCT\n \n product_type_page_reference_attribute.value_required = True\n product_type_page_reference_attribute.save(update_fields=[\"value_required\"])\n-\n product_id = graphene.Node.to_global_id(\"Product\", product.pk)\n-\n attribute_id = graphene.Node.to_global_id(\n \"Attribute\", product_type_page_reference_attribute.pk\n )\n product_type.product_attributes.add(product_type_page_reference_attribute)\n@@ -1394,16 +1389,13 @@\n \n product = product_list[0]\n product_id = graphene.Node.to_global_id(\"Product\", product.pk)\n product_ref = product_list[1]\n-\n attribute_id = graphene.Node.to_global_id(\n \"Attribute\", product_type_product_reference_attribute.pk\n )\n product_type.product_attributes.add(product_type_product_reference_attribute)\n-\n values_count = product_type_product_reference_attribute.values.count()\n-\n reference = graphene.Node.to_global_id(\"Product\", product_ref.pk)\n \n variables = {\n \"productId\": product_id,\n@@ -1462,16 +1454,13 @@\n \n product = product_list[0]\n product_id = graphene.Node.to_global_id(\"Product\", product.pk)\n variant_ref = product_list[1].variants.first()\n-\n attribute_id = graphene.Node.to_global_id(\n \"Attribute\", product_type_variant_reference_attribute.pk\n )\n product_type.product_attributes.add(product_type_variant_reference_attribute)\n-\n values_count = product_type_variant_reference_attribute.values.count()\n-\n reference = graphene.Node.to_global_id(\"ProductVariant\", variant_ref.pk)\n \n variables = {\n \"productId\": product_id,\n@@ -1508,15 +1497,141 @@\n }\n ],\n }\n assert expected_file_att_data in attributes\n+ product_type_variant_reference_attribute.refresh_from_db()\n+ assert product_type_variant_reference_attribute.values.count() == values_count + 1\n \n updated_webhook_mock.assert_called_once_with(product)\n \n- product_type_variant_reference_attribute.refresh_from_db()\n- assert product_type_variant_reference_attribute.values.count() == values_count + 1\n \n+@patch(\"saleor.plugins.manager.PluginsManager.product_updated\")\n+def test_update_product_with_category_reference_attribute_value(\n+ updated_webhook_mock,\n+ staff_api_client,\n+ product_type_category_reference_attribute,\n+ product,\n+ product_type,\n+ category,\n+ permission_manage_products,\n+):\n+ # given\n+ query = MUTATION_UPDATE_PRODUCT\n \n+ product_id = graphene.Node.to_global_id(\"Product\", product.pk)\n+ attribute_id = graphene.Node.to_global_id(\n+ \"Attribute\", product_type_category_reference_attribute.pk\n+ )\n+ product_type.product_attributes.add(product_type_category_reference_attribute)\n+ values_count = product_type_category_reference_attribute.values.count()\n+ reference = graphene.Node.to_global_id(\"Category\", category.pk)\n+\n+ variables = {\n+ \"productId\": product_id,\n+ \"input\": {\"attributes\": [{\"id\": attribute_id, \"references\": [reference]}]},\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"productUpdate\"]\n+ assert data[\"errors\"] == []\n+\n+ attributes = data[\"product\"][\"attributes\"]\n+\n+ assert len(attributes) == 2\n+ expected_file_att_data = {\n+ \"attribute\": {\n+ \"id\": attribute_id,\n+ \"name\": product_type_category_reference_attribute.name,\n+ },\n+ \"values\": [\n+ {\n+ \"id\": ANY,\n+ \"name\": category.name,\n+ \"slug\": f\"{product.id}_{category.id}\",\n+ \"file\": None,\n+ \"reference\": reference,\n+ \"boolean\": None,\n+ \"plainText\": None,\n+ }\n+ ],\n+ }\n+ assert expected_file_att_data in attributes\n+ product_type_category_reference_attribute.refresh_from_db()\n+ assert product_type_category_reference_attribute.values.count() == values_count + 1\n+\n+ updated_webhook_mock.assert_called_once_with(product)\n+\n+\n+@patch(\"saleor.plugins.manager.PluginsManager.product_updated\")\n+def test_update_product_with_collection_reference_attribute_value(\n+ updated_webhook_mock,\n+ staff_api_client,\n+ product_type_collection_reference_attribute,\n+ product,\n+ product_type,\n+ collection,\n+ permission_manage_products,\n+):\n+ # given\n+ query = MUTATION_UPDATE_PRODUCT\n+\n+ product_id = graphene.Node.to_global_id(\"Product\", product.pk)\n+ attribute_id = graphene.Node.to_global_id(\n+ \"Attribute\", product_type_collection_reference_attribute.pk\n+ )\n+ product_type.product_attributes.add(product_type_collection_reference_attribute)\n+ values_count = product_type_collection_reference_attribute.values.count()\n+ reference = graphene.Node.to_global_id(\"Collection\", collection.pk)\n+\n+ variables = {\n+ \"productId\": product_id,\n+ \"input\": {\"attributes\": [{\"id\": attribute_id, \"references\": [reference]}]},\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ data = content[\"data\"][\"productUpdate\"]\n+ assert data[\"errors\"] == []\n+\n+ attributes = data[\"product\"][\"attributes\"]\n+ assert len(attributes) == 2\n+ expected_file_att_data = {\n+ \"attribute\": {\n+ \"id\": attribute_id,\n+ \"name\": product_type_collection_reference_attribute.name,\n+ },\n+ \"values\": [\n+ {\n+ \"id\": ANY,\n+ \"name\": collection.name,\n+ \"slug\": f\"{product.id}_{collection.id}\",\n+ \"file\": None,\n+ \"reference\": reference,\n+ \"boolean\": None,\n+ \"plainText\": None,\n+ }\n+ ],\n+ }\n+ assert expected_file_att_data in attributes\n+ product_type_collection_reference_attribute.refresh_from_db()\n+ assert (\n+ product_type_collection_reference_attribute.values.count() == values_count + 1\n+ )\n+\n+ updated_webhook_mock.assert_called_once_with(product)\n+\n+\n def test_update_product_with_attribute_without_id_or_external_ref(\n staff_api_client, product, permission_manage_products, color_attribute\n ):\n \"\"\"Ensure only supplying values triggers a validation error.\"\"\"\n"
1335
- },
1336
- {
1337
- "path": "saleor/graphql/product/tests/mutations/test_product_variant_create.py",
1338
- "status": "modified",
1339
- "diff": "Index: saleor/graphql/product/tests/mutations/test_product_variant_create.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_product_variant_create.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_product_variant_create.py\t223f354 (commit)\n@@ -1119,8 +1119,212 @@\n updated_webhook_mock.assert_not_called()\n \n \n @patch(\"saleor.plugins.manager.PluginsManager.product_variant_created\")\n+def test_create_variant_with_category_reference_attribute(\n+ created_webhook_mock,\n+ staff_api_client,\n+ product,\n+ product_type,\n+ product_type_category_reference_attribute,\n+ category_list,\n+ permission_manage_products,\n+ warehouse,\n+):\n+ # given\n+ query = CREATE_VARIANT_MUTATION\n+ product_id = graphene.Node.to_global_id(\"Product\", product.pk)\n+ sku = \"1\"\n+\n+ product_type_category_reference_attribute.value_required = True\n+ product_type_category_reference_attribute.save(update_fields=[\"value_required\"])\n+\n+ product_type.variant_attributes.clear()\n+ product_type.variant_attributes.add(product_type_category_reference_attribute)\n+ ref_attr_id = graphene.Node.to_global_id(\n+ \"Attribute\", product_type_category_reference_attribute.id\n+ )\n+\n+ category_ref_1 = graphene.Node.to_global_id(\"Category\", category_list[0].pk)\n+ category_ref_2 = graphene.Node.to_global_id(\"Category\", category_list[1].pk)\n+\n+ values_count = product_type_category_reference_attribute.values.count()\n+\n+ stocks = [\n+ {\n+ \"warehouse\": graphene.Node.to_global_id(\"Warehouse\", warehouse.pk),\n+ \"quantity\": 20,\n+ }\n+ ]\n+ variables = {\n+ \"input\": {\n+ \"product\": product_id,\n+ \"sku\": sku,\n+ \"stocks\": stocks,\n+ \"attributes\": [\n+ {\"id\": ref_attr_id, \"references\": [category_ref_1, category_ref_2]}\n+ ],\n+ \"trackInventory\": True,\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)[\"data\"][\"productVariantCreate\"]\n+\n+ assert not content[\"errors\"]\n+ data = content[\"productVariant\"]\n+ assert data[\"sku\"] == sku\n+ variant_id = data[\"id\"]\n+ _, variant_pk = graphene.Node.from_global_id(variant_id)\n+ assert (\n+ data[\"attributes\"][0][\"attribute\"][\"slug\"]\n+ == product_type_category_reference_attribute.slug\n+ )\n+ expected_values = [\n+ {\n+ \"slug\": f\"{variant_pk}_{category_list[0].pk}\",\n+ \"file\": None,\n+ \"richText\": None,\n+ \"plainText\": None,\n+ \"reference\": category_ref_1,\n+ \"name\": category_list[0].name,\n+ \"boolean\": None,\n+ \"date\": None,\n+ \"dateTime\": None,\n+ },\n+ {\n+ \"slug\": f\"{variant_pk}_{category_list[1].pk}\",\n+ \"file\": None,\n+ \"richText\": None,\n+ \"plainText\": None,\n+ \"reference\": category_ref_2,\n+ \"name\": category_list[1].name,\n+ \"boolean\": None,\n+ \"date\": None,\n+ \"dateTime\": None,\n+ },\n+ ]\n+ for value in expected_values:\n+ assert value in data[\"attributes\"][0][\"values\"]\n+ assert len(data[\"stocks\"]) == 1\n+ assert data[\"stocks\"][0][\"quantity\"] == stocks[0][\"quantity\"]\n+ assert data[\"stocks\"][0][\"warehouse\"][\"slug\"] == warehouse.slug\n+\n+ product_type_category_reference_attribute.refresh_from_db()\n+ assert product_type_category_reference_attribute.values.count() == values_count + 2\n+\n+ created_webhook_mock.assert_called_once_with(product.variants.last())\n+\n+\n+@patch(\"saleor.plugins.manager.PluginsManager.product_variant_created\")\n+def test_create_variant_with_collection_reference_attribute(\n+ created_webhook_mock,\n+ staff_api_client,\n+ product,\n+ product_type,\n+ product_type_collection_reference_attribute,\n+ collection_list,\n+ permission_manage_products,\n+ warehouse,\n+):\n+ # given\n+ query = CREATE_VARIANT_MUTATION\n+ product_id = graphene.Node.to_global_id(\"Product\", product.pk)\n+ sku = \"1\"\n+\n+ product_type_collection_reference_attribute.value_required = True\n+ product_type_collection_reference_attribute.save(update_fields=[\"value_required\"])\n+\n+ product_type.variant_attributes.clear()\n+ product_type.variant_attributes.add(product_type_collection_reference_attribute)\n+ ref_attr_id = graphene.Node.to_global_id(\n+ \"Attribute\", product_type_collection_reference_attribute.id\n+ )\n+\n+ collection_ref_1 = graphene.Node.to_global_id(\"Collection\", collection_list[0].pk)\n+ collection_ref_2 = graphene.Node.to_global_id(\"Collection\", collection_list[1].pk)\n+\n+ values_count = product_type_collection_reference_attribute.values.count()\n+\n+ stocks = [\n+ {\n+ \"warehouse\": graphene.Node.to_global_id(\"Warehouse\", warehouse.pk),\n+ \"quantity\": 20,\n+ }\n+ ]\n+ variables = {\n+ \"input\": {\n+ \"product\": product_id,\n+ \"sku\": sku,\n+ \"stocks\": stocks,\n+ \"attributes\": [\n+ {\"id\": ref_attr_id, \"references\": [collection_ref_1, collection_ref_2]}\n+ ],\n+ \"trackInventory\": True,\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ query, variables, permissions=[permission_manage_products]\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)[\"data\"][\"productVariantCreate\"]\n+\n+ assert not content[\"errors\"]\n+ data = content[\"productVariant\"]\n+ assert data[\"sku\"] == sku\n+ variant_id = data[\"id\"]\n+ _, variant_pk = graphene.Node.from_global_id(variant_id)\n+ assert (\n+ data[\"attributes\"][0][\"attribute\"][\"slug\"]\n+ == product_type_collection_reference_attribute.slug\n+ )\n+ expected_values = [\n+ {\n+ \"slug\": f\"{variant_pk}_{collection_list[0].pk}\",\n+ \"file\": None,\n+ \"richText\": None,\n+ \"plainText\": None,\n+ \"reference\": collection_ref_1,\n+ \"name\": collection_list[0].name,\n+ \"boolean\": None,\n+ \"date\": None,\n+ \"dateTime\": None,\n+ },\n+ {\n+ \"slug\": f\"{variant_pk}_{collection_list[1].pk}\",\n+ \"file\": None,\n+ \"richText\": None,\n+ \"plainText\": None,\n+ \"reference\": collection_ref_2,\n+ \"name\": collection_list[1].name,\n+ \"boolean\": None,\n+ \"date\": None,\n+ \"dateTime\": None,\n+ },\n+ ]\n+ for value in expected_values:\n+ assert value in data[\"attributes\"][0][\"values\"]\n+ assert len(data[\"stocks\"]) == 1\n+ assert data[\"stocks\"][0][\"quantity\"] == stocks[0][\"quantity\"]\n+ assert data[\"stocks\"][0][\"warehouse\"][\"slug\"] == warehouse.slug\n+\n+ product_type_collection_reference_attribute.refresh_from_db()\n+ assert (\n+ product_type_collection_reference_attribute.values.count() == values_count + 2\n+ )\n+\n+ created_webhook_mock.assert_called_once_with(product.variants.last())\n+\n+\n+@patch(\"saleor.plugins.manager.PluginsManager.product_variant_created\")\n def test_create_variant_with_numeric_attribute(\n created_webhook_mock,\n staff_api_client,\n product,\n"
1340
- },
1341
- {
1342
- "path": "saleor/graphql/product/tests/mutations/test_product_variant_update.py",
1343
- "status": "modified",
1344
- "diff": "Index: saleor/graphql/product/tests/mutations/test_product_variant_update.py\n===================================================================\n--- saleor/graphql/product/tests/mutations/test_product_variant_update.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/mutations/test_product_variant_update.py\t223f354 (commit)\n@@ -1960,8 +1960,63 @@\n )\n assert variant_data[\"attributes\"][0][\"values\"][0][\"reference\"] == reference\n \n \n+def test_update_product_variant_with_category_reference_attribute(\n+ staff_api_client,\n+ product,\n+ category,\n+ product_type_category_reference_attribute,\n+ permission_manage_products,\n+):\n+ # given\n+ variant = product.variants.first()\n+ sku = str(uuid4())[:12]\n+ assert not variant.sku == sku\n+\n+ product_type = product.product_type\n+ product_type.variant_attributes.clear()\n+ product_type.variant_attributes.add(product_type_category_reference_attribute)\n+ variant_id = graphene.Node.to_global_id(\"ProductVariant\", variant.pk)\n+ ref_attribute_id = graphene.Node.to_global_id(\n+ \"Attribute\", product_type_category_reference_attribute.pk\n+ )\n+ reference = graphene.Node.to_global_id(\"Category\", category.pk)\n+\n+ variables = {\n+ \"id\": variant_id,\n+ \"sku\": sku,\n+ \"attributes\": [{\"id\": ref_attribute_id, \"references\": [reference]}],\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_UPDATE_VARIANT_ATTRIBUTES,\n+ variables,\n+ permissions=[permission_manage_products],\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+\n+ data = content[\"data\"][\"productVariantUpdate\"]\n+ assert not data[\"errors\"]\n+ variant_data = data[\"productVariant\"]\n+ assert variant_data\n+ assert variant_data[\"sku\"] == sku\n+ assert len(variant_data[\"attributes\"]) == 1\n+ assert (\n+ variant_data[\"attributes\"][0][\"attribute\"][\"slug\"]\n+ == product_type_category_reference_attribute.slug\n+ )\n+ assert len(variant_data[\"attributes\"][0][\"values\"]) == 1\n+ assert (\n+ variant_data[\"attributes\"][0][\"values\"][0][\"slug\"]\n+ == f\"{variant.pk}_{category.pk}\"\n+ )\n+ assert variant_data[\"attributes\"][0][\"values\"][0][\"reference\"] == reference\n+\n+\n def test_update_product_variant_change_attribute_values_ordering(\n staff_api_client,\n variant,\n product_type_product_reference_attribute,\n"
1345
- },
1346
- {
1347
- "path": "saleor/graphql/product/tests/queries/test_categories_query_with_filter.py",
1348
- "status": "modified",
1349
- "diff": "Index: saleor/graphql/product/tests/queries/test_categories_query_with_filter.py\n===================================================================\n--- saleor/graphql/product/tests/queries/test_categories_query_with_filter.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/queries/test_categories_query_with_filter.py\t223f354 (commit)\n@@ -1,8 +1,7 @@\n import graphene\n import pytest\n from freezegun import freeze_time\n-from graphql_relay import to_global_id\n \n from .....product.models import (\n Category,\n Product,\n@@ -854,9 +853,17 @@\n ({\"search\": \"Category1\"}, 1),\n ({\"search\": \"cat1\"}, 3),\n ({\"search\": \"Description cat1.\"}, 2),\n ({\"search\": \"Subcategory_description\"}, 1),\n- ({\"ids\": [to_global_id(\"Category\", 2), to_global_id(\"Category\", 3)]}, 2),\n+ (\n+ {\n+ \"ids\": [\n+ graphene.Node.to_global_id(\"Category\", 2),\n+ graphene.Node.to_global_id(\"Category\", 3),\n+ ]\n+ },\n+ 2,\n+ ),\n ],\n )\n def test_categories_query_with_filter(\n category_filter,\n"
1350
- },
1351
- {
1352
- "path": "saleor/graphql/product/tests/queries/test_collections_query_with_filter_and_sort.py",
1353
- "status": "modified",
1354
- "diff": "Index: saleor/graphql/product/tests/queries/test_collections_query_with_filter_and_sort.py\n===================================================================\n--- saleor/graphql/product/tests/queries/test_collections_query_with_filter_and_sort.py\t01138c1 (parent)\n+++ saleor/graphql/product/tests/queries/test_collections_query_with_filter_and_sort.py\t223f354 (commit)\n@@ -1,9 +1,8 @@\n import datetime\n \n import graphene\n import pytest\n-from graphql_relay import to_global_id\n \n from .....product.models import Collection, CollectionChannelListing\n from .....tests.utils import dummy_editorjs\n from ....tests.utils import (\n@@ -18,9 +17,17 @@\n ({\"published\": \"PUBLISHED\"}, 2),\n ({\"published\": \"HIDDEN\"}, 1),\n ({\"search\": \"-published1\"}, 1),\n ({\"search\": \"Collection3\"}, 1),\n- ({\"ids\": [to_global_id(\"Collection\", 2), to_global_id(\"Collection\", 3)]}, 2),\n+ (\n+ {\n+ \"ids\": [\n+ graphene.Node.to_global_id(\"Collection\", 2),\n+ graphene.Node.to_global_id(\"Collection\", 3),\n+ ]\n+ },\n+ 2,\n+ ),\n ],\n )\n def test_collections_query_with_filter(\n collection_filter,\n"
1355
- },
1356
- {
1357
- "path": "saleor/graphql/schema.graphql",
1358
- "status": "modified",
1359
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\t01138c1 (parent)\n+++ saleor/graphql/schema.graphql\t223f354 (commit)\n@@ -6159,8 +6159,10 @@\n enum AttributeEntityTypeEnum @doc(category: \"Attributes\") {\n PAGE\n PRODUCT\n PRODUCT_VARIANT\n+ CATEGORY\n+ COLLECTION\n }\n \n enum AttributeTypeEnum @doc(category: \"Attributes\") {\n PRODUCT_TYPE\n"
1360
- }
1361
- ]
1362
- },
1363
- {
1364
- "id": "add-page-attr-filter",
1365
- "sha": "01138c1e4e6fd014ee214cb048f03560a302df5e",
1366
- "parentSha": "f50682afab6aeb2438d8073f00cf26e099a13097",
1367
- "spec": "Implement where-filtering of pages by associated attributes and update schema, tests, and changelog accordingly.\n\nScope\n- Extend the pages where-input to accept an \"attributes\" filter array. Support filtering by:\n - Attribute existence (provide only attribute slug)\n - Attribute value slug/name (eq/oneOf)\n - Numeric values (eq/oneOf/range)\n - Boolean values (True/False)\n - Date values (range on date portion of date_time)\n - DateTime values (range)\n- Validate input for duplicates, empty or multiple value keys, and ensure type-specific value keys match the attribute input type.\n\nFiles and required changes\n1) saleor/graphql/page/filters.py\n- Add imports: Exists, FloatField, OuterRef, Cast, GraphQLError; AttributeInputType; AssignedPageAttributeValue, Attribute, AttributeValue; BaseInputObjectType, DateRangeInput, DateTimeRangeInput; ListObjectTypeWhereFilter; DecimalFilterInput; filter_range_field; filter_where_by_numeric_field.\n- Define helper functions for attribute filtering:\n - filter_by_slug_or_name(attr_id, attr_value, db): Returns Q(Exists(...)) matching AttributeValue.slug/name by eq/oneOf and AssignedPageAttributeValue by page.\n - filter_by_numeric_attribute(attr_id, numeric_value, db): Cast AttributeValue.name to float and filter by eq/oneOf/range; wrap in Exists on AssignedPageAttributeValue.\n - filter_by_boolean_attribute(attr_id, boolean_value, db): Filter AttributeValue.boolean and wrap in Exists.\n - filter_by_date_attribute(attr_id, date_value, db): Filter AttributeValue.date_time__date with gte/lte and wrap in Exists.\n - filter_by_date_time_attribute(attr_id, date_time_value, db): Filter AttributeValue.date_time with gte/lte and wrap in Exists.\n- Define filter_pages_by_attributes(qs, value):\n - Resolve provided attribute slugs to Attribute records; if any slug is unknown, return qs.none().\n - Support entries without \"value\": filter pages that have any value assigned for those attributes via Exists, OR’ed into the overall expression and AND’ed with other value-specific filters.\n - For value-specific entries, dispatch per attribute.input_type or slug/name presence and AND all conditions.\n - Apply the combined Q expression to qs or return qs.none() if no expression was built.\n- Define validate_attribute_value_input(attributes, db):\n - Reject duplicate attribute slugs.\n - If a \"value\" key is present but empty or None, raise GraphQLError.\n - Reject entries where \"value\" contains more than one key (e.g., both slug and name); raise GraphQLError.\n - For type-specific keys (numeric/date/date_time/boolean), ensure the attribute’s input_type matches; collect mismatches and raise GraphQLError.\n - Error messages must match the expectations: \n - Duplicates: \"Duplicated attribute slugs in attribute 'where' input are not allowed.\"\n - Empty/null value: \"Incorrect input for attributes with slugs: <comma-separated>. Provided 'value' cannot be empty or null.\"\n - Multiple keys: \"Incorrect input for attributes with slugs: <comma-separated>. Provided 'value' must have only one input key.\"\n - Type mismatch: \"Incorrect input for attributes with slugs: <comma-separated>. Provided 'value' do not match the attribute input type.\"\n- Define input shapes for the new where inputs:\n - class AttributeValuePageInput(BaseInputObjectType): fields slug (StringFilterInput), name (StringFilterInput), numeric (DecimalFilterInput), date (DateRangeInput), date_time (DateTimeRangeInput), boolean (Boolean).\n - class AttributePageWhereInput(BaseInputObjectType): fields slug (String, required), value (AttributeValuePageInput, optional; exactly one of its fields must be provided if present).\n- Extend PageWhere to add:\n - attributes = ListObjectTypeWhereFilter(input_class=AttributePageWhereInput, method=\"filter_attributes\", help_text=\"Filter by attributes associated with the page.\")\n - filter_attributes(qs, _, value): if value present, return filter_pages_by_attributes(qs, value); else return qs.\n - Override is_valid to call validate_attribute_value_input on provided attributes prior to super().is_valid().\n\n2) saleor/graphql/schema.graphql\n- Update PageWhereInput to include:\n - attributes: [AttributePageWhereInput!] with description \"Filter by attributes associated with the page.\"\n- Add definitions:\n - input AttributePageWhereInput { slug: String!; value: AttributeValuePageInput }\n - input AttributeValuePageInput { slug: StringFilterInput; name: StringFilterInput; numeric: DecimalFilterInput; date: DateRangeInput; dateTime: DateTimeRangeInput; boolean: Boolean }\n - Ensure dateTime uses camelCase in SDL (Graphene will map from date_time in Python).\n\n3) saleor/graphql/page/tests/queries/test_pages_with_where.py\n- Add tests to cover new behavior, including but not limited to:\n - Filtering by attribute slug (returns pages that have any value for the attribute assigned).\n - Filtering by attribute value slug eq and oneOf; name eq and oneOf.\n - Numeric values via both numeric (eq, oneOf, range) and string-based slug/name equality; set attribute type to PAGE_TYPE and associate to page type before assignments.\n - Date values via date range (gte/lte) and name/slug equality; ensure date_time on AttributeValue reflects dates; set attribute.type = PAGE_TYPE.\n - DateTime values via dateTime range (gte/lte) and name/slug equality; set attribute.type = PAGE_TYPE.\n - Boolean attribute filtering via boolean true/false and name/slug equality of the true value; set attribute.type = PAGE_TYPE.\n - Validation failures: duplicated attribute slugs; empty/None value; multiple keys in value; type-mismatch (e.g., boolean provided to numeric attribute). Assert GraphQLError and null data.\n - Non-matching cases: unknown attribute slug; known attribute with value that doesn’t exist; numeric out-of-range; boolean with no matching records; mixed list where one attr doesn’t exist should yield zero results, not errors.\n - Multiple attribute filters combined should AND conditions across attributes and return the expected count.\n- Reuse existing helper utilities (associate_attribute_values_to_instance) and fixtures from the test suite. Ensure variable names and GraphQL input naming match the schema (e.g., dateTime, value, slug).\n\n4) CHANGELOG.md\n- Add a bullet under the GraphQL query changes section: \"Add support for filtering `pages` by associated attributes\" and maintain adjacent formatting as in the diff (preserve spacing/blank lines).\n\nBehavioral notes\n- When any provided attribute slug does not exist in the DB, return an empty queryset rather than raising an error.\n- For entries without a \"value\" block, match pages having at least one value assigned for the given attribute.\n- All provided attribute filters in the list are combined with logical AND at the page level.\n- Where-input operators behave as existing core operators: eq matches exact value; oneOf matches any in list; range supports gte/lte; boolean is a simple boolean.\n\nPerformance constraints\n- Use Exists/OuterRef subqueries for attribute value assignment checks to avoid joins and keep DB-side evaluation.\n- For numeric comparison, annotate and cast AttributeValue.name to Float for filter evaluation.\n\nDocumentation and typing\n- Match GraphQL descriptions from the product analogs where appropriate.\n- Ensure field names align with Graphene’s snake_case to camelCase mapping (e.g., date_time -> dateTime in SDL/tests).\n",
1368
- "prompt": "Add support for filtering pages by their associated attributes in the pages where-input. Extend the pages GraphQL where filter to accept a list of attribute conditions that can match by attribute slug alone (existence) or by a single value field, supporting string (slug/name), numeric (with eq/oneOf/range), boolean, date (range), and dateTime (range). Combine multiple attribute conditions with AND. Validate that each attribute condition has a unique slug, that when a value is provided it contains exactly one key and is non-empty, and that any type-specific key matches the attribute’s input type. Update the GraphQL schema/types accordingly, use efficient DB-side filtering (Exists/OuterRef), and add comprehensive tests covering positive cases, validation errors, non-matching filters, and multi-attribute combinations. Also add a concise changelog entry announcing the new pages attribute filtering capability.",
1369
- "supplementalFiles": [
1370
- "saleor/graphql/core/filters/where_filters.py",
1371
- "saleor/graphql/core/filters/where_input.py",
1372
- "saleor/graphql/utils/filters.py",
1373
- "saleor/graphql/product/filters.py",
1374
- "saleor/graphql/attribute/types.py",
1375
- "saleor/graphql/page/types.py",
1376
- "saleor/page/models.py",
1377
- "saleor/attribute/models/page.py"
1378
- ],
1379
- "fileDiffs": [
1380
- {
1381
- "path": "CHANGELOG.md",
1382
- "status": "modified",
1383
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\tf50682a (parent)\n+++ CHANGELOG.md\t01138c1 (commit)\n@@ -11,8 +11,9 @@\n - Added support for filtering products by attribute value names. The `AttributeInput` now includes a `valueNames` field, enabling filtering by the names of attribute values, in addition to the existing filtering by value slugs.\n - You can now filter and search orders using the new `where` and `search` fields on the `pages` query.\n - Use `where` to define complex conditions with `AND`/`OR` logic and operators like `eq`, `oneOf`, `range`.\n - Use `search` to perform full-text search across relevant fields.\n+- Add support for filtering `pages` by associated attributes\n - You can now filter and search orders using the new `where` and `search` fields on the `orders` query.\n - Use `where` to define complex conditions with `AND`/`OR` logic and operators like `eq`, `oneOf`, `range`.\n - Use `search` to perform full-text search across relevant fields.\n - Added filtering options for orders:\n@@ -77,8 +78,9 @@\n - `category.products`\n - `collection.products`\n - `pageType.availableAttributes`\n \n+\n ### Webhooks\n - Transaction webhooks responsible for processing payments can now return payment method details`, which will be associated with the corresponding transaction. See [docs](https://docs.saleor.io/developer/extending/webhooks/synchronous-events/transaction#response-4) to learn more.\n \n ### Other changes\n"
1384
- },
1385
- {
1386
- "path": "saleor/graphql/page/filters.py",
1387
- "status": "modified",
1388
- "diff": "Index: saleor/graphql/page/filters.py\n===================================================================\n--- saleor/graphql/page/filters.py\tf50682a (parent)\n+++ saleor/graphql/page/filters.py\t01138c1 (commit)\n@@ -1,8 +1,12 @@\n import django_filters\n import graphene\n-from django.db.models import Q\n+from django.db.models import Exists, FloatField, OuterRef, Q\n+from django.db.models.functions import Cast\n+from graphql import GraphQLError\n \n+from ...attribute import AttributeInputType\n+from ...attribute.models import AssignedPageAttributeValue, Attribute, AttributeValue\n from ...page import models\n from ..core.doc_category import DOC_CATEGORY_PAGES\n from ..core.filters import (\n FilterInputObjectType,\n@@ -11,22 +15,28 @@\n MetadataFilterBase,\n )\n from ..core.filters.where_filters import (\n GlobalIDMultipleChoiceWhereFilter,\n+ ListObjectTypeWhereFilter,\n MetadataWhereBase,\n OperationObjectTypeWhereFilter,\n )\n from ..core.filters.where_input import (\n+ DecimalFilterInput,\n GlobalIDFilterInput,\n StringFilterInput,\n WhereInputObjectType,\n )\n+from ..core.types.base import BaseInputObjectType\n+from ..core.types.common import DateRangeInput, DateTimeRangeInput\n from ..utils import resolve_global_ids_to_primary_keys\n from ..utils.filters import (\n filter_by_id,\n filter_by_ids,\n+ filter_range_field,\n filter_slug_list,\n filter_where_by_id_field,\n+ filter_where_by_numeric_field,\n filter_where_by_value_field,\n )\n from .types import Page, PageType\n \n@@ -53,8 +63,267 @@\n return qs\n return qs.filter(Q(name__trigram_similar=value) | Q(slug__trigram_similar=value))\n \n \n+def filter_by_slug_or_name(attr_id, attr_value, db_connection_name: str):\n+ attribute_values = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute_id=attr_id\n+ )\n+ if \"slug\" in attr_value:\n+ attribute_values = filter_where_by_value_field(\n+ attribute_values, \"slug\", attr_value[\"slug\"]\n+ )\n+ if \"name\" in attr_value:\n+ attribute_values = filter_where_by_value_field(\n+ attribute_values, \"name\", attr_value[\"name\"]\n+ )\n+ assigned_attr_value = AssignedPageAttributeValue.objects.using(\n+ db_connection_name\n+ ).filter(\n+ Exists(attribute_values.filter(id=OuterRef(\"value_id\"))),\n+ page_id=OuterRef(\"id\"),\n+ )\n+ return Q(Exists(assigned_attr_value))\n+\n+\n+def filter_by_numeric_attribute(attr_id, numeric_value, db_connection_name: str):\n+ qs_by_numeric = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute_id=attr_id\n+ )\n+ qs_by_numeric = qs_by_numeric.annotate(numeric_value=Cast(\"name\", FloatField()))\n+ qs_by_numeric = filter_where_by_numeric_field(\n+ qs_by_numeric,\n+ \"numeric_value\",\n+ numeric_value,\n+ )\n+ assigned_attr_value = AssignedPageAttributeValue.objects.using(\n+ db_connection_name\n+ ).filter(\n+ Exists(qs_by_numeric.filter(id=OuterRef(\"value_id\"))),\n+ page_id=OuterRef(\"id\"),\n+ )\n+ return Q(Exists(assigned_attr_value))\n+\n+\n+def filter_by_boolean_attribute(attr_id, boolean_value, db_connection_name: str):\n+ qs_by_boolean = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute_id=attr_id\n+ )\n+ qs_by_boolean = qs_by_boolean.filter(boolean=boolean_value)\n+ assigned_attr_value = AssignedPageAttributeValue.objects.using(\n+ db_connection_name\n+ ).filter(\n+ Exists(qs_by_boolean.filter(id=OuterRef(\"value_id\"))),\n+ page_id=OuterRef(\"id\"),\n+ )\n+ return Q(Exists(assigned_attr_value))\n+\n+\n+def filter_by_date_attribute(attr_id, date_value, db_connection_name: str):\n+ qs_by_date = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute_id=attr_id\n+ )\n+ qs_by_date = filter_range_field(\n+ qs_by_date,\n+ \"date_time__date\",\n+ date_value,\n+ )\n+ assigned_attr_value = AssignedPageAttributeValue.objects.using(\n+ db_connection_name\n+ ).filter(\n+ Exists(qs_by_date.filter(id=OuterRef(\"value_id\"))),\n+ page_id=OuterRef(\"id\"),\n+ )\n+ return Q(Exists(assigned_attr_value))\n+\n+\n+def filter_by_date_time_attribute(attr_id, date_time_value, db_connection_name: str):\n+ qs_by_date_time = AttributeValue.objects.using(db_connection_name).filter(\n+ attribute_id=attr_id\n+ )\n+ qs_by_date_time = filter_range_field(\n+ qs_by_date_time,\n+ \"date_time\",\n+ date_time_value,\n+ )\n+ assigned_attr_value = AssignedPageAttributeValue.objects.using(\n+ db_connection_name\n+ ).filter(\n+ Exists(qs_by_date_time.filter(id=OuterRef(\"value_id\"))),\n+ page_id=OuterRef(\"id\"),\n+ )\n+ return Exists(assigned_attr_value)\n+\n+\n+def filter_pages_by_attributes(qs, value):\n+ attribute_slugs = {attr_filter[\"slug\"] for attr_filter in value}\n+ attributes_map = {\n+ attr.slug: attr\n+ for attr in Attribute.objects.using(qs.db).filter(slug__in=attribute_slugs)\n+ }\n+ if len(attribute_slugs) != len(attributes_map.keys()):\n+ # Filter over non existing attribute\n+ return qs.none()\n+\n+ attr_filter_expression = Q()\n+ attr_without_values_input = [\n+ attributes_map[attr_filter[\"slug\"]]\n+ for attr_filter in value\n+ if not attr_filter.get(\"value\")\n+ ]\n+ if attr_without_values_input:\n+ atr_value_qs = AttributeValue.objects.using(qs.db).filter(\n+ attribute_id__in=[attr.id for attr in attr_without_values_input]\n+ )\n+ assigned_attr_value = AssignedPageAttributeValue.objects.using(qs.db).filter(\n+ Exists(atr_value_qs.filter(id=OuterRef(\"value_id\"))),\n+ page_id=OuterRef(\"id\"),\n+ )\n+ attr_filter_expression = Q(Exists(assigned_attr_value))\n+\n+ for attr_filter in value:\n+ attr_value = attr_filter.get(\"value\")\n+ if not attr_value:\n+ # attrs without value input are handled separately\n+ continue\n+\n+ attr = attributes_map[attr_filter[\"slug\"]]\n+ attr_value = attr_filter[\"value\"]\n+ if \"slug\" in attr_value or \"name\" in attr_value:\n+ attr_filter_expression &= filter_by_slug_or_name(\n+ attr.id,\n+ attr_value,\n+ qs.db,\n+ )\n+ elif attr.input_type == AttributeInputType.NUMERIC:\n+ attr_filter_expression &= filter_by_numeric_attribute(\n+ attr.id, attr_value[\"numeric\"], qs.db\n+ )\n+ elif attr.input_type == AttributeInputType.BOOLEAN:\n+ attr_filter_expression &= filter_by_boolean_attribute(\n+ attr.id, attr_value[\"boolean\"], qs.db\n+ )\n+ elif attr.input_type == AttributeInputType.DATE:\n+ attr_filter_expression &= filter_by_date_attribute(\n+ attr.id, attr_value[\"date\"], qs.db\n+ )\n+ elif attr.input_type == AttributeInputType.DATE_TIME:\n+ attr_filter_expression &= filter_by_date_time_attribute(\n+ attr.id, attr_value[\"date_time\"], qs.db\n+ )\n+ if attr_filter_expression != Q():\n+ return qs.filter(attr_filter_expression)\n+ return qs.none()\n+\n+\n+def validate_attribute_value_input(attributes: list[dict], db_connection_name: str):\n+ slug_list = [attr[\"slug\"] for attr in attributes]\n+ value_as_empty_list = []\n+ value_more_than_one_list = []\n+ invalid_input_type_list = []\n+ if len(slug_list) != len(set(slug_list)):\n+ raise GraphQLError(\n+ message=\"Duplicated attribute slugs in attribute 'where' input are not allowed.\"\n+ )\n+\n+ type_specific_value_list = {}\n+ for attr in attributes:\n+ if \"value\" not in attr:\n+ continue\n+ value = attr[\"value\"]\n+ if not value:\n+ value_as_empty_list.append(attr[\"slug\"])\n+ continue\n+ value_keys = value.keys()\n+ if len(value_keys) > 1:\n+ value_more_than_one_list.append(attr[\"slug\"])\n+ continue\n+ value_key = list(value_keys)[0]\n+ if value_key not in [\"slug\", \"name\"]:\n+ type_specific_value_list[attr[\"slug\"]] = value_key\n+ if value[value_key] is None:\n+ value_as_empty_list.append(attr[\"slug\"])\n+ continue\n+\n+ if type_specific_value_list:\n+ attribute_input_type_map = Attribute.objects.using(db_connection_name).in_bulk(\n+ type_specific_value_list.keys(),\n+ field_name=\"slug\",\n+ )\n+\n+ for attr_slug, value_key in type_specific_value_list.items():\n+ if attr_slug not in attribute_input_type_map:\n+ continue\n+\n+ input_type = attribute_input_type_map[attr_slug].input_type\n+ if \"numeric\" == value_key and input_type != AttributeInputType.NUMERIC:\n+ invalid_input_type_list.append(attr_slug)\n+ if \"date\" == value_key and input_type != AttributeInputType.DATE:\n+ invalid_input_type_list.append(attr_slug)\n+ if \"date_time\" == value_key and input_type != AttributeInputType.DATE_TIME:\n+ invalid_input_type_list.append(attr_slug)\n+ if \"boolean\" == value_key and input_type != AttributeInputType.BOOLEAN:\n+ invalid_input_type_list.append(attr_slug)\n+\n+ if value_as_empty_list:\n+ raise GraphQLError(\n+ message=(\n+ f\"Incorrect input for attributes with slugs: {','.join(value_as_empty_list)}. \"\n+ \"Provided 'value' cannot be empty or null.\"\n+ )\n+ )\n+ if value_more_than_one_list:\n+ raise GraphQLError(\n+ message=(\n+ f\"Incorrect input for attributes with slugs: {','.join(value_more_than_one_list)}. \"\n+ \"Provided 'value' must have only one input key.\"\n+ )\n+ )\n+ if invalid_input_type_list:\n+ raise GraphQLError(\n+ message=(\n+ f\"Incorrect input for attributes with slugs: {','.join(invalid_input_type_list)}. \"\n+ \"Provided 'value' do not match the attribute input type.\"\n+ )\n+ )\n+\n+\n+class AttributeValuePageInput(BaseInputObjectType):\n+ slug = StringFilterInput(\n+ description=\"Filter by slug assigned to AttributeValue.\",\n+ )\n+ name = StringFilterInput(\n+ description=\"Filter by name assigned to AttributeValue.\",\n+ )\n+ numeric = DecimalFilterInput(\n+ required=False,\n+ description=\"Filter by numeric value for attributes of numeric type.\",\n+ )\n+ date = DateRangeInput(\n+ required=False,\n+ description=\"Filter by date value for attributes of date type.\",\n+ )\n+ date_time = DateTimeRangeInput(\n+ required=False,\n+ description=\"Filter by date time value for attributes of date time type.\",\n+ )\n+ boolean = graphene.Boolean(\n+ required=False,\n+ description=\"Filter by boolean value for attributes of boolean type.\",\n+ )\n+\n+\n+class AttributePageWhereInput(BaseInputObjectType):\n+ slug = graphene.String(description=\"Filter by attribute slug.\", required=True)\n+ value = AttributeValuePageInput(\n+ required=False,\n+ description=(\n+ \"Filter by value of the attribute. Only one value input field is allowed. \"\n+ \"If provided more than one, the error will be raised.\"\n+ ),\n+ )\n+\n+\n class PageWhere(MetadataWhereBase):\n ids = GlobalIDMultipleChoiceWhereFilter(method=filter_by_ids(\"Page\"))\n slug = OperationObjectTypeWhereFilter(\n input_class=StringFilterInput,\n@@ -65,8 +334,13 @@\n input_class=GlobalIDFilterInput,\n method=\"filter_page_type\",\n help_text=\"Filter by page type.\",\n )\n+ attributes = ListObjectTypeWhereFilter(\n+ input_class=AttributePageWhereInput,\n+ method=\"filter_attributes\",\n+ help_text=\"Filter by attributes associated with the page.\",\n+ )\n \n @staticmethod\n def filter_page_slug(qs, _, value):\n return filter_where_by_value_field(qs, \"slug\", value)\n@@ -76,9 +350,20 @@\n if not value:\n return qs\n return filter_where_by_id_field(qs, \"page_type\", value, \"PageType\")\n \n+ @staticmethod\n+ def filter_attributes(qs, _, value):\n+ if not value:\n+ return qs\n+ return filter_pages_by_attributes(qs, value)\n \n+ def is_valid(self):\n+ if attributes := self.data.get(\"attributes\"):\n+ validate_attribute_value_input(attributes, self.queryset.db)\n+ return super().is_valid()\n+\n+\n def filter_page_search(qs, _, value):\n # Skip search, as search is applied on resolver side.\n return qs\n \n"
1389
- },
1390
- {
1391
- "path": "saleor/graphql/page/tests/queries/test_pages_with_where.py",
1392
- "status": "modified",
1393
- "diff": "Index: saleor/graphql/page/tests/queries/test_pages_with_where.py\n===================================================================\n--- saleor/graphql/page/tests/queries/test_pages_with_where.py\tf50682a (parent)\n+++ saleor/graphql/page/tests/queries/test_pages_with_where.py\t01138c1 (commit)\n@@ -1,7 +1,11 @@\n+import datetime\n+\n import graphene\n import pytest\n \n+from .....attribute import AttributeInputType\n+from .....attribute.utils import associate_attribute_values_to_instance\n from .....page.models import Page, PageType\n from ....tests.utils import get_graphql_content\n \n QUERY_PAGES_WITH_WHERE = \"\"\"\n@@ -143,15 +147,869 @@\n \n def test_pages_query_with_where_by_ids(\n staff_api_client, permission_manage_pages, page_list, page_list_unpublished\n ):\n+ # given\n query = QUERY_PAGES_WITH_WHERE\n \n page_ids = [\n graphene.Node.to_global_id(\"Page\", page.pk)\n for page in [page_list[0], page_list_unpublished[-1]]\n ]\n variables = {\"where\": {\"ids\": page_ids}}\n+\n+ # when\n staff_api_client.user.user_permissions.add(permission_manage_pages)\n+\n+ # then\n response = staff_api_client.post_graphql(query, variables)\n content = get_graphql_content(response)\n assert content[\"data\"][\"pages\"][\"totalCount\"] == len(page_ids)\n+\n+\n+def test_pages_query_with_attribute_slug(\n+ staff_api_client, page_list, page_type, size_page_attribute\n+):\n+ # given\n+ page_type.page_attributes.add(size_page_attribute)\n+ page_attr_value = size_page_attribute.values.first()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0], {size_page_attribute.pk: [page_attr_value]}\n+ )\n+\n+ variables = {\"where\": {\"attributes\": [{\"slug\": size_page_attribute.slug}]}}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == 1\n+ assert pages_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"Page\", page_list[0].pk\n+ )\n+\n+\n+@pytest.mark.parametrize(\n+ (\"slug_input\", \"expected_count\"),\n+ [\n+ ({\"eq\": \"test-slug-1\"}, 1),\n+ ({\"oneOf\": [\"test-slug-1\", \"test-slug-2\"]}, 2),\n+ ],\n+)\n+def test_pages_query_with_attribute_value_slug(\n+ slug_input,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ size_page_attribute,\n+):\n+ # given\n+ page_type.page_attributes.add(size_page_attribute)\n+\n+ attr_value_1 = size_page_attribute.values.first()\n+ attr_value_1.slug = \"test-slug-1\"\n+ attr_value_1.save()\n+\n+ attr_value_2 = size_page_attribute.values.last()\n+ attr_value_2.slug = \"test-slug-2\"\n+ attr_value_2.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0], {size_page_attribute.pk: [attr_value_1]}\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page_list[1], {size_page_attribute.pk: [attr_value_2]}\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\"slug\": size_page_attribute.slug, \"value\": {\"slug\": slug_input}}\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"name_input\", \"expected_count\"),\n+ [\n+ ({\"eq\": \"test-name-1\"}, 1),\n+ ({\"oneOf\": [\"test-name-1\", \"test-name-2\"]}, 2),\n+ ],\n+)\n+def test_pages_query_with_attribute_value_name(\n+ name_input,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ size_page_attribute,\n+):\n+ # given\n+ page_type.page_attributes.add(size_page_attribute)\n+\n+ attr_value_1 = size_page_attribute.values.first()\n+ attr_value_1.name = \"test-name-1\"\n+ attr_value_1.save()\n+\n+ attr_value_2 = size_page_attribute.values.last()\n+ attr_value_2.name = \"test-name-2\"\n+ attr_value_2.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0], {size_page_attribute.pk: [attr_value_1]}\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page_list[1], {size_page_attribute.pk: [attr_value_2]}\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\"slug\": size_page_attribute.slug, \"value\": {\"name\": name_input}}\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"numeric_input\", \"expected_count\"),\n+ [\n+ ({\"numeric\": {\"eq\": 1.2}}, 1),\n+ ({\"numeric\": {\"oneOf\": [1.2, 2]}}, 2),\n+ ({\"numeric\": {\"range\": {\"gte\": 1, \"lte\": 2}}}, 2),\n+ ({\"name\": {\"eq\": \"1.2\"}}, 1),\n+ ({\"slug\": {\"eq\": \"1.2\"}}, 1),\n+ ({\"name\": {\"oneOf\": [\"1.2\", \"2\"]}}, 2),\n+ ({\"slug\": {\"oneOf\": [\"1.2\", \"2\"]}}, 2),\n+ ],\n+)\n+def test_pages_query_with_attribute_value_numeric(\n+ numeric_input,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ numeric_attribute_without_unit,\n+):\n+ # given\n+ numeric_attribute_without_unit.type = \"PAGE_TYPE\"\n+ numeric_attribute_without_unit.save()\n+\n+ page_type.page_attributes.add(numeric_attribute_without_unit)\n+\n+ attr_value_1 = numeric_attribute_without_unit.values.first()\n+ attr_value_1.name = \"1.2\"\n+ attr_value_1.slug = \"1.2\"\n+ attr_value_1.save()\n+\n+ attr_value_2 = numeric_attribute_without_unit.values.last()\n+ attr_value_2.name = \"2\"\n+ attr_value_2.slug = \"2\"\n+ attr_value_2.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0], {numeric_attribute_without_unit.pk: [attr_value_1]}\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page_list[1], {numeric_attribute_without_unit.pk: [attr_value_2]}\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": numeric_attribute_without_unit.slug,\n+ \"value\": numeric_input,\n+ }\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"date_input\", \"expected_count\"),\n+ [\n+ ({\"date\": {\"gte\": \"2021-01-01\"}}, 2),\n+ ({\"name\": {\"eq\": \"date-name-1\"}}, 1),\n+ ({\"slug\": {\"eq\": \"date-slug-1\"}}, 1),\n+ (\n+ {\n+ \"name\": {\"oneOf\": [\"date-name-1\", \"date-name-2\"]},\n+ },\n+ 2,\n+ ),\n+ (\n+ {\n+ \"slug\": {\"oneOf\": [\"date-slug-1\", \"date-slug-2\"]},\n+ },\n+ 2,\n+ ),\n+ ({\"date\": {\"gte\": \"2021-01-01\", \"lte\": \"2021-01-02\"}}, 1),\n+ ],\n+)\n+def test_pages_query_with_attribute_value_date(\n+ date_input,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ date_attribute,\n+):\n+ # given\n+ date_attribute.type = \"PAGE_TYPE\"\n+ date_attribute.save()\n+\n+ page_type.page_attributes.add(date_attribute)\n+\n+ attr_value_1 = date_attribute.values.first()\n+ attr_value_1.date_time = datetime.datetime(2021, 1, 3, tzinfo=datetime.UTC)\n+ attr_value_1.name = \"date-name-1\"\n+ attr_value_1.slug = \"date-slug-1\"\n+ attr_value_1.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0], {date_attribute.pk: [attr_value_1]}\n+ )\n+\n+ second_attr_value = date_attribute.values.last()\n+ second_attr_value.date_time = datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC)\n+ second_attr_value.name = \"date-name-2\"\n+ second_attr_value.slug = \"date-slug-2\"\n+ second_attr_value.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[1], {date_attribute.pk: [second_attr_value]}\n+ )\n+\n+ variables = {\n+ \"where\": {\"attributes\": [{\"slug\": date_attribute.slug, \"value\": date_input}]}\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"date_time_input\", \"expected_count\"),\n+ [\n+ (\n+ {\n+ \"name\": {\"eq\": \"datetime-name-1\"},\n+ },\n+ 1,\n+ ),\n+ (\n+ {\n+ \"slug\": {\"eq\": \"datetime-slug-1\"},\n+ },\n+ 1,\n+ ),\n+ (\n+ {\n+ \"name\": {\"oneOf\": [\"datetime-name-1\", \"datetime-name-2\"]},\n+ },\n+ 2,\n+ ),\n+ (\n+ {\n+ \"slug\": {\"oneOf\": [\"datetime-slug-1\", \"datetime-slug-2\"]},\n+ },\n+ 2,\n+ ),\n+ ({\"dateTime\": {\"gte\": \"2021-01-01T00:00:00Z\"}}, 2),\n+ (\n+ {\n+ \"dateTime\": {\n+ \"gte\": \"2021-01-01T00:00:00Z\",\n+ \"lte\": \"2021-01-02T00:00:00Z\",\n+ }\n+ },\n+ 1,\n+ ),\n+ ],\n+)\n+def test_pages_query_with_attribute_value_date_time(\n+ date_time_input,\n+ expected_count,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ date_time_attribute,\n+):\n+ # given\n+ date_time_attribute.type = \"PAGE_TYPE\"\n+ date_time_attribute.save()\n+\n+ page_type.page_attributes.add(date_time_attribute)\n+\n+ attr_value_1 = date_time_attribute.values.first()\n+ attr_value_1.date_time = datetime.datetime(2021, 1, 3, tzinfo=datetime.UTC)\n+ attr_value_1.name = \"datetime-name-1\"\n+ attr_value_1.slug = \"datetime-slug-1\"\n+ attr_value_1.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0], {date_time_attribute.pk: [attr_value_1]}\n+ )\n+\n+ second_attr_value = date_time_attribute.values.last()\n+ second_attr_value.date_time = datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC)\n+ second_attr_value.name = \"datetime-name-2\"\n+ second_attr_value.slug = \"datetime-slug-2\"\n+ second_attr_value.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[1], {date_time_attribute.pk: [second_attr_value]}\n+ )\n+\n+ variables = {\n+ \"where\": {\n+ \"attributes\": [\n+ {\n+ \"slug\": date_time_attribute.slug,\n+ \"value\": date_time_input,\n+ }\n+ ]\n+ }\n+ }\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ \"boolean_input\",\n+ [\n+ {\"boolean\": True},\n+ {\n+ \"name\": {\"eq\": \"True-name\"},\n+ },\n+ {\n+ \"slug\": {\"eq\": \"true_slug\"},\n+ },\n+ {\"name\": {\"oneOf\": [\"True-name\", \"True-name-2\"]}},\n+ {\n+ \"slug\": {\"oneOf\": [\"true_slug\"]},\n+ },\n+ ],\n+)\n+def test_pages_query_with_attribute_value_boolean(\n+ boolean_input,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ boolean_attribute,\n+):\n+ # given\n+ boolean_attribute.type = \"PAGE_TYPE\"\n+ boolean_attribute.save()\n+\n+ page_type.page_attributes.add(boolean_attribute)\n+\n+ true_value = boolean_attribute.values.filter(boolean=True).first()\n+ true_value.name = \"True-name\"\n+ true_value.slug = \"true_slug\"\n+ true_value.save()\n+\n+ false_value = boolean_attribute.values.filter(boolean=False).first()\n+ false_value.name = \"False-name\"\n+ false_value.slug = \"false_slug\"\n+ false_value.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0], {boolean_attribute.pk: [true_value]}\n+ )\n+\n+ associate_attribute_values_to_instance(\n+ page_list[1], {boolean_attribute.pk: [false_value]}\n+ )\n+\n+ variables = {\"where\": {\"attributes\": [{\"slug\": \"boolean\", \"value\": boolean_input}]}}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == 1\n+ assert pages_nodes[0][\"node\"][\"id\"] == graphene.Node.to_global_id(\n+ \"Page\", page_list[0].pk\n+ )\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_filter\",\n+ [\n+ # When input receives None\n+ [{\"slug\": \"page-size\"}, {\"slug\": \"page-size\"}],\n+ [{\"slug\": \"page-size\", \"value\": {\"slug\": None}}],\n+ [{\"slug\": \"page-size\", \"value\": {\"name\": None}}],\n+ # Cant have multiple value input fields\n+ [\n+ {\n+ \"slug\": \"page-size\",\n+ \"value\": {\n+ \"slug\": {\"eq\": \"true_slug\"},\n+ \"name\": {\"eq\": \"name\"},\n+ },\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"page-size\",\n+ \"value\": {\n+ \"slug\": {\"oneOf\": [\"true_slug\"]},\n+ \"name\": {\"oneOf\": [\"name\"]},\n+ },\n+ }\n+ ],\n+ [\n+ {\n+ \"slug\": \"page-size\",\n+ \"value\": {\n+ \"name\": {\"eq\": \"name\"},\n+ },\n+ },\n+ {\"slug\": \"count\", \"value\": {\"numeric\": None}},\n+ ],\n+ # numeric attribute\n+ [{\"slug\": \"count\", \"value\": {\"numeric\": None}}],\n+ [{\"slug\": \"count\", \"value\": {\"name\": None}}],\n+ [{\"slug\": \"count\", \"value\": {\"slug\": None}}],\n+ # Numeric can't be used with non numeric fields\n+ [{\"slug\": \"count\", \"value\": {\"boolean\": False}}],\n+ # boolean attribute\n+ [{\"slug\": \"boolean\", \"value\": {\"boolean\": None}}],\n+ [{\"slug\": \"boolean\", \"value\": {\"name\": None}}],\n+ [{\"slug\": \"boolean\", \"value\": {\"slug\": None}}],\n+ # Boolean can't be used with non boolean fields\n+ [{\"slug\": \"boolean\", \"value\": {\"numeric\": {\"eq\": 1.2}}}],\n+ # date attribute\n+ [{\"slug\": \"date\", \"value\": {\"date\": None}}],\n+ [{\"slug\": \"date\", \"value\": {\"name\": None}}],\n+ [{\"slug\": \"date\", \"value\": {\"slug\": None}}],\n+ # Date can't be used with non date fields\n+ [{\"slug\": \"date\", \"value\": {\"numeric\": {\"eq\": 1.2}}}],\n+ # datetime attribute\n+ [{\"slug\": \"date_time\", \"value\": {\"dateTime\": None}}],\n+ [{\"slug\": \"date_time\", \"value\": {\"name\": None}}],\n+ [{\"slug\": \"date_time\", \"value\": {\"slug\": None}}],\n+ # Date time can't be used with non date time fields\n+ [{\"slug\": \"date_time\", \"value\": {\"numeric\": {\"eq\": 1.2}}}],\n+ ],\n+)\n+def test_pages_query_failed_filter_validation(\n+ attribute_filter,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ size_page_attribute,\n+ tag_page_attribute,\n+ boolean_attribute,\n+ numeric_attribute_without_unit,\n+ date_attribute,\n+ date_time_attribute,\n+):\n+ # given\n+ boolean_attribute.type = \"PAGE_TYPE\"\n+ boolean_attribute.save()\n+ numeric_attribute_without_unit.type = \"PAGE_TYPE\"\n+ numeric_attribute_without_unit.save()\n+\n+ page_type.page_attributes.add(size_page_attribute)\n+ page_type.page_attributes.add(tag_page_attribute)\n+ page_type.page_attributes.add(boolean_attribute)\n+ page_type.page_attributes.add(numeric_attribute_without_unit)\n+ page_type.page_attributes.add(date_attribute)\n+ page_type.page_attributes.add(date_time_attribute)\n+\n+ size_value = size_page_attribute.values.get(slug=\"10\")\n+ tag_value = tag_page_attribute.values.get(name=\"About\")\n+ boolean_value = boolean_attribute.values.filter(boolean=True).first()\n+ numeric_value = numeric_attribute_without_unit.values.first()\n+ date_time_value = date_time_attribute.values.first()\n+ date_value = date_attribute.values.first()\n+\n+ date_attribute.slug = \"date\"\n+ date_attribute.save()\n+ date_time_attribute.slug = \"date_time\"\n+ date_time_attribute.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0],\n+ {\n+ size_page_attribute.pk: [size_value],\n+ tag_page_attribute.pk: [tag_value],\n+ boolean_attribute.pk: [boolean_value],\n+ numeric_attribute_without_unit.pk: [numeric_value],\n+ date_attribute.pk: [date_value],\n+ date_time_attribute.pk: [date_time_value],\n+ },\n+ )\n+\n+ variables = {\"where\": {\"attributes\": attribute_filter}}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response, ignore_errors=True)\n+ assert \"errors\" in content\n+ assert content[\"data\"][\"pages\"] is None\n+\n+\n+@pytest.mark.parametrize(\n+ \"attribute_filter\",\n+ [\n+ # Non-existing attribute slug\n+ [{\"slug\": \"non-existing-attribute\"}],\n+ # Existing attribute with non-existing value name\n+ [{\"slug\": \"tag\", \"value\": {\"name\": {\"eq\": \"Non-existing Name\"}}}],\n+ # Existing numeric attribute with out-of-range value\n+ [{\"slug\": \"count\", \"value\": {\"numeric\": {\"eq\": 999}}}],\n+ # Existing boolean attribute with no matching boolean value\n+ [{\"slug\": \"boolean\", \"value\": {\"boolean\": False}}],\n+ # Multiple attributes where one doesn't exist\n+ [\n+ {\"slug\": \"page-size\", \"value\": {\"slug\": {\"eq\": \"10\"}}},\n+ {\"slug\": \"non-existing-attr\", \"value\": {\"slug\": {\"eq\": \"some-value\"}}},\n+ ],\n+ ],\n+)\n+def test_pages_query_with_non_matching_records(\n+ attribute_filter,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ size_page_attribute,\n+ tag_page_attribute,\n+ boolean_attribute,\n+ numeric_attribute_without_unit,\n+ date_attribute,\n+ date_time_attribute,\n+):\n+ # given\n+ boolean_attribute.type = \"PAGE_TYPE\"\n+ boolean_attribute.save()\n+ numeric_attribute_without_unit.type = \"PAGE_TYPE\"\n+ numeric_attribute_without_unit.save()\n+\n+ page_type.page_attributes.add(size_page_attribute)\n+ page_type.page_attributes.add(tag_page_attribute)\n+ page_type.page_attributes.add(boolean_attribute)\n+ page_type.page_attributes.add(numeric_attribute_without_unit)\n+ page_type.page_attributes.add(date_attribute)\n+ page_type.page_attributes.add(date_time_attribute)\n+\n+ size_value = size_page_attribute.values.get(slug=\"10\")\n+ tag_value = tag_page_attribute.values.get(name=\"About\")\n+ boolean_value = boolean_attribute.values.filter(boolean=True).first()\n+ numeric_value = numeric_attribute_without_unit.values.first()\n+ date_time_value = date_time_attribute.values.first()\n+ date_value = date_attribute.values.first()\n+\n+ date_attribute.slug = \"date\"\n+ date_attribute.save()\n+ date_time_attribute.slug = \"date_time\"\n+ date_time_attribute.save()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0],\n+ {\n+ size_page_attribute.pk: [size_value],\n+ tag_page_attribute.pk: [tag_value],\n+ boolean_attribute.pk: [boolean_value],\n+ numeric_attribute_without_unit.pk: [numeric_value],\n+ date_attribute.pk: [date_value],\n+ date_time_attribute.pk: [date_time_value],\n+ },\n+ )\n+\n+ variables = {\"where\": {\"attributes\": attribute_filter}}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == 0\n+\n+\n+@pytest.mark.parametrize(\n+ (\"attribute_where_input\", \"expected_count_result\"),\n+ [\n+ (\n+ [\n+ {\n+ \"slug\": \"page-size\",\n+ \"value\": {\n+ \"numeric\": {\"range\": {\"lte\": 89}},\n+ },\n+ },\n+ {\n+ \"slug\": \"tag\",\n+ \"value\": {\"name\": {\"oneOf\": [\"About\", \"Help\"]}},\n+ },\n+ {\n+ \"slug\": \"author\",\n+ \"value\": {\n+ \"slug\": {\"oneOf\": [\"test-author-1\"]},\n+ },\n+ },\n+ {\"slug\": \"boolean\", \"value\": {\"boolean\": True}},\n+ ],\n+ 1,\n+ ),\n+ # (\n+ # [\n+ # {\n+ # \"slug\": \"page-size\",\n+ # \"value\": {\n+ # \"slug\": {\"eq\": \"10\"},\n+ # },\n+ # },\n+ # {\n+ # \"slug\": \"tag\",\n+ # \"value\": {\"name\": {\"oneOf\": [\"About\", \"Help\"]}},\n+ # },\n+ # ],\n+ # 1,\n+ # ),\n+ # (\n+ # [\n+ # {\n+ # \"slug\": \"page-size\",\n+ # \"value\": {\"slug\": {\"eq\": \"10\"}},\n+ # },\n+ # {\"slug\": \"boolean\", \"value\": {\"boolean\": False}},\n+ # ],\n+ # 0,\n+ # ),\n+ # (\n+ # [\n+ # {\n+ # \"slug\": \"tag\",\n+ # \"value\": {\n+ # \"name\": {\"eq\": \"About\"},\n+ # },\n+ # },\n+ # {\n+ # \"slug\": \"page-size\",\n+ # \"value\": {\"slug\": {\"eq\": \"10\"}},\n+ # },\n+ # ],\n+ # 1,\n+ # ),\n+ # (\n+ # [\n+ # {\n+ # \"slug\": \"page-size\",\n+ # \"value\": {\"slug\": {\"eq\": \"15\"}},\n+ # },\n+ # {\n+ # \"slug\": \"tag\",\n+ # \"value\": {\"name\": {\"eq\": \"Help\"}},\n+ # },\n+ # {\"slug\": \"boolean\", \"value\": {\"boolean\": False}},\n+ # ],\n+ # 0,\n+ # ),\n+ # (\n+ # [\n+ # {\n+ # \"slug\": \"author\",\n+ # \"value\": {\"slug\": {\"oneOf\": [\"test-author-1\", \"test-author-2\"]}},\n+ # },\n+ # {\n+ # \"slug\": \"page-size\",\n+ # \"value\": {\"slug\": {\"eq\": \"10\"}},\n+ # },\n+ # ],\n+ # 1,\n+ # ),\n+ # (\n+ # [\n+ # {\n+ # \"slug\": \"page-size\",\n+ # \"value\": {\"slug\": {\"eq\": \"10\"}},\n+ # },\n+ # {\n+ # \"slug\": \"author\",\n+ # \"value\": {\"name\": {\"eq\": \"Test author 1\"}},\n+ # },\n+ # ],\n+ # 1,\n+ # ),\n+ # (\n+ # [\n+ # {\n+ # \"slug\": \"page-size\",\n+ # \"value\": {\"slug\": {\"eq\": \"10\"}},\n+ # },\n+ # {\n+ # \"slug\": \"tag\",\n+ # \"value\": {\"name\": {\"eq\": \"About\"}},\n+ # },\n+ # {\n+ # \"slug\": \"author\",\n+ # \"value\": {\"slug\": {\"eq\": \"test-author-1\"}},\n+ # },\n+ # ],\n+ # 1,\n+ # ),\n+ # (\n+ # [\n+ # {\n+ # \"slug\": \"page-size\",\n+ # \"value\": {\"slug\": {\"oneOf\": [\"10\", \"15\"]}},\n+ # },\n+ # {\n+ # \"slug\": \"tag\",\n+ # \"value\": {\"name\": {\"oneOf\": [\"About\", \"Help\"]}},\n+ # },\n+ # ],\n+ # 2,\n+ # ),\n+ # (\n+ # [\n+ # {\n+ # \"slug\": \"page-size\",\n+ # \"value\": {\"slug\": {\"oneOf\": [\"10\", \"15\"]}},\n+ # },\n+ # {\"slug\": \"boolean\", \"value\": {\"boolean\": True}},\n+ # ],\n+ # 1,\n+ # ),\n+ ],\n+)\n+def test_pages_query_with_multiple_attribute_filters(\n+ attribute_where_input,\n+ expected_count_result,\n+ staff_api_client,\n+ page_list,\n+ page_type,\n+ size_page_attribute,\n+ tag_page_attribute,\n+ author_page_attribute,\n+ boolean_attribute,\n+):\n+ # given\n+ boolean_attribute.type = \"PAGE_TYPE\"\n+ boolean_attribute.save()\n+\n+ page_type.page_attributes.add(size_page_attribute)\n+ size_page_attribute.input_type = AttributeInputType.NUMERIC\n+ size_page_attribute.save()\n+\n+ page_type.page_attributes.add(tag_page_attribute)\n+ page_type.page_attributes.add(author_page_attribute)\n+ page_type.page_attributes.add(boolean_attribute)\n+\n+ size_value = size_page_attribute.values.get(slug=\"10\")\n+ tag_value = tag_page_attribute.values.get(name=\"About\")\n+ author_value = author_page_attribute.values.get(slug=\"test-author-1\")\n+ boolean_value = boolean_attribute.values.filter(boolean=True).first()\n+\n+ associate_attribute_values_to_instance(\n+ page_list[0],\n+ {\n+ size_page_attribute.pk: [size_value],\n+ tag_page_attribute.pk: [tag_value],\n+ author_page_attribute.pk: [author_value],\n+ boolean_attribute.pk: [boolean_value],\n+ },\n+ )\n+\n+ tag_value_2 = tag_page_attribute.values.get(name=\"Help\")\n+ size_value_15 = size_page_attribute.values.get(slug=\"15\")\n+\n+ associate_attribute_values_to_instance(\n+ page_list[1],\n+ {\n+ size_page_attribute.pk: [size_value_15],\n+ tag_page_attribute.pk: [tag_value_2],\n+ },\n+ )\n+\n+ variables = {\"where\": {\"attributes\": attribute_where_input}}\n+\n+ # when\n+ response = staff_api_client.post_graphql(\n+ QUERY_PAGES_WITH_WHERE,\n+ variables,\n+ )\n+\n+ # then\n+ content = get_graphql_content(response)\n+ pages_nodes = content[\"data\"][\"pages\"][\"edges\"]\n+ assert len(pages_nodes) == expected_count_result\n"
1394
- },
1395
- {
1396
- "path": "saleor/graphql/schema.graphql",
1397
- "status": "modified",
1398
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\tf50682a (parent)\n+++ saleor/graphql/schema.graphql\t01138c1 (commit)\n@@ -12866,8 +12866,11 @@\n \n \"\"\"Filter by page type.\"\"\"\n pageType: GlobalIDFilterInput\n \n+ \"\"\"Filter by attributes associated with the page.\"\"\"\n+ attributes: [AttributePageWhereInput!]\n+\n \"\"\"List of conditions that must be met.\"\"\"\n AND: [PageWhereInput!]\n \n \"\"\"A list of conditions of which at least one must be met.\"\"\"\n@@ -12903,8 +12906,38 @@\n \"\"\"The value included in.\"\"\"\n oneOf: [String!]\n }\n \n+input AttributePageWhereInput {\n+ \"\"\"Filter by attribute slug.\"\"\"\n+ slug: String!\n+\n+ \"\"\"\n+ Filter by value of the attribute. Only one value input field is allowed. If provided more than one, the error will be raised.\n+ \"\"\"\n+ value: AttributeValuePageInput\n+}\n+\n+input AttributeValuePageInput {\n+ \"\"\"Filter by slug assigned to AttributeValue.\"\"\"\n+ slug: StringFilterInput\n+\n+ \"\"\"Filter by name assigned to AttributeValue.\"\"\"\n+ name: StringFilterInput\n+\n+ \"\"\"Filter by numeric value for attributes of numeric type.\"\"\"\n+ numeric: DecimalFilterInput\n+\n+ \"\"\"Filter by date value for attributes of date type.\"\"\"\n+ date: DateRangeInput\n+\n+ \"\"\"Filter by date time value for attributes of date time type.\"\"\"\n+ dateTime: DateTimeRangeInput\n+\n+ \"\"\"Filter by boolean value for attributes of boolean type.\"\"\"\n+ boolean: Boolean\n+}\n+\n type PageTypeCountableConnection @doc(category: \"Pages\") {\n \"\"\"Pagination data for this connection.\"\"\"\n pageInfo: PageInfo!\n edges: [PageTypeCountableEdge!]!\n"
1399
- }
1400
- ]
1401
- },
1402
- {
1403
- "id": "fix-account-migrations",
1404
- "sha": "68008240bbeabc59934b96e488a34b8596c5cda8",
1405
- "parentSha": "82e92a9504ad8f0598279b14c0e7b2df48366429",
1406
- "spec": "Implement the following changes to adjust migration sequencing and improve task robustness:\n\n1) Update the placeholder migration comment\n- File: saleor/account/migrations/0091_populate_user_number_of_orders.py\n- Change the inline comment so it states that the population task is called in migration 0095, not 0094. Do not add operations; leave it as an empty migration with the same dependency on (\"account\", \"0090_user_number_of_orders\").\n\n2) Make 0094 a no-op placeholder and move the trigger to 0095\n- File: saleor/account/migrations/0094_repopulate_user_number_of_orders.py\n- Remove the RunPython operation and all related imports/logic that connect post_migrate and trigger the Celery task. Replace with an empty migration (operations = []) and a brief comment indicating the population task is called by 0095. Keep its dependency on (\"account\", \"0093_user_user_number_of_orders_idx\").\n\n3) Add a new migration to trigger the population after 0094\n- File: saleor/account/migrations/0095_repopulate_user_number_of_orders.py (new file)\n- Create a migration that:\n - Imports django.apps.apps as registry, django.db.migrations, and django.db.models.signals.post_migrate.\n - Imports populate_user_number_of_orders_task from .tasks.saleor3_22.\n - Defines a function used by RunPython that, on migration completion, connects a post_migrate handler (weak=False) with sender set to the order app config (registry.get_app_config(\"order\")) which calls populate_user_number_of_orders_task.delay().\n - Sets dependencies to (\"account\", \"0094_repopulate_user_number_of_orders\").\n - Adds a RunPython operation invoking the function with reverse_code set to migrations.RunPython.noop.\n\n4) Guard against missing order counts during backfill\n- File: saleor/account/migrations/tasks/saleor3_22.py\n- In populate_user_number_of_orders_task, when reading from user_total_orders for each user, use a default value of 0 if the user has no non-draft orders. This should be done by retrieving the count with a default of 0 so the assignment to user.number_of_orders never uses None and the equality check works correctly. Do not change batching, locking, or recursion behavior.\n\nBehavioral expectations:\n- After applying migrations, the user.number_of_orders backfill is triggered once post-migrate for the order app, but only from the new 0095 migration. 0094 remains a placeholder.\n- Users without any non-draft orders end up with number_of_orders=0, not None.\n- No changes are required to Celery configuration or to how the order app is referenced; the sender remains the order app config with label \"order\".",
1407
- "prompt": "Adjust the account data migrations to trigger the user order count backfill at the correct point in the sequence and make the backfill robust for users with zero orders. Introduce a new migration after the existing placeholder so the post-migrate hook fires there, keep the earlier migration as a no-op, and update any outdated comments to reflect the new sequencing. Ensure the asynchronous backfill task is invoked after migrations for the order app complete and that users without orders are assigned a zero count instead of a null value.",
1408
- "supplementalFiles": [
1409
- "saleor/celeryconf.py",
1410
- "saleor/account/models.py",
1411
- "saleor/order/actions.py",
1412
- "saleor/order/tasks.py"
1413
- ],
1414
- "fileDiffs": [
1415
- {
1416
- "path": "saleor/account/migrations/0091_populate_user_number_of_orders.py",
1417
- "status": "modified",
1418
- "diff": "Index: saleor/account/migrations/0091_populate_user_number_of_orders.py\n===================================================================\n--- saleor/account/migrations/0091_populate_user_number_of_orders.py\t82e92a9 (parent)\n+++ saleor/account/migrations/0091_populate_user_number_of_orders.py\t6800824 (commit)\n@@ -7,6 +7,6 @@\n dependencies = [\n (\"account\", \"0090_user_number_of_orders\"),\n ]\n \n- # empty migration for consistency, the populating task called in 0094\n+ # empty migration for consistency, the populating task called in 0095\n operations = []\n"
1419
- },
1420
- {
1421
- "path": "saleor/account/migrations/0094_repopulate_user_number_of_orders.py",
1422
- "status": "modified",
1423
- "diff": "Index: saleor/account/migrations/0094_repopulate_user_number_of_orders.py\n===================================================================\n--- saleor/account/migrations/0094_repopulate_user_number_of_orders.py\t82e92a9 (parent)\n+++ saleor/account/migrations/0094_repopulate_user_number_of_orders.py\t6800824 (commit)\n@@ -1,28 +1,12 @@\n # Generated by Django 5.2.1 on 2025-06-27 07:09\n \n-from django.apps import apps as registry\n from django.db import migrations\n-from django.db.models.signals import post_migrate\n \n-from .tasks.saleor3_22 import populate_user_number_of_orders_task\n \n-\n-def populate_user_number_of_orders(apps, _schema_editor):\n- def on_migrations_complete(sender=None, **kwargs):\n- populate_user_number_of_orders_task.delay()\n-\n- sender = registry.get_app_config(\"order\")\n- post_migrate.connect(on_migrations_complete, weak=False, sender=sender)\n-\n-\n class Migration(migrations.Migration):\n dependencies = [\n (\"account\", \"0093_user_user_number_of_orders_idx\"),\n ]\n \n- operations = [\n- migrations.RunPython(\n- populate_user_number_of_orders,\n- reverse_code=migrations.RunPython.noop,\n- )\n- ]\n+ # empty migration for consistency, the populating task called in 0095\n+ operations = []\n"
1424
- },
1425
- {
1426
- "path": "saleor/account/migrations/0095_repopulate_user_number_of_orders.py",
1427
- "status": "added",
1428
- "diff": "Index: saleor/account/migrations/0095_repopulate_user_number_of_orders.py\n===================================================================\n--- saleor/account/migrations/0095_repopulate_user_number_of_orders.py\t82e92a9 (parent)\n+++ saleor/account/migrations/0095_repopulate_user_number_of_orders.py\t6800824 (commit)\n@@ -1,1 +1,28 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-06-27 12:53\n+\n+from django.apps import apps as registry\n+from django.db import migrations\n+from django.db.models.signals import post_migrate\n+\n+from .tasks.saleor3_22 import populate_user_number_of_orders_task\n+\n+\n+def populate_user_number_of_orders(apps, _schema_editor):\n+ def on_migrations_complete(sender=None, **kwargs):\n+ populate_user_number_of_orders_task.delay()\n+\n+ sender = registry.get_app_config(\"order\")\n+ post_migrate.connect(on_migrations_complete, weak=False, sender=sender)\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"account\", \"0094_repopulate_user_number_of_orders\"),\n+ ]\n+\n+ operations = [\n+ migrations.RunPython(\n+ populate_user_number_of_orders,\n+ reverse_code=migrations.RunPython.noop,\n+ )\n+ ]\n"
1429
- },
1430
- {
1431
- "path": "saleor/account/migrations/tasks/saleor3_22.py",
1432
- "status": "modified",
1433
- "diff": "Index: saleor/account/migrations/tasks/saleor3_22.py\n===================================================================\n--- saleor/account/migrations/tasks/saleor3_22.py\t82e92a9 (parent)\n+++ saleor/account/migrations/tasks/saleor3_22.py\t6800824 (commit)\n@@ -37,9 +37,9 @@\n .select_for_update(of=([\"self\"]))\n )\n users_to_update = []\n for user in users:\n- total_orders = user_total_orders.get(user.id)\n+ total_orders = user_total_orders.get(user.id, 0)\n \n # Skip if the total orders count is the same to avoid unnecessary updates\n if total_orders == user.number_of_orders:\n continue\n"
1434
- }
1435
- ]
1436
- },
1437
- {
1438
- "id": "fix-migration-task",
1439
- "sha": "83c3d6f6cd2c8d7f21aadf9b300fa5f8222c77a6",
1440
- "parentSha": "0c782487f40ecd7dd91e9f73a2369120c85f958e",
1441
- "spec": "Implement migration task discovery and adjust when the user order count population is triggered.\n\nRequired changes:\n\n1) Update Celery task autodiscovery\n- File: saleor/celeryconf.py\n- Add the package \"saleor.account.migrations.tasks\" to the explicit app.autodiscover_tasks(...) call that uses related_name=\"saleor3_22\". This ensures Celery discovers the account migration tasks module alongside the existing order tasks.\n\n2) Make migration 0091 a no-op\n- File: saleor/account/migrations/0091_populate_user_number_of_orders.py\n- Remove the post_migrate signal hookup and the RunPython operation. Leave only the Migration class with the existing dependency on (\"account\", \"0090_user_number_of_orders\"). Set operations = []. Add a brief comment indicating this migration is intentionally empty and that the population task is triggered in 0094.\n\n3) Add a new migration that triggers the task after migrations complete\n- New file: saleor/account/migrations/0094_repopulate_user_number_of_orders.py\n- Define a migration that:\n - Imports django.apps.apps as registry, django.db.migrations, django.db.models.signals.post_migrate, and the Celery task from .tasks.saleor3_22.\n - Defines a function (e.g., populate_user_number_of_orders) that connects an on_migrations_complete handler to post_migrate with weak=False and sender set to registry.get_app_config(\"order\"), and in the handler schedules populate_user_number_of_orders_task.delay().\n - Sets dependencies to (\"account\", \"0093_user_user_number_of_orders_idx\").\n - Uses migrations.RunPython to invoke the function with reverse_code set to migrations.RunPython.noop.\n\nBehavioral expectations:\n- After applying migrations through 0094, post_migrate fires for the order app, which triggers the Celery task to batch-populate User.number_of_orders.\n- Celery must be able to discover the account migration task module via the added autodiscovery entry.\n- Earlier migration 0091 remains in history but performs no operation, keeping migration numbering consistent.",
1442
- "prompt": "We need to ensure the Celery task that populates each user’s number_of_orders is properly discovered and triggered at the right time. Update the Celery configuration to autodiscover the account migrations tasks module for the saleor3_22 task namespace. Then, make the existing account migration that previously triggered the task into a no-op, and add a new migration that hooks into post_migrate to schedule the population task after the relevant migrations complete. The new migration should depend on the index addition and trigger the task once the order app’s migrations are finished.",
1443
- "supplementalFiles": [
1444
- "saleor/account/migrations/tasks/saleor3_22.py",
1445
- "saleor/order/migrations/tasks/saleor3_22.py",
1446
- "saleor/order/migrations/0210_populated_order_line_product_type_id.py",
1447
- "saleor/account/migrations/0090_user_number_of_orders.py",
1448
- "saleor/account/migrations/0093_user_user_number_of_orders_idx.py"
1449
- ],
1450
- "fileDiffs": [
1451
- {
1452
- "path": "saleor/account/migrations/0091_populate_user_number_of_orders.py",
1453
- "status": "modified",
1454
- "diff": "Index: saleor/account/migrations/0091_populate_user_number_of_orders.py\n===================================================================\n--- saleor/account/migrations/0091_populate_user_number_of_orders.py\t0c78248 (parent)\n+++ saleor/account/migrations/0091_populate_user_number_of_orders.py\t83c3d6f (commit)\n@@ -1,28 +1,12 @@\n # Generated by Django 5.2.1 on 2025-06-24 08:18\n \n-from django.apps import apps as registry\n from django.db import migrations\n-from django.db.models.signals import post_migrate\n \n-from .tasks.saleor3_22 import populate_user_number_of_orders_task\n \n-\n-def populate_user_number_of_orders(apps, _schema_editor):\n- def on_migrations_complete(sender=None, **kwargs):\n- populate_user_number_of_orders_task.delay()\n-\n- sender = registry.get_app_config(\"order\")\n- post_migrate.connect(on_migrations_complete, weak=False, sender=sender)\n-\n-\n class Migration(migrations.Migration):\n dependencies = [\n (\"account\", \"0090_user_number_of_orders\"),\n ]\n \n- operations = [\n- migrations.RunPython(\n- populate_user_number_of_orders,\n- reverse_code=migrations.RunPython.noop,\n- )\n- ]\n+ # empty migration for consistency, the populating task called in 0094\n+ operations = []\n"
1455
- },
1456
- {
1457
- "path": "saleor/account/migrations/0094_repopulate_user_number_of_orders.py",
1458
- "status": "added",
1459
- "diff": "Index: saleor/account/migrations/0094_repopulate_user_number_of_orders.py\n===================================================================\n--- saleor/account/migrations/0094_repopulate_user_number_of_orders.py\t0c78248 (parent)\n+++ saleor/account/migrations/0094_repopulate_user_number_of_orders.py\t83c3d6f (commit)\n@@ -1,1 +1,28 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-06-27 07:09\n+\n+from django.apps import apps as registry\n+from django.db import migrations\n+from django.db.models.signals import post_migrate\n+\n+from .tasks.saleor3_22 import populate_user_number_of_orders_task\n+\n+\n+def populate_user_number_of_orders(apps, _schema_editor):\n+ def on_migrations_complete(sender=None, **kwargs):\n+ populate_user_number_of_orders_task.delay()\n+\n+ sender = registry.get_app_config(\"order\")\n+ post_migrate.connect(on_migrations_complete, weak=False, sender=sender)\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"account\", \"0093_user_user_number_of_orders_idx\"),\n+ ]\n+\n+ operations = [\n+ migrations.RunPython(\n+ populate_user_number_of_orders,\n+ reverse_code=migrations.RunPython.noop,\n+ )\n+ ]\n"
1460
- },
1461
- {
1462
- "path": "saleor/celeryconf.py",
1463
- "status": "modified",
1464
- "diff": "Index: saleor/celeryconf.py\n===================================================================\n--- saleor/celeryconf.py\t0c78248 (parent)\n+++ saleor/celeryconf.py\t83c3d6f (commit)\n@@ -35,8 +35,9 @@\n app.autodiscover_tasks()\n app.autodiscover_tasks(\n packages=[\n \"saleor.order.migrations.tasks\",\n+ \"saleor.account.migrations.tasks\",\n ],\n related_name=\"saleor3_22\",\n )\n app.autodiscover_tasks(lambda: discover_plugins_modules(settings.PLUGINS))\n"
1465
- }
1466
- ]
1467
- },
1468
- {
1469
- "id": "fix-attribute-filter",
1470
- "sha": "c27ceb2bd51a8c329312a0e41f88c15fc7775dae",
1471
- "parentSha": "81c59e694b8112d1d11d152710c311a873d2df1c",
1472
- "spec": "Implement comprehensive attribute value aggregation in GraphQL product attribute filtering and update tests accordingly.\n\nScope:\n1) saleor/graphql/product/filters.py\n- In the helper that builds the attribute value map (function named _populate_value_map):\n - Change the structure to map attribute_slug -> field_value -> list of AttributeValue PKs. Use a nested default mapping that accumulates multiple PKs for the same field_value instead of overwriting a single PK.\n - When iterating AttributeValue query results, append each value_pk to the map entry for (attr_slug, field_value) rather than assigning a single integer.\n- In the helper that updates the queries from the value map (function named _update_queries):\n - For each provided attribute value in the filter input, extend the queries[attr_pk] list with all PKs collected for that field value (from the value map), not just a single PK.\n - Retain the current behavior for unknown attribute names (raise ValueError) so that invalid input still results in an empty queryset at the top-level filter handler.\n- No changes to downstream filter composition are required; filter_products_by_attributes_values should keep using the list of value IDs per attribute to build Exists-based product and variant attribute filters.\n\nBehavioral requirements:\n- Filtering products by attributes via the where input (attributes[].values and attributes[].valueNames) must match all products that have any AttributeValue whose slug or name equals any provided value. If multiple AttributeValue rows share the same name for a given attribute, the filter must include all of them by aggregating their PKs.\n- When the same AttributeValue instance is assigned to multiple products, filtering by that value should return all matching products (not just one).\n- Error handling for unknown attribute slugs remains unchanged (results in none()).\n\n2) saleor/graphql/product/tests/queries/test_products_query_with_where.py\n- Update the test that filters by attribute value slug to associate the same AttributeValue to two different products and assert that both products are returned in the query result.\n- Use attribute.pk consistently when calling associate_attribute_values_to_instance in these attribute filtering tests.\n- Where the test previously asserted a single product, update assertions to verify two distinct product IDs are returned for the shared value case.\n\nAcceptance criteria:\n- All existing tests in test_products_query_with_where.py pass, including the updated case that expects two products for a shared attribute value.\n- Attribute filtering by value names properly handles multiple AttributeValue rows sharing a name by returning all corresponding products.\n- No regressions in other product filters (price, availability, stocks, metadata, etc.).",
1473
- "prompt": "Update the GraphQL product attribute filtering so that filtering by attribute values (by slug or name) returns all products that have matching values, even when multiple AttributeValue rows share a field value or when the same value is assigned to multiple products. Adjust the internal value mapping to aggregate a list of value IDs per field value and use it when building the query filters. Update the relevant tests to associate the same attribute value with two products and assert that both are returned.",
1474
- "supplementalFiles": [
1475
- "saleor/graphql/product/schema.py",
1476
- "saleor/graphql/product/resolvers.py",
1477
- "saleor/attribute/utils.py",
1478
- "saleor/product/models.py",
1479
- "saleor/graphql/core/filters/where_filters.py",
1480
- "saleor/graphql/product/types/products.py"
1481
- ],
1482
- "fileDiffs": [
1483
- {
1484
- "path": "saleor/graphql/product/filters.py",
1485
- "status": "modified",
1486
- "diff": "Index: saleor/graphql/product/filters.py\n===================================================================\n--- saleor/graphql/product/filters.py\t81c59e6 (parent)\n+++ saleor/graphql/product/filters.py\tc27ceb2 (commit)\n@@ -118,9 +118,9 @@\n \n def _populate_value_map(\n database_connection_name, field, values, attribute_qs, attributes_pk_slug_map\n ):\n- value_maps: dict[str, dict[str, int]] = defaultdict(dict)\n+ value_maps: dict[str, dict[str, list[int]]] = defaultdict(lambda: defaultdict(list))\n for (\n attr_pk,\n value_pk,\n field_value,\n@@ -130,9 +130,9 @@\n .filter(**{f\"{field}__in\": values})\n .values_list(\"attribute_id\", \"pk\", field)\n ):\n attr_slug = attributes_pk_slug_map[attr_pk]\n- value_maps[attr_slug][field_value] = value_pk\n+ value_maps[attr_slug][field_value].append(value_pk)\n \n return value_maps\n \n \n@@ -140,11 +140,12 @@\n for attr_name, vals in filter_values:\n if attr_name not in attributes_slug_pk_map:\n raise ValueError(f\"Unknown attribute name: {attr_name}\")\n attr_pk = attributes_slug_pk_map[attr_name]\n- attr_val_pk = [\n- value_maps[attr_name][val] for val in vals if val in value_maps[attr_name]\n- ]\n+ attr_val_pk = []\n+ for val in vals:\n+ if val in value_maps[attr_name]:\n+ attr_val_pk.extend(value_maps[attr_name][val])\n queries[attr_pk] += attr_val_pk\n \n \n def _clean_product_attributes_range_filter_input(\n"
1487
- },
1488
- {
1489
- "path": "saleor/graphql/product/tests/queries/test_products_query_with_where.py",
1490
- "status": "modified",
1491
- "diff": "Index: saleor/graphql/product/tests/queries/test_products_query_with_where.py\n===================================================================\n--- saleor/graphql/product/tests/queries/test_products_query_with_where.py\t81c59e6 (parent)\n+++ saleor/graphql/product/tests/queries/test_products_query_with_where.py\tc27ceb2 (commit)\n@@ -10,9 +10,13 @@\n get_product_attributes,\n )\n from .....attribute.utils import associate_attribute_values_to_instance\n from .....product import ProductTypeKind\n-from .....product.models import Product, ProductChannelListing, ProductType\n+from .....product.models import (\n+ Product,\n+ ProductChannelListing,\n+ ProductType,\n+)\n from .....warehouse.models import Allocation, Reservation, Stock, Warehouse\n from ....tests.utils import get_graphql_content\n \n PRODUCTS_WHERE_QUERY = \"\"\"\n@@ -667,16 +671,25 @@\n attribute.product_types.add(product_type)\n attr_value = AttributeValue.objects.create(\n attribute=attribute, name=\"First\", slug=\"first\"\n )\n- product = product_list[0]\n- product.product_type = product_type\n- product.save()\n+ # Associate the same attribute value to two products\n+ product1 = product_list[0]\n+ product1.product_type = product_type\n+ product1.save()\n associate_attribute_values_to_instance(\n- product,\n- {attribute.id: [attr_value]},\n+ product1,\n+ {attribute.pk: [attr_value]},\n )\n \n+ product2 = product_list[1]\n+ product2.product_type = product_type\n+ product2.save()\n+ associate_attribute_values_to_instance(\n+ product2,\n+ {attribute.pk: [attr_value]},\n+ )\n+\n variables = {\n \"channel\": channel_USD.slug,\n \"where\": {\n \"attributes\": [{\"slug\": attribute.slug, \"values\": [attr_value.slug]}],\n@@ -687,14 +700,15 @@\n response = api_client.post_graphql(PRODUCTS_WHERE_QUERY, variables)\n content = get_graphql_content(response)\n \n # then\n- product_id = graphene.Node.to_global_id(\"Product\", product.id)\n+ product1_id = graphene.Node.to_global_id(\"Product\", product1.id)\n+ product2_id = graphene.Node.to_global_id(\"Product\", product2.id)\n products = content[\"data\"][\"products\"][\"edges\"]\n \n- assert len(products) == 1\n- assert products[0][\"node\"][\"id\"] == product_id\n- assert products[0][\"node\"][\"name\"] == product.name\n+ assert len(products) == 2\n+ returned_ids = {product[\"node\"][\"id\"] for product in products}\n+ assert returned_ids == {product1_id, product2_id}\n \n \n def test_products_filter_by_attributes_value_name(\n api_client,\n@@ -718,9 +732,9 @@\n product.product_type = product_type\n product.save()\n associate_attribute_values_to_instance(\n product,\n- {attribute.id: [attr_value]},\n+ {attribute.pk: [attr_value]},\n )\n \n variables = {\n \"channel\": channel_USD.slug,\n@@ -764,9 +778,9 @@\n product.product_type = product_type\n product.save()\n associate_attribute_values_to_instance(\n product,\n- {attribute.id: [attr_value]},\n+ {attribute.pk: [attr_value]},\n )\n \n variables = {\n \"channel\": channel_USD.slug,\n"
1492
- }
1493
- ]
1494
- },
1495
- {
1496
- "id": "refactor-locking-utils",
1497
- "sha": "20a2b1f48f34d70b4a21c9b3d66d7f744495d4cf",
1498
- "parentSha": "22d9d8914f175c13a69ac50f3c98cd97fec86778",
1499
- "spec": "Implement a refactor to standardize and centralize database row locking helpers using select_for_update into per-app lock_objects.py modules and update all call sites accordingly.\n\nScope and required changes:\n\n1) Add lock_objects.py modules with helpers\n- saleor/checkout/lock_objects.py:\n - Define checkout_qs_select_for_update() returning Checkout queryset ordered by pk with select_for_update(of=(\"self\",)).\n - Define checkout_lines_qs_select_for_update() returning CheckoutLine queryset ordered by pk with select_for_update(of=(\"self\")).\n\n- saleor/order/lock_objects.py:\n - Define order_qs_select_for_update() returning Order queryset ordered by pk with select_for_update(of=(\"self\")).\n - Define order_lines_qs_select_for_update() returning OrderLine queryset ordered by pk with select_for_update(of=(\"self\")).\n\n- saleor/payment/lock_objects.py:\n - Define transaction_item_qs_select_for_update() returning TransactionItem queryset ordered by pk with select_for_update(of=(\"self\")).\n - Define get_order_and_transaction_item_locked_for_update(order_id, transaction_item_id) that returns a tuple (Order, TransactionItem) locked for update, acquiring locks in a defined order using the helpers above.\n - Define get_checkout_and_transaction_item_locked_for_update(checkout_id, transaction_item_id) that returns a tuple (Optional[Checkout], TransactionItem) locked for update, acquiring locks via checkout_qs_select_for_update (filter first) and transaction_item_qs_select_for_update.\n\n- saleor/warehouse/lock_objects.py:\n - Define stock_select_for_update_for_existing_qs(qs) that orders by pk and applies select_for_update(of=(\"self\")).\n - Define stock_qs_select_for_update() that calls stock_select_for_update_for_existing_qs(Stock.objects.all()).\n - Define allocation_with_stock_qs_select_for_update() returning Allocation queryset select_related(\"stock\"), ordered by stock__pk, locked with select_for_update(of=(\"self\", \"stock\")).\n\n2) Remove duplicate helpers and update imports/usages\n- saleor/checkout/utils.py:\n - Remove the local checkout_lines_qs_select_for_update() function.\n - Remove the now-unused QuerySet import if only used by that function.\n - Import and use checkout_lines_qs_select_for_update from .lock_objects wherever needed (e.g., in bulk update/delete flows).\n\n- saleor/order/utils.py:\n - Remove local order_qs_select_for_update() and order_lines_qs_select_for_update() definitions.\n - Ensure any remaining references in this module use the new helpers from saleor/order/lock_objects.py.\n\n- saleor/discount/utils/order.py:\n - Replace local/inline imports of order_qs_select_for_update with an import from ...order.lock_objects and use it within atomic sections and functions that modify orders and discounts.\n\n- saleor/discount/utils/promotion.py:\n - Import checkout_lines_qs_select_for_update from ...checkout.lock_objects and order_lines_qs_select_for_update from ...order.lock_objects; update usages accordingly.\n - Remove any prior dependency on ...checkout.utils.checkout_lines_qs_select_for_update and ...order.utils.order_lines_qs_select_for_update.\n\n- saleor/graphql/product/bulk_mutations/product_variant_bulk_delete.py:\n - Update imports to use ....order.lock_objects.order_lines_qs_select_for_update instead of the previous utils import.\n\n- saleor/graphql/warehouse/bulk_mutations/stock_bulk_update.py:\n - Update imports to use ....warehouse.lock_objects.stock_qs_select_for_update.\n\n- saleor/warehouse/management.py:\n - Remove local definitions for stock_select_for_update_for_existing_qs, stock_qs_select_for_update, and allocation_with_stock_qs_select_for_update.\n - Import these from .lock_objects and update all references in stock/allocation mutation flows to call the imported helpers.\n\n- saleor/warehouse/reservations.py:\n - Import stock_qs_select_for_update from .lock_objects (and keep sort_stocks import from .management) and update usages accordingly.\n\n- saleor/order/actions.py:\n - Replace import of order_lines_qs_select_for_update from ..order.utils with ..order.lock_objects and update usages.\n\n- saleor/graphql/payment/mutations/transaction/transaction_event_report.py:\n - Replace direct select_for_update locking of Order and TransactionItem with calls to get_order_and_transaction_item_locked_for_update and get_checkout_and_transaction_item_locked_for_update.\n - Replace direct select_for_update on TransactionItem during idempotent event creation with transaction_item_qs_select_for_update().\n\n3) Documentation\n- CONTRIBUTING.md:\n - Add a section explaining Locking Objects guidelines: use select_for_update on querysets, lock multiple objects in consistent order (e.g., by pk), define clear multi-model locking order (e.g., Order before OrderLine), and centralize helpers in lock_objects.py within the relevant app (place multi-model helper in the module of the last model being locked).\n\nQuality and behavior requirements:\n- All helper querysets must be ordered by pk before applying select_for_update to guarantee deterministic lock ordering.\n- For multi-model locks (e.g., allocation_with_stock_qs_select_for_update or the payment helpers), ensure the select_for_update of parameter lists only the intended models (e.g., \"self\", \"stock\") and that locks are acquired in the documented order to minimize deadlocks.\n- All call sites must use the new helpers; there should be no lingering direct select_for_update calls that duplicate this logic in the edited modules.\n- Ensure type hints remain consistent (e.g., returning QuerySet[...] or tuples of models) and remove unused imports caused by function removals.",
1500
- "prompt": "Refactor the project’s database row locking approach to centralize select_for_update usage into per-app lock helper modules and update the codebase to use them consistently. Add lock_objects.py modules for checkout, order, payment, and warehouse with helpers to lock single and related models in a deterministic order. Remove duplicate helpers from utils modules and update all call sites (discount utilities, order actions, GraphQL mutations, reservations, and warehouse management) to import and use the new helpers. Ensure deterministic ordering (by primary key) and correct multi-model lock sequences to reduce deadlocks. Update contributing documentation to include guidelines for locking patterns and helper placement.",
1501
- "supplementalFiles": [
1502
- "saleor/account/lock_objects.py",
1503
- "saleor/checkout/models.py",
1504
- "saleor/payment/transaction_item_calculations.py",
1505
- "saleor/checkout/complete_checkout.py",
1506
- "saleor/order/base_calculations.py"
1507
- ],
1508
- "fileDiffs": [
1509
- {
1510
- "path": "CONTRIBUTING.md",
1511
- "status": "modified",
1512
- "diff": "Index: CONTRIBUTING.md\n===================================================================\n--- CONTRIBUTING.md\t22d9d89 (parent)\n+++ CONTRIBUTING.md\t20a2b1f (commit)\n@@ -405,8 +405,28 @@\n otherwise to the main module directory, usually to the `utils.py` file.\n Try to find a name as descriptive as possible when writing such a method.\n Also, do not forget about the docstring, especially in a complicated function.\n \n+### Locking Objects\n+\n+To lock objects and prevent race conditions in concurrent operations, use Django's [`select_for_update()`](https://docs.djangoproject.com/en/stable/ref/models/querysets/#select-for-update) method on querysets.\n+\n+#### General Guidelines\n+\n+- When locking **multiple objects**, always lock them **in a consistent order** to avoid deadlocks.\n+ - Use a clear and stable ordering—typically by `pk`.\n+ - When locking **across multiple models**, always acquire locks in a defined order. For example, lock the `Order` model **before** `OrderLine`.\n+\n+#### Best Practices\n+\n+To reduce the chance of deadlocks and to keep the locking logic consistent:\n+\n+- Wrap all locking logic in **helper functions**.\n+- Place these helper functions in a file named `lock_objects.py` located in the same app directory as the model you're locking.\n+ - For single-model locks, use the model's app directory (e.g. `orders/lock_objects.py`).\n+ - For multi-model locks, place the function in the directory of the **last model being locked**.\n+ - Example: If locking `Order` and `TransactionItem`, and `TransactionItem` is locked last, use `payment/lock_objects.py`.\n+\n ### Searching\n \n So far, we have mainly used the `GinIndex` and `ilike` operators for searching, but currently, we are testing a new solution with the use of `SearchVector` and `SearchRank`.\n You can find it in this PR [#9344](https://github.com/saleor/saleor/pull/9344).\n"
1513
- },
1514
- {
1515
- "path": "saleor/checkout/lock_objects.py",
1516
- "status": "added",
1517
- "diff": "Index: saleor/checkout/lock_objects.py\n===================================================================\n--- saleor/checkout/lock_objects.py\t22d9d89 (parent)\n+++ saleor/checkout/lock_objects.py\t20a2b1f (commit)\n@@ -1,1 +1,11 @@\n-[NEW FILE]\n\\n+from django.db.models import QuerySet\n+\n+from .models import Checkout, CheckoutLine\n+\n+\n+def checkout_qs_select_for_update() -> QuerySet[Checkout]:\n+ return Checkout.objects.order_by(\"pk\").select_for_update(of=([\"self\"]))\n+\n+\n+def checkout_lines_qs_select_for_update() -> QuerySet[CheckoutLine]:\n+ return CheckoutLine.objects.order_by(\"pk\").select_for_update(of=([\"self\"]))\n"
1518
- },
1519
- {
1520
- "path": "saleor/checkout/utils.py",
1521
- "status": "modified",
1522
- "diff": "Index: saleor/checkout/utils.py\n===================================================================\n--- saleor/checkout/utils.py\t22d9d89 (parent)\n+++ saleor/checkout/utils.py\t20a2b1f (commit)\n@@ -8,9 +8,9 @@\n import graphene\n from django.conf import settings\n from django.core.exceptions import ValidationError\n from django.db import transaction\n-from django.db.models import QuerySet, prefetch_related_objects\n+from django.db.models import prefetch_related_objects\n from django.utils import timezone\n from prices import Money\n \n from ..account.models import User\n@@ -60,8 +60,9 @@\n from ..warehouse.models import Warehouse\n from ..warehouse.reservations import reserve_stocks_and_preorders\n from . import AddressType, base_calculations, calculations\n from .error_codes import CheckoutErrorCode\n+from .lock_objects import checkout_lines_qs_select_for_update\n from .models import Checkout, CheckoutLine, CheckoutMetadata\n \n if TYPE_CHECKING:\n from measurement.measures import Weight\n@@ -122,12 +123,8 @@\n \n return updated_fields\n \n \n-def checkout_lines_qs_select_for_update() -> QuerySet[CheckoutLine]:\n- return CheckoutLine.objects.order_by(\"id\").select_for_update(of=([\"self\"]))\n-\n-\n def checkout_lines_bulk_update(\n lines_to_update: list[\"CheckoutLine\"], fields_to_update: list[str]\n ):\n \"\"\"Bulk update on CheckoutLines with lock applied on them.\"\"\"\n"
1523
- },
1524
- {
1525
- "path": "saleor/discount/utils/order.py",
1526
- "status": "modified",
1527
- "diff": "Index: saleor/discount/utils/order.py\n===================================================================\n--- saleor/discount/utils/order.py\t22d9d89 (parent)\n+++ saleor/discount/utils/order.py\t20a2b1f (commit)\n@@ -12,8 +12,9 @@\n from ...core.db.connection import allow_writer\n from ...core.prices import quantize_price\n from ...core.taxes import zero_money\n from ...order.base_calculations import base_order_subtotal\n+from ...order.lock_objects import order_qs_select_for_update\n from ...order.models import Order, OrderLine\n from .. import DiscountType\n from ..interface import VariantPromotionRuleInfo\n from ..models import DiscountValueType, OrderLineDiscount\n@@ -42,10 +43,8 @@\n list[OrderLineDiscount],\n list[str],\n ],\n ) -> None | list[\"EditableOrderLineInfo\"]:\n- from ...order.utils import order_qs_select_for_update\n-\n if not discount_data or not lines_info:\n return None\n \n (\n@@ -281,10 +280,8 @@\n line: \"OrderLine\",\n rules_info: Iterable[VariantPromotionRuleInfo],\n channel: Channel,\n ) -> list[\"OrderLineDiscount\"]:\n- from ...order.utils import order_qs_select_for_update\n-\n line_discounts_to_create: list[OrderLineDiscount] = []\n for rule_info in rules_info:\n line_discount = _create_order_line_discount_for_catalogue_promotion(\n line, rule_info, channel\n"
1528
- },
1529
- {
1530
- "path": "saleor/discount/utils/promotion.py",
1531
- "status": "modified",
1532
- "diff": "Index: saleor/discount/utils/promotion.py\n===================================================================\n--- saleor/discount/utils/promotion.py\t22d9d89 (parent)\n+++ saleor/discount/utils/promotion.py\t20a2b1f (commit)\n@@ -14,13 +14,15 @@\n from prices import Money\n \n from ...channel.models import Channel\n from ...checkout.fetch import CheckoutLineInfo\n+from ...checkout.lock_objects import checkout_lines_qs_select_for_update\n from ...checkout.models import Checkout, CheckoutLine\n from ...core.db.connection import allow_writer\n from ...core.exceptions import InsufficientStock\n from ...core.taxes import zero_money\n from ...order.fetch import EditableOrderLineInfo\n+from ...order.lock_objects import order_lines_qs_select_for_update\n from ...order.models import Order\n from ...product.models import (\n Product,\n ProductChannelListing,\n@@ -403,11 +405,8 @@\n @allow_writer()\n def delete_gift_lines_qs(\n order_or_checkout: Checkout | Order,\n ):\n- from ...checkout.utils import checkout_lines_qs_select_for_update\n- from ...order.utils import order_lines_qs_select_for_update\n-\n with transaction.atomic():\n if isinstance(order_or_checkout, Checkout):\n locked_checkout_lines_qs = checkout_lines_qs_select_for_update()\n locked_checkout_lines_qs.filter(\n"
1533
- },
1534
- {
1535
- "path": "saleor/graphql/payment/mutations/transaction/transaction_event_report.py",
1536
- "status": "modified",
1537
- "diff": "Index: saleor/graphql/payment/mutations/transaction/transaction_event_report.py\n===================================================================\n--- saleor/graphql/payment/mutations/transaction/transaction_event_report.py\t22d9d89 (parent)\n+++ saleor/graphql/payment/mutations/transaction/transaction_event_report.py\t20a2b1f (commit)\n@@ -8,9 +8,8 @@\n from .....app.models import App\n from .....checkout.actions import (\n transaction_amounts_for_checkout_updated_without_price_recalculation,\n )\n-from .....checkout.models import Checkout\n from .....core.exceptions import PermissionDenied\n from .....core.prices import quantize_price\n from .....core.tracing import traced_atomic_transaction\n from .....core.utils.events import call_event\n@@ -24,8 +23,13 @@\n )\n from .....payment import OPTIONAL_AMOUNT_EVENTS, TransactionEventType\n from .....payment import models as payment_models\n from .....payment.interface import PaymentMethodDetails\n+from .....payment.lock_objects import (\n+ get_checkout_and_transaction_item_locked_for_update,\n+ get_order_and_transaction_item_locked_for_update,\n+ transaction_item_qs_select_for_update,\n+)\n from .....payment.transaction_item_calculations import recalculate_transaction_amounts\n from .....payment.utils import (\n authorization_success_already_exists,\n create_failed_transaction_event,\n@@ -311,18 +315,11 @@\n related_granted_refund: order_models.OrderGrantedRefund | None,\n ):\n order = cast(order_models.Order, transaction.order)\n with traced_atomic_transaction():\n- order = (\n- order_models.Order.objects.prefetch_related(\n- \"payments\", \"payment_transactions\", \"granted_refunds\"\n- )\n- .select_for_update()\n- .get(pk=order.pk)\n+ order, transaction = get_order_and_transaction_item_locked_for_update(\n+ order.pk, transaction.pk\n )\n- transaction = payment_models.TransactionItem.objects.select_for_update(\n- of=(\"self\",)\n- ).get(pk=transaction.pk)\n updates_amounts_for_order(order)\n update_order_search_vector(order)\n order_info = fetch_order_info(order)\n order_transaction_updated(\n@@ -352,16 +349,13 @@\n ):\n checkout_deleted = False\n if transaction.checkout_id:\n with traced_atomic_transaction():\n- locked_checkout = (\n- Checkout.objects.select_for_update()\n- .filter(pk=transaction.checkout_id)\n- .first()\n+ locked_checkout, transaction = (\n+ get_checkout_and_transaction_item_locked_for_update(\n+ transaction.checkout_id, transaction.pk\n+ )\n )\n- transaction = payment_models.TransactionItem.objects.select_for_update(\n- of=(\"self\",)\n- ).get(pk=transaction.pk)\n if transaction.checkout_id and locked_checkout:\n transaction_amounts_for_checkout_updated_without_price_recalculation(\n transaction, locked_checkout, manager, user, app\n )\n@@ -484,10 +478,10 @@\n # The mutation can be called multiple times by the app. That can cause a\n # thread race. We need to be sure, that we will always create a single event\n # on our side for specific action.\n _transaction = (\n- payment_models.TransactionItem.objects.filter(pk=transaction.pk)\n- .select_for_update(of=(\"self\",))\n+ transaction_item_qs_select_for_update()\n+ .filter(pk=transaction.pk)\n .first()\n )\n \n existing_event = get_already_existing_event(transaction_event)\n"
1538
- },
1539
- {
1540
- "path": "saleor/graphql/product/bulk_mutations/product_variant_bulk_delete.py",
1541
- "status": "modified",
1542
- "diff": "Index: saleor/graphql/product/bulk_mutations/product_variant_bulk_delete.py\n===================================================================\n--- saleor/graphql/product/bulk_mutations/product_variant_bulk_delete.py\t22d9d89 (parent)\n+++ saleor/graphql/product/bulk_mutations/product_variant_bulk_delete.py\t20a2b1f (commit)\n@@ -12,10 +12,10 @@\n from ....core.postgres import FlatConcatSearchVector\n from ....core.tracing import traced_atomic_transaction\n from ....discount.utils.promotion import mark_active_catalogue_promotion_rules_as_dirty\n from ....order import events as order_events\n+from ....order.lock_objects import order_lines_qs_select_for_update\n from ....order.tasks import recalculate_orders_task\n-from ....order.utils import order_lines_qs_select_for_update\n from ....permission.enums import ProductPermissions\n from ....product import models\n from ....product.search import prepare_product_search_vector_value\n from ....webhook.event_types import WebhookEventAsyncType\n"
1543
- },
1544
- {
1545
- "path": "saleor/graphql/warehouse/bulk_mutations/stock_bulk_update.py",
1546
- "status": "modified",
1547
- "diff": "Index: saleor/graphql/warehouse/bulk_mutations/stock_bulk_update.py\n===================================================================\n--- saleor/graphql/warehouse/bulk_mutations/stock_bulk_update.py\t22d9d89 (parent)\n+++ saleor/graphql/warehouse/bulk_mutations/stock_bulk_update.py\t20a2b1f (commit)\n@@ -7,9 +7,9 @@\n from ....core.tracing import traced_atomic_transaction\n from ....permission.enums import ProductPermissions\n from ....warehouse import models\n from ....warehouse.error_codes import StockBulkUpdateErrorCode\n-from ....warehouse.management import stock_qs_select_for_update\n+from ....warehouse.lock_objects import stock_qs_select_for_update\n from ....webhook.event_types import WebhookEventAsyncType\n from ....webhook.utils import get_webhooks_for_event\n from ...core.doc_category import DOC_CATEGORY_PRODUCTS\n from ...core.enums import ErrorPolicyEnum\n"
1548
- },
1549
- {
1550
- "path": "saleor/order/actions.py",
1551
- "status": "modified",
1552
- "diff": "Index: saleor/order/actions.py\n===================================================================\n--- saleor/order/actions.py\t22d9d89 (parent)\n+++ saleor/order/actions.py\t20a2b1f (commit)\n@@ -21,9 +21,9 @@\n call_event_including_protected_events,\n webhook_async_event_requires_sync_webhooks_to_trigger,\n )\n from ..giftcard import GiftCardLineData\n-from ..order.utils import order_lines_qs_select_for_update\n+from ..order.lock_objects import order_lines_qs_select_for_update\n from ..payment import (\n ChargeStatus,\n CustomPaymentChoices,\n PaymentError,\n"
1553
- },
1554
- {
1555
- "path": "saleor/order/lock_objects.py",
1556
- "status": "added",
1557
- "diff": "Index: saleor/order/lock_objects.py\n===================================================================\n--- saleor/order/lock_objects.py\t22d9d89 (parent)\n+++ saleor/order/lock_objects.py\t20a2b1f (commit)\n@@ -1,1 +1,11 @@\n-[NEW FILE]\n\\n+from django.db.models import QuerySet\n+\n+from .models import Order, OrderLine\n+\n+\n+def order_lines_qs_select_for_update() -> QuerySet[OrderLine]:\n+ return OrderLine.objects.order_by(\"pk\").select_for_update(of=[\"self\"])\n+\n+\n+def order_qs_select_for_update() -> QuerySet[Order]:\n+ return Order.objects.order_by(\"pk\").select_for_update(of=([\"self\"]))\n"
1558
- },
1559
- {
1560
- "path": "saleor/order/utils.py",
1561
- "status": "modified",
1562
- "diff": "Index: saleor/order/utils.py\n===================================================================\n--- saleor/order/utils.py\t22d9d89 (parent)\n+++ saleor/order/utils.py\t20a2b1f (commit)\n@@ -83,16 +83,8 @@\n \n logger = logging.getLogger(__name__)\n \n \n-def order_qs_select_for_update():\n- return Order.objects.order_by(\"id\").select_for_update(of=([\"self\"]))\n-\n-\n-def order_lines_qs_select_for_update() -> QuerySet[OrderLine]:\n- return OrderLine.objects.order_by(\"pk\").select_for_update(of=[\"self\"])\n-\n-\n def get_order_country(order: Order) -> str:\n \"\"\"Return country to which order will be shipped.\"\"\"\n return get_active_country(\n order.channel, order.shipping_address, order.billing_address\n"
1563
- },
1564
- {
1565
- "path": "saleor/payment/lock_objects.py",
1566
- "status": "added",
1567
- "diff": "Index: saleor/payment/lock_objects.py\n===================================================================\n--- saleor/payment/lock_objects.py\t22d9d89 (parent)\n+++ saleor/payment/lock_objects.py\t20a2b1f (commit)\n@@ -1,1 +1,36 @@\n-[NEW FILE]\n\\n+from typing import TYPE_CHECKING, Optional\n+from uuid import UUID\n+\n+from django.db.models import QuerySet\n+\n+from ..checkout.lock_objects import checkout_qs_select_for_update\n+from ..order.lock_objects import order_qs_select_for_update\n+from .models import TransactionItem\n+\n+if TYPE_CHECKING:\n+ from ..checkout.models import Checkout\n+ from ..order.models import Order\n+\n+\n+def transaction_item_qs_select_for_update() -> QuerySet[TransactionItem]:\n+ return TransactionItem.objects.order_by(\"pk\").select_for_update(of=[\"self\"])\n+\n+\n+def get_order_and_transaction_item_locked_for_update(\n+ order_id: UUID, transaction_item_id: int\n+) -> tuple[\"Order\", TransactionItem]:\n+ order = order_qs_select_for_update().get(pk=order_id)\n+ transaction_item = transaction_item_qs_select_for_update().get(\n+ pk=transaction_item_id\n+ )\n+ return order, transaction_item\n+\n+\n+def get_checkout_and_transaction_item_locked_for_update(\n+ checkout_id: UUID, transaction_item_id: int\n+) -> tuple[Optional[\"Checkout\"], TransactionItem]:\n+ checkout = checkout_qs_select_for_update().filter(pk=checkout_id).first()\n+ transaction_item = transaction_item_qs_select_for_update().get(\n+ pk=transaction_item_id\n+ )\n+ return checkout, transaction_item\n"
1568
- },
1569
- {
1570
- "path": "saleor/warehouse/lock_objects.py",
1571
- "status": "added",
1572
- "diff": "Index: saleor/warehouse/lock_objects.py\n===================================================================\n--- saleor/warehouse/lock_objects.py\t22d9d89 (parent)\n+++ saleor/warehouse/lock_objects.py\t20a2b1f (commit)\n@@ -1,1 +1,22 @@\n-[NEW FILE]\n\\n+from .models import Allocation, Stock\n+\n+\n+def stock_select_for_update_for_existing_qs(qs):\n+ return qs.order_by(\"pk\").select_for_update(of=([\"self\"]))\n+\n+\n+def stock_qs_select_for_update():\n+ return stock_select_for_update_for_existing_qs(Stock.objects.all())\n+\n+\n+def allocation_with_stock_qs_select_for_update():\n+ return (\n+ Allocation.objects.select_related(\"stock\")\n+ .select_for_update(\n+ of=(\n+ \"self\",\n+ \"stock\",\n+ )\n+ )\n+ .order_by(\"stock__pk\")\n+ )\n"
1573
- },
1574
- {
1575
- "path": "saleor/warehouse/management.py",
1576
- "status": "modified",
1577
- "diff": "Index: saleor/warehouse/management.py\n===================================================================\n--- saleor/warehouse/management.py\t22d9d89 (parent)\n+++ saleor/warehouse/management.py\t20a2b1f (commit)\n@@ -22,8 +22,13 @@\n from ..order.fetch import OrderLineInfo\n from ..order.models import OrderLine\n from ..plugins.manager import PluginsManager\n from ..product.models import ProductVariant, ProductVariantChannelListing\n+from .lock_objects import (\n+ allocation_with_stock_qs_select_for_update,\n+ stock_qs_select_for_update,\n+ stock_select_for_update_for_existing_qs,\n+)\n from .models import (\n Allocation,\n ChannelWarehouse,\n PreorderAllocation,\n@@ -41,16 +46,8 @@\n pk: int\n quantity: int\n \n \n-def stock_select_for_update_for_existing_qs(qs):\n- return qs.order_by(\"pk\").select_for_update(of=([\"self\"]))\n-\n-\n-def stock_qs_select_for_update():\n- return stock_select_for_update_for_existing_qs(Stock.objects.all())\n-\n-\n def delete_stocks(stock_pks_to_delete: list[int]):\n with transaction.atomic():\n return Stock.objects.filter(\n id__in=Stock.objects.order_by(\"pk\")\n@@ -69,21 +66,8 @@\n )\n Stock.objects.bulk_update(stocks, fields_to_update)\n \n \n-def allocation_with_stock_qs_select_for_update():\n- return (\n- Allocation.objects.select_related(\"stock\")\n- .select_for_update(\n- of=(\n- \"self\",\n- \"stock\",\n- )\n- )\n- .order_by(\"stock__pk\")\n- )\n-\n-\n def delete_allocations(allocation_pks_to_delete: list[int]):\n with transaction.atomic():\n return Allocation.objects.filter(\n id__in=Allocation.objects.order_by(\"stock_id\")\n"
1578
- },
1579
- {
1580
- "path": "saleor/warehouse/reservations.py",
1581
- "status": "modified",
1582
- "diff": "Index: saleor/warehouse/reservations.py\n===================================================================\n--- saleor/warehouse/reservations.py\t22d9d89 (parent)\n+++ saleor/warehouse/reservations.py\t20a2b1f (commit)\n@@ -10,9 +10,10 @@\n \n from ..core.exceptions import InsufficientStock, InsufficientStockData\n from ..core.tracing import traced_atomic_transaction\n from ..product.models import ProductVariant, ProductVariantChannelListing\n-from .management import sort_stocks, stock_qs_select_for_update\n+from .lock_objects import stock_qs_select_for_update\n+from .management import sort_stocks\n from .models import Allocation, PreorderReservation, Reservation\n \n if TYPE_CHECKING:\n from ..channel.models import Channel\n"
1583
- }
1584
- ]
1585
- },
1586
- {
1587
- "id": "improve-extension-validation",
1588
- "sha": "7be9ab204ece983732b2fdc50044636d1a6d977e",
1589
- "parentSha": "d4ec8484816f0620c987f402e20355e98153e7cb",
1590
- "spec": "- Update app extension manifest validation (saleor/app/manifest_validations.py):\n 1) Relative URL support for NEW_TAB:\n - Allow extension urls that start with \"/\" when target == NEW_TAB only if manifest_data[\"appUrl\"] is provided.\n - If target == NEW_TAB and a relative URL is used without appUrl set, raise ValidationError with the exact message: \"To use relative URL, you must specify appUrl.\"\n - Keep existing APP_PAGE behavior (cannot start with protocol; raise a ValidationError if it does).\n 2) Clarify POST-method validation branches:\n - Compute and use booleans for POST method cases: is_new_tab_post and is_widget_post (based on target == NEW_TAB/WIDGET and presence of respective POST options) to validate HTTPS-only (when ENABLE_SSL is True) and appUrl host matching for POST flows.\n - Replace inline compound conditions with the computed booleans in the URL validation logic.\n 3) Tighten options validation:\n - In _clean_extension_options, enforce that widgetTarget options are allowed only when target == WIDGET, and newTabTarget options only when target == NEW_TAB. If mismatched, raise ValidationError with existing messaging semantics.\n 4) Restrict WIDGET target mounts:\n - Implement a private helper _validate_mounts_for_widget(mount: str) that allows only the following mounts for WIDGET target:\n - ORDER_DETAILS_WIDGETS\n - PRODUCT_DETAILS_WIDGETS\n - VOUCHER_DETAILS_WIDGETS\n - DRAFT_ORDER_DETAILS_WIDGETS\n - GIFT_CARD_DETAILS_WIDGETS\n - CUSTOMER_DETAILS_WIDGETS\n - COLLECTION_DETAILS_WIDGETS\n - If an extension with target == WIDGET uses a different mount, raise a ValidationError under the \"mount\" key with code AppErrorCode.INVALID and message: \"Mount {MOUNT} is not available for WIDGET target.\" where {MOUNT} is uppercased.\n 5) Integrate mount validation in _clean_extensions:\n - After cleaning enum fields for target and mount, if target == WIDGET, call _validate_mounts_for_widget(extension[\"mount\"]). Catch ValidationError and append to errors[\"extensions\"].\n - Continue to validate the extension URL via _clean_extension_url and append any ValidationError to errors[\"extensions\"].\n\n- Update tests to align with new behavior:\n - saleor/app/tests/test_installation_utils.py:\n - For WIDGET-target extension setup, change mount to PRODUCT_DETAILS_WIDGETS and update corresponding assertions to AppExtensionMount.PRODUCT_DETAILS_WIDGETS.\n - saleor/app/tests/test_validators.py:\n - Accept relative URLs for NEW_TAB when appUrl is present (no error).\n - Add a test verifying that NEW_TAB with a relative URL and missing appUrl raises ValidationError.\n - Parametrize tests confirming the allowed WIDGET mounts listed above produce no errors in _clean_extensions.\n - Add a test that an invalid WIDGET mount results in an error added under errors[\"extensions\"].\n - Maintain HTTPS enforcement when settings.ENABLE_SSL is True and allow HTTP when False.\n - saleor/graphql/app/tests/mutations/test_app_fetch_manifest.py:\n - Remove/update cases that treat NEW_TAB relative URLs as incorrect. If appUrl is present, relative NEW_TAB URLs should no longer be considered invalid.\n\n- Notes/assumptions:\n - Targets and mounts should be normalized to their enum values before validations.\n - Validation errors should be appended to errors[\"extensions\"] consistently with existing manifest validation error handling.",
1591
- "prompt": "Enhance the app extension manifest validation to: 1) allow relative URLs for NEW_TAB extensions only when an appUrl is provided, erroring when it’s missing; 2) clarify POST-method validation by using explicit booleans for NEW_TAB/WIDGET POST cases to enforce HTTPS and same-host rules; 3) ensure widgetTarget options are only allowed for WIDGET, and newTabTarget options only for NEW_TAB; and 4) restrict WIDGET extensions to a predefined set of mounts, returning a specific validation error if an invalid mount is used. Update the relevant unit and GraphQL tests to match these behaviors, including changing the WIDGET test mount and adjusting expectations around NEW_TAB relative URLs.",
1592
- "supplementalFiles": [
1593
- "saleor/app/types.py",
1594
- "saleor/app/models.py",
1595
- "saleor/app/validators.py",
1596
- "saleor/app/installation_utils.py",
1597
- "saleor/graphql/app/mutations/app_fetch_manifest.py"
1598
- ],
1599
- "fileDiffs": [
1600
- {
1601
- "path": "saleor/app/manifest_validations.py",
1602
- "status": "modified",
1603
- "diff": "Index: saleor/app/manifest_validations.py\n===================================================================\n--- saleor/app/manifest_validations.py\td4ec848 (parent)\n+++ saleor/app/manifest_validations.py\t7be9ab2 (commit)\n@@ -49,10 +49,10 @@\n manifest_data: dict, target: str, extension_url: str\n ):\n if target == AppExtensionTarget.APP_PAGE:\n return\n- if target == AppExtensionTarget.NEW_TAB:\n- raise ValidationError(\"NEW_TAB target should be absolute path\")\n+ if target == AppExtensionTarget.NEW_TAB and not manifest_data[\"appUrl\"]:\n+ raise ValidationError(\"To use relative URL, you must specify appUrl.\")\n if manifest_data[\"appUrl\"]:\n _clean_app_url(manifest_data[\"appUrl\"])\n else:\n msg = (\n@@ -72,8 +72,9 @@\n b) appUrl is provided\n - url cannot start with protocol when target == \"APP_PAGE\"\n \"\"\"\n extension_url = extension[\"url\"]\n+ # At this point target should be already cleaned enum AppExtensionTarget\n target = extension.get(\"target\") or AppExtensionTarget.POPUP\n \n # Assume app URL is the one that originally received the token.\n app_url = manifest_data.get(\"tokenTargetUrl\")\n@@ -87,17 +88,18 @@\n \n if not app_url:\n raise ValidationError(\"Manifest is invalid, token_target_url is missing\")\n \n+ is_new_tab_post = target == AppExtensionTarget.NEW_TAB and new_tab_method_post\n+ is_widget_post = target == AppExtensionTarget.WIDGET and widget_method_post\n+\n if extension_url.startswith(\"/\"):\n _clean_extension_url_with_only_path(manifest_data, target, extension_url)\n elif target == AppExtensionTarget.APP_PAGE:\n msg = \"Url cannot start with protocol when target == APP_PAGE\"\n logger.warning(msg)\n raise ValidationError(msg)\n- elif (target == AppExtensionTarget.NEW_TAB and new_tab_method_post) or (\n- target == AppExtensionTarget.WIDGET and widget_method_post\n- ):\n+ elif (is_new_tab_post) or is_widget_post:\n parsed_app_url = urlparse(app_url)\n parsed_extension_url = urlparse(extension_url)\n \n if parsed_extension_url.scheme != \"https\" and settings.ENABLE_SSL:\n@@ -239,21 +241,17 @@\n \"\"\"Validate the options field in an extension.\"\"\"\n options = extension.get(\"options\", {})\n try:\n validated_options = AppExtensionOptions.model_validate(options)\n+ is_widget = extension.get(\"target\") == AppExtensionTarget.WIDGET\n+ is_new_tab = extension.get(\"target\") == AppExtensionTarget.NEW_TAB\n \n- if (\n- validated_options.widget_target\n- and extension.get(\"target\") != AppExtensionTarget.WIDGET\n- ):\n+ if validated_options.widget_target and not is_widget:\n raise ValidationError(\n \"widgetTarget options must be set only on WIDGET target\"\n )\n \n- if (\n- validated_options.new_tab_target\n- and extension.get(\"target\") != AppExtensionTarget.NEW_TAB\n- ):\n+ if validated_options.new_tab_target and not is_new_tab:\n raise ValidationError(\n \"newTabTarget options must be set only on NEW_TAB target\"\n )\n \n@@ -267,8 +265,30 @@\n )\n )\n \n \n+def _validate_mounts_for_widget(mount: str):\n+ widget_available_mounts = [\n+ AppExtensionMount.ORDER_DETAILS_WIDGETS,\n+ AppExtensionMount.PRODUCT_DETAILS_WIDGETS,\n+ AppExtensionMount.VOUCHER_DETAILS_WIDGETS,\n+ AppExtensionMount.DRAFT_ORDER_DETAILS_WIDGETS,\n+ AppExtensionMount.GIFT_CARD_DETAILS_WIDGETS,\n+ AppExtensionMount.CUSTOMER_DETAILS_WIDGETS,\n+ AppExtensionMount.COLLECTION_DETAILS_WIDGETS,\n+ ]\n+\n+ if mount not in widget_available_mounts:\n+ raise ValidationError(\n+ {\n+ \"mount\": ValidationError(\n+ f\"Mount {mount.upper()} is not available for WIDGET target.\",\n+ code=AppErrorCode.INVALID.value,\n+ )\n+ }\n+ )\n+\n+\n def _clean_extensions(manifest_data, app_permissions, errors):\n extensions = manifest_data.get(\"extensions\", [])\n \n for extension in extensions:\n@@ -279,8 +299,14 @@\n \n _clean_extension_enum_field(AppExtensionMount, \"mount\", extension, errors)\n \n try:\n+ if extension[\"target\"] == AppExtensionTarget.WIDGET:\n+ _validate_mounts_for_widget(extension[\"mount\"])\n+ except ValidationError as invalid_mount_error:\n+ errors[\"extensions\"].append(invalid_mount_error)\n+\n+ try:\n _clean_extension_url(extension, manifest_data)\n except (ValidationError, AttributeError):\n errors[\"extensions\"].append(\n ValidationError(\n"
1604
- },
1605
- {
1606
- "path": "saleor/app/tests/test_installation_utils.py",
1607
- "status": "modified",
1608
- "diff": "Index: saleor/app/tests/test_installation_utils.py\n===================================================================\n--- saleor/app/tests/test_installation_utils.py\td4ec848 (parent)\n+++ saleor/app/tests/test_installation_utils.py\t7be9ab2 (commit)\n@@ -326,9 +326,9 @@\n app_manifest[\"extensions\"] = [\n {\n \"label\": label,\n \"url\": url,\n- \"mount\": \"PRODUCT_OVERVIEW_CREATE\",\n+ \"mount\": \"PRODUCT_DETAILS_WIDGETS\",\n \"permissions\": [\"MANAGE_PRODUCTS\"],\n \"target\": \"WIDGET\",\n \"options\": options,\n }\n@@ -351,9 +351,9 @@\n app_extension = app.extensions.get()\n \n assert app_extension.label == label\n assert app_extension.url == url\n- assert app_extension.mount == AppExtensionMount.PRODUCT_OVERVIEW_CREATE\n+ assert app_extension.mount == AppExtensionMount.PRODUCT_DETAILS_WIDGETS\n assert app_extension.target == AppExtensionTarget.WIDGET\n assert list(app_extension.permissions.all()) == [permission_manage_products]\n assert app_extension.http_target_method == \"POST\"\n \n"
1609
- },
1610
- {
1611
- "path": "saleor/app/tests/test_validators.py",
1612
- "status": "modified",
1613
- "diff": "Index: saleor/app/tests/test_validators.py\n===================================================================\n--- saleor/app/tests/test_validators.py\td4ec848 (parent)\n+++ saleor/app/tests/test_validators.py\t7be9ab2 (commit)\n@@ -10,8 +10,9 @@\n from ..manifest_validations import (\n _clean_author,\n _clean_extension_options,\n _clean_extension_url,\n+ _clean_extensions,\n _clean_required_saleor_version,\n _parse_version,\n )\n from ..types import AppExtensionTarget\n@@ -97,19 +98,17 @@\n assert error.value.code == AppErrorCode.INVALID_URL_FORMAT.value\n \n \n def test_clean_extensions_new_tab_valid_relative_url(app_manifest):\n+ app_manifest[\"appUrl\"] = \"https://app.example.com\"\n extension = {\n \"url\": \"/relative/path\",\n \"target\": AppExtensionTarget.NEW_TAB,\n }\n \n- with pytest.raises(ValidationError) as error:\n- _clean_extension_url(extension, app_manifest)\n+ _clean_extension_url(extension, app_manifest)\n \n- assert error.value.message == \"NEW_TAB target should be absolute path\"\n \n-\n @pytest.mark.parametrize(\n (\"extension\", \"manifest\", \"should_raise\"),\n [\n # url starts with /, target APP_PAGE, appUrl provided\n@@ -120,16 +119,16 @@\n \"appUrl\": \"https://app.example.com\",\n },\n False,\n ),\n- # url starts with /, target NEW_TAB, should raise\n+ # url starts with /, target NEW_TAB, should not raise\n (\n {\"url\": \"/tab\", \"target\": AppExtensionTarget.NEW_TAB},\n {\n \"tokenTargetUrl\": \"https://app.example.com\",\n \"appUrl\": \"https://app.example.com\",\n },\n- True,\n+ False,\n ),\n # url starts with protocol, target APP_PAGE, should raise\n (\n {\n@@ -175,11 +174,29 @@\n else:\n _clean_extension_url(extension, manifest)\n \n \n+def test_new_tab_relative_url_without_app_url(app_manifest):\n+ # given\n+ app_manifest[\"appUrl\"] = None\n+\n+ extension = {\n+ \"url\": \"/relative/path\",\n+ \"target\": AppExtensionTarget.NEW_TAB,\n+ }\n+\n+ app_manifest[\"extensions\"] = [extension]\n+\n+ # when & then\n+ with pytest.raises(ValidationError):\n+ _clean_extension_url(extension, manifest_data=app_manifest)\n+\n+\n def test_clean_extension_url_https_only(settings):\n+ # given\n settings.ENABLE_SSL = True\n \n+ # when & then\n with pytest.raises(ValidationError):\n _clean_extension_url(\n {\n \"url\": \"http://app.example.com/page\",\n@@ -193,22 +210,26 @@\n )\n \n \n def test_clean_extension_url_http_if_SSL_disabled(settings):\n+ # given\n settings.ENABLE_SSL = False\n \n+ # when\n result = _clean_extension_url(\n {\"url\": \"http://app.example.com/page\", \"target\": AppExtensionTarget.NEW_TAB},\n {\n \"tokenTargetUrl\": \"https://app.example.com\",\n \"appUrl\": \"https://app.example.com\",\n },\n )\n \n+ # then\n assert result is None\n \n \n def test_app_extension_options_accepts_only_one():\n+ # Given\n parsed = AppExtensionOptions().model_validate({\"widgetTarget\": {\"method\": \"GET\"}})\n \n assert parsed.new_tab_target is None\n assert parsed.widget_target is not None\n@@ -227,10 +248,12 @@\n \"widgetTarget\": {\"method\": \"GET\"},\n }\n )\n \n+ # when\n parsed = AppExtensionOptions().model_validate({})\n \n+ # then\n assert parsed.new_tab_target is None\n assert parsed.widget_target is None\n \n \n@@ -404,4 +427,58 @@\n # Then\n assert \"extensions\" not in errors\n assert \"options\" in extension\n assert extension[\"options\"] == {}\n+\n+\n+@pytest.mark.parametrize(\n+ \"mount\",\n+ [\n+ \"ORDER_DETAILS_WIDGETS\",\n+ \"PRODUCT_DETAILS_WIDGETS\",\n+ \"VOUCHER_DETAILS_WIDGETS\",\n+ \"DRAFT_ORDER_DETAILS_WIDGETS\",\n+ \"GIFT_CARD_DETAILS_WIDGETS\",\n+ \"CUSTOMER_DETAILS_WIDGETS\",\n+ \"COLLECTION_DETAILS_WIDGETS\",\n+ ],\n+)\n+def test_widget_target_available_mounts_valid(mount, app_manifest):\n+ # Given\n+ extension = {\n+ \"target\": \"WIDGET\",\n+ \"mount\": mount,\n+ \"url\": \"https://example.com/widget\",\n+ \"label\": \"label\",\n+ }\n+ errors = {\"extensions\": []}\n+\n+ app_manifest[\"extensions\"] = [extension]\n+\n+ # When\n+ _clean_extensions(app_manifest, [], errors)\n+\n+ # Then\n+ assert len(errors[\"extensions\"]) == 0\n+\n+\n+@pytest.mark.parametrize(\n+ \"mount\",\n+ [\"CATEGORY_OVERVIEW_CREATECATEGORY_OVERVIEW_MORE_ACTIONS\"],\n+)\n+def test_widget_target_available_mounts_invalid(mount, app_manifest):\n+ # Given\n+ extension = {\n+ \"target\": \"WIDGET\",\n+ \"mount\": mount,\n+ \"url\": \"https://example.com/widget\",\n+ \"label\": \"label\",\n+ }\n+ errors = {\"extensions\": []}\n+\n+ app_manifest[\"extensions\"] = [extension]\n+\n+ # When\n+ _clean_extensions(app_manifest, [], errors)\n+\n+ # Then\n+ assert \"extensions\" in errors\n"
1614
- },
1615
- {
1616
- "path": "saleor/graphql/app/tests/mutations/test_app_fetch_manifest.py",
1617
- "status": "modified",
1618
- "diff": "Index: saleor/graphql/app/tests/mutations/test_app_fetch_manifest.py\n===================================================================\n--- saleor/graphql/app/tests/mutations/test_app_fetch_manifest.py\td4ec848 (parent)\n+++ saleor/graphql/app/tests/mutations/test_app_fetch_manifest.py\t7be9ab2 (commit)\n@@ -496,9 +496,8 @@\n (\"/app\", AppExtensionTargetEnum.POPUP.name),\n (\"www.example.com/app\", AppExtensionTargetEnum.POPUP.name),\n (\"https://www.example.com/app\", AppExtensionTargetEnum.APP_PAGE.name),\n (\"http://www.example.com/app\", AppExtensionTargetEnum.APP_PAGE.name),\n- (\"/relative-app-url\", AppExtensionTargetEnum.NEW_TAB.name),\n ],\n )\n def test_app_fetch_manifest_extensions_incorrect_url(\n url, target, app_manifest, monkeypatch, staff_api_client, permission_manage_apps\n"
1619
- }
1620
- ]
1621
- },
1622
- {
1623
- "id": "add-order-transaction-filters",
1624
- "sha": "d1e6e0e2bbbcef285ea217d2e3ea27fcf12dce2b",
1625
- "parentSha": "b513608cf1cc2e2407dbb8662886f44ca139e1b5",
1626
- "spec": "Implement GraphQL filters to query orders by transaction payment method details and add supporting indexes and enums.\n\nScope:\n1) GraphQL filter inputs and OrderWhere integration\n- In saleor/graphql/order/filters.py:\n - Add PaymentMethodTypeEnumFilterInput with fields: eq and one_of, using PaymentMethodTypeEnum.\n - Add PaymentMethodDetailsCardFilterInput with brand: StringFilterInput.\n - Add PaymentMethodDetailsFilterInput with:\n - type: PaymentMethodTypeEnumFilterInput\n - card: PaymentMethodDetailsCardFilterInput\n - Static methods:\n - filter_card(qs, _, value):\n • If value is None return qs.none().\n • Build a TransactionItem queryset limited to payment_method_type=CARD.\n • If value contains brand, apply filter_where_by_value_field on cc_brand.\n • Filter orders where Exists(transactions filtered by order_id=OuterRef(\"id\")).\n - filter_type(qs, _, value):\n • If value is None return qs.none().\n • Use filter_where_by_value_field on TransactionItem to filter by payment_method_type.\n • Filter orders where Exists(transactions filtered by order_id=OuterRef(\"id\")).\n - Add TransactionFilterInput with field paymentMethodDetails: PaymentMethodDetailsFilterInput.\n - Static method filter_payment_method_details(qs, _, value):\n • If value is None return qs.none().\n • If card provided, delegate to PaymentMethodDetailsFilterInput.filter_card.\n • Else if type provided, delegate to PaymentMethodDetailsFilterInput.filter_type.\n • Else return qs.none().\n - Extend OrderWhere with a transactions: ObjectTypeWhereFilter using TransactionFilterInput and a filter_transactions method that delegates to TransactionFilterInput.filter_payment_method_details when input present and returns qs.none() otherwise.\n - Import PaymentMethodType and TransactionItem from payment module, and PaymentMethodTypeEnum from GraphQL payment enums. Use Exists and OuterRef to correlate TransactionItem to Order by order_id.\n\n2) GraphQL enums exposure\n- In saleor/graphql/payment/enums.py:\n - Expose PaymentMethodType as GraphQL enum via to_enum with type name PaymentMethodTypeEnum and assign doc_category.\n\n3) GraphQL schema updates\n- In saleor/graphql/schema.graphql:\n - Add the new inputs and enum:\n • enum PaymentMethodTypeEnum with values CARD and OTHER.\n • input PaymentMethodTypeEnumFilterInput with eq and oneOf.\n • input PaymentMethodDetailsCardFilterInput with brand: StringFilterInput.\n • input PaymentMethodDetailsFilterInput with fields type and card (card overrides type when provided).\n • input TransactionFilterInput with paymentMethodDetails.\n - Extend OrderWhereInput to include transactions: TransactionFilterInput with appropriate description.\n\n4) Database indexes for filtering performance\n- In saleor/payment/models.py (TransactionItem.Meta):\n - Add BTreeIndex on payment_method_type named payment_method_type_ids.\n - Add BTreeIndex on cc_brand named cc_brand_idx.\n- Add a Django migration (saleor/payment/migrations/0063_transactionitem_payment_method_type_ids_and_more.py) that uses AddIndexConcurrently to create both indexes on transactionitem with the specified names and atomic=False, depending on the latest relevant migrations in account, app, checkout, order, and payment.\n\n5) Test fixtures support\n- In saleor/payment/tests/fixtures/transaction_item.py:\n - Extend transaction_item_generator fixture signature to accept payment_method_type, payment_method_name, cc_brand, cc_first_digits, cc_last_digits, cc_exp_month, cc_exp_year (default to None).\n - Pass these values when creating TransactionItem so tests can create transactions with payment details.\n\n6) Order filtering tests (reference behavior)\n- Ensure tests can:\n - Filter orders where any related transaction has type CARD or OTHER via eq and oneOf.\n - Filter orders where any related transaction has card brand matching eq or oneOf.\n - When card filter is provided, type filter is skipped.\n - Only orders having at least one matching transaction appear in results.\n\nBehavioral expectations:\n- The GraphQL orders query with where.transactions.paymentMethodDetails filters returns orders whose related TransactionItem rows match the provided method type and/or card brand criteria.\n- Invalid or missing filter inputs under paymentMethodDetails return no results (qs.none()) for the transactions sub-filter, consistent with existing where filter semantics.\n- The new PaymentMethodTypeEnum is available for inputs and documented in the schema.\n- DB indexes exist to support efficient filtering by payment_method_type and cc_brand.\n\nNon-goals:\n- Do not change existing payment details output types.\n- Do not alter unrelated order filtering behavior.\n- Do not add new fields to TransactionItem beyond indexes and fixture support.",
1627
- "prompt": "Add GraphQL support to filter orders by the payment method details used in their transactions. Introduce a transactions filter in the orders where input that can narrow results by payment method type (e.g., card vs other) and by card brand. Expose an input enum for the payment method type, and provide inputs to filter by type or card brand with both equality and inclusion options. Ensure the filter queries orders by correlating to their transaction items and supports EXISTS semantics. Update the schema to include the new inputs and enum, and add database indexes on transaction items to make filtering by payment method type and card brand efficient. Extend the test fixture for creating transaction items so tests can construct transactions with method type/name and card details. Keep behavior consistent with existing where-filter utilities (return empty results for invalid sub-inputs), and ensure documentation strings are present in the schema.",
1628
- "supplementalFiles": [
1629
- "saleor/graphql/utils/filters.py",
1630
- "saleor/graphql/core/filters/where_filters.py",
1631
- "saleor/graphql/core/enums.py",
1632
- "saleor/graphql/payment/types.py",
1633
- "saleor/payment/interface.py",
1634
- "saleor/order/models.py",
1635
- "saleor/payment/migrations/0062_transactionitem_cc_brand_and_more.py"
1636
- ],
1637
- "fileDiffs": [
1638
- {
1639
- "path": "CHANGELOG.md",
1640
- "status": "modified",
1641
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\tb513608 (parent)\n+++ CHANGELOG.md\td1e6e0e (commit)\n@@ -26,8 +26,9 @@\n - Filter by order metadata.\n - Filter by order by associated lines metadata.\n - Filter by the product type of related order lines.\n - Filter by associated event type and date\n+ - Filter by associated payment method name and type\n - Extend the `Page` type with an `attribute` field. Adds support for querying a specific attribute on a page by `slug`, returning the matching attribute and its assigned values, or null if no match is found.\n - Enhanced order search options. Orders can now be searched using:\n - The order's ID\n - IDs of invoices linked to the order\n"
1642
- },
1643
- {
1644
- "path": "saleor/graphql/order/filters.py",
1645
- "status": "modified",
1646
- "diff": "Index: saleor/graphql/order/filters.py\n===================================================================\n--- saleor/graphql/order/filters.py\tb513608 (parent)\n+++ saleor/graphql/order/filters.py\td1e6e0e (commit)\n@@ -13,9 +13,10 @@\n from ...giftcard.models import GiftCardEvent\n from ...invoice.models import Invoice\n from ...order.models import Fulfillment, Order, OrderEvent, OrderLine\n from ...order.search import search_orders\n-from ...payment import ChargeStatus\n+from ...payment import ChargeStatus, PaymentMethodType\n+from ...payment.models import TransactionItem\n from ...product.models import ProductVariant\n from ..channel.filters import get_currency_from_filter_data\n from ..core.doc_category import DOC_CATEGORY_ORDERS\n from ..core.filters import (\n@@ -47,9 +48,9 @@\n NonNullList,\n )\n from ..core.utils import from_global_id_or_error\n from ..discount.filters import DiscountedObjectWhere\n-from ..payment.enums import PaymentChargeStatusEnum\n+from ..payment.enums import PaymentChargeStatusEnum, PaymentMethodTypeEnum\n from ..utils import resolve_global_ids_to_primary_keys\n from ..utils.filters import (\n filter_by_ids,\n filter_range_field,\n@@ -455,8 +456,77 @@\n doc_category = DOC_CATEGORY_ORDERS\n description = \"Filter input for order events data.\"\n \n \n+class PaymentMethodTypeEnumFilterInput(BaseInputObjectType):\n+ eq = PaymentMethodTypeEnum(description=FilterInputDescriptions.EQ, required=False)\n+ one_of = NonNullList(\n+ PaymentMethodTypeEnum,\n+ description=FilterInputDescriptions.ONE_OF,\n+ required=False,\n+ )\n+\n+\n+class PaymentMethodDetailsCardFilterInput(BaseInputObjectType):\n+ brand = StringFilterInput(\n+ description=\"Filter by payment method brand used to pay for the order.\",\n+ )\n+\n+\n+class PaymentMethodDetailsFilterInput(BaseInputObjectType):\n+ type = PaymentMethodTypeEnumFilterInput(\n+ description=\"Filter by payment method type used to pay for the order.\",\n+ )\n+ card = PaymentMethodDetailsCardFilterInput(\n+ description=\"Filter by card details used to pay for the order. Skips `type` filter if provided.\",\n+ )\n+\n+ @staticmethod\n+ def filter_card(qs, _, value):\n+ if value is None:\n+ return qs.none()\n+ transaction_query = TransactionItem.objects.using(qs.db).filter(\n+ payment_method_type=PaymentMethodType.CARD\n+ )\n+ if brand_filter_value := value.get(\"brand\"):\n+ transactions = filter_where_by_value_field(\n+ transaction_query, \"cc_brand\", brand_filter_value\n+ )\n+ return qs.filter(Exists(transactions.filter(order_id=OuterRef(\"id\"))))\n+\n+ @staticmethod\n+ def filter_type(qs, _, value):\n+ if value is None:\n+ return qs.none()\n+ transactions = filter_where_by_value_field(\n+ TransactionItem.objects.using(qs.db),\n+ \"payment_method_type\",\n+ value,\n+ )\n+ return qs.filter(Exists(transactions.filter(order_id=OuterRef(\"id\"))))\n+\n+\n+class TransactionFilterInput(BaseInputObjectType):\n+ payment_method_details = PaymentMethodDetailsFilterInput(\n+ description=\"Filter by payment method details used to pay for the order.\",\n+ )\n+\n+ class Meta:\n+ doc_category = DOC_CATEGORY_ORDERS\n+ description = \"Filter input for transactions.\"\n+\n+ @staticmethod\n+ def filter_payment_method_details(qs, _, value):\n+ if value is None:\n+ return qs.none()\n+\n+ if filter_value := value.get(\"card\"):\n+ return PaymentMethodDetailsFilterInput.filter_card(qs, _, filter_value)\n+ if filter_value := value.get(\"type\"):\n+ return PaymentMethodDetailsFilterInput.filter_type(qs, _, filter_value)\n+ return qs.none()\n+\n+\n class OrderWhere(MetadataWhereBase):\n ids = GlobalIDMultipleChoiceWhereFilter(method=filter_by_ids(\"Order\"))\n number = OperationObjectTypeWhereFilter(\n input_class=IntFilterInput,\n@@ -561,8 +631,13 @@\n input_class=IntFilterInput,\n method=\"filter_lines_count\",\n help_text=\"Filter by number of lines in the order.\",\n )\n+ transactions = ObjectTypeWhereFilter(\n+ input_class=TransactionFilterInput,\n+ method=\"filter_transactions\",\n+ help_text=\"Filter by transaction data associated with the order.\",\n+ )\n total_gross = ObjectTypeWhereFilter(\n input_class=PriceFilterInput,\n method=\"filter_total_gross\",\n help_text=\"Filter by total gross amount of the order.\",\n@@ -695,8 +770,18 @@\n def filter_lines_count(qs, _, value):\n return filter_where_by_numeric_field(qs, \"lines_count\", value)\n \n @staticmethod\n+ def filter_transactions(qs, _, value):\n+ if value is None:\n+ return qs.none()\n+ if filter_value := value.get(\"payment_method_details\"):\n+ return TransactionFilterInput.filter_payment_method_details(\n+ qs, _, filter_value\n+ )\n+ return qs.none()\n+\n+ @staticmethod\n def filter_total_gross(qs, _, value):\n return filter_where_by_price_field(qs, \"total_gross_amount\", value)\n \n @staticmethod\n"
1647
- },
1648
- {
1649
- "path": "saleor/graphql/order/tests/queries/test_order_with_where.py",
1650
- "status": "modified",
1651
- "diff": "Index: saleor/graphql/order/tests/queries/test_order_with_where.py\n===================================================================\n--- saleor/graphql/order/tests/queries/test_order_with_where.py\tb513608 (parent)\n+++ saleor/graphql/order/tests/queries/test_order_with_where.py\td1e6e0e (commit)\n@@ -2371,4 +2371,144 @@\n orders = content[\"data\"][\"orders\"][\"edges\"]\n assert len(orders) == len(expected_indexes)\n numbers = {node[\"node\"][\"number\"] for node in orders}\n assert numbers == {str(order_list[i].number) for i in expected_indexes}\n+\n+\n+@pytest.mark.parametrize(\n+ (\"where\", \"indexes\"),\n+ [\n+ (\n+ {\n+ \"paymentMethodDetails\": {\n+ \"type\": {\"eq\": \"CARD\"},\n+ }\n+ },\n+ [0, 2],\n+ ),\n+ (\n+ {\n+ \"paymentMethodDetails\": {\n+ \"type\": {\"eq\": \"OTHER\"},\n+ }\n+ },\n+ [1],\n+ ),\n+ (\n+ {\n+ \"paymentMethodDetails\": {\n+ \"card\": {\n+ \"brand\": {\"eq\": \"Brand\"},\n+ }\n+ }\n+ },\n+ [0],\n+ ),\n+ (\n+ {\n+ \"paymentMethodDetails\": {\n+ \"card\": {\n+ \"brand\": {\"eq\": \"Brand4\"},\n+ }\n+ }\n+ },\n+ [2],\n+ ),\n+ (\n+ {\n+ \"paymentMethodDetails\": {\n+ \"card\": {\n+ \"brand\": {\"eq\": \"Brand2\"},\n+ }\n+ }\n+ },\n+ [0],\n+ ),\n+ (\n+ {\n+ \"paymentMethodDetails\": {\n+ \"type\": {\"oneOf\": [\"CARD\", \"OTHER\"]},\n+ }\n+ },\n+ [0, 1, 2],\n+ ),\n+ (\n+ {\n+ \"paymentMethodDetails\": {\n+ \"card\": {\n+ \"brand\": {\"oneOf\": [\"Brand2\", \"Brand4\"]},\n+ }\n+ }\n+ },\n+ [0, 2],\n+ ),\n+ ],\n+)\n+def test_orders_filter_by_transaction_payment_details(\n+ where,\n+ indexes,\n+ order_list,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ transaction_item_generator,\n+):\n+ # given\n+ # first_transaction\n+ transaction_item_generator(\n+ order_id=order_list[0].pk,\n+ charged_value=order_list[0].total.gross.amount,\n+ payment_method_type=\"card\",\n+ payment_method_name=\"Credit card\",\n+ cc_brand=\"Brand\",\n+ cc_first_digits=\"1234\",\n+ cc_last_digits=\"5678\",\n+ cc_exp_month=12,\n+ cc_exp_year=2025,\n+ )\n+\n+ # second_transaction\n+ transaction_item_generator(\n+ order_id=order_list[0].pk,\n+ charged_value=order_list[0].total.gross.amount,\n+ payment_method_type=\"card\",\n+ payment_method_name=\"Second Credit card\",\n+ cc_brand=\"Brand2\",\n+ cc_first_digits=\"1234\",\n+ cc_last_digits=\"5678\",\n+ cc_exp_month=12,\n+ cc_exp_year=2025,\n+ )\n+\n+ # third_transaction\n+ transaction_item_generator(\n+ order_id=order_list[1].pk,\n+ charged_value=order_list[1].total.gross.amount,\n+ payment_method_type=\"other\",\n+ payment_method_name=\"Third payment method\",\n+ cc_brand=None,\n+ cc_first_digits=None,\n+ cc_last_digits=None,\n+ cc_exp_month=None,\n+ cc_exp_year=None,\n+ )\n+\n+ # fourth_transaction\n+ transaction_item_generator(\n+ order_id=order_list[2].pk,\n+ charged_value=order_list[2].total.gross.amount,\n+ payment_method_type=\"card\",\n+ payment_method_name=\"Fourth Credit card\",\n+ cc_brand=\"Brand4\",\n+ )\n+\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+ variables = {\"where\": {\"transactions\": where}}\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_WHERE_QUERY, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ orders = content[\"data\"][\"orders\"][\"edges\"]\n+ assert len(orders) == len(indexes)\n+ numbers = {node[\"node\"][\"number\"] for node in orders}\n+ assert numbers == {str(order_list[index].number) for index in indexes}\n"
1652
- },
1653
- {
1654
- "path": "saleor/graphql/payment/enums.py",
1655
- "status": "modified",
1656
- "diff": "Index: saleor/graphql/payment/enums.py\n===================================================================\n--- saleor/graphql/payment/enums.py\tb513608 (parent)\n+++ saleor/graphql/payment/enums.py\td1e6e0e (commit)\n@@ -1,8 +1,9 @@\n import graphene\n \n from ...payment import (\n ChargeStatus,\n+ PaymentMethodType,\n StorePaymentMethod,\n TokenizedPaymentFlow,\n TransactionAction,\n TransactionEventType,\n@@ -34,9 +35,16 @@\n TransactionEventType, description=TransactionEventType.__doc__\n )\n TransactionEventTypeEnum.doc_category = DOC_CATEGORY_PAYMENTS\n \n+PaymentMethodTypeEnum = to_enum(\n+ PaymentMethodType,\n+ type_name=\"PaymentMethodTypeEnum\",\n+ description=PaymentMethodType.__doc__,\n+)\n+PaymentMethodTypeEnum.doc_category = DOC_CATEGORY_PAYMENTS\n \n+\n class OrderAction(BaseEnum):\n CAPTURE = \"CAPTURE\"\n MARK_AS_PAID = \"MARK_AS_PAID\"\n REFUND = \"REFUND\"\n"
1657
- },
1658
- {
1659
- "path": "saleor/graphql/schema.graphql",
1660
- "status": "modified",
1661
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\tb513608 (parent)\n+++ saleor/graphql/schema.graphql\td1e6e0e (commit)\n@@ -13090,8 +13090,11 @@\n \n \"\"\"Filter by number of lines in the order.\"\"\"\n linesCount: IntFilterInput\n \n+ \"\"\"Filter by transaction data associated with the order.\"\"\"\n+ transactions: TransactionFilterInput\n+\n \"\"\"Filter by total gross amount of the order.\"\"\"\n totalGross: PriceFilterInput\n \n \"\"\"Filter by total net amount of the order.\"\"\"\n@@ -13187,8 +13190,49 @@\n \"\"\"Filter by metadata fields of order lines.\"\"\"\n metadata: MetadataFilterInput\n }\n \n+\"\"\"Filter input for transactions.\"\"\"\n+input TransactionFilterInput @doc(category: \"Orders\") {\n+ \"\"\"Filter by payment method details used to pay for the order.\"\"\"\n+ paymentMethodDetails: PaymentMethodDetailsFilterInput\n+}\n+\n+input PaymentMethodDetailsFilterInput {\n+ \"\"\"Filter by payment method type used to pay for the order.\"\"\"\n+ type: PaymentMethodTypeEnumFilterInput\n+\n+ \"\"\"\n+ Filter by card details used to pay for the order. Skips `type` filter if provided.\n+ \"\"\"\n+ card: PaymentMethodDetailsCardFilterInput\n+}\n+\n+input PaymentMethodTypeEnumFilterInput {\n+ \"\"\"The value equal to.\"\"\"\n+ eq: PaymentMethodTypeEnum\n+\n+ \"\"\"The value included in.\"\"\"\n+ oneOf: [PaymentMethodTypeEnum!]\n+}\n+\n+\"\"\"\n+Represents possible payment method types.\n+\n+ The following types are possible:\n+ CARD - represents a card payment method.\n+ OTHER - represents any payment method that is not a card payment.\n+\"\"\"\n+enum PaymentMethodTypeEnum @doc(category: \"Payments\") {\n+ CARD\n+ OTHER\n+}\n+\n+input PaymentMethodDetailsCardFilterInput {\n+ \"\"\"Filter by payment method brand used to pay for the order.\"\"\"\n+ brand: StringFilterInput\n+}\n+\n input PriceFilterInput {\n \"\"\"The currency of the price to filter by.\"\"\"\n currency: String\n \n"
1662
- },
1663
- {
1664
- "path": "saleor/payment/migrations/0063_transactionitem_payment_method_type_ids_and_more.py",
1665
- "status": "added",
1666
- "diff": "Index: saleor/payment/migrations/0063_transactionitem_payment_method_type_ids_and_more.py\n===================================================================\n--- saleor/payment/migrations/0063_transactionitem_payment_method_type_ids_and_more.py\tb513608 (parent)\n+++ saleor/payment/migrations/0063_transactionitem_payment_method_type_ids_and_more.py\td1e6e0e (commit)\n@@ -1,1 +1,30 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-06-18 08:13\n+\n+from django.contrib.postgres.indexes import BTreeIndex\n+from django.contrib.postgres.operations import AddIndexConcurrently\n+from django.db import migrations\n+\n+\n+class Migration(migrations.Migration):\n+ atomic = False\n+\n+ dependencies = [\n+ (\"account\", \"0087_alter_address_metadata_and_more\"),\n+ (\"app\", \"0031_alter_appextension_mount_alter_appextension_target\"),\n+ (\"checkout\", \"0080_merge_20250527_1210\"),\n+ (\"order\", \"0210_populated_order_line_product_type_id\"),\n+ (\"payment\", \"0062_transactionitem_cc_brand_and_more\"),\n+ ]\n+\n+ operations = [\n+ AddIndexConcurrently(\n+ model_name=\"transactionitem\",\n+ index=BTreeIndex(\n+ fields=[\"payment_method_type\"], name=\"payment_method_type_ids\"\n+ ),\n+ ),\n+ AddIndexConcurrently(\n+ model_name=\"transactionitem\",\n+ index=BTreeIndex(fields=[\"cc_brand\"], name=\"cc_brand_idx\"),\n+ ),\n+ ]\n"
1667
- },
1668
- {
1669
- "path": "saleor/payment/models.py",
1670
- "status": "modified",
1671
- "diff": "Index: saleor/payment/models.py\n===================================================================\n--- saleor/payment/models.py\tb513608 (parent)\n+++ saleor/payment/models.py\td1e6e0e (commit)\n@@ -3,9 +3,9 @@\n from uuid import uuid4\n \n from django.conf import settings\n from django.contrib.postgres.fields import ArrayField\n-from django.contrib.postgres.indexes import GinIndex\n+from django.contrib.postgres.indexes import BTreeIndex, GinIndex\n from django.core.serializers.json import DjangoJSONEncoder\n from django.core.validators import MaxValueValidator, MinValueValidator\n from django.db import models\n from django.db.models import JSONField\n@@ -179,8 +179,10 @@\n class Meta:\n ordering = (\"pk\",)\n indexes = [\n *ModelWithMetadata.Meta.indexes,\n+ BTreeIndex(fields=[\"payment_method_type\"], name=\"payment_method_type_ids\"),\n+ BTreeIndex(fields=[\"cc_brand\"], name=\"cc_brand_idx\"),\n ]\n constraints = [\n models.UniqueConstraint(\n fields=[\"app_identifier\", \"idempotency_key\"],\n"
1672
- },
1673
- {
1674
- "path": "saleor/payment/tests/fixtures/transaction_item.py",
1675
- "status": "modified",
1676
- "diff": "Index: saleor/payment/tests/fixtures/transaction_item.py\n===================================================================\n--- saleor/payment/tests/fixtures/transaction_item.py\tb513608 (parent)\n+++ saleor/payment/tests/fixtures/transaction_item.py\td1e6e0e (commit)\n@@ -24,8 +24,15 @@\n refunded_value=Decimal(0),\n canceled_value=Decimal(0),\n use_old_id=False,\n last_refund_success=True,\n+ payment_method_type=None,\n+ payment_method_name=None,\n+ cc_brand=None,\n+ cc_first_digits=None,\n+ cc_last_digits=None,\n+ cc_exp_month=None,\n+ cc_exp_year=None,\n ):\n if available_actions is None:\n available_actions = []\n transaction = TransactionItem.objects.create(\n@@ -41,8 +48,15 @@\n app=app,\n user=user,\n use_old_id=use_old_id,\n last_refund_success=last_refund_success,\n+ payment_method_type=payment_method_type,\n+ payment_method_name=payment_method_name,\n+ cc_brand=cc_brand,\n+ cc_first_digits=cc_first_digits,\n+ cc_last_digits=cc_last_digits,\n+ cc_exp_month=cc_exp_month,\n+ cc_exp_year=cc_exp_year,\n )\n create_manual_adjustment_events(\n transaction=transaction,\n money_data={\n"
1677
- }
1678
- ]
1679
- },
1680
- {
1681
- "id": "filter-order-events",
1682
- "sha": "d38c5f7025a2776a87e4444fcb8e10cdc1abb49e",
1683
- "parentSha": "4815c54985113204e8370c0d86451c89ad69ad65",
1684
- "spec": "Implement order filtering by associated order events (date range and type) across GraphQL and database:\n\n1) GraphQL filter inputs and where filter\n- In saleor/graphql/order/filters.py:\n - Import OrderEvent model and OrderEventsEnum.\n - Define OrderEventTypeEnumFilterInput with two optional fields: eq (OrderEventsEnum) and one_of (NonNullList(OrderEventsEnum)).\n - Define OrderEventFilterInput with two optional fields: date (DateTimeRangeInput) and type (OrderEventTypeEnumFilterInput). Set doc_category to Orders and provide meaningful descriptions.\n - Add a new field to OrderWhere: events = ObjectTypeWhereFilter(input_class=OrderEventFilterInput, method=\"filter_events\", help_text=\"Filter by order events.\").\n - Implement static method filter_events(qs, _, value) that:\n - Returns qs.none() if value is empty or does not include at least one of {\"date\", \"type\"}.\n - If date provided, filters orders where Exists(OrderEvent with order_id=OuterRef(\"id\")) and whose date satisfies filter_where_by_range_field on the OrderEvent.date field.\n - If type provided, filters orders where Exists(OrderEvent with order_id=OuterRef(\"id\")) and whose type satisfies filter_where_by_value_field on the OrderEvent.type field.\n - Chains both constraints if both are present (i.e., orders must have an event matching each provided constraint).\n\n2) GraphQL schema snapshot\n- Update saleor/graphql/schema.graphql to expose:\n - New input OrderEventTypeEnumFilterInput with fields eq and oneOf (OrderEventsEnum values).\n - New input OrderEventFilterInput with fields date (DateTimeRangeInput) and type (OrderEventTypeEnumFilterInput), documented under Orders category.\n - New field events: OrderEventFilterInput on OrderWhereInput with description \"Filter by order events.\" Ensure AND/OR fields remain.\n\n3) Database indexing and model metadata\n- Add a Django migration in saleor/order/migrations to create a concurrent BTree index on OrderEvent.date, named order_orderevent_date_idx. Set atomic = False and add proper dependencies following the latest order app migration. Use AddIndexConcurrently with BTreeIndex(fields=[\"date\"], name=\"order_orderevent_date_idx\").\n- In saleor/order/models.py, extend OrderEvent.Meta.indexes to include BTreeIndex(fields=[\"date\"], name=\"order_orderevent_date_idx\") alongside existing indexes; keep ordering by date.\n\n4) Tests\n- In saleor/graphql/order/tests/queries/test_order_with_where.py:\n - Import OrderEvents and OrderEvent.\n - Add a parametrized test that creates OrderEvent records at fixed times using freeze_time to simulate distinct event dates and types (e.g., PLACED for all orders on 2025-01-01, ORDER_FULLY_PAID for first two orders on 2025-02-02).\n - For multiple event input variants (date gte 2025-01-01 with type eq PLACED, date gte 2025-01-01 with type eq ORDER_FULLY_PAID, date gte 2026-01-01 only, date gte 2020-01-01 only, type oneOf [PLACED, ORDER_FULLY_PAID]), assert the orders query filtered by where: { events: <event_input> } returns the expected subset by order numbers.\n - Ensure staff API client has Manage Orders permission.\n\n5) Changelog\n- In CHANGELOG.md, under the Orders filtering enhancements list, add an entry noting that orders can be filtered by associated event type and date.\n\nBehavioral expectations:\n- The orders GraphQL where filter supports filtering by event date range (inclusive gte/lte semantics per DateTimeRangeInput) and by event type values (equality or inclusion via oneOf, using OrderEventsEnum).\n- When both date and type are supplied, only orders having events satisfying each respective criterion are returned (logical AND across provided constraints). If neither date nor type is provided in the events filter, the filter yields no results.\n- Schema reflects the new inputs and field; the migration and model index ensure efficient querying by event date.",
1685
- "prompt": "Extend the orders GraphQL filtering to support querying orders by their associated events. Add a new events filter to the orders where input that can filter by an event date range and by an event type (using the existing order events enum). Update the schema to include the new filter inputs and field. Implement the filter so it returns orders that have matching events (supporting both date-only, type-only, and combined date+type cases). Add a concurrent database index on the order event date field for performance and reflect it in the model metadata. Finally, add tests that create events at known timestamps and verify the orders query returns the expected subsets across several scenarios, and update the changelog to announce the new filter capability.",
1686
- "supplementalFiles": [
1687
- "saleor/graphql/utils/filters.py",
1688
- "saleor/graphql/order/enums.py",
1689
- "saleor/graphql/core/filters/where_filters.py",
1690
- "saleor/graphql/core/filters/where_input.py",
1691
- "saleor/graphql/core/types.py",
1692
- "saleor/order/events.py",
1693
- "saleor/graphql/order/types.py"
1694
- ],
1695
- "fileDiffs": [
1696
- {
1697
- "path": "CHANGELOG.md",
1698
- "status": "modified",
1699
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\t4815c54 (parent)\n+++ CHANGELOG.md\td38c5f7 (commit)\n@@ -22,8 +22,9 @@\n - Filter by order total gross and net price amount.\n - Filter by order metadata.\n - Filter by order by associated lines metadata.\n - Filter by the product type of related order lines.\n+ - Filter by associated event type and date\n - Extend the `Page` type with an `attribute` field. Adds support for querying a specific attribute on a page by `slug`, returning the matching attribute and its assigned values, or null if no match is found.\n - Enhanced order search options. Orders can now be searched using:\n - The order's ID\n - IDs of invoices linked to the order\n"
1700
- },
1701
- {
1702
- "path": "saleor/graphql/order/filters.py",
1703
- "status": "modified",
1704
- "diff": "Index: saleor/graphql/order/filters.py\n===================================================================\n--- saleor/graphql/order/filters.py\t4815c54 (parent)\n+++ saleor/graphql/order/filters.py\td38c5f7 (commit)\n@@ -11,9 +11,9 @@\n from ...core.postgres import FlatConcat\n from ...giftcard import GiftCardEvents\n from ...giftcard.models import GiftCardEvent\n from ...invoice.models import Invoice\n-from ...order.models import Fulfillment, Order, OrderLine\n+from ...order.models import Fulfillment, Order, OrderEvent, OrderLine\n from ...order.search import search_orders\n from ...payment import ChargeStatus\n from ...product.models import ProductVariant\n from ..channel.filters import get_currency_from_filter_data\n@@ -62,8 +62,9 @@\n from .enums import (\n FulfillmentStatusEnum,\n OrderAuthorizeStatusEnum,\n OrderChargeStatusEnum,\n+ OrderEventsEnum,\n OrderStatusEnum,\n OrderStatusFilter,\n )\n \n@@ -432,8 +433,30 @@\n doc_category = DOC_CATEGORY_ORDERS\n description = \"Filter input for order lines data.\"\n \n \n+class OrderEventTypeEnumFilterInput(BaseInputObjectType):\n+ eq = OrderEventsEnum(description=FilterInputDescriptions.EQ, required=False)\n+ one_of = NonNullList(\n+ OrderEventsEnum,\n+ description=FilterInputDescriptions.ONE_OF,\n+ required=False,\n+ )\n+\n+\n+class OrderEventFilterInput(BaseInputObjectType):\n+ date = DateTimeRangeInput(\n+ description=\"Filter order events by date.\",\n+ )\n+ type = OrderEventTypeEnumFilterInput(\n+ description=\"Filter order events by type.\",\n+ )\n+\n+ class Meta:\n+ doc_category = DOC_CATEGORY_ORDERS\n+ description = \"Filter input for order events data.\"\n+\n+\n class OrderWhere(MetadataWhereBase):\n ids = GlobalIDMultipleChoiceWhereFilter(method=filter_by_ids(\"Order\"))\n number = OperationObjectTypeWhereFilter(\n input_class=IntFilterInput,\n@@ -553,8 +576,13 @@\n input_class=GlobalIDFilterInput,\n method=\"filter_product_type_id\",\n help_text=\"Filter by the product type of related order lines.\",\n )\n+ events = ObjectTypeWhereFilter(\n+ input_class=OrderEventFilterInput,\n+ method=\"filter_events\",\n+ help_text=\"Filter by order events.\",\n+ )\n \n @staticmethod\n def filter_number(qs, _, value):\n return filter_where_by_numeric_field(qs, \"number\", value)\n@@ -684,9 +712,27 @@\n OrderLine.objects.using(qs.db), \"product_type_id\", value, \"ProductType\"\n )\n return qs.filter(Exists(line_qs.filter(order_id=OuterRef(\"id\"))))\n \n+ @staticmethod\n+ def filter_events(qs, _, value):\n+ if not value:\n+ return qs.none()\n+ if not {\"date\", \"type\"}.intersection(value.keys()):\n+ return qs.none()\n+ if filter_value := value.get(\"date\"):\n+ events = filter_where_by_range_field(\n+ OrderEvent.objects.using(qs.db), \"date\", filter_value\n+ )\n+ qs = qs.filter(Exists(events.filter(order_id=OuterRef(\"id\"))))\n+ if filter_value := value.get(\"type\"):\n+ events = filter_where_by_value_field(\n+ OrderEvent.objects.using(qs.db), \"type\", filter_value\n+ )\n+ qs = qs.filter(Exists(events.filter(order_id=OuterRef(\"id\"))))\n+ return qs\n \n+\n class OrderWhereInput(WhereInputObjectType):\n class Meta:\n doc_category = DOC_CATEGORY_ORDERS\n filterset_class = OrderWhere\n"
1705
- },
1706
- {
1707
- "path": "saleor/graphql/order/tests/queries/test_order_with_where.py",
1708
- "status": "modified",
1709
- "diff": "Index: saleor/graphql/order/tests/queries/test_order_with_where.py\n===================================================================\n--- saleor/graphql/order/tests/queries/test_order_with_where.py\t4815c54 (parent)\n+++ saleor/graphql/order/tests/queries/test_order_with_where.py\td38c5f7 (commit)\n@@ -12,14 +12,13 @@\n from .....order import (\n FulfillmentStatus,\n OrderAuthorizeStatus,\n OrderChargeStatus,\n+ OrderEvents,\n OrderStatus,\n )\n-from .....order.models import Order, OrderLine\n-from .....order.search import (\n- prepare_order_search_vector_value,\n-)\n+from .....order.models import Order, OrderEvent, OrderLine\n+from .....order.search import prepare_order_search_vector_value\n from ....tests.utils import get_graphql_content, get_graphql_content_from_response\n \n \n @pytest.fixture\n@@ -2295,4 +2294,81 @@\n # then\n content = get_graphql_content(response)\n orders = content[\"data\"][\"orders\"][\"edges\"]\n assert len(orders) == 0\n+\n+\n+@pytest.mark.parametrize(\n+ (\"event_input\", \"expected_indexes\"),\n+ [\n+ (\n+ {\n+ \"date\": {\"gte\": \"2025-01-01T00:00:00Z\"},\n+ \"type\": {\"eq\": OrderEvents.PLACED.upper()},\n+ },\n+ [0, 1, 2],\n+ ),\n+ (\n+ {\n+ \"date\": {\"gte\": \"2025-01-01T00:00:00Z\"},\n+ \"type\": {\"eq\": OrderEvents.ORDER_FULLY_PAID.upper()},\n+ },\n+ [0, 1],\n+ ),\n+ (\n+ {\n+ \"date\": {\"gte\": \"2026-01-01T00:00:00Z\"},\n+ },\n+ [],\n+ ),\n+ (\n+ {\n+ \"date\": {\"gte\": \"2020-01-01T00:00:00Z\"},\n+ },\n+ [0, 1, 2],\n+ ),\n+ (\n+ {\n+ \"type\": {\n+ \"oneOf\": [\n+ OrderEvents.PLACED.upper(),\n+ OrderEvents.ORDER_FULLY_PAID.upper(),\n+ ]\n+ },\n+ },\n+ [0, 1, 2],\n+ ),\n+ ],\n+)\n+def test_orders_filter_by_order_events(\n+ event_input,\n+ expected_indexes,\n+ order_list,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+):\n+ # given\n+ with freeze_time(\"2025-01-01T00:00:00Z\"):\n+ OrderEvent.objects.bulk_create(\n+ [OrderEvent(order=order, type=OrderEvents.PLACED) for order in order_list]\n+ )\n+\n+ with freeze_time(\"2025-02-02T00:00:00Z\"):\n+ OrderEvent.objects.bulk_create(\n+ [\n+ OrderEvent(order=order, type=OrderEvents.ORDER_FULLY_PAID)\n+ for order in order_list[:2]\n+ ]\n+ )\n+\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+ variables = {\"where\": {\"events\": event_input}}\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_WHERE_QUERY, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ orders = content[\"data\"][\"orders\"][\"edges\"]\n+ assert len(orders) == len(expected_indexes)\n+ numbers = {node[\"node\"][\"number\"] for node in orders}\n+ assert numbers == {str(order_list[i].number) for i in expected_indexes}\n"
1710
- },
1711
- {
1712
- "path": "saleor/graphql/schema.graphql",
1713
- "status": "modified",
1714
- "diff": "Index: saleor/graphql/schema.graphql\n===================================================================\n--- saleor/graphql/schema.graphql\t4815c54 (parent)\n+++ saleor/graphql/schema.graphql\td38c5f7 (commit)\n@@ -12942,8 +12942,11 @@\n \n \"\"\"Filter by the product type of related order lines.\"\"\"\n productTypeId: GlobalIDFilterInput\n \n+ \"\"\"Filter by order events.\"\"\"\n+ events: OrderEventFilterInput\n+\n \"\"\"List of conditions that must be met.\"\"\"\n AND: [OrderWhereInput!]\n \n \"\"\"A list of conditions of which at least one must be met.\"\"\"\n@@ -13065,8 +13068,25 @@\n \"\"\"The amount of the price to filter by.\"\"\"\n amount: DecimalFilterInput!\n }\n \n+\"\"\"Filter input for order events data.\"\"\"\n+input OrderEventFilterInput @doc(category: \"Orders\") {\n+ \"\"\"Filter order events by date.\"\"\"\n+ date: DateTimeRangeInput\n+\n+ \"\"\"Filter order events by type.\"\"\"\n+ type: OrderEventTypeEnumFilterInput\n+}\n+\n+input OrderEventTypeEnumFilterInput {\n+ \"\"\"The value equal to.\"\"\"\n+ eq: OrderEventsEnum\n+\n+ \"\"\"The value included in.\"\"\"\n+ oneOf: [OrderEventsEnum!]\n+}\n+\n input OrderDraftFilterInput @doc(category: \"Orders\") {\n customer: String\n created: DateRangeInput\n search: String\n"
1715
- },
1716
- {
1717
- "path": "saleor/order/migrations/0214_orderevent_order_orderevent_date_idx.py",
1718
- "status": "added",
1719
- "diff": "Index: saleor/order/migrations/0214_orderevent_order_orderevent_date_idx.py\n===================================================================\n--- saleor/order/migrations/0214_orderevent_order_orderevent_date_idx.py\t4815c54 (parent)\n+++ saleor/order/migrations/0214_orderevent_order_orderevent_date_idx.py\td38c5f7 (commit)\n@@ -1,1 +1,22 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-06-18 13:50\n+\n+from django.contrib.postgres.indexes import BTreeIndex\n+from django.contrib.postgres.operations import AddIndexConcurrently\n+from django.db import migrations\n+\n+\n+class Migration(migrations.Migration):\n+ atomic = False\n+\n+ dependencies = [\n+ (\"account\", \"0087_alter_address_metadata_and_more\"),\n+ (\"app\", \"0032_appextension_http_target_method_and_more\"),\n+ (\"order\", \"0213_auto_20250618_1246\"),\n+ ]\n+\n+ operations = [\n+ AddIndexConcurrently(\n+ model_name=\"orderevent\",\n+ index=BTreeIndex(fields=[\"date\"], name=\"order_orderevent_date_idx\"),\n+ ),\n+ ]\n"
1720
- },
1721
- {
1722
- "path": "saleor/order/models.py",
1723
- "status": "modified",
1724
- "diff": "Index: saleor/order/models.py\n===================================================================\n--- saleor/order/models.py\t4815c54 (parent)\n+++ saleor/order/models.py\td38c5f7 (commit)\n@@ -897,8 +897,9 @@\n ordering = (\"date\",)\n indexes = [\n BTreeIndex(fields=[\"related\"], name=\"order_orderevent_related_id_idx\"),\n models.Index(fields=[\"type\"]),\n+ BTreeIndex(fields=[\"date\"], name=\"order_orderevent_date_idx\"),\n ]\n \n def __repr__(self):\n return f\"{self.__class__.__name__}(type={self.type!r}, user={self.user!r})\"\n"
1725
- }
1726
- ]
1727
- },
1728
- {
1729
- "id": "extend-order-search",
1730
- "sha": "4815c54985113204e8370c0d86451c89ad69ad65",
1731
- "parentSha": "d7bef8c489af1c6006dd2faa6047a69e0bb9dd48",
1732
- "spec": "Goal: Extend order search to include additional data sources and keep vectors up-to-date, while optimizing the backfill task and making address vector generation consistent.\n\nImplement the following changes:\n\n1) Add CHANGELOG entry\n- In CHANGELOG.md under the appropriate section, add a bullet describing enhanced order search options that now support search by:\n - Order ID\n - Invoice IDs linked to the order\n - Messages from order events\n - Customer note content\n - External reference\n\n2) Account address vector config\n- File: saleor/account/search.py\n- In generate_address_search_vector_value:\n - For the initial combined NoValidationSearchVector(...) call and all subsequent appended calls (company_name, country_area, city, city_area, street_address_2, postal_code, phone), pass config=\"simple\" to NoValidationSearchVector. Leave weight as-is.\n - Ensure the base combined vector still includes: first_name, last_name, street_address_1, country.name, country.code, and that it passes config=\"simple\" and the provided weight.\n\n3) Extend order search vector generation\n- File: saleor/order/search.py\n- Update prepare_order_search_vector_value to prefetch and include additional relations:\n - Ensure prefetch includes: user, billing_address, shipping_address, payments, discounts, lines, payment_transactions__events, invoices, and events (in addition to current ones).\n- Build search_vectors to include:\n - Existing: order.number as a string, config=\"simple\", weight=\"A\".\n - New: the order’s GraphQL global ID: graphene.Node.to_global_id(\"Order\", order.id), config=\"simple\", weight=\"A\".\n - Existing user fields and address vectors (reuse generate_address_search_vector_value).\n - New: if order.customer_note is non-empty, add it with config=\"simple\", weight=\"B\".\n - New: if order.external_reference is non-empty, add it with config=\"simple\", weight=\"B\".\n - Existing: payments, discounts, lines, transactions vectors.\n - New: append values from two new helpers (see below): generate_order_invoices_search_vector_value and generate_order_events_search_vector_value.\n- Add two new helpers:\n - generate_order_invoices_search_vector_value(order): for the most recent settings.SEARCH_ORDERS_MAX_INDEXED_INVOICES invoices (ordered by -created_at), add vectors for each invoice’s GraphQL global ID (graphene.Node.to_global_id(\"Invoice\", invoice.id)), config=\"simple\", weight=\"D\".\n - generate_order_events_search_vector_value(order): filter order.events to only NOTE_ADDED and NOTE_UPDATED (use OrderEvents), order by -date, take the first settings.SEARCH_ORDERS_MAX_INDEXED_EVENTS, and for each, if parameters[\"message\"] exists, add it as a vector with config=\"simple\", weight=\"D\".\n- Keep using FlatConcatSearchVector(*) to assemble the final order.search_vector in update_order_search_vector.\n\n4) Update background task for backfilling order search vectors\n- File: saleor/core/search_tasks.py\n- Introduce ORDER_BATCH_SIZE = 100.\n- Change set_order_search_document_values signature to accept:\n - update_all: bool = False\n - database_connection_name: str = settings.DATABASE_CONNECTION_REPLICA_NAME\n - updated_count: int = 0\n - order_number: int = 0\n- Behavior:\n - Build lookup = {\"number__gte\": order_number}. If update_all is False, also require search_vector=None.\n - Query orders using the provided database_connection_name, filter(**lookup), order_by(\"number\").\n - Extract a list of the next ORDER_BATCH_SIZE numbers, and if empty, log and return.\n - Using allow_writer(), fetch writer-side orders by those numbers, prefetching:\n user, billing_address, shipping_address, payments, discounts, lines, payment_transactions__events, invoices, events; order_by(\"pk\").\n - Within a transaction.atomic() block, select_for_update(of=[\"self\"]) to lock rows, then set_search_vector_values with prepare_order_search_vector_value(already_prefetched=True) to update search_vector.\n - Log progress and schedule the next batch with set_order_search_document_values.delay(update_all, database_connection_name, updated_count, order_number=numbers[-1]).\n\n5) Update GraphQL mutations to refresh order vectors upon note/invoice changes\n- File: saleor/graphql/order/mutations/order_note_add.py\n - Import update_order_search_vector from saleor.order.search and call update_order_search_vector(order) after call_event_by_order_status.\n- File: saleor/graphql/order/mutations/order_note_update.py\n - Same: import and call update_order_search_vector(order) after call_event_by_order_status.\n- File: saleor/graphql/invoice/mutations/invoice_create.py\n - Import update_order_search_vector and call update_order_search_vector(order) after order_events.invoice_generated_event and before returning.\n- File: saleor/graphql/invoice/mutations/invoice_delete.py\n - Import update_order_search_vector and, after emitting invoice_deleted_event, call update_order_search_vector(order). Ensure you keep a reference to the order before deletion and use it for permission checks and vector update.\n\n6) Settings: add limits for invoices and events\n- File: saleor/settings.py\n- Define:\n - SEARCH_ORDERS_MAX_INDEXED_INVOICES = 20\n - SEARCH_ORDERS_MAX_INDEXED_EVENTS = 50\n\n7) Migration to trigger search vector backfill\n- Add a new migration in saleor/order/migrations, e.g., 0213_auto_20250618_1246.py, with:\n - dependency on the latest order migration (match the number from your branch; use the one shown in the diff).\n - RunPython operation that connects a post_migrate signal handler for the order app to queue set_order_search_document_values.delay() after migrations complete. Import the Celery task from saleor.core.search_tasks.\n\n8) Tests: ensure vectors are set and move/search tests accordingly\n- File: saleor/checkout/tests/test_checkout_complete.py\n - After creating or completing an order in the tests that subsequently inspect order.events, insert order.refresh_from_db() immediately before unpacking/reading events (five places as per the diff locations) to ensure state is current.\n- Files: saleor/graphql/invoice/tests/test_invoice_create.py and saleor/graphql/invoice/tests/test_invoice_delete.py\n - After invoice creation/deletion, refresh order from DB and assert that order.search_vector is set/non-null.\n- Files: saleor/graphql/order/tests/mutations/test_order_note_add.py and saleor/graphql/order/tests/mutations/test_order_note_update.py\n - After performing note add/update, refresh order and assert order.search_vector is set/non-null.\n- File: saleor/graphql/order/tests/queries/test_orders.py\n - Remove the embedded order search test block (ORDERS_QUERY_WITH_SEARCH and its parametrized test) and keep the rest of the tests intact.\n- Add a new test file: saleor/graphql/order/tests/queries/test_orders_search.py\n - Provide ORDERS_QUERY_WITH_SEARCH and a helper update_orders_search_vector that uses FlatConcatSearchVector(*prepare_order_search_vector_value(order)) with a bulk_update to set vectors for test orders.\n - Add tests covering searches by: discount names, translated discount names, user email, first name, last name, full name (e.g., \"Leslie Wade\"), blank search returning all, PSP reference (ExternalID), product SKU, order global ID, invoice global ID, order event message substring, partial customer_note matches, product name, variant name, billing/shipping address fields and country codes, and external_reference; assert totalCount and that matching order IDs/numbers are returned.\n\n9) GraphQL filtering remains as-is\n- No change to saleor/graphql/order/filters.py is necessary; search_orders should now cover the expanded search due to updated vectors.\n\nAcceptance criteria:\n- Querying orders via the existing GraphQL search filter returns matches when searching by: order number or global ID, any included user fields, address fields, payment/transaction fields, discount names, line names/SKUs, invoice global IDs, order event note messages, customer note content, and external_reference.\n- Creating, updating, or deleting invoices and adding/updating order notes results in an updated order.search_vector immediately after the mutation.\n- The batch backfill task processes orders in ascending number order, locks rows when updating, and can resume from a given number; it supports update_all=True to rebuild all vectors.\n- New settings constants are present and respected for invoice/event indexing limits.\n- The new migration schedules the backfill task post-migrate.\n- All updated tests pass, and the new test suite for order search validates the new search capabilities.\n",
1733
- "prompt": "Enhance the order search capabilities and supporting infrastructure.\n\nHigh-level requirements:\n- Make orders searchable by more sources: the order’s global ID, IDs of related invoices, messages from order note events, the content of customer notes, and the order’s external reference. Keep existing searchable sources (number, user and address fields, payments/transactions, discounts, and lines).\n- Update the code that assembles order search vectors to include the above sources, and apply a consistent Postgres FTS config to address vectors.\n- Ensure order search vectors are refreshed when an order note is added/updated or when an invoice is created/deleted.\n- Optimize the background task that backfills missing order search vectors to work in small batches, lock rows during update, include the necessary relations for vector building, and support updating all orders or only those with empty vectors.\n- Add limits for the number of invoices and events indexed per order in settings.\n- Add a post-migration hook that schedules a backfill of order search vectors.\n- Update or move tests to validate search across the new sources, ensure vectors are set after relevant mutations, and add any necessary refresh_from_db() calls to stabilize tests that immediately read order state.\n- Update the changelog accordingly.\n\nDo not change the GraphQL filter itself; broaden the search by enriching the order.search_vector. Keep backward compatibility for existing search behavior.",
1734
- "supplementalFiles": [
1735
- "saleor/core/postgres.py",
1736
- "saleor/graphql/order/filters.py",
1737
- "saleor/order/models.py",
1738
- "saleor/invoice/models.py"
1739
- ],
1740
- "fileDiffs": [
1741
- {
1742
- "path": "CHANGELOG.md",
1743
- "status": "modified",
1744
- "diff": "Index: CHANGELOG.md\n===================================================================\n--- CHANGELOG.md\td7bef8c (parent)\n+++ CHANGELOG.md\t4815c54 (commit)\n@@ -23,8 +23,14 @@\n - Filter by order metadata.\n - Filter by order by associated lines metadata.\n - Filter by the product type of related order lines.\n - Extend the `Page` type with an `attribute` field. Adds support for querying a specific attribute on a page by `slug`, returning the matching attribute and its assigned values, or null if no match is found.\n+- Enhanced order search options. Orders can now be searched using:\n+ - The order's ID\n+ - IDs of invoices linked to the order\n+ - Messages from related order events\n+ - The content of customer note\n+ - The order external reference\n \n ### Webhooks\n \n ### Other changes\n"
1745
- },
1746
- {
1747
- "path": "saleor/account/search.py",
1748
- "status": "modified",
1749
- "diff": "Index: saleor/account/search.py\n===================================================================\n--- saleor/account/search.py\td7bef8c (parent)\n+++ saleor/account/search.py\t4815c54 (commit)\n@@ -71,38 +71,53 @@\n Value(address.last_name),\n Value(address.street_address_1),\n Value(address.country.name),\n Value(address.country.code),\n+ config=\"simple\",\n weight=weight,\n- )\n+ ),\n ]\n if address.company_name:\n search_vectors.append(\n- NoValidationSearchVector(Value(address.company_name), weight=weight)\n+ NoValidationSearchVector(\n+ Value(address.company_name), config=\"simple\", weight=weight\n+ )\n )\n if address.country_area:\n search_vectors.append(\n- NoValidationSearchVector(Value(address.country_area), weight=weight)\n+ NoValidationSearchVector(\n+ Value(address.country_area), config=\"simple\", weight=weight\n+ )\n )\n if address.city:\n search_vectors.append(\n- NoValidationSearchVector(Value(address.city), weight=weight)\n+ NoValidationSearchVector(\n+ Value(address.city), config=\"simple\", weight=weight\n+ )\n )\n if address.city_area:\n search_vectors.append(\n- NoValidationSearchVector(Value(address.city_area), weight=weight)\n+ NoValidationSearchVector(\n+ Value(address.city_area), config=\"simple\", weight=weight\n+ )\n )\n if address.street_address_2:\n search_vectors.append(\n- NoValidationSearchVector(Value(address.street_address_2), weight=weight)\n+ NoValidationSearchVector(\n+ Value(address.street_address_2), config=\"simple\", weight=weight\n+ )\n )\n if address.postal_code:\n search_vectors.append(\n- NoValidationSearchVector(Value(address.postal_code), weight=weight)\n+ NoValidationSearchVector(\n+ Value(address.postal_code), config=\"simple\", weight=weight\n+ )\n )\n if address.phone:\n search_vectors.append(\n- NoValidationSearchVector(Value(address.phone.as_e164), weight=weight)\n+ NoValidationSearchVector(\n+ Value(address.phone.as_e164), config=\"simple\", weight=weight\n+ )\n )\n return search_vectors\n \n \n"
1750
- },
1751
- {
1752
- "path": "saleor/checkout/tests/test_checkout_complete.py",
1753
- "status": "modified",
1754
- "diff": "Index: saleor/checkout/tests/test_checkout_complete.py\n===================================================================\n--- saleor/checkout/tests/test_checkout_complete.py\td7bef8c (parent)\n+++ saleor/checkout/tests/test_checkout_complete.py\t4815c54 (commit)\n@@ -91,8 +91,9 @@\n app=None,\n manager=manager,\n )\n \n+ order.refresh_from_db()\n (\n order_placed_event,\n payment_captured_event,\n order_fully_paid_event,\n@@ -255,8 +256,9 @@\n app=None,\n manager=manager,\n )\n \n+ order.refresh_from_db()\n (\n order_placed_event,\n payment_captured_event,\n order_fully_paid_event,\n@@ -414,8 +416,9 @@\n app=None,\n manager=manager,\n )\n \n+ order.refresh_from_db()\n (\n order_placed_event,\n payment_authorized_event,\n order_confirmed_event,\n@@ -530,8 +533,9 @@\n app=None,\n manager=manager,\n )\n \n+ order.refresh_from_db()\n (\n order_placed_event,\n payment_captured_event,\n order_confirmed_event,\n@@ -1307,8 +1311,9 @@\n user=customer_user,\n app=app,\n )\n \n+ order.refresh_from_db()\n (\n order_marked_as_paid,\n order_placed_event,\n order_fully_paid,\n"
1755
- },
1756
- {
1757
- "path": "saleor/core/search_tasks.py",
1758
- "status": "modified",
1759
- "diff": "Index: saleor/core/search_tasks.py\n===================================================================\n--- saleor/core/search_tasks.py\td7bef8c (parent)\n+++ saleor/core/search_tasks.py\t4815c54 (commit)\n@@ -1,6 +1,9 @@\n+from typing import Any\n+\n from celery.utils.log import get_task_logger\n from django.conf import settings\n+from django.db import transaction\n \n from ..account.models import User\n from ..account.search import prepare_user_search_document_value\n from ..celeryconf import app\n@@ -15,8 +18,10 @@\n from .postgres import FlatConcatSearchVector\n \n task_logger = get_task_logger(__name__)\n \n+ORDER_BATCH_SIZE = 100\n+\n BATCH_SIZE = 500\n # Based on local testing, 500 should be a good balance between performance\n # total time and memory usage. Should be tested after some time and adjusted by\n # running the task on different thresholds and measure memory usage, total time\n@@ -52,41 +57,66 @@\n set_user_search_document_values.delay(updated_count)\n \n \n @app.task\n-def set_order_search_document_values(updated_count: int = 0) -> None:\n- orders = list(\n- Order.objects.using(settings.DATABASE_CONNECTION_REPLICA_NAME)\n- .filter(search_vector=None)\n- .prefetch_related(\n- \"user\",\n- \"billing_address\",\n- \"shipping_address\",\n- \"payments\",\n- \"discounts\",\n- \"lines\",\n- )\n- .order_by(\"-number\")[:BATCH_SIZE]\n+def set_order_search_document_values(\n+ update_all: bool = False,\n+ database_connection_name: str = settings.DATABASE_CONNECTION_REPLICA_NAME,\n+ updated_count: int = 0,\n+ order_number: int = 0,\n+) -> None:\n+ \"\"\"Update search document values for orders.\n+\n+ If `update_all` is False, it will update only orders with search_vector=None.\n+ \"\"\"\n+ lookup: dict[str, Any] = {\"number__gte\": order_number}\n+ if not update_all:\n+ lookup[\"search_vector\"] = None\n+\n+ orders_qs = (\n+ Order.objects.using(database_connection_name)\n+ .filter(**lookup)\n+ .order_by(\"number\")\n )\n \n- if not orders:\n+ numbers = list(orders_qs.values_list(\"number\", flat=True)[:ORDER_BATCH_SIZE])\n+ if not numbers:\n task_logger.info(\"No orders to update.\")\n return\n \n with allow_writer():\n- updated_count += set_search_vector_values(\n- orders, prepare_order_search_vector_value\n+ orders = (\n+ Order.objects.filter(number__in=numbers)\n+ .prefetch_related(\n+ \"user\",\n+ \"billing_address\",\n+ \"shipping_address\",\n+ \"payments\",\n+ \"discounts\",\n+ \"lines\",\n+ \"payment_transactions__events\",\n+ \"invoices\",\n+ \"events\",\n+ )\n+ .order_by(\"pk\")\n )\n+ with transaction.atomic():\n+ _orders_lock = list(orders.select_for_update(of=([\"self\"])))\n+ updated_count += set_search_vector_values(\n+ list(orders), prepare_order_search_vector_value\n+ )\n \n task_logger.info(\"Updated %d orders\", updated_count)\n \n- if len(orders) < BATCH_SIZE:\n+ if len(numbers) < ORDER_BATCH_SIZE:\n task_logger.info(\"Setting order search document values finished.\")\n return\n \n del orders\n \n- set_order_search_document_values.delay(updated_count)\n+ set_order_search_document_values.delay(\n+ update_all, database_connection_name, updated_count, order_number=numbers[-1]\n+ )\n \n \n @app.task\n def set_product_search_document_values(updated_count: int = 0) -> None:\n"
1760
- },
1761
- {
1762
- "path": "saleor/graphql/invoice/mutations/invoice_create.py",
1763
- "status": "modified",
1764
- "diff": "Index: saleor/graphql/invoice/mutations/invoice_create.py\n===================================================================\n--- saleor/graphql/invoice/mutations/invoice_create.py\td7bef8c (parent)\n+++ saleor/graphql/invoice/mutations/invoice_create.py\t4815c54 (commit)\n@@ -4,8 +4,9 @@\n from ....core import JobStatus\n from ....invoice import events, models\n from ....invoice.error_codes import InvoiceErrorCode\n from ....order import events as order_events\n+from ....order.search import update_order_search_vector\n from ....permission.enums import OrderPermissions\n from ...app.dataloaders import get_app_promise\n from ...core import ResolveInfo\n from ...core.doc_category import DOC_CATEGORY_ORDERS\n@@ -133,5 +134,6 @@\n user=info.context.user,\n app=app,\n invoice_number=cleaned_input[\"number\"],\n )\n+ update_order_search_vector(order)\n return InvoiceCreate(invoice=invoice)\n"
1765
- },
1766
- {
1767
- "path": "saleor/graphql/invoice/mutations/invoice_delete.py",
1768
- "status": "modified",
1769
- "diff": "Index: saleor/graphql/invoice/mutations/invoice_delete.py\n===================================================================\n--- saleor/graphql/invoice/mutations/invoice_delete.py\td7bef8c (parent)\n+++ saleor/graphql/invoice/mutations/invoice_delete.py\t4815c54 (commit)\n@@ -1,7 +1,8 @@\n import graphene\n \n from ....invoice import events, models\n+from ....order.search import update_order_search_vector\n from ....permission.enums import OrderPermissions\n from ...app.dataloaders import get_app_promise\n from ...core import ResolveInfo\n from ...core.mutations import ModelDeleteMutation\n@@ -23,11 +24,13 @@\n \n @classmethod\n def perform_mutation(cls, _root, info: ResolveInfo, /, **data):\n invoice = cls.get_instance(info, **data)\n- cls.check_channel_permissions(info, [invoice.order.channel_id])\n+ order = invoice.order\n+ cls.check_channel_permissions(info, [order.channel_id])\n response = super().perform_mutation(_root, info, **data)\n app = get_app_promise(info.context).get()\n events.invoice_deleted_event(\n user=info.context.user, app=app, invoice_id=invoice.pk\n )\n+ update_order_search_vector(order)\n return response\n"
1770
- },
1771
- {
1772
- "path": "saleor/graphql/invoice/tests/test_invoice_create.py",
1773
- "status": "modified",
1774
- "diff": "Index: saleor/graphql/invoice/tests/test_invoice_create.py\n===================================================================\n--- saleor/graphql/invoice/tests/test_invoice_create.py\td7bef8c (parent)\n+++ saleor/graphql/invoice/tests/test_invoice_create.py\t4815c54 (commit)\n@@ -88,8 +88,10 @@\n order=order,\n user=staff_api_client.user,\n parameters__invoice_number=number,\n ).exists()\n+ order.refresh_from_db()\n+ assert order.search_vector\n \n \n def test_create_invoice_by_user_no_channel_access(\n staff_api_client, permission_group_all_perms_channel_USD_only, order, channel_PLN\n@@ -160,8 +162,10 @@\n user=None,\n app=app_api_client.app,\n parameters__invoice_number=number,\n ).exists()\n+ order.refresh_from_db()\n+ assert order.search_vector\n \n \n def test_create_invoice_no_billing_address(\n staff_api_client, permission_group_manage_orders, order\n"
1775
- },
1776
- {
1777
- "path": "saleor/graphql/invoice/tests/test_invoice_delete.py",
1778
- "status": "modified",
1779
- "diff": "Index: saleor/graphql/invoice/tests/test_invoice_delete.py\n===================================================================\n--- saleor/graphql/invoice/tests/test_invoice_delete.py\td7bef8c (parent)\n+++ saleor/graphql/invoice/tests/test_invoice_delete.py\t4815c54 (commit)\n@@ -37,8 +37,10 @@\n type=InvoiceEvents.DELETED,\n user=staff_api_client.user,\n parameters__invoice_id=invoice.id,\n ).exists()\n+ order.refresh_from_db()\n+ assert order.search_vector\n \n \n def test_invoice_delete_by_user_no_channel_access(\n staff_api_client, permission_group_all_perms_channel_USD_only, order, channel_PLN\n@@ -78,8 +80,10 @@\n user=None,\n app=app_api_client.app,\n parameters__invoice_id=invoice.id,\n ).exists()\n+ order.refresh_from_db()\n+ assert order.search_vector\n \n \n @patch(\"saleor.plugins.manager.PluginsManager.invoice_delete\")\n def test_invoice_delete_invalid_id(\n"
1780
- },
1781
- {
1782
- "path": "saleor/graphql/order/mutations/order_note_add.py",
1783
- "status": "modified",
1784
- "diff": "Index: saleor/graphql/order/mutations/order_note_add.py\n===================================================================\n--- saleor/graphql/order/mutations/order_note_add.py\td7bef8c (parent)\n+++ saleor/graphql/order/mutations/order_note_add.py\t4815c54 (commit)\n@@ -1,8 +1,9 @@\n import graphene\n from django.db import transaction\n \n from ....order import error_codes, events\n+from ....order.search import update_order_search_vector\n from ....permission.enums import OrderPermissions\n from ...app.dataloaders import get_app_promise\n from ...core import ResolveInfo\n from ...core.context import SyncWebhookControlContext\n@@ -57,8 +58,9 @@\n app=app,\n message=cleaned_input[\"message\"],\n )\n call_event_by_order_status(order, manager)\n+ update_order_search_vector(order)\n return OrderNoteAdd(\n order=SyncWebhookControlContext(order),\n event=SyncWebhookControlContext(event),\n )\n"
1785
- },
1786
- {
1787
- "path": "saleor/graphql/order/mutations/order_note_update.py",
1788
- "status": "modified",
1789
- "diff": "Index: saleor/graphql/order/mutations/order_note_update.py\n===================================================================\n--- saleor/graphql/order/mutations/order_note_update.py\td7bef8c (parent)\n+++ saleor/graphql/order/mutations/order_note_update.py\t4815c54 (commit)\n@@ -1,8 +1,9 @@\n import graphene\n from django.db import transaction\n \n from ....order import OrderEvents, error_codes, events, models\n+from ....order.search import update_order_search_vector\n from ....permission.enums import OrderPermissions\n from ...app.dataloaders import get_app_promise\n from ...core import ResolveInfo\n from ...core.context import SyncWebhookControlContext\n@@ -63,8 +64,9 @@\n message=cleaned_input[\"message\"],\n related_event=order_event_to_update,\n )\n call_event_by_order_status(order, manager)\n+ update_order_search_vector(order)\n return OrderNoteUpdate(\n order=SyncWebhookControlContext(order),\n event=SyncWebhookControlContext(event),\n )\n"
1790
- },
1791
- {
1792
- "path": "saleor/graphql/order/tests/mutations/test_order_note_add.py",
1793
- "status": "modified",
1794
- "diff": "Index: saleor/graphql/order/tests/mutations/test_order_note_add.py\n===================================================================\n--- saleor/graphql/order/tests/mutations/test_order_note_add.py\td7bef8c (parent)\n+++ saleor/graphql/order/tests/mutations/test_order_note_add.py\t4815c54 (commit)\n@@ -67,8 +67,9 @@\n order_updated_webhook_mock.assert_called_once_with(order, webhooks=set())\n \n order.refresh_from_db()\n assert order.status == OrderStatus.UNFULFILLED\n+ assert order.search_vector\n \n # Ensure the correct order event was created\n event = order.events.get()\n assert event.type == order_events.OrderEvents.NOTE_ADDED\n"
1795
- },
1796
- {
1797
- "path": "saleor/graphql/order/tests/mutations/test_order_note_update.py",
1798
- "status": "modified",
1799
- "diff": "Index: saleor/graphql/order/tests/mutations/test_order_note_update.py\n===================================================================\n--- saleor/graphql/order/tests/mutations/test_order_note_update.py\td7bef8c (parent)\n+++ saleor/graphql/order/tests/mutations/test_order_note_update.py\t4815c54 (commit)\n@@ -78,8 +78,9 @@\n order_updated_webhook_mock.assert_called_once_with(order, webhooks=set())\n \n order.refresh_from_db()\n assert order.status == OrderStatus.UNFULFILLED\n+ assert order.search_vector\n \n assert OrderEvent.objects.filter(order=order).count() == 2\n new_note = OrderEvent.objects.filter(order=order).exclude(pk=note.pk).get()\n assert new_note.type == OrderEvents.NOTE_UPDATED\n"
1800
- },
1801
- {
1802
- "path": "saleor/graphql/order/tests/queries/test_orders.py",
1803
- "status": "modified",
1804
- "diff": "Index: saleor/graphql/order/tests/queries/test_orders.py\n===================================================================\n--- saleor/graphql/order/tests/queries/test_orders.py\td7bef8c (parent)\n+++ saleor/graphql/order/tests/queries/test_orders.py\t4815c54 (commit)\n@@ -1,20 +1,13 @@\n from decimal import Decimal\n from unittest.mock import patch\n \n-import pytest\n-from prices import Money, TaxedMoney\n-\n-from .....core.postgres import FlatConcatSearchVector\n-from .....discount.models import OrderDiscount\n from .....order import OrderStatus\n from .....order.events import (\n draft_order_created_from_replace_event,\n fulfillment_fulfilled_items_event,\n order_added_products_event,\n )\n-from .....order.models import Order, Payment\n-from .....order.search import prepare_order_search_vector_value\n from .....plugins.manager import PluginsManager\n from .....tax.calculations.order import update_order_prices_with_flat_rates\n from ....tests.utils import get_graphql_content\n from .shared_query_fragments import ORDER_FRAGMENT_WITH_WEBHOOK_RELATED_FIELDS\n@@ -259,127 +252,4 @@\n order_with_lines.refresh_from_db()\n assert not order_with_lines.should_refresh_prices\n assert order_with_lines.total_gross_amount != Decimal(0)\n mocked_webhook_handler.assert_not_called()\n-\n-\n-ORDERS_QUERY_WITH_SEARCH = \"\"\"\n- query ($search: String) {\n- orders(first: 10, search:$search) {\n- totalCount\n- edges {\n- node {\n- id\n- }\n- }\n- }\n- }\n-\"\"\"\n-\n-\n-@pytest.mark.parametrize(\n- (\"search_value\", \"count\"),\n- [\n- (\"discount name\", 2),\n- (\"Some other\", 1),\n- (\"translated\", 1),\n- (\"test@mirumee.com\", 1),\n- (\"Leslie\", 1),\n- (\"Wade\", 1),\n- (\"\", 3),\n- (\"ExternalID\", 1),\n- (\"SKU_A\", 1),\n- ],\n-)\n-def test_orders_query_with_search(\n- search_value,\n- count,\n- staff_api_client,\n- permission_group_manage_orders,\n- customer_user,\n- channel_USD,\n- product,\n- variant,\n-):\n- # given\n- orders = Order.objects.bulk_create(\n- [\n- Order(\n- user=customer_user,\n- user_email=\"test@mirumee.com\",\n- channel=channel_USD,\n- lines_count=0,\n- ),\n- Order(\n- user_email=\"user_email1@example.com\",\n- channel=channel_USD,\n- lines_count=0,\n- ),\n- Order(\n- user_email=\"user_email2@example.com\",\n- channel=channel_USD,\n- lines_count=0,\n- ),\n- ]\n- )\n-\n- OrderDiscount.objects.bulk_create(\n- [\n- OrderDiscount(\n- order=orders[0],\n- name=\"Some discount name\",\n- value=Decimal(\"1\"),\n- amount_value=Decimal(\"1\"),\n- translated_name=\"translated\",\n- ),\n- OrderDiscount(\n- order=orders[2],\n- name=\"Some other discount name\",\n- value=Decimal(\"10\"),\n- amount_value=Decimal(\"10\"),\n- translated_name=\"PL_name\",\n- ),\n- ]\n- )\n- order_with_payment = orders[1]\n- payment = Payment.objects.create(\n- order=order_with_payment, psp_reference=\"ExternalID\"\n- )\n- payment.transactions.create(gateway_response={}, is_success=True)\n-\n- order_with_orderline = orders[2]\n- channel = order_with_orderline.channel\n- channel_listing = variant.channel_listings.get(channel=channel)\n- net = variant.get_price(channel_listing)\n- currency = net.currency\n- gross = Money(amount=net.amount * Decimal(1.23), currency=currency)\n- unit_price = TaxedMoney(net=net, gross=gross)\n- order_with_orderline.lines.create(\n- product_name=str(product),\n- variant_name=str(variant),\n- product_sku=variant.sku,\n- product_variant_id=variant.get_global_id(),\n- is_shipping_required=variant.is_shipping_required(),\n- is_gift_card=variant.is_gift_card(),\n- quantity=3,\n- variant=variant,\n- unit_price=unit_price,\n- total_price=unit_price * 3,\n- undiscounted_unit_price=unit_price,\n- undiscounted_total_price=unit_price * 3,\n- tax_rate=Decimal(\"0.23\"),\n- )\n- for order in orders:\n- order.search_vector = FlatConcatSearchVector(\n- *prepare_order_search_vector_value(order)\n- )\n- Order.objects.bulk_update(orders, [\"search_vector\"])\n-\n- variables = {\"search\": search_value}\n- permission_group_manage_orders.user_set.add(staff_api_client.user)\n-\n- # when\n- response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n-\n- # then\n- content = get_graphql_content(response)\n- assert content[\"data\"][\"orders\"][\"totalCount\"] == count\n"
1805
- },
1806
- {
1807
- "path": "saleor/graphql/order/tests/queries/test_orders_search.py",
1808
- "status": "added",
1809
- "diff": "Index: saleor/graphql/order/tests/queries/test_orders_search.py\n===================================================================\n--- saleor/graphql/order/tests/queries/test_orders_search.py\td7bef8c (parent)\n+++ saleor/graphql/order/tests/queries/test_orders_search.py\t4815c54 (commit)\n@@ -1,1 +1,536 @@\n-[NEW FILE]\n\\n+from decimal import Decimal\n+\n+import graphene\n+import pytest\n+from prices import Money, TaxedMoney\n+\n+from .....core.postgres import FlatConcatSearchVector\n+from .....discount.models import OrderDiscount\n+from .....invoice.models import Invoice\n+from .....order import OrderEvents\n+from .....order.models import Order, Payment\n+from .....order.search import prepare_order_search_vector_value\n+from ....tests.utils import get_graphql_content\n+\n+ORDERS_QUERY_WITH_SEARCH = \"\"\"\n+ query ($search: String) {\n+ orders(first: 10, search:$search) {\n+ totalCount\n+ edges {\n+ node {\n+ id\n+ number\n+ }\n+ }\n+ }\n+ }\n+\"\"\"\n+\n+\n+def update_orders_search_vector(orders):\n+ for order in orders:\n+ order.search_vector = FlatConcatSearchVector(\n+ *prepare_order_search_vector_value(order)\n+ )\n+ Order.objects.bulk_update(orders, [\"search_vector\"])\n+\n+\n+@pytest.mark.parametrize(\n+ (\"search_value\", \"count\"),\n+ [\n+ (\"discount name\", 2),\n+ (\"Some other\", 1),\n+ (\"translated\", 1),\n+ (\"test@mirumee.com\", 1),\n+ (\"Leslie\", 1),\n+ (\"Wade\", 1),\n+ (\"Leslie Wade\", 1),\n+ (\"\", 3),\n+ (\"ExternalID\", 1),\n+ (\"SKU_A\", 1),\n+ ],\n+)\n+def test_orders_query_with_search(\n+ search_value,\n+ count,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ customer_user,\n+ channel_USD,\n+ product,\n+ variant,\n+):\n+ # given\n+ orders = Order.objects.bulk_create(\n+ [\n+ Order(\n+ user=customer_user,\n+ user_email=\"test@mirumee.com\",\n+ channel=channel_USD,\n+ lines_count=0,\n+ ),\n+ Order(\n+ user_email=\"user_email1@example.com\",\n+ channel=channel_USD,\n+ lines_count=0,\n+ ),\n+ Order(\n+ user_email=\"user_email2@example.com\",\n+ channel=channel_USD,\n+ lines_count=0,\n+ ),\n+ ]\n+ )\n+\n+ OrderDiscount.objects.bulk_create(\n+ [\n+ OrderDiscount(\n+ order=orders[0],\n+ name=\"Some discount name\",\n+ value=Decimal(\"1\"),\n+ amount_value=Decimal(\"1\"),\n+ translated_name=\"translated\",\n+ ),\n+ OrderDiscount(\n+ order=orders[2],\n+ name=\"Some other discount name\",\n+ value=Decimal(\"10\"),\n+ amount_value=Decimal(\"10\"),\n+ translated_name=\"PL_name\",\n+ ),\n+ ]\n+ )\n+ order_with_payment = orders[1]\n+ payment = Payment.objects.create(\n+ order=order_with_payment, psp_reference=\"ExternalID\"\n+ )\n+ payment.transactions.create(gateway_response={}, is_success=True)\n+\n+ order_with_orderline = orders[2]\n+ channel = order_with_orderline.channel\n+ channel_listing = variant.channel_listings.get(channel=channel)\n+ net = variant.get_price(channel_listing)\n+ currency = net.currency\n+ gross = Money(amount=net.amount * Decimal(1.23), currency=currency)\n+ unit_price = TaxedMoney(net=net, gross=gross)\n+ order_with_orderline.lines.create(\n+ product_name=str(product),\n+ variant_name=str(variant),\n+ product_sku=variant.sku,\n+ product_variant_id=variant.get_global_id(),\n+ is_shipping_required=variant.is_shipping_required(),\n+ is_gift_card=variant.is_gift_card(),\n+ quantity=3,\n+ variant=variant,\n+ unit_price=unit_price,\n+ total_price=unit_price * 3,\n+ undiscounted_unit_price=unit_price,\n+ undiscounted_total_price=unit_price * 3,\n+ tax_rate=Decimal(\"0.23\"),\n+ )\n+\n+ update_orders_search_vector(orders)\n+\n+ variables = {\"search\": search_value}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == count\n+\n+\n+def test_orders_query_with_search_by_order_id(\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+):\n+ # given\n+ update_orders_search_vector(order_list)\n+\n+ search_value = graphene.Node.to_global_id(\"Order\", order_list[1].pk)\n+ variables = {\"search\": search_value}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == 1\n+ assert content[\"data\"][\"orders\"][\"edges\"][0][\"node\"][\"id\"] == search_value\n+\n+\n+def test_orders_query_with_search_by_invoice_id(\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+):\n+ # given\n+ invoices = Invoice.objects.bulk_create(\n+ [Invoice(order=order, number=f\"INV-{order.pk}\") for order in order_list]\n+ )\n+ update_orders_search_vector(order_list)\n+\n+ search_value = graphene.Node.to_global_id(\"Invoice\", invoices[2].pk)\n+ variables = {\"search\": search_value}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == 1\n+ assert content[\"data\"][\"orders\"][\"edges\"][0][\"node\"][\n+ \"id\"\n+ ] == graphene.Node.to_global_id(\"Order\", order_list[2].pk)\n+\n+\n+def test_orders_query_with_search_by_order_event_message(\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+):\n+ # given\n+ event_message = \"Special event message for search\"\n+ order = order_list[0]\n+ order.events.create(\n+ type=OrderEvents.NOTE_ADDED,\n+ user=None,\n+ parameters={\"message\": event_message},\n+ )\n+\n+ update_orders_search_vector(order_list)\n+\n+ variables = {\"search\": \"Special event message\"}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == 1\n+ assert content[\"data\"][\"orders\"][\"edges\"][0][\"node\"][\n+ \"id\"\n+ ] == graphene.Node.to_global_id(\"Order\", order_list[0].pk)\n+\n+\n+@pytest.mark.parametrize(\n+ (\"search_value\", \"expected_count\"),\n+ [\n+ (\"match in\", 1),\n+ (\"note\", 2),\n+ (\"partial\", 1),\n+ (\"unrelated\", 0),\n+ ],\n+)\n+def test_orders_query_with_search_by_partial_customer_note(\n+ search_value,\n+ expected_count,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+):\n+ # given\n+ notes = [\n+ \"This is a match in the customer note\",\n+ \"This note has a partial match\",\n+ \"\",\n+ ]\n+ for order, note in zip(order_list, notes, strict=True):\n+ order.customer_note = note\n+\n+ Order.objects.bulk_update(order_list, [\"customer_note\"])\n+ update_orders_search_vector(order_list)\n+\n+ variables = {\"search\": search_value}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == expected_count\n+\n+\n+def test_orders_query_with_search_by_product_name(\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+ product,\n+ variant,\n+):\n+ # given\n+ order = order_list[0]\n+ channel = order.channel\n+ channel_listing = variant.channel_listings.get(channel=channel)\n+ net = variant.get_price(channel_listing)\n+ currency = net.currency\n+ gross = Money(amount=net.amount * Decimal(1.23), currency=currency)\n+ unit_price = TaxedMoney(net=net, gross=gross)\n+ product_name = str(product)\n+ order.lines.create(\n+ product_name=product_name,\n+ variant_name=str(variant),\n+ product_sku=variant.sku,\n+ product_variant_id=variant.get_global_id(),\n+ is_shipping_required=variant.is_shipping_required(),\n+ is_gift_card=variant.is_gift_card(),\n+ quantity=2,\n+ variant=variant,\n+ unit_price=unit_price,\n+ total_price=unit_price * 2,\n+ undiscounted_unit_price=unit_price,\n+ undiscounted_total_price=unit_price * 2,\n+ tax_rate=Decimal(\"0.23\"),\n+ )\n+\n+ update_orders_search_vector(order_list)\n+\n+ variables = {\"search\": product_name}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == 1\n+ assert content[\"data\"][\"orders\"][\"edges\"][0][\"node\"][\n+ \"id\"\n+ ] == graphene.Node.to_global_id(\"Order\", order.pk)\n+\n+\n+def test_orders_query_with_search_by_variant_name(\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+ product,\n+ variant,\n+):\n+ # given\n+ order = order_list[1]\n+ channel = order.channel\n+ channel_listing = variant.channel_listings.get(channel=channel)\n+ net = variant.get_price(channel_listing)\n+ currency = net.currency\n+ gross = Money(amount=net.amount * Decimal(1.23), currency=currency)\n+ unit_price = TaxedMoney(net=net, gross=gross)\n+ variant_name = str(variant)\n+ order.lines.create(\n+ product_name=str(product),\n+ variant_name=variant_name,\n+ product_sku=variant.sku,\n+ product_variant_id=variant.get_global_id(),\n+ is_shipping_required=variant.is_shipping_required(),\n+ is_gift_card=variant.is_gift_card(),\n+ quantity=1,\n+ variant=variant,\n+ unit_price=unit_price,\n+ total_price=unit_price,\n+ undiscounted_unit_price=unit_price,\n+ undiscounted_total_price=unit_price,\n+ tax_rate=Decimal(\"0.23\"),\n+ )\n+\n+ update_orders_search_vector(order_list)\n+\n+ variables = {\"search\": variant_name}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == 1\n+ assert content[\"data\"][\"orders\"][\"edges\"][0][\"node\"][\n+ \"id\"\n+ ] == graphene.Node.to_global_id(\"Order\", order.pk)\n+\n+\n+def test_orders_query_with_search_by_product_sku(\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+ product,\n+ variant,\n+):\n+ # given\n+ order = order_list[2]\n+ channel = order.channel\n+ channel_listing = variant.channel_listings.get(channel=channel)\n+ net = variant.get_price(channel_listing)\n+ currency = net.currency\n+ gross = Money(amount=net.amount * Decimal(1.23), currency=currency)\n+ unit_price = TaxedMoney(net=net, gross=gross)\n+ sku = variant.sku\n+ order.lines.create(\n+ product_name=str(product),\n+ variant_name=str(variant),\n+ product_sku=sku,\n+ product_variant_id=variant.get_global_id(),\n+ is_shipping_required=variant.is_shipping_required(),\n+ is_gift_card=variant.is_gift_card(),\n+ quantity=4,\n+ variant=variant,\n+ unit_price=unit_price,\n+ total_price=unit_price * 4,\n+ undiscounted_unit_price=unit_price,\n+ undiscounted_total_price=unit_price * 4,\n+ tax_rate=Decimal(\"0.23\"),\n+ )\n+\n+ update_orders_search_vector(order_list)\n+\n+ variables = {\"search\": sku}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == 1\n+ assert content[\"data\"][\"orders\"][\"edges\"][0][\"node\"][\n+ \"id\"\n+ ] == graphene.Node.to_global_id(\"Order\", order.pk)\n+\n+\n+@pytest.mark.parametrize(\n+ (\"search_value\", \"expected_count\"),\n+ [\n+ (\"First\", 1),\n+ (\"Last\", 1),\n+ (\"First Last\", 1),\n+ (\"Billing Street\", 1),\n+ (\"PL\", 1),\n+ (\"US\", 2),\n+ (\"Nonexistent\", 0),\n+ ],\n+)\n+def test_orders_query_with_search_by_billing_address_fields(\n+ search_value,\n+ expected_count,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+ address,\n+ address_usa,\n+):\n+ # given\n+ order = order_list[0]\n+ address.first_name = \"First\"\n+ address.last_name = \"Last\"\n+ address.street_address_1 = \"Billing Street\"\n+ address.country = \"PL\"\n+ address.save()\n+\n+ order.billing_address = address\n+ for order in order_list[1:]:\n+ order.billing_address = address_usa\n+ Order.objects.bulk_update(order_list, [\"billing_address\"])\n+\n+ update_orders_search_vector(order_list)\n+\n+ variables = {\"search\": search_value}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"search_value\", \"expected_count\"),\n+ [\n+ (\"First\", 1),\n+ (\"Last\", 1),\n+ (\"First Last\", 1),\n+ (\"Shipping Street\", 1),\n+ (\"JP\", 1),\n+ (\"US\", 2),\n+ (\"Nonexistent\", 0),\n+ ],\n+)\n+def test_orders_query_with_search_by_shipping_address_fields(\n+ search_value,\n+ expected_count,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+ address,\n+ address_usa,\n+):\n+ # given\n+ order = order_list[0]\n+ address.first_name = \"First\"\n+ address.last_name = \"Last\"\n+ address.street_address_1 = \"Shipping Street\"\n+ address.country = \"JP\"\n+ address.save()\n+\n+ order.shipping_address = address\n+ for order in order_list[1:]:\n+ order.shipping_address = address_usa\n+ Order.objects.bulk_update(order_list, [\"shipping_address\"])\n+\n+ update_orders_search_vector(order_list)\n+\n+ variables = {\"search\": search_value}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == expected_count\n+\n+\n+@pytest.mark.parametrize(\n+ (\"search_value\", \"expected_order_idxes\"),\n+ [\n+ (\"EXT-REF-12345\", [0]),\n+ (\"REF\", [0, 1]),\n+ (\"ANOTHER-REF-67890\", [1]),\n+ (\"nonexistent-ref\", []),\n+ ],\n+)\n+def test_orders_query_with_search_by_external_reference(\n+ search_value,\n+ expected_order_idxes,\n+ staff_api_client,\n+ permission_group_manage_orders,\n+ order_list,\n+):\n+ # given\n+ external_references = [\"EXT-REF-12345\", \"ANOTHER-REF-67890\", \"\"]\n+ for order, ext_ref in zip(order_list, external_references, strict=True):\n+ order.external_reference = ext_ref\n+ Order.objects.bulk_update(order_list, [\"external_reference\"])\n+\n+ update_orders_search_vector(order_list)\n+\n+ variables = {\"search\": search_value}\n+ permission_group_manage_orders.user_set.add(staff_api_client.user)\n+\n+ # when\n+ response = staff_api_client.post_graphql(ORDERS_QUERY_WITH_SEARCH, variables)\n+\n+ # then\n+ content = get_graphql_content(response)\n+ assert content[\"data\"][\"orders\"][\"totalCount\"] == len(expected_order_idxes)\n+ returned_numbers = [\n+ edge[\"node\"][\"number\"] for edge in content[\"data\"][\"orders\"][\"edges\"]\n+ ]\n+ expected_numbers = [str(order_list[idx].number) for idx in expected_order_idxes]\n+ assert set(returned_numbers) == set(expected_numbers)\n"
1810
- },
1811
- {
1812
- "path": "saleor/order/migrations/0213_auto_20250618_1246.py",
1813
- "status": "added",
1814
- "diff": "Index: saleor/order/migrations/0213_auto_20250618_1246.py\n===================================================================\n--- saleor/order/migrations/0213_auto_20250618_1246.py\td7bef8c (parent)\n+++ saleor/order/migrations/0213_auto_20250618_1246.py\t4815c54 (commit)\n@@ -1,1 +1,28 @@\n-[NEW FILE]\n\\n+# Generated by Django 5.2.1 on 2025-06-18 12:46\n+\n+from django.apps import apps as registry\n+from django.db import migrations\n+from django.db.models.signals import post_migrate\n+\n+from ...core.search_tasks import set_order_search_document_values\n+\n+\n+def update_order_search_vector(apps, _schema_editor):\n+ def on_migrations_complete(sender=None, **kwargs):\n+ set_order_search_document_values.delay()\n+\n+ sender = registry.get_app_config(\"order\")\n+ post_migrate.connect(on_migrations_complete, weak=False, sender=sender)\n+\n+\n+class Migration(migrations.Migration):\n+ dependencies = [\n+ (\"order\", \"0212_orderline_product_type_id_btree_idx\"),\n+ ]\n+\n+ operations = [\n+ migrations.RunPython(\n+ update_order_search_vector,\n+ reverse_code=migrations.RunPython.noop,\n+ )\n+ ]\n"
1815
- },
1816
- {
1817
- "path": "saleor/order/search.py",
1818
- "status": "modified",
1819
- "diff": "Index: saleor/order/search.py\n===================================================================\n--- saleor/order/search.py\td7bef8c (parent)\n+++ saleor/order/search.py\t4815c54 (commit)\n@@ -6,8 +6,9 @@\n from django.db.models import F, Q, Value, prefetch_related_objects\n \n from ..account.search import generate_address_search_vector_value\n from ..core.postgres import FlatConcatSearchVector, NoValidationSearchVector\n+from . import OrderEvents\n \n if TYPE_CHECKING:\n from django.db.models import QuerySet\n \n@@ -34,11 +35,18 @@\n \"payments\",\n \"discounts\",\n \"lines\",\n \"payment_transactions__events\",\n+ \"invoices\",\n+ \"events\",\n )\n search_vectors = [\n- NoValidationSearchVector(Value(str(order.number)), config=\"simple\", weight=\"A\")\n+ NoValidationSearchVector(Value(str(order.number)), config=\"simple\", weight=\"A\"),\n+ NoValidationSearchVector(\n+ Value(graphene.Node.to_global_id(\"Order\", order.id)),\n+ config=\"simple\",\n+ weight=\"A\",\n+ ),\n ]\n if order.user_email:\n search_vectors.append(\n NoValidationSearchVector(\n@@ -63,21 +71,36 @@\n Value(order.user.last_name), config=\"simple\", weight=\"A\"\n )\n )\n \n+ if order.customer_note:\n+ search_vectors.append(\n+ NoValidationSearchVector(\n+ Value(order.customer_note), config=\"simple\", weight=\"B\"\n+ )\n+ )\n+\n if order.billing_address:\n search_vectors += generate_address_search_vector_value(\n order.billing_address, weight=\"B\"\n )\n if order.shipping_address:\n search_vectors += generate_address_search_vector_value(\n order.shipping_address, weight=\"B\"\n )\n+ if order.external_reference:\n+ search_vectors.append(\n+ NoValidationSearchVector(\n+ Value(order.external_reference), config=\"simple\", weight=\"B\"\n+ )\n+ )\n \n search_vectors += generate_order_payments_search_vector_value(order)\n search_vectors += generate_order_discounts_search_vector_value(order)\n search_vectors += generate_order_lines_search_vector_value(order)\n search_vectors += generate_order_transactions_search_vector_value(order)\n+ search_vectors += generate_order_invoices_search_vector_value(order)\n+ search_vectors += generate_order_events_search_vector_value(order)\n return search_vectors\n \n \n def generate_order_transactions_search_vector_value(\n@@ -212,8 +235,44 @@\n )\n return line_vectors\n \n \n+def generate_order_invoices_search_vector_value(\n+ order: \"Order\",\n+) -> list[NoValidationSearchVector]:\n+ invoice_vectors = []\n+ for invoice in order.invoices.all().order_by(\"-created_at\")[\n+ : settings.SEARCH_ORDERS_MAX_INDEXED_INVOICES\n+ ]:\n+ invoice_vectors.append(\n+ NoValidationSearchVector(\n+ Value(graphene.Node.to_global_id(\"Invoice\", invoice.id)),\n+ config=\"simple\",\n+ weight=\"D\",\n+ )\n+ )\n+ return invoice_vectors\n+\n+\n+def generate_order_events_search_vector_value(\n+ order: \"Order\",\n+) -> list[NoValidationSearchVector]:\n+ event_vectors = []\n+ events = order.events.filter(\n+ type__in=[OrderEvents.NOTE_ADDED, OrderEvents.NOTE_UPDATED]\n+ ).order_by(\"-date\")\n+ for event in events[: settings.SEARCH_ORDERS_MAX_INDEXED_EVENTS]:\n+ if message := event.parameters.get(\"message\"):\n+ event_vectors.append(\n+ NoValidationSearchVector(\n+ Value(message),\n+ config=\"simple\",\n+ weight=\"D\",\n+ )\n+ )\n+ return event_vectors\n+\n+\n def search_orders(qs: \"QuerySet[Order]\", value) -> \"QuerySet[Order]\":\n if value:\n query = SearchQuery(value, search_type=\"websearch\", config=\"simple\")\n lookup = Q(search_vector=query)\n"
1820
- },
1821
- {
1822
- "path": "saleor/settings.py",
1823
- "status": "modified",
1824
- "diff": "Index: saleor/settings.py\n===================================================================\n--- saleor/settings.py\td7bef8c (parent)\n+++ saleor/settings.py\t4815c54 (commit)\n@@ -927,8 +927,10 @@\n SEARCH_ORDERS_MAX_INDEXED_TRANSACTIONS = 20\n SEARCH_ORDERS_MAX_INDEXED_PAYMENTS = 20\n SEARCH_ORDERS_MAX_INDEXED_DISCOUNTS = 20\n SEARCH_ORDERS_MAX_INDEXED_LINES = 100\n+SEARCH_ORDERS_MAX_INDEXED_INVOICES = 20\n+SEARCH_ORDERS_MAX_INDEXED_EVENTS = 50\n \n # Maximum related objects that can be indexed in a product\n PRODUCT_MAX_INDEXED_ATTRIBUTES = 1000\n PRODUCT_MAX_INDEXED_ATTRIBUTE_VALUES = 100\n"
1825
- }
1826
- ]
1827
- }
1828
- ]
1829
- }