onchange: backed by a constraint on its field, refused only when taken back
Browse filesTwo changes to onchange-is-not-validation, shipped together because the
first reads true only with the second. A constraint backs an onchange
only when it covers a field the onchange watches (models.Constraint and
_sql_constraints by the names in their SQL, @api.constrains by its plain
names, unreadable ones cover everything); before, any constraint in the
file cleared it. And a returned warning dict counts as a refusal only
when its branch also writes a watched field or clears a field on the
record; a warning beside no write is a notice the user saves past in
the form as in an import. A raise still counts on its own.
OCA 99 unchanged (account_loan's rate note stays out). OpenSPP 153 ->
157, all read into classification-openspp-rest: spp_area level cap,
CR type required documents, one head per group true; spp_import_match
false, backed by the parent's constraint. Core 2345 -> 2339: +11 (8
true: four partner blocks, loyalty expiry, negative price rounding,
expense edit rights, a component equal to its product; 3 checked on the
server elsewhere), -17, all read, all notices (OCR checksum, GST name
length, token archiving, open cart pricelist, ...). A first draft that
counted only watched-field writes lost spp_change_request_v2's
registrant clear, which nothing else enforces; clearing any field
counts now. 280 of 280 hand-classified true positives still reported.
ARCHITECTURE 3.68; 3.67's worklist line updated.
- ARCHITECTURE.md +61 -2
- README.md +10 -10
- bin/semantics.py +150 -32
- classification-openspp-rest-2026-09.json +44 -7
- corpus/build_corpus.py +5 -2
- fieldtest-oca-2026-09.json +1 -1
- fieldtest-odoo-core-2026-09.json +111 -171
- fieldtest-openspp-2026-09.json +41 -1
- tests/test_semantics.py +58 -0
|
@@ -3354,8 +3354,8 @@ warning dict as a refusal. Narrowing the guard first would ship that noise, 25 f
|
|
| 3354 |
true, so it was not taken. A variant that counts a warning dict only when the onchange also
|
| 3355 |
resets a field it watches was run for information. It keeps all 11 true additions and 4 of the
|
| 3356 |
false ones, but it also removes 17 existing core findings and 1 on OpenSPP that nobody has read.
|
| 3357 |
-
Whether that is the right next change
|
| 3358 |
-
|
| 3359 |
|
| 3360 |
**The series half ships.** A `_sql_constraints` block no longer counts as backing on a tree
|
| 3361 |
that pins 19 or later; on 16 to 18 and on an unknown series it still does, as §3.45 requires.
|
|
@@ -3366,6 +3366,65 @@ includes the state before OpenSPP's own incident fix (208d9758), whose file also
|
|
| 3366 |
registry throws away is not a backstop, and the checker should not say it is. All 277
|
| 3367 |
hand-classified true positives are still reported.
|
| 3368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3369 |
## 4. Performance traps, all found by measurement
|
| 3370 |
|
| 3371 |
Within four instrumented runs the report showed the 7B at ~10 tok/s against a
|
|
|
|
| 3354 |
true, so it was not taken. A variant that counts a warning dict only when the onchange also
|
| 3355 |
resets a field it watches was run for information. It keeps all 11 true additions and 4 of the
|
| 3356 |
false ones, but it also removes 17 existing core findings and 1 on OpenSPP that nobody has read.
|
| 3357 |
+
Whether that is the right next change depended on reading those 18 first. They were read the
|
| 3358 |
+
same day, and §3.68 is what came of it.
|
| 3359 |
|
| 3360 |
**The series half ships.** A `_sql_constraints` block no longer counts as backing on a tree
|
| 3361 |
that pins 19 or later; on 16 to 18 and on an unknown series it still does, as §3.45 requires.
|
|
|
|
| 3366 |
registry throws away is not a backstop, and the checker should not say it is. All 277
|
| 3367 |
hand-classified true positives are still reported.
|
| 3368 |
|
| 3369 |
+
### 3.68 A warning refuses only when it takes the value back
|
| 3370 |
+
|
| 3371 |
+
**Measured 2026-09-27.** §3.67 left the field-matched guard out because the file-wide one was
|
| 3372 |
+
hiding a wider test: any returned warning dict counted as a refusal. Both changes ship now,
|
| 3373 |
+
together, and the second is what makes the first read true.
|
| 3374 |
+
|
| 3375 |
+
`_refuses_in_branch` asks one thing of a warning: does the branch that returns it also take the
|
| 3376 |
+
value back? The branch is the innermost `if`, `for` or `while` around the warning, or the whole
|
| 3377 |
+
body when there is none. Taking the value back is writing a field the onchange watches (the
|
| 3378 |
+
quantity clamped to zero, the removed document restored, the component taken out again), or
|
| 3379 |
+
clearing any field on the record to `False`, `None` or empty (the blocked partner, the
|
| 3380 |
+
registrant that no longer fits). A `raise` still counts on its own. A warning with no write
|
| 3381 |
+
beside it is a notice, and the user saves past it in the form as easily as in an import, so
|
| 3382 |
+
nothing is being enforced anywhere and the rule's harm is not there. A write outside the
|
| 3383 |
+
warning's branch does not count either: `account.move` sets its `show_name_warning` flag in
|
| 3384 |
+
an earlier `if`, and that flag is not a refusal.
|
| 3385 |
+
|
| 3386 |
+
The first draft only looked for writes to watched fields, and reading what it removed stopped
|
| 3387 |
+
it. `spp_change_request_v2`'s `_onchange_request_type_id` watches the request type and clears
|
| 3388 |
+
the registrant when a group lands on an individual-only type. Nothing else checks that pairing:
|
| 3389 |
+
the registrant's domain is a form attribute, so a change request made through the API can pair
|
| 3390 |
+
them. Clearing a field the onchange does not watch is a refusal too, and the test now says so.
|
| 3391 |
+
Setting one to a suggested value is not: a scrap order's recommended location, moved in for
|
| 3392 |
+
the user with a note, refuses nothing.
|
| 3393 |
+
|
| 3394 |
+
| | before | after |
|
| 3395 |
+
|---|---|---|
|
| 3396 |
+
| OCA | 99 | 99 |
|
| 3397 |
+
| OpenSPP | 153 | **157** |
|
| 3398 |
+
| odoo core | 2,345 | **2,339** |
|
| 3399 |
+
|
| 3400 |
+
Every change was read. Added: on OpenSPP the four from §3.67, 3 true and 1 false, now in
|
| 3401 |
+
`classification-openspp-rest-2026-09.json` under a new class, `backed-by-the-parent-constraint`.
|
| 3402 |
+
On core, 11: the 8 true from §3.67 (the four partner blocks, loyalty expiry, negative price
|
| 3403 |
+
rounding, expense edit rights, a component equal to its product) and 3 of the 4 that are checked
|
| 3404 |
+
on the server elsewhere (the work order's dates, the reference unit, the Argentinian journal).
|
| 3405 |
+
The API key's duration no longer appears: its onchange only warns. OCA's `account_loan` note
|
| 3406 |
+
does not come back.
|
| 3407 |
+
|
| 3408 |
+
Removed: 17 on core, all read, and none refuses anything. Six are advice about a value that
|
| 3409 |
+
saves anyway: an OCR number that fails its checksum, an invoice name too long for GST rule
|
| 3410 |
+
46(b), an EGP rate with more than five decimals, a rounding finer than the decimal accuracy, a
|
| 3411 |
+
package heavier than its type allows, a pricelist minimum quantity that event tickets will
|
| 3412 |
+
ignore. The other eleven say what is about to happen: tokens archived with a payment method or
|
| 3413 |
+
provider, a payment method attached to a provider, pricelists archived with the feature, a
|
| 3414 |
+
carrier's rating error, a subcontracting purchase order, a decreased service quantity, tracked
|
| 3415 |
+
products already in stock, a storage category with no location, an open cart keeping its
|
| 3416 |
+
pricelist, and a scrap order's recommended location. None of them is in a classification file.
|
| 3417 |
+
|
| 3418 |
+
So the rule now reports 15 more onchanges than it did and 17 fewer, and of the 15, 11 are
|
| 3419 |
+
true. OpenSPP's census gains 3 true and 1 false; core's 17 removals were notices nobody had
|
| 3420 |
+
read, and they are not claimed as false positives fixed. All 280 hand-classified true
|
| 3421 |
+
positives are still reported, the 3 new ones among them.
|
| 3422 |
+
|
| 3423 |
+
What it still does not see: a constraint whose fields sit under another name, as
|
| 3424 |
+
`spp_import_match`'s parent `@api.constrains("field_ids")` does for its child's `field_id`, and a
|
| 3425 |
+
check the server makes in an inverse, a wizard's `create()` or at posting, none of which this
|
| 3426 |
+
checker reads as a constraint. The 3 false additions on core are that.
|
| 3427 |
+
|
| 3428 |
## 4. Performance traps, all found by measurement
|
| 3429 |
|
| 3430 |
Within four instrumented runs the report showed the 7B at ~10 tok/s against a
|
|
@@ -143,8 +143,8 @@ fails if this paragraph stops agreeing with them.
|
|
| 143 |
|
| 144 |
On <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP the rate is
|
| 145 |
<!--fig:openspp.rate-->0.7<!--/fig-->, and those findings have been read too: of the
|
| 146 |
-
<!--fig:openspp.findings-->
|
| 147 |
-
**<!--fig:openspp.read.true-->
|
| 148 |
false, <!--fig:openspp.read.undecidable-->3<!--/fig--> undecidable**, with
|
| 149 |
<!--fig:openspp.unread-->3<!--/fig--> not yet read. One rule produced 97 false positives
|
| 150 |
along the way and has been rewritten because of them — that story is
|
|
@@ -611,13 +611,13 @@ Same principle as `z.infer<typeof Schema>`: one definition, derived artifacts, n
|
|
| 611 |
| Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
|
| 612 |
| Hit rate on <!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code | <!--fig:oca.rate-->2.9<!--/fig--> per 1000 lines: <!--fig:oca.findings-->99<!--/fig--> findings |
|
| 613 |
| Those findings, read by hand | **<!--fig:oca.read.true-->83<!--/fig--> true positives**, <!--fig:oca.read.false-->6<!--/fig--> false, <!--fig:oca.read.undecidable-->7<!--/fig--> undecidable from one file, <!--fig:oca.unread-->3<!--/fig--> not yet read |
|
| 614 |
-
| Hit rate on <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP (Odoo 19) | <!--fig:openspp.rate-->0.7<!--/fig--> per 1000 lines: <!--fig:openspp.findings-->
|
| 615 |
-
| Those findings, read by hand | **<!--fig:openspp.read.true-->
|
| 616 |
| …the two `depends`/`constrains` rules alone | **<!--fig:cls.openspp.true-->87<!--/fig--> true**, <!--fig:cls.openspp.false-->15<!--/fig--> false, <!--fig:cls.openspp.undecidable-->3<!--/fig--> undecidable: <!--fig:cls.openspp.precision-->85<!--/fig-->% |
|
| 617 |
| …the 8 composed-in Python rules alone | **<!--fig:cls.python.true-->9<!--/fig--> true**, <!--fig:cls.python.false-->42<!--/fig--> false: <!--fig:cls.python.precision-->18<!--/fig-->%, after 18 datetime verdicts were reversed: Odoo runs in UTC |
|
| 618 |
| …`sudo-bypasses-record-rules` alone, before it was narrowed | <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.true-->6<!--/fig--> true, <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.false-->97<!--/fig--> false — 5.8%, and the reason it is now an AST pass |
|
| 619 |
-
| Hit rate on <!--fig:core.lines-->559,663<!--/fig--> lines of odoo core 18.0 | <!--fig:core.rate-->4.2<!--/fig--> per 1000 lines: <!--fig:core.findings-->2,
|
| 620 |
-
| Hand-classified true positives still reported | **<!--fig:survival.found-->
|
| 621 |
| **Recall against real Odoo bugfixes** | **<!--fig:recall.all.caught-->20<!--/fig--> of <!--fig:recall.all.in_scope-->334<!--/fig-->, <!--fig:recall.all.pct-->6<!--/fig-->%, across three ecosystems. See below.** |
|
| 622 |
| Verdict stability, 12 questions x 5 runs | 12/12 same verdict every run |
|
| 623 |
| Escalation rescued a bad draft | 11 of 21, 95% CI 32–72% |
|
|
@@ -743,8 +743,8 @@ files on a `float_compare` written somewhere else in them.
|
|
| 743 |
The check that makes all of this safe is the same one every time, and it is worth
|
| 744 |
stating as a number rather than a promise: across all
|
| 745 |
<!--fig:survival.artifacts-->9<!--/fig--> classification artifacts,
|
| 746 |
-
**<!--fig:survival.true-->
|
| 747 |
-
<!--fig:survival.found-->
|
| 748 |
`python bin/figures.py --check`, which matches every `true` verdict to a finding in the
|
| 749 |
committed fieldtest artifact for the same checkout at the same commit, and runs in CI.
|
| 750 |
It is not done by reading the diff and hoping, and no longer by re-running against a
|
|
@@ -1087,7 +1087,7 @@ Reading them turned up three more checker defects, all of the same family as the
|
|
| 1087 |
`sudo(False)` one above: a model with `_auto = False` is a SQL view with no foreign key,
|
| 1088 |
so `ondelete` is not a decision about it; `sudo(flag=False)` is de-escalation by
|
| 1089 |
keyword; and `x.sudo(self.env.su)` restores the ambient level and cannot raise
|
| 1090 |
-
privilege. Core is now **<!--fig:core.findings-->2,
|
| 1091 |
<!--fig:core.rate-->4.2<!--/fig--> per 1000 lines**.
|
| 1092 |
|
| 1093 |
One finding is worth acting on outside this project. Core's
|
|
@@ -1101,7 +1101,7 @@ Seven rules are now sampled on core, <!--fig:cls.odoo-core.rows-->105<!--/fig-->
|
|
| 1101 |
read, and the <!--fig:cls.sudo-core.rows-->45<!--/fig--> `sudo` findings a stricter
|
| 1102 |
re-scoping test added were read in full: <!--fig:cls.sudo-core.true-->1<!--/fig--> true,
|
| 1103 |
<!--fig:cls.sudo-core.false-->44<!--/fig--> false. Core makes
|
| 1104 |
-
<!--fig:core.findings-->2,
|
| 1105 |
and a rate on core is a statement about how often the rules fire there, not how often
|
| 1106 |
they are right.
|
| 1107 |
|
|
|
|
| 143 |
|
| 144 |
On <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP the rate is
|
| 145 |
<!--fig:openspp.rate-->0.7<!--/fig-->, and those findings have been read too: of the
|
| 146 |
+
<!--fig:openspp.findings-->157<!--/fig--> it makes today,
|
| 147 |
+
**<!--fig:openspp.read.true-->134<!--/fig--> true, <!--fig:openspp.read.false-->17<!--/fig-->
|
| 148 |
false, <!--fig:openspp.read.undecidable-->3<!--/fig--> undecidable**, with
|
| 149 |
<!--fig:openspp.unread-->3<!--/fig--> not yet read. One rule produced 97 false positives
|
| 150 |
along the way and has been rewritten because of them — that story is
|
|
|
|
| 611 |
| Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
|
| 612 |
| Hit rate on <!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code | <!--fig:oca.rate-->2.9<!--/fig--> per 1000 lines: <!--fig:oca.findings-->99<!--/fig--> findings |
|
| 613 |
| Those findings, read by hand | **<!--fig:oca.read.true-->83<!--/fig--> true positives**, <!--fig:oca.read.false-->6<!--/fig--> false, <!--fig:oca.read.undecidable-->7<!--/fig--> undecidable from one file, <!--fig:oca.unread-->3<!--/fig--> not yet read |
|
| 614 |
+
| Hit rate on <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP (Odoo 19) | <!--fig:openspp.rate-->0.7<!--/fig--> per 1000 lines: <!--fig:openspp.findings-->157<!--/fig--> findings, +<!--fig:openspp.decided-->127<!--/fig--> another tool had already flagged, +<!--fig:openspp.advisory-->82<!--/fig--> advisory |
|
| 615 |
+
| Those findings, read by hand | **<!--fig:openspp.read.true-->134<!--/fig--> true positives**, <!--fig:openspp.read.false-->17<!--/fig--> false, <!--fig:openspp.read.undecidable-->3<!--/fig--> undecidable, <!--fig:openspp.unread-->3<!--/fig--> not yet read |
|
| 616 |
| …the two `depends`/`constrains` rules alone | **<!--fig:cls.openspp.true-->87<!--/fig--> true**, <!--fig:cls.openspp.false-->15<!--/fig--> false, <!--fig:cls.openspp.undecidable-->3<!--/fig--> undecidable: <!--fig:cls.openspp.precision-->85<!--/fig-->% |
|
| 617 |
| …the 8 composed-in Python rules alone | **<!--fig:cls.python.true-->9<!--/fig--> true**, <!--fig:cls.python.false-->42<!--/fig--> false: <!--fig:cls.python.precision-->18<!--/fig-->%, after 18 datetime verdicts were reversed: Odoo runs in UTC |
|
| 618 |
| …`sudo-bypasses-record-rules` alone, before it was narrowed | <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.true-->6<!--/fig--> true, <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.false-->97<!--/fig--> false — 5.8%, and the reason it is now an AST pass |
|
| 619 |
+
| Hit rate on <!--fig:core.lines-->559,663<!--/fig--> lines of odoo core 18.0 | <!--fig:core.rate-->4.2<!--/fig--> per 1000 lines: <!--fig:core.findings-->2,339<!--/fig--> findings, <!--fig:core.read-->89<!--/fig--> of them read by hand: <!--fig:core.read.true-->45<!--/fig--> true, <!--fig:core.read.false-->44<!--/fig--> false |
|
| 620 |
+
| Hand-classified true positives still reported | **<!--fig:survival.found-->280<!--/fig--> of <!--fig:survival.true-->280<!--/fig-->**, across <!--fig:survival.artifacts-->9<!--/fig--> classifications |
|
| 621 |
| **Recall against real Odoo bugfixes** | **<!--fig:recall.all.caught-->20<!--/fig--> of <!--fig:recall.all.in_scope-->334<!--/fig-->, <!--fig:recall.all.pct-->6<!--/fig-->%, across three ecosystems. See below.** |
|
| 622 |
| Verdict stability, 12 questions x 5 runs | 12/12 same verdict every run |
|
| 623 |
| Escalation rescued a bad draft | 11 of 21, 95% CI 32–72% |
|
|
|
|
| 743 |
The check that makes all of this safe is the same one every time, and it is worth
|
| 744 |
stating as a number rather than a promise: across all
|
| 745 |
<!--fig:survival.artifacts-->9<!--/fig--> classification artifacts,
|
| 746 |
+
**<!--fig:survival.true-->280<!--/fig--> hand-classified true positives,
|
| 747 |
+
<!--fig:survival.found-->280<!--/fig--> still found, none lost.** That is asserted by
|
| 748 |
`python bin/figures.py --check`, which matches every `true` verdict to a finding in the
|
| 749 |
committed fieldtest artifact for the same checkout at the same commit, and runs in CI.
|
| 750 |
It is not done by reading the diff and hoping, and no longer by re-running against a
|
|
|
|
| 1087 |
`sudo(False)` one above: a model with `_auto = False` is a SQL view with no foreign key,
|
| 1088 |
so `ondelete` is not a decision about it; `sudo(flag=False)` is de-escalation by
|
| 1089 |
keyword; and `x.sudo(self.env.su)` restores the ambient level and cannot raise
|
| 1090 |
+
privilege. Core is now **<!--fig:core.findings-->2,339<!--/fig--> findings at
|
| 1091 |
<!--fig:core.rate-->4.2<!--/fig--> per 1000 lines**.
|
| 1092 |
|
| 1093 |
One finding is worth acting on outside this project. Core's
|
|
|
|
| 1101 |
read, and the <!--fig:cls.sudo-core.rows-->45<!--/fig--> `sudo` findings a stricter
|
| 1102 |
re-scoping test added were read in full: <!--fig:cls.sudo-core.true-->1<!--/fig--> true,
|
| 1103 |
<!--fig:cls.sudo-core.false-->44<!--/fig--> false. Core makes
|
| 1104 |
+
<!--fig:core.findings-->2,339<!--/fig--> live ones. The rest stay unread,
|
| 1105 |
and a rate on core is a statement about how often the rules fire there, not how often
|
| 1106 |
they are right.
|
| 1107 |
|
|
@@ -3054,11 +3054,130 @@ def float_coercion_of_external_money(code):
|
|
| 3054 |
return findings
|
| 3055 |
|
| 3056 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3057 |
def onchange_tries_to_enforce(code, facts=None):
|
| 3058 |
"""odoo.onchange-is-not-validation
|
| 3059 |
|
| 3060 |
-
An @api.onchange method that RAISES or returns a warning
|
| 3061 |
-
to enforce an invariant the web client is the
|
|
|
|
| 3062 |
|
| 3063 |
The regex this replaces read 400 characters after the decorator and could
|
| 3064 |
not see where the method ended. All three of its false positives are the
|
|
@@ -3071,40 +3190,39 @@ def onchange_tries_to_enforce(code, facts=None):
|
|
| 3071 |
positives -- an onchange that really does raise ValidationError, and ones
|
| 3072 |
that return the warning dict -- all stand.
|
| 3073 |
|
| 3074 |
-
A constraint
|
| 3075 |
-
|
| 3076 |
-
|
| 3077 |
-
|
|
|
|
|
|
|
| 3078 |
there, and not when the tree pins 19 or later, where the registry never
|
| 3079 |
-
creates it
|
| 3080 |
-
|
| 3081 |
-
|
| 3082 |
-
|
| 3083 |
-
|
| 3084 |
-
the
|
| 3085 |
-
|
| 3086 |
-
|
| 3087 |
-
|
| 3088 |
-
3.67.
|
| 3089 |
"""
|
| 3090 |
tree, _ = parse_python(code)
|
| 3091 |
if tree is None:
|
| 3092 |
return []
|
| 3093 |
-
|
| 3094 |
-
|
| 3095 |
-
backed = False
|
| 3096 |
-
for node in ast.walk(tree):
|
| 3097 |
-
if isinstance(node, ast.Name) and node.id == "_sql_constraints":
|
| 3098 |
-
backed = backed or not legacy_dead
|
| 3099 |
-
elif isinstance(node, ast.Attribute) and node.attr in ("constrains",
|
| 3100 |
-
"Constraint"):
|
| 3101 |
-
backed = True
|
| 3102 |
-
if backed:
|
| 3103 |
-
return []
|
| 3104 |
|
| 3105 |
findings = []
|
| 3106 |
for fn in _functions(tree):
|
| 3107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3108 |
continue
|
| 3109 |
# Anchored on the decorator, not on the def below it. That is where
|
| 3110 |
# the rule is, where a reader looking the finding up will look, and
|
|
@@ -3124,10 +3242,10 @@ def onchange_tries_to_enforce(code, facts=None):
|
|
| 3124 |
else _final_name(exc)
|
| 3125 |
if name and ("Error" in name or "Warning" in name):
|
| 3126 |
spot = node.lineno
|
| 3127 |
-
elif isinstance(node, ast.Dict)
|
| 3128 |
-
|
| 3129 |
-
|
| 3130 |
-
|
| 3131 |
if spot is None:
|
| 3132 |
continue
|
| 3133 |
findings.append((
|
|
|
|
| 3054 |
return findings
|
| 3055 |
|
| 3056 |
|
| 3057 |
+
class _Every(object):
|
| 3058 |
+
"""A constraint is present and which fields it covers cannot be read."""
|
| 3059 |
+
|
| 3060 |
+
|
| 3061 |
+
EVERY_FIELD = _Every()
|
| 3062 |
+
|
| 3063 |
+
|
| 3064 |
+
def _sql_identifiers(node):
|
| 3065 |
+
"""Names inside a constraint's SQL text, or EVERY_FIELD when the text is
|
| 3066 |
+
not a literal. Keywords come along (CHECK, UNIQUE, NOT) and match no field."""
|
| 3067 |
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
| 3068 |
+
return set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", node.value))
|
| 3069 |
+
return EVERY_FIELD
|
| 3070 |
+
|
| 3071 |
+
|
| 3072 |
+
def _constrained_fields(tree, legacy_dead):
|
| 3073 |
+
"""Field names some constraint in this file covers, or EVERY_FIELD.
|
| 3074 |
+
|
| 3075 |
+
Three spellings: @api.constrains (its plain names -- a dotted one registers
|
| 3076 |
+
nothing), models.Constraint (the names in its SQL) and _sql_constraints (the
|
| 3077 |
+
names in each entry's SQL, and nothing at all when `legacy_dead`, because
|
| 3078 |
+
from 19 the registry never creates it). A constraint that is present but
|
| 3079 |
+
cannot be read -- @api.constrains(*FIELDS), a _sql_constraints inherited and
|
| 3080 |
+
extended -- covers every field, which is the reading this had before it
|
| 3081 |
+
looked at fields at all.
|
| 3082 |
+
"""
|
| 3083 |
+
covered = set()
|
| 3084 |
+
|
| 3085 |
+
def add(names):
|
| 3086 |
+
if names is EVERY_FIELD:
|
| 3087 |
+
return True
|
| 3088 |
+
covered.update(names)
|
| 3089 |
+
return False
|
| 3090 |
+
|
| 3091 |
+
for fn in _functions(tree):
|
| 3092 |
+
declared = _decorator_args(fn, "api", "constrains")
|
| 3093 |
+
if declared is UNREADABLE:
|
| 3094 |
+
return EVERY_FIELD
|
| 3095 |
+
if declared:
|
| 3096 |
+
covered.update(d for d in declared if "." not in d)
|
| 3097 |
+
for node in ast.walk(tree):
|
| 3098 |
+
if isinstance(node, ast.Call):
|
| 3099 |
+
func = node.func
|
| 3100 |
+
name = func.attr if isinstance(func, ast.Attribute) else \
|
| 3101 |
+
func.id if isinstance(func, ast.Name) else None
|
| 3102 |
+
if name == "Constraint":
|
| 3103 |
+
if add(_sql_identifiers(node.args[0]) if node.args else EVERY_FIELD):
|
| 3104 |
+
return EVERY_FIELD
|
| 3105 |
+
continue
|
| 3106 |
+
if isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
|
| 3107 |
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
| 3108 |
+
if not any(isinstance(t, ast.Name) and t.id == "_sql_constraints"
|
| 3109 |
+
for t in targets) or node.value is None or legacy_dead:
|
| 3110 |
+
continue
|
| 3111 |
+
if not isinstance(node.value, (ast.List, ast.Tuple)):
|
| 3112 |
+
return EVERY_FIELD
|
| 3113 |
+
for entry in node.value.elts:
|
| 3114 |
+
if not (isinstance(entry, (ast.List, ast.Tuple)) and len(entry.elts) > 1):
|
| 3115 |
+
return EVERY_FIELD
|
| 3116 |
+
if add(_sql_identifiers(entry.elts[1])):
|
| 3117 |
+
return EVERY_FIELD
|
| 3118 |
+
return covered
|
| 3119 |
+
|
| 3120 |
+
|
| 3121 |
+
def _clears(value):
|
| 3122 |
+
"""False, None, '' or an empty collection: what a form resets a field to."""
|
| 3123 |
+
if isinstance(value, ast.Constant):
|
| 3124 |
+
return value.value is False or value.value is None or value.value == ""
|
| 3125 |
+
if isinstance(value, ast.Dict):
|
| 3126 |
+
return not value.keys
|
| 3127 |
+
return isinstance(value, (ast.List, ast.Tuple, ast.Set)) and not value.elts
|
| 3128 |
+
|
| 3129 |
+
|
| 3130 |
+
def _refuses_in_branch(fn, warning, watched):
|
| 3131 |
+
"""Does the branch that returns this warning also take the value back?
|
| 3132 |
+
|
| 3133 |
+
A warning dict on its own informs: "this reference already exists", "the
|
| 3134 |
+
rate is far from the previous one", "please choose another package type".
|
| 3135 |
+
The user saves anyway, from the form as from an import, so nothing is
|
| 3136 |
+
enforced anywhere and the rule's harm -- a check only the form applies --
|
| 3137 |
+
is not there. What refuses is the same branch writing back a field the
|
| 3138 |
+
onchange watches (the quantity clamped, the partner cleared, the removed
|
| 3139 |
+
document restored) or clearing any field on the record (a registrant that
|
| 3140 |
+
does not fit the new request type). "The same branch" is the innermost
|
| 3141 |
+
if, for or while around the warning, or the whole body when there is none.
|
| 3142 |
+
"""
|
| 3143 |
+
parents = {}
|
| 3144 |
+
for node in ast.walk(fn):
|
| 3145 |
+
for child in ast.iter_child_nodes(node):
|
| 3146 |
+
parents[id(child)] = node
|
| 3147 |
+
branch = warning
|
| 3148 |
+
while branch is not fn and not isinstance(branch, (ast.If, ast.For, ast.AsyncFor,
|
| 3149 |
+
ast.While)):
|
| 3150 |
+
branch = parents.get(id(branch), fn)
|
| 3151 |
+
roots = set() if watched is UNREADABLE else {w.split(".")[0] for w in watched}
|
| 3152 |
+
aliases = _aliases(fn)
|
| 3153 |
+
|
| 3154 |
+
def on_record(node):
|
| 3155 |
+
return isinstance(node, ast.Name) and aliases.get(node.id) == ()
|
| 3156 |
+
|
| 3157 |
+
for node in ast.walk(branch):
|
| 3158 |
+
if isinstance(node, (ast.Assign, ast.AugAssign)):
|
| 3159 |
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
| 3160 |
+
for target in targets:
|
| 3161 |
+
if isinstance(target, ast.Attribute) and on_record(target.value) and (
|
| 3162 |
+
watched is UNREADABLE or target.attr in roots
|
| 3163 |
+
or _clears(node.value)):
|
| 3164 |
+
return True
|
| 3165 |
+
elif (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
| 3166 |
+
and node.func.attr == "update" and on_record(node.func.value)
|
| 3167 |
+
and node.args and isinstance(node.args[0], ast.Dict)):
|
| 3168 |
+
for key, value in zip(node.args[0].keys, node.args[0].values):
|
| 3169 |
+
if isinstance(key, ast.Constant) and (
|
| 3170 |
+
watched is UNREADABLE or key.value in roots or _clears(value)):
|
| 3171 |
+
return True
|
| 3172 |
+
return False
|
| 3173 |
+
|
| 3174 |
+
|
| 3175 |
def onchange_tries_to_enforce(code, facts=None):
|
| 3176 |
"""odoo.onchange-is-not-validation
|
| 3177 |
|
| 3178 |
+
An @api.onchange method that RAISES, or returns a warning while taking the
|
| 3179 |
+
value back -- it is trying to enforce an invariant the web client is the
|
| 3180 |
+
only thing that will apply.
|
| 3181 |
|
| 3182 |
The regex this replaces read 400 characters after the decorator and could
|
| 3183 |
not see where the method ended. All three of its false positives are the
|
|
|
|
| 3190 |
positives -- an onchange that really does raise ValidationError, and ones
|
| 3191 |
that return the warning dict -- all stand.
|
| 3192 |
|
| 3193 |
+
A constraint in the file clears it when it covers a field the onchange
|
| 3194 |
+
watches: an onchange beside a real @api.constrains on the same field is
|
| 3195 |
+
the faster feedback the rule explicitly allows. Until 2026-09-27 ANY
|
| 3196 |
+
constraint anywhere in the file cleared it, so a UNIQUE(reference)
|
| 3197 |
+
silenced an onchange that raised on credit_limit. _sql_constraints counts
|
| 3198 |
+
on 16 through 18 and on an unknown series, because it IS a constraint
|
| 3199 |
there, and not when the tree pins 19 or later, where the registry never
|
| 3200 |
+
creates it (_constrained_fields).
|
| 3201 |
+
|
| 3202 |
+
Narrowing the guard alone added 36 findings and 11 read true, because the
|
| 3203 |
+
wide guard had been hiding a second problem: a returned warning dict was
|
| 3204 |
+
read as a refusal whatever it said. Most were notices -- "this reference
|
| 3205 |
+
already exists", "the rate is far from the previous one" -- that the user
|
| 3206 |
+
saves past in the form as easily as in an import, so nothing is enforced
|
| 3207 |
+
anywhere. A warning counts now only when its branch also takes the value
|
| 3208 |
+
back (_refuses_in_branch). Both together: OCA unchanged, OpenSPP +4 (3
|
| 3209 |
+
true), core +11 (8 true) and -17, all 17 notices. ARCHITECTURE 3.67, 3.68.
|
| 3210 |
"""
|
| 3211 |
tree, _ = parse_python(code)
|
| 3212 |
if tree is None:
|
| 3213 |
return []
|
| 3214 |
+
covered = _constrained_fields(
|
| 3215 |
+
tree, _series_at_least((facts or {}).get("odoo_series"), 19.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3216 |
|
| 3217 |
findings = []
|
| 3218 |
for fn in _functions(tree):
|
| 3219 |
+
watched = _decorator_args(fn, "api", "onchange")
|
| 3220 |
+
if watched is None:
|
| 3221 |
+
continue
|
| 3222 |
+
if covered is EVERY_FIELD or (watched is UNREADABLE and covered):
|
| 3223 |
+
continue
|
| 3224 |
+
if watched is not UNREADABLE and any(w.split(".")[0] in covered
|
| 3225 |
+
for w in watched):
|
| 3226 |
continue
|
| 3227 |
# Anchored on the decorator, not on the def below it. That is where
|
| 3228 |
# the rule is, where a reader looking the finding up will look, and
|
|
|
|
| 3242 |
else _final_name(exc)
|
| 3243 |
if name and ("Error" in name or "Warning" in name):
|
| 3244 |
spot = node.lineno
|
| 3245 |
+
elif isinstance(node, ast.Dict) and any(
|
| 3246 |
+
isinstance(key, ast.Constant) and key.value == "warning"
|
| 3247 |
+
for key in node.keys) and _refuses_in_branch(fn, node, watched):
|
| 3248 |
+
spot = node.lineno
|
| 3249 |
if spot is None:
|
| 3250 |
continue
|
| 3251 |
findings.append((
|
|
@@ -11,8 +11,8 @@
|
|
| 11 |
"files": 1351,
|
| 12 |
"lines": 226937,
|
| 13 |
"totals": {
|
| 14 |
-
"true":
|
| 15 |
-
"false":
|
| 16 |
"undecidable": 3
|
| 17 |
},
|
| 18 |
"by_rule": {
|
|
@@ -33,8 +33,8 @@
|
|
| 33 |
"false": 1
|
| 34 |
},
|
| 35 |
"odoo.onchange-is-not-validation": {
|
| 36 |
-
"false":
|
| 37 |
-
"true":
|
| 38 |
},
|
| 39 |
"odoo.float-comparison-needs-float-utils": {
|
| 40 |
"true": 2,
|
|
@@ -60,7 +60,8 @@
|
|
| 60 |
"identifier-not-value": 2,
|
| 61 |
"sql-view-model": 1,
|
| 62 |
"deliberate-recompute-trigger": 1,
|
| 63 |
-
"parent-store-refuses-the-cycle": 5
|
|
|
|
| 64 |
},
|
| 65 |
"hits": [
|
| 66 |
{
|
|
@@ -1640,8 +1641,44 @@
|
|
| 1640 |
"class": "no-record-rule-on-this-model",
|
| 1641 |
"why": "no ir.rule is declared for this model anywhere in the tree, so sudo bypasses nothing",
|
| 1642 |
"evidence": null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1643 |
}
|
| 1644 |
],
|
| 1645 |
-
"what_this_changed": "sudo-bypasses-record-rules went 6 true / 97 false / 3 undecidable, 5.8%. 73 of the 97 were on models the tree gives no ir.rule at all and 10 on a model whose only rule is [(1,'=',1)], so sudo dropped nothing at 83 of 106 sites. The rule is now an AST pass that asks the tree whether a filtering rule exists, and answers UNKNOWN -- keeping the finding -- for any model the walk did not see declared. The 14 remaining false positives constrain the search by hand to records the caller already named, in a spelling the rule does not recognise because it names only company_id. That is a worklist entry, not a narrowing: deciding a domain is already scoped means evaluating it.\n\n2026-09-26: 5 recursion-check-on-hierarchy verdict(s) reversed from true to false, class parent-store-refuses-the-cycle. Reading spp_area behind a true verdict showed the model sets _parent_store = True, which makes the ORM refuse the cycle itself; every other true verdict for the rule was checked the same way. The rule moved to an AST pass (semantics.parent_can_form_a_cycle) that asks whether the field points at its own model and whether _parent_store already guards it. Each reversed row keeps was: true.",
|
| 1646 |
"worklist": "float-comparison-needs-float-utils is the next one down at 2 true / 5 false. All five compare an INTEGER to zero -- search_count, a read_group _count, SELECT COUNT(*) -- which the regex cannot see because it matches on the variable's name. Fixing it means tracing the assignment, so it is named here and not yet done. onchange-is-not-validation is 4 true / 3 false, and all three false ones are an onchange that sets a field rather than enforcing anything, which the rule's existing narrowing was supposed to exclude and does not catch when the write is to a Many2one."
|
| 1647 |
-
}
|
|
|
|
| 11 |
"files": 1351,
|
| 12 |
"lines": 226937,
|
| 13 |
"totals": {
|
| 14 |
+
"true": 49,
|
| 15 |
+
"false": 115,
|
| 16 |
"undecidable": 3
|
| 17 |
},
|
| 18 |
"by_rule": {
|
|
|
|
| 33 |
"false": 1
|
| 34 |
},
|
| 35 |
"odoo.onchange-is-not-validation": {
|
| 36 |
+
"false": 4,
|
| 37 |
+
"true": 7
|
| 38 |
},
|
| 39 |
"odoo.float-comparison-needs-float-utils": {
|
| 40 |
"true": 2,
|
|
|
|
| 60 |
"identifier-not-value": 2,
|
| 61 |
"sql-view-model": 1,
|
| 62 |
"deliberate-recompute-trigger": 1,
|
| 63 |
+
"parent-store-refuses-the-cycle": 5,
|
| 64 |
+
"backed-by-the-parent-constraint": 1
|
| 65 |
},
|
| 66 |
"hits": [
|
| 67 |
{
|
|
|
|
| 1641 |
"class": "no-record-rule-on-this-model",
|
| 1642 |
"why": "no ir.rule is declared for this model anywhere in the tree, so sudo bypasses nothing",
|
| 1643 |
"evidence": null
|
| 1644 |
+
},
|
| 1645 |
+
{
|
| 1646 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 1647 |
+
"path": "spp_area/models/area_core.py",
|
| 1648 |
+
"line": 88,
|
| 1649 |
+
"evidence": "@api.onchange(\"parent_id\")",
|
| 1650 |
+
"verdict": "true",
|
| 1651 |
+
"class": null,
|
| 1652 |
+
"why": "caps an area at level 10 here and nowhere else; the file's only constraints are unique(code), so an import or RPC can store level 11"
|
| 1653 |
+
},
|
| 1654 |
+
{
|
| 1655 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 1656 |
+
"path": "spp_change_request_v2/models/change_request_type.py",
|
| 1657 |
+
"line": 206,
|
| 1658 |
+
"evidence": "@api.onchange(\"available_document_ids\")",
|
| 1659 |
+
"verdict": "true",
|
| 1660 |
+
"class": null,
|
| 1661 |
+
"why": "keeps required documents inside the available list only in the form; the file's constraints cover code, detail_model and the apply strategy"
|
| 1662 |
+
},
|
| 1663 |
+
{
|
| 1664 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 1665 |
+
"path": "spp_import_match/models/import_match.py",
|
| 1666 |
+
"line": 187,
|
| 1667 |
+
"evidence": "@api.onchange(\"field_id\")",
|
| 1668 |
+
"verdict": "false",
|
| 1669 |
+
"class": "backed-by-the-parent-constraint",
|
| 1670 |
+
"why": "the parent spp.import.match's @api.constrains('field_ids') enforces the same no-duplicate rule, under a field name the child's onchange does not watch"
|
| 1671 |
+
},
|
| 1672 |
+
{
|
| 1673 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 1674 |
+
"path": "spp_registry/models/group_membership.py",
|
| 1675 |
+
"line": 71,
|
| 1676 |
+
"evidence": "@api.onchange(\"membership_type_ids\")",
|
| 1677 |
+
"verdict": "true",
|
| 1678 |
+
"class": null,
|
| 1679 |
+
"why": "one head per group is checked here and in the group's write(); spp_change_request_v2's add_member creates memberships directly, past both"
|
| 1680 |
}
|
| 1681 |
],
|
| 1682 |
+
"what_this_changed": "sudo-bypasses-record-rules went 6 true / 97 false / 3 undecidable, 5.8%. 73 of the 97 were on models the tree gives no ir.rule at all and 10 on a model whose only rule is [(1,'=',1)], so sudo dropped nothing at 83 of 106 sites. The rule is now an AST pass that asks the tree whether a filtering rule exists, and answers UNKNOWN -- keeping the finding -- for any model the walk did not see declared. The 14 remaining false positives constrain the search by hand to records the caller already named, in a spelling the rule does not recognise because it names only company_id. That is a worklist entry, not a narrowing: deciding a domain is already scoped means evaluating it.\n\n2026-09-26: 5 recursion-check-on-hierarchy verdict(s) reversed from true to false, class parent-store-refuses-the-cycle. Reading spp_area behind a true verdict showed the model sets _parent_store = True, which makes the ORM refuse the cycle itself; every other true verdict for the rule was checked the same way. The rule moved to an AST pass (semantics.parent_can_form_a_cycle) that asks whether the field points at its own model and whether _parent_store already guards it. Each reversed row keeps was: true.\n\n2026-09-27: 4 onchange-is-not-validation findings added and read, surfaced when the checker stopped letting any constraint in the file clear it and started asking whether a constraint covers a field the onchange watches (ARCHITECTURE 3.68). 3 true, 1 false (backed-by-the-parent-constraint).",
|
| 1683 |
"worklist": "float-comparison-needs-float-utils is the next one down at 2 true / 5 false. All five compare an INTEGER to zero -- search_count, a read_group _count, SELECT COUNT(*) -- which the regex cannot see because it matches on the variable's name. Fixing it means tracing the assignment, so it is named here and not yet done. onchange-is-not-validation is 4 true / 3 false, and all three false ones are an onchange that sets a field rather than enforcing anything, which the rule's existing narrowing was supposed to exclude and does not catch when the write is to a Many2one."
|
| 1684 |
+
}
|
|
@@ -674,7 +674,9 @@ ENRICH = {
|
|
| 674 |
},
|
| 675 |
"odoo.onchange-is-not-validation": {
|
| 676 |
# An onchange that TRIES to enforce something: it raises, or it returns
|
| 677 |
-
# the warning dict
|
|
|
|
|
|
|
| 678 |
# Bare @api.onchange is not a violation -- setting a default, filling a
|
| 679 |
# field from a provider and narrowing a domain are what onchange is for,
|
| 680 |
# and flagging those fired on all 16 onchange declarations in two OCA
|
|
@@ -696,7 +698,8 @@ ENRICH = {
|
|
| 696 |
# and backs nothing the onchange watches; the finding was false because
|
| 697 |
# the onchange's warning informs and refuses nothing.) The checker reads
|
| 698 |
# odoo_series off the tree facts and stops counting _sql_constraints
|
| 699 |
-
# when it says 19+, where the registry never creates it
|
|
|
|
| 700 |
"semantic_check": "onchange_tries_to_enforce",
|
| 701 |
"source": ODOO_ONCHANGE,
|
| 702 |
},
|
|
|
|
| 674 |
},
|
| 675 |
"odoo.onchange-is-not-validation": {
|
| 676 |
# An onchange that TRIES to enforce something: it raises, or it returns
|
| 677 |
+
# the warning dict while taking the value back in the same branch,
|
| 678 |
+
# which is the other way to refuse a value in the form. A warning that
|
| 679 |
+
# only informs is saved past in the form too (ARCHITECTURE 3.68).
|
| 680 |
# Bare @api.onchange is not a violation -- setting a default, filling a
|
| 681 |
# field from a provider and narrowing a domain are what onchange is for,
|
| 682 |
# and flagging those fired on all 16 onchange declarations in two OCA
|
|
|
|
| 698 |
# and backs nothing the onchange watches; the finding was false because
|
| 699 |
# the onchange's warning informs and refuses nothing.) The checker reads
|
| 700 |
# odoo_series off the tree facts and stops counting _sql_constraints
|
| 701 |
+
# when it says 19+, where the registry never creates it, and counts a
|
| 702 |
+
# constraint only when it covers a field the onchange watches.
|
| 703 |
"semantic_check": "onchange_tries_to_enforce",
|
| 704 |
"source": ODOO_ONCHANGE,
|
| 705 |
},
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_utc": "2026-09-
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"server-tools",
|
|
|
|
| 1 |
{
|
| 2 |
+
"generated_utc": "2026-09-27T15:03:24+00:00",
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"server-tools",
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_utc": "2026-09-
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"odoo"
|
|
@@ -631,6 +631,16 @@
|
|
| 631 |
"likelihood": "unmeasured",
|
| 632 |
"path": "addons/account/models/account_move.py"
|
| 633 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
{
|
| 635 |
"rule": "odoo.constrains-must-list-every-field-read",
|
| 636 |
"severity": "critical",
|
|
@@ -3741,16 +3751,6 @@
|
|
| 3741 |
"likelihood": "unproven",
|
| 3742 |
"path": "addons/delivery/models/sale_order.py"
|
| 3743 |
},
|
| 3744 |
-
{
|
| 3745 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 3746 |
-
"severity": "critical",
|
| 3747 |
-
"line": 43,
|
| 3748 |
-
"evidence": "@api.onchange '_onchange_order_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 3749 |
-
"suppressed": false,
|
| 3750 |
-
"advisory": false,
|
| 3751 |
-
"likelihood": "unmeasured",
|
| 3752 |
-
"path": "addons/delivery/wizard/choose_delivery_carrier.py"
|
| 3753 |
-
},
|
| 3754 |
{
|
| 3755 |
"rule": "odoo.constrains-iterate-recordset",
|
| 3756 |
"severity": "high",
|
|
@@ -5211,6 +5211,16 @@
|
|
| 5211 |
"likelihood": "construct",
|
| 5212 |
"path": "addons/hr_expense/models/hr_expense.py"
|
| 5213 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5214 |
{
|
| 5215 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 5216 |
"severity": "high",
|
|
@@ -7101,6 +7111,16 @@
|
|
| 7101 |
"likelihood": "unmeasured",
|
| 7102 |
"path": "addons/l10n_account_withholding_tax/wizards/account_payment_register_withholding_line.py"
|
| 7103 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7104 |
{
|
| 7105 |
"rule": "odoo.noupdate-on-user-editable-data",
|
| 7106 |
"severity": "medium",
|
|
@@ -7311,16 +7331,6 @@
|
|
| 7311 |
"likelihood": "construct",
|
| 7312 |
"path": "addons/l10n_dk_nemhandel_response/models/account_move.py"
|
| 7313 |
},
|
| 7314 |
-
{
|
| 7315 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 7316 |
-
"severity": "critical",
|
| 7317 |
-
"line": 12,
|
| 7318 |
-
"evidence": "@api.onchange '_onchange_rate_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 7319 |
-
"suppressed": false,
|
| 7320 |
-
"advisory": false,
|
| 7321 |
-
"likelihood": "unmeasured",
|
| 7322 |
-
"path": "addons/l10n_eg_edi_eta/models/res_currency_rate.py"
|
| 7323 |
-
},
|
| 7324 |
{
|
| 7325 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 7326 |
"severity": "critical",
|
|
@@ -8251,16 +8261,6 @@
|
|
| 8251 |
"likelihood": "construct",
|
| 8252 |
"path": "addons/l10n_in/models/account_invoice.py"
|
| 8253 |
},
|
| 8254 |
-
{
|
| 8255 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 8256 |
-
"severity": "critical",
|
| 8257 |
-
"line": 135,
|
| 8258 |
-
"evidence": "@api.onchange '_onchange_name_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 8259 |
-
"suppressed": false,
|
| 8260 |
-
"advisory": false,
|
| 8261 |
-
"likelihood": "unmeasured",
|
| 8262 |
-
"path": "addons/l10n_in/models/account_invoice.py"
|
| 8263 |
-
},
|
| 8264 |
{
|
| 8265 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 8266 |
"severity": "critical",
|
|
@@ -9781,16 +9781,6 @@
|
|
| 9781 |
"likelihood": "construct",
|
| 9782 |
"path": "addons/l10n_se/models/account_move.py"
|
| 9783 |
},
|
| 9784 |
-
{
|
| 9785 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 9786 |
-
"severity": "critical",
|
| 9787 |
-
"line": 14,
|
| 9788 |
-
"evidence": "@api.onchange 'onchange_l10n_se_default_vendor_payment_ref' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 9789 |
-
"suppressed": false,
|
| 9790 |
-
"advisory": false,
|
| 9791 |
-
"likelihood": "unmeasured",
|
| 9792 |
-
"path": "addons/l10n_se/models/res_partner.py"
|
| 9793 |
-
},
|
| 9794 |
{
|
| 9795 |
"rule": "python.bare-except-catches-the-exit",
|
| 9796 |
"severity": "high",
|
|
@@ -10091,6 +10081,16 @@
|
|
| 10091 |
"likelihood": "defect",
|
| 10092 |
"path": "addons/loyalty/controllers/portal.py"
|
| 10093 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10094 |
{
|
| 10095 |
"rule": "odoo.ondelete-is-a-decision",
|
| 10096 |
"severity": "high",
|
|
@@ -12061,6 +12061,16 @@
|
|
| 12061 |
"likelihood": "unmeasured",
|
| 12062 |
"path": "addons/mrp/models/mrp_production.py"
|
| 12063 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12064 |
{
|
| 12065 |
"rule": "odoo.constrains-must-list-every-field-read",
|
| 12066 |
"severity": "critical",
|
|
@@ -12331,6 +12341,16 @@
|
|
| 12331 |
"likelihood": "construct",
|
| 12332 |
"path": "addons/mrp/models/mrp_workorder.py"
|
| 12333 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12334 |
{
|
| 12335 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 12336 |
"severity": "high",
|
|
@@ -12581,16 +12601,6 @@
|
|
| 12581 |
"likelihood": "construct",
|
| 12582 |
"path": "addons/mrp/models/stock_scrap.py"
|
| 12583 |
},
|
| 12584 |
-
{
|
| 12585 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 12586 |
-
"severity": "critical",
|
| 12587 |
-
"line": 51,
|
| 12588 |
-
"evidence": "@api.onchange '_onchange_serial_number' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 12589 |
-
"suppressed": false,
|
| 12590 |
-
"advisory": false,
|
| 12591 |
-
"likelihood": "unmeasured",
|
| 12592 |
-
"path": "addons/mrp/models/stock_scrap.py"
|
| 12593 |
-
},
|
| 12594 |
{
|
| 12595 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 12596 |
"severity": "critical",
|
|
@@ -12791,16 +12801,6 @@
|
|
| 12791 |
"likelihood": "defect",
|
| 12792 |
"path": "addons/mrp_subcontracting/models/stock_warehouse.py"
|
| 12793 |
},
|
| 12794 |
-
{
|
| 12795 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 12796 |
-
"severity": "critical",
|
| 12797 |
-
"line": 21,
|
| 12798 |
-
"evidence": "@api.onchange 'onchange_picking_type_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 12799 |
-
"suppressed": false,
|
| 12800 |
-
"advisory": false,
|
| 12801 |
-
"likelihood": "unmeasured",
|
| 12802 |
-
"path": "addons/mrp_subcontracting_dropshipping/models/purchase.py"
|
| 12803 |
-
},
|
| 12804 |
{
|
| 12805 |
"rule": "python.assert-is-not-validation",
|
| 12806 |
"severity": "critical",
|
|
@@ -12831,26 +12831,6 @@
|
|
| 12831 |
"likelihood": "unproven",
|
| 12832 |
"path": "addons/payment/data/payment_cron.xml"
|
| 12833 |
},
|
| 12834 |
-
{
|
| 12835 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 12836 |
-
"severity": "critical",
|
| 12837 |
-
"line": 113,
|
| 12838 |
-
"evidence": "@api.onchange '_onchange_warn_before_disabling_tokens' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 12839 |
-
"suppressed": false,
|
| 12840 |
-
"advisory": false,
|
| 12841 |
-
"likelihood": "unmeasured",
|
| 12842 |
-
"path": "addons/payment/models/payment_method.py"
|
| 12843 |
-
},
|
| 12844 |
-
{
|
| 12845 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 12846 |
-
"severity": "critical",
|
| 12847 |
-
"line": 146,
|
| 12848 |
-
"evidence": "@api.onchange '_onchange_provider_ids_warn_before_attaching_payment_method' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 12849 |
-
"suppressed": false,
|
| 12850 |
-
"advisory": false,
|
| 12851 |
-
"likelihood": "unmeasured",
|
| 12852 |
-
"path": "addons/payment/models/payment_method.py"
|
| 12853 |
-
},
|
| 12854 |
{
|
| 12855 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 12856 |
"severity": "critical",
|
|
@@ -12861,16 +12841,6 @@
|
|
| 12861 |
"likelihood": "construct",
|
| 12862 |
"path": "addons/payment/models/payment_provider.py"
|
| 12863 |
},
|
| 12864 |
-
{
|
| 12865 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 12866 |
-
"severity": "critical",
|
| 12867 |
-
"line": 258,
|
| 12868 |
-
"evidence": "@api.onchange '_onchange_state_warn_before_disabling_tokens' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 12869 |
-
"suppressed": false,
|
| 12870 |
-
"advisory": false,
|
| 12871 |
-
"likelihood": "unmeasured",
|
| 12872 |
-
"path": "addons/payment/models/payment_provider.py"
|
| 12873 |
-
},
|
| 12874 |
{
|
| 12875 |
"rule": "odoo.onchange-is-not-validation",
|
| 12876 |
"severity": "critical",
|
|
@@ -14411,6 +14381,16 @@
|
|
| 14411 |
"likelihood": "construct",
|
| 14412 |
"path": "addons/product/models/product_pricelist_item.py"
|
| 14413 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14414 |
{
|
| 14415 |
"rule": "odoo.sql-needs-query-parameters",
|
| 14416 |
"severity": "critical",
|
|
@@ -14481,16 +14461,6 @@
|
|
| 14481 |
"likelihood": "construct",
|
| 14482 |
"path": "addons/product/models/product_template_attribute_value.py"
|
| 14483 |
},
|
| 14484 |
-
{
|
| 14485 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 14486 |
-
"severity": "critical",
|
| 14487 |
-
"line": 25,
|
| 14488 |
-
"evidence": "@api.onchange '_onchange_group_sale_pricelist' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 14489 |
-
"suppressed": false,
|
| 14490 |
-
"advisory": false,
|
| 14491 |
-
"likelihood": "unmeasured",
|
| 14492 |
-
"path": "addons/product/models/res_config_settings.py"
|
| 14493 |
-
},
|
| 14494 |
{
|
| 14495 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 14496 |
"severity": "critical",
|
|
@@ -14521,16 +14491,6 @@
|
|
| 14521 |
"likelihood": "unmeasured",
|
| 14522 |
"path": "addons/product/models/res_partner.py"
|
| 14523 |
},
|
| 14524 |
-
{
|
| 14525 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 14526 |
-
"severity": "critical",
|
| 14527 |
-
"line": 10,
|
| 14528 |
-
"evidence": "@api.onchange '_onchange_rounding' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 14529 |
-
"suppressed": false,
|
| 14530 |
-
"advisory": false,
|
| 14531 |
-
"likelihood": "unmeasured",
|
| 14532 |
-
"path": "addons/product/models/uom_uom.py"
|
| 14533 |
-
},
|
| 14534 |
{
|
| 14535 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 14536 |
"severity": "critical",
|
|
@@ -15211,6 +15171,16 @@
|
|
| 15211 |
"likelihood": "construct",
|
| 15212 |
"path": "addons/purchase/models/purchase_order.py"
|
| 15213 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15214 |
{
|
| 15215 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 15216 |
"severity": "critical",
|
|
@@ -16511,6 +16481,16 @@
|
|
| 16511 |
"likelihood": "construct",
|
| 16512 |
"path": "addons/sale/models/sale_order.py"
|
| 16513 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16514 |
{
|
| 16515 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 16516 |
"severity": "high",
|
|
@@ -18071,16 +18051,6 @@
|
|
| 18071 |
"likelihood": "unproven",
|
| 18072 |
"path": "addons/sale_project_stock/models/stock_picking.py"
|
| 18073 |
},
|
| 18074 |
-
{
|
| 18075 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 18076 |
-
"severity": "critical",
|
| 18077 |
-
"line": 25,
|
| 18078 |
-
"evidence": "@api.onchange '_onchange_service_product_uom_qty' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 18079 |
-
"suppressed": false,
|
| 18080 |
-
"advisory": false,
|
| 18081 |
-
"likelihood": "unmeasured",
|
| 18082 |
-
"path": "addons/sale_purchase/models/sale_order_line.py"
|
| 18083 |
-
},
|
| 18084 |
{
|
| 18085 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 18086 |
"severity": "critical",
|
|
@@ -18761,16 +18731,6 @@
|
|
| 18761 |
"likelihood": "construct",
|
| 18762 |
"path": "addons/spreadsheet/models/spreadsheet_mixin.py"
|
| 18763 |
},
|
| 18764 |
-
{
|
| 18765 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 18766 |
-
"severity": "critical",
|
| 18767 |
-
"line": 464,
|
| 18768 |
-
"evidence": "@api.onchange '_onchange_tracking' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 18769 |
-
"suppressed": false,
|
| 18770 |
-
"advisory": false,
|
| 18771 |
-
"likelihood": "unmeasured",
|
| 18772 |
-
"path": "addons/stock/models/product.py"
|
| 18773 |
-
},
|
| 18774 |
{
|
| 18775 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 18776 |
"severity": "high",
|
|
@@ -18831,16 +18791,6 @@
|
|
| 18831 |
"likelihood": "defect",
|
| 18832 |
"path": "addons/stock/models/product.py"
|
| 18833 |
},
|
| 18834 |
-
{
|
| 18835 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 18836 |
-
"severity": "critical",
|
| 18837 |
-
"line": 82,
|
| 18838 |
-
"evidence": "@api.onchange '_onchange_sublocation' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 18839 |
-
"suppressed": false,
|
| 18840 |
-
"advisory": false,
|
| 18841 |
-
"likelihood": "unmeasured",
|
| 18842 |
-
"path": "addons/stock/models/product_strategy.py"
|
| 18843 |
-
},
|
| 18844 |
{
|
| 18845 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 18846 |
"severity": "critical",
|
|
@@ -19711,6 +19661,16 @@
|
|
| 19711 |
"likelihood": "construct",
|
| 19712 |
"path": "addons/stock/models/stock_picking.py"
|
| 19713 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19714 |
{
|
| 19715 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 19716 |
"severity": "high",
|
|
@@ -20361,16 +20321,6 @@
|
|
| 20361 |
"likelihood": "unmeasured",
|
| 20362 |
"path": "addons/stock_delivery/wizard/choose_delivery_package.py"
|
| 20363 |
},
|
| 20364 |
-
{
|
| 20365 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 20366 |
-
"severity": "critical",
|
| 20367 |
-
"line": 38,
|
| 20368 |
-
"evidence": "@api.onchange '_onchange_package_type_weight' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 20369 |
-
"suppressed": false,
|
| 20370 |
-
"advisory": false,
|
| 20371 |
-
"likelihood": "unmeasured",
|
| 20372 |
-
"path": "addons/stock_delivery/wizard/choose_delivery_package.py"
|
| 20373 |
-
},
|
| 20374 |
{
|
| 20375 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 20376 |
"severity": "critical",
|
|
@@ -20861,6 +20811,16 @@
|
|
| 20861 |
"likelihood": "defect",
|
| 20862 |
"path": "addons/transifex/models/transifex_code_translation.py"
|
| 20863 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20864 |
{
|
| 20865 |
"rule": "odoo.noupdate-on-user-editable-data",
|
| 20866 |
"severity": "medium",
|
|
@@ -21591,16 +21551,6 @@
|
|
| 21591 |
"likelihood": "unmeasured",
|
| 21592 |
"path": "addons/website_event_sale/controllers/main.py"
|
| 21593 |
},
|
| 21594 |
-
{
|
| 21595 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 21596 |
-
"severity": "critical",
|
| 21597 |
-
"line": 8,
|
| 21598 |
-
"evidence": "@api.onchange '_onchange_event_sale_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 21599 |
-
"suppressed": false,
|
| 21600 |
-
"advisory": false,
|
| 21601 |
-
"likelihood": "unmeasured",
|
| 21602 |
-
"path": "addons/website_event_sale/models/product_pricelist.py"
|
| 21603 |
-
},
|
| 21604 |
{
|
| 21605 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 21606 |
"severity": "critical",
|
|
@@ -22361,16 +22311,6 @@
|
|
| 22361 |
"likelihood": "defect",
|
| 22362 |
"path": "addons/website_sale/models/product_template.py"
|
| 22363 |
},
|
| 22364 |
-
{
|
| 22365 |
-
"rule": "odoo.onchange-is-not-validation",
|
| 22366 |
-
"severity": "critical",
|
| 22367 |
-
"line": 32,
|
| 22368 |
-
"evidence": "@api.onchange '_onchange_property_product_pricelist' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 22369 |
-
"suppressed": false,
|
| 22370 |
-
"advisory": false,
|
| 22371 |
-
"likelihood": "unmeasured",
|
| 22372 |
-
"path": "addons/website_sale/models/res_partner.py"
|
| 22373 |
-
},
|
| 22374 |
{
|
| 22375 |
"rule": "odoo.ondelete-is-a-decision",
|
| 22376 |
"severity": "high",
|
|
|
|
| 1 |
{
|
| 2 |
+
"generated_utc": "2026-09-27T15:09:46+00:00",
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"odoo"
|
|
|
|
| 631 |
"likelihood": "unmeasured",
|
| 632 |
"path": "addons/account/models/account_move.py"
|
| 633 |
},
|
| 634 |
+
{
|
| 635 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 636 |
+
"severity": "critical",
|
| 637 |
+
"line": 2384,
|
| 638 |
+
"evidence": "@api.onchange '_onchange_partner_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 639 |
+
"suppressed": false,
|
| 640 |
+
"advisory": false,
|
| 641 |
+
"likelihood": "unmeasured",
|
| 642 |
+
"path": "addons/account/models/account_move.py"
|
| 643 |
+
},
|
| 644 |
{
|
| 645 |
"rule": "odoo.constrains-must-list-every-field-read",
|
| 646 |
"severity": "critical",
|
|
|
|
| 3751 |
"likelihood": "unproven",
|
| 3752 |
"path": "addons/delivery/models/sale_order.py"
|
| 3753 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3754 |
{
|
| 3755 |
"rule": "odoo.constrains-iterate-recordset",
|
| 3756 |
"severity": "high",
|
|
|
|
| 5211 |
"likelihood": "construct",
|
| 5212 |
"path": "addons/hr_expense/models/hr_expense.py"
|
| 5213 |
},
|
| 5214 |
+
{
|
| 5215 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 5216 |
+
"severity": "critical",
|
| 5217 |
+
"line": 324,
|
| 5218 |
+
"evidence": "@api.onchange '_inverse_total_amount_currency' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 5219 |
+
"suppressed": false,
|
| 5220 |
+
"advisory": false,
|
| 5221 |
+
"likelihood": "unmeasured",
|
| 5222 |
+
"path": "addons/hr_expense/models/hr_expense.py"
|
| 5223 |
+
},
|
| 5224 |
{
|
| 5225 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 5226 |
"severity": "high",
|
|
|
|
| 7111 |
"likelihood": "unmeasured",
|
| 7112 |
"path": "addons/l10n_account_withholding_tax/wizards/account_payment_register_withholding_line.py"
|
| 7113 |
},
|
| 7114 |
+
{
|
| 7115 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 7116 |
+
"severity": "critical",
|
| 7117 |
+
"line": 172,
|
| 7118 |
+
"evidence": "@api.onchange '_onchange_partner_journal' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 7119 |
+
"suppressed": false,
|
| 7120 |
+
"advisory": false,
|
| 7121 |
+
"likelihood": "unmeasured",
|
| 7122 |
+
"path": "addons/l10n_ar/models/account_move.py"
|
| 7123 |
+
},
|
| 7124 |
{
|
| 7125 |
"rule": "odoo.noupdate-on-user-editable-data",
|
| 7126 |
"severity": "medium",
|
|
|
|
| 7331 |
"likelihood": "construct",
|
| 7332 |
"path": "addons/l10n_dk_nemhandel_response/models/account_move.py"
|
| 7333 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7334 |
{
|
| 7335 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 7336 |
"severity": "critical",
|
|
|
|
| 8261 |
"likelihood": "construct",
|
| 8262 |
"path": "addons/l10n_in/models/account_invoice.py"
|
| 8263 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8264 |
{
|
| 8265 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 8266 |
"severity": "critical",
|
|
|
|
| 9781 |
"likelihood": "construct",
|
| 9782 |
"path": "addons/l10n_se/models/account_move.py"
|
| 9783 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9784 |
{
|
| 9785 |
"rule": "python.bare-except-catches-the-exit",
|
| 9786 |
"severity": "high",
|
|
|
|
| 10081 |
"likelihood": "defect",
|
| 10082 |
"path": "addons/loyalty/controllers/portal.py"
|
| 10083 |
},
|
| 10084 |
+
{
|
| 10085 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 10086 |
+
"severity": "critical",
|
| 10087 |
+
"line": 65,
|
| 10088 |
+
"evidence": "@api.onchange '_restrict_expiration_on_loyalty' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 10089 |
+
"suppressed": false,
|
| 10090 |
+
"advisory": false,
|
| 10091 |
+
"likelihood": "unmeasured",
|
| 10092 |
+
"path": "addons/loyalty/models/loyalty_card.py"
|
| 10093 |
+
},
|
| 10094 |
{
|
| 10095 |
"rule": "odoo.ondelete-is-a-decision",
|
| 10096 |
"severity": "high",
|
|
|
|
| 12061 |
"likelihood": "unmeasured",
|
| 12062 |
"path": "addons/mrp/models/mrp_production.py"
|
| 12063 |
},
|
| 12064 |
+
{
|
| 12065 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 12066 |
+
"severity": "critical",
|
| 12067 |
+
"line": 915,
|
| 12068 |
+
"evidence": "@api.onchange '_onchange_product_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 12069 |
+
"suppressed": false,
|
| 12070 |
+
"advisory": false,
|
| 12071 |
+
"likelihood": "unmeasured",
|
| 12072 |
+
"path": "addons/mrp/models/mrp_production.py"
|
| 12073 |
+
},
|
| 12074 |
{
|
| 12075 |
"rule": "odoo.constrains-must-list-every-field-read",
|
| 12076 |
"severity": "critical",
|
|
|
|
| 12341 |
"likelihood": "construct",
|
| 12342 |
"path": "addons/mrp/models/mrp_workorder.py"
|
| 12343 |
},
|
| 12344 |
+
{
|
| 12345 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 12346 |
+
"severity": "critical",
|
| 12347 |
+
"line": 446,
|
| 12348 |
+
"evidence": "@api.onchange '_onchange_date_finished' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 12349 |
+
"suppressed": false,
|
| 12350 |
+
"advisory": false,
|
| 12351 |
+
"likelihood": "unmeasured",
|
| 12352 |
+
"path": "addons/mrp/models/mrp_workorder.py"
|
| 12353 |
+
},
|
| 12354 |
{
|
| 12355 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 12356 |
"severity": "high",
|
|
|
|
| 12601 |
"likelihood": "construct",
|
| 12602 |
"path": "addons/mrp/models/stock_scrap.py"
|
| 12603 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12604 |
{
|
| 12605 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 12606 |
"severity": "critical",
|
|
|
|
| 12801 |
"likelihood": "defect",
|
| 12802 |
"path": "addons/mrp_subcontracting/models/stock_warehouse.py"
|
| 12803 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12804 |
{
|
| 12805 |
"rule": "python.assert-is-not-validation",
|
| 12806 |
"severity": "critical",
|
|
|
|
| 12831 |
"likelihood": "unproven",
|
| 12832 |
"path": "addons/payment/data/payment_cron.xml"
|
| 12833 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12834 |
{
|
| 12835 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 12836 |
"severity": "critical",
|
|
|
|
| 12841 |
"likelihood": "construct",
|
| 12842 |
"path": "addons/payment/models/payment_provider.py"
|
| 12843 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12844 |
{
|
| 12845 |
"rule": "odoo.onchange-is-not-validation",
|
| 12846 |
"severity": "critical",
|
|
|
|
| 14381 |
"likelihood": "construct",
|
| 14382 |
"path": "addons/product/models/product_pricelist_item.py"
|
| 14383 |
},
|
| 14384 |
+
{
|
| 14385 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 14386 |
+
"severity": "critical",
|
| 14387 |
+
"line": 422,
|
| 14388 |
+
"evidence": "@api.onchange '_onchange_price_round' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 14389 |
+
"suppressed": false,
|
| 14390 |
+
"advisory": false,
|
| 14391 |
+
"likelihood": "unmeasured",
|
| 14392 |
+
"path": "addons/product/models/product_pricelist_item.py"
|
| 14393 |
+
},
|
| 14394 |
{
|
| 14395 |
"rule": "odoo.sql-needs-query-parameters",
|
| 14396 |
"severity": "critical",
|
|
|
|
| 14461 |
"likelihood": "construct",
|
| 14462 |
"path": "addons/product/models/product_template_attribute_value.py"
|
| 14463 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14464 |
{
|
| 14465 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 14466 |
"severity": "critical",
|
|
|
|
| 14491 |
"likelihood": "unmeasured",
|
| 14492 |
"path": "addons/product/models/res_partner.py"
|
| 14493 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14494 |
{
|
| 14495 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 14496 |
"severity": "critical",
|
|
|
|
| 15171 |
"likelihood": "construct",
|
| 15172 |
"path": "addons/purchase/models/purchase_order.py"
|
| 15173 |
},
|
| 15174 |
+
{
|
| 15175 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 15176 |
+
"severity": "critical",
|
| 15177 |
+
"line": 365,
|
| 15178 |
+
"evidence": "@api.onchange 'onchange_partner_id_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 15179 |
+
"suppressed": false,
|
| 15180 |
+
"advisory": false,
|
| 15181 |
+
"likelihood": "unmeasured",
|
| 15182 |
+
"path": "addons/purchase/models/purchase_order.py"
|
| 15183 |
+
},
|
| 15184 |
{
|
| 15185 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 15186 |
"severity": "critical",
|
|
|
|
| 16481 |
"likelihood": "construct",
|
| 16482 |
"path": "addons/sale/models/sale_order.py"
|
| 16483 |
},
|
| 16484 |
+
{
|
| 16485 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 16486 |
+
"severity": "critical",
|
| 16487 |
+
"line": 895,
|
| 16488 |
+
"evidence": "@api.onchange '_onchange_partner_id_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 16489 |
+
"suppressed": false,
|
| 16490 |
+
"advisory": false,
|
| 16491 |
+
"likelihood": "unmeasured",
|
| 16492 |
+
"path": "addons/sale/models/sale_order.py"
|
| 16493 |
+
},
|
| 16494 |
{
|
| 16495 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 16496 |
"severity": "high",
|
|
|
|
| 18051 |
"likelihood": "unproven",
|
| 18052 |
"path": "addons/sale_project_stock/models/stock_picking.py"
|
| 18053 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18054 |
{
|
| 18055 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 18056 |
"severity": "critical",
|
|
|
|
| 18731 |
"likelihood": "construct",
|
| 18732 |
"path": "addons/spreadsheet/models/spreadsheet_mixin.py"
|
| 18733 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18734 |
{
|
| 18735 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 18736 |
"severity": "high",
|
|
|
|
| 18791 |
"likelihood": "defect",
|
| 18792 |
"path": "addons/stock/models/product.py"
|
| 18793 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18794 |
{
|
| 18795 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 18796 |
"severity": "critical",
|
|
|
|
| 19661 |
"likelihood": "construct",
|
| 19662 |
"path": "addons/stock/models/stock_picking.py"
|
| 19663 |
},
|
| 19664 |
+
{
|
| 19665 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 19666 |
+
"severity": "critical",
|
| 19667 |
+
"line": 1033,
|
| 19668 |
+
"evidence": "@api.onchange '_onchange_picking_type' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 19669 |
+
"suppressed": false,
|
| 19670 |
+
"advisory": false,
|
| 19671 |
+
"likelihood": "unmeasured",
|
| 19672 |
+
"path": "addons/stock/models/stock_picking.py"
|
| 19673 |
+
},
|
| 19674 |
{
|
| 19675 |
"rule": "odoo.float-comparison-needs-float-utils",
|
| 19676 |
"severity": "high",
|
|
|
|
| 20321 |
"likelihood": "unmeasured",
|
| 20322 |
"path": "addons/stock_delivery/wizard/choose_delivery_package.py"
|
| 20323 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20324 |
{
|
| 20325 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 20326 |
"severity": "critical",
|
|
|
|
| 20811 |
"likelihood": "defect",
|
| 20812 |
"path": "addons/transifex/models/transifex_code_translation.py"
|
| 20813 |
},
|
| 20814 |
+
{
|
| 20815 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 20816 |
+
"severity": "critical",
|
| 20817 |
+
"line": 20,
|
| 20818 |
+
"evidence": "@api.onchange '_onchange_uom_ids' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 20819 |
+
"suppressed": false,
|
| 20820 |
+
"advisory": false,
|
| 20821 |
+
"likelihood": "unmeasured",
|
| 20822 |
+
"path": "addons/uom/models/uom_uom.py"
|
| 20823 |
+
},
|
| 20824 |
{
|
| 20825 |
"rule": "odoo.noupdate-on-user-editable-data",
|
| 20826 |
"severity": "medium",
|
|
|
|
| 21551 |
"likelihood": "unmeasured",
|
| 21552 |
"path": "addons/website_event_sale/controllers/main.py"
|
| 21553 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21554 |
{
|
| 21555 |
"rule": "odoo.sudo-bypasses-record-rules",
|
| 21556 |
"severity": "critical",
|
|
|
|
| 22311 |
"likelihood": "defect",
|
| 22312 |
"path": "addons/website_sale/models/product_template.py"
|
| 22313 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22314 |
{
|
| 22315 |
"rule": "odoo.ondelete-is-a-decision",
|
| 22316 |
"severity": "high",
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_utc": "2026-09-
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"OpenSPP2-19.0"
|
|
@@ -841,6 +841,16 @@
|
|
| 841 |
"likelihood": "construct",
|
| 842 |
"path": "spp_area/models/area_core.py"
|
| 843 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
{
|
| 845 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 846 |
"severity": "critical",
|
|
@@ -1451,6 +1461,16 @@
|
|
| 1451 |
"likelihood": "unproven",
|
| 1452 |
"path": "spp_change_request_v2/models/change_request.py"
|
| 1453 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1454 |
{
|
| 1455 |
"rule": "odoo.prefer-sql-constraint",
|
| 1456 |
"severity": "high",
|
|
@@ -2601,6 +2621,16 @@
|
|
| 2601 |
"likelihood": "preference",
|
| 2602 |
"path": "spp_import_match/models/import_match.py"
|
| 2603 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2604 |
{
|
| 2605 |
"rule": "odoo.ondelete-is-a-decision",
|
| 2606 |
"severity": "high",
|
|
@@ -3101,6 +3131,16 @@
|
|
| 3101 |
"likelihood": "unmeasured",
|
| 3102 |
"path": "spp_registry/models/group.py"
|
| 3103 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3104 |
{
|
| 3105 |
"rule": "odoo.constrains-must-list-every-field-read",
|
| 3106 |
"severity": "critical",
|
|
|
|
| 1 |
{
|
| 2 |
+
"generated_utc": "2026-09-27T15:05:40+00:00",
|
| 3 |
"stack": "odoo",
|
| 4 |
"roots": [
|
| 5 |
"OpenSPP2-19.0"
|
|
|
|
| 841 |
"likelihood": "construct",
|
| 842 |
"path": "spp_area/models/area_core.py"
|
| 843 |
},
|
| 844 |
+
{
|
| 845 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 846 |
+
"severity": "critical",
|
| 847 |
+
"line": 88,
|
| 848 |
+
"evidence": "@api.onchange '_onchange_parent_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 849 |
+
"suppressed": false,
|
| 850 |
+
"advisory": false,
|
| 851 |
+
"likelihood": "unmeasured",
|
| 852 |
+
"path": "spp_area/models/area_core.py"
|
| 853 |
+
},
|
| 854 |
{
|
| 855 |
"rule": "odoo.stored-compute-needs-complete-depends",
|
| 856 |
"severity": "critical",
|
|
|
|
| 1461 |
"likelihood": "unproven",
|
| 1462 |
"path": "spp_change_request_v2/models/change_request.py"
|
| 1463 |
},
|
| 1464 |
+
{
|
| 1465 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 1466 |
+
"severity": "critical",
|
| 1467 |
+
"line": 206,
|
| 1468 |
+
"evidence": "@api.onchange '_onchange_available_document_ids' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 1469 |
+
"suppressed": false,
|
| 1470 |
+
"advisory": false,
|
| 1471 |
+
"likelihood": "unmeasured",
|
| 1472 |
+
"path": "spp_change_request_v2/models/change_request_type.py"
|
| 1473 |
+
},
|
| 1474 |
{
|
| 1475 |
"rule": "odoo.prefer-sql-constraint",
|
| 1476 |
"severity": "high",
|
|
|
|
| 2621 |
"likelihood": "preference",
|
| 2622 |
"path": "spp_import_match/models/import_match.py"
|
| 2623 |
},
|
| 2624 |
+
{
|
| 2625 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 2626 |
+
"severity": "critical",
|
| 2627 |
+
"line": 187,
|
| 2628 |
+
"evidence": "@api.onchange '_onchange_field_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 2629 |
+
"suppressed": false,
|
| 2630 |
+
"advisory": false,
|
| 2631 |
+
"likelihood": "unmeasured",
|
| 2632 |
+
"path": "spp_import_match/models/import_match.py"
|
| 2633 |
+
},
|
| 2634 |
{
|
| 2635 |
"rule": "odoo.ondelete-is-a-decision",
|
| 2636 |
"severity": "high",
|
|
|
|
| 3131 |
"likelihood": "unmeasured",
|
| 3132 |
"path": "spp_registry/models/group.py"
|
| 3133 |
},
|
| 3134 |
+
{
|
| 3135 |
+
"rule": "odoo.onchange-is-not-validation",
|
| 3136 |
+
"severity": "critical",
|
| 3137 |
+
"line": 71,
|
| 3138 |
+
"evidence": "@api.onchange '_membership_type_onchange' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
|
| 3139 |
+
"suppressed": false,
|
| 3140 |
+
"advisory": false,
|
| 3141 |
+
"likelihood": "unmeasured",
|
| 3142 |
+
"path": "spp_registry/models/group_membership.py"
|
| 3143 |
+
},
|
| 3144 |
{
|
| 3145 |
"rule": "odoo.constrains-must-list-every-field-read",
|
| 3146 |
"severity": "critical",
|
|
@@ -1033,6 +1033,64 @@ def test_a_dead_sql_constraint_does_not_back_an_onchange_on_19():
|
|
| 1033 |
{"odoo_series": "19.0"}) == []
|
| 1034 |
|
| 1035 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1036 |
# ------------------------------------------------ the value-origin trace
|
| 1037 |
#
|
| 1038 |
# Four rules were classified false for one reason: each read a variable's NAME
|
|
|
|
| 1033 |
{"odoo_series": "19.0"}) == []
|
| 1034 |
|
| 1035 |
|
| 1036 |
+
CREDIT_ONCHANGE = ('class M(models.Model):\n'
|
| 1037 |
+
'{constraint}'
|
| 1038 |
+
' @api.onchange("credit_limit")\n'
|
| 1039 |
+
' def _onchange_credit_limit(self):\n'
|
| 1040 |
+
' if self.credit_limit < 0:\n'
|
| 1041 |
+
' raise ValidationError("no")\n')
|
| 1042 |
+
|
| 1043 |
+
|
| 1044 |
+
@pytest.mark.parametrize("constraint", [
|
| 1045 |
+
' _ref = models.Constraint("UNIQUE(reference)", "no")\n',
|
| 1046 |
+
' _sql_constraints = [("ref", "UNIQUE(reference)", "no")]\n',
|
| 1047 |
+
' @api.constrains("reference")\n def _check(self):\n pass\n',
|
| 1048 |
+
' @api.constrains("partner_id.credit_limit")\n def _check(self):\n pass\n',
|
| 1049 |
+
])
|
| 1050 |
+
def test_a_constraint_on_another_field_does_not_back_an_onchange(constraint):
|
| 1051 |
+
"""A uniqueness rule on `reference` used to silence an onchange raising
|
| 1052 |
+
on `credit_limit`. A dotted @api.constrains name registers nothing."""
|
| 1053 |
+
code = CREDIT_ONCHANGE.format(constraint=constraint)
|
| 1054 |
+
assert semantics.run_check("onchange_tries_to_enforce", code), constraint
|
| 1055 |
+
|
| 1056 |
+
|
| 1057 |
+
@pytest.mark.parametrize("constraint", [
|
| 1058 |
+
' _c = models.Constraint("CHECK(credit_limit >= 0)", "no")\n',
|
| 1059 |
+
' @api.constrains("credit_limit")\n def _check(self):\n pass\n',
|
| 1060 |
+
' @api.constrains(*FIELDS)\n def _check(self):\n pass\n',
|
| 1061 |
+
' _sql_constraints = BASE + [("c", "CHECK(credit_limit >= 0)", "no")]\n',
|
| 1062 |
+
])
|
| 1063 |
+
def test_a_constraint_on_a_watched_field_or_an_unreadable_one_backs_it(constraint):
|
| 1064 |
+
code = CREDIT_ONCHANGE.format(constraint=constraint)
|
| 1065 |
+
assert semantics.run_check("onchange_tries_to_enforce", code) == [], constraint
|
| 1066 |
+
|
| 1067 |
+
|
| 1068 |
+
WARNING_ONCHANGE = ('class M(models.Model):\n'
|
| 1069 |
+
' @api.onchange("qty")\n'
|
| 1070 |
+
' def _onchange_qty(self):\n'
|
| 1071 |
+
' self.hint = False\n'
|
| 1072 |
+
' if self.qty < 0:\n'
|
| 1073 |
+
'{branch}'
|
| 1074 |
+
' return {{"warning": {{"title": "x", "message": "y"}}}}\n')
|
| 1075 |
+
|
| 1076 |
+
|
| 1077 |
+
@pytest.mark.parametrize("branch,refuses", [
|
| 1078 |
+
("", False), # a notice
|
| 1079 |
+
(" self.qty = 0\n", True), # the watched field back
|
| 1080 |
+
(" self.update({'qty': 0})\n", True),
|
| 1081 |
+
(" self.partner_id = False\n", True), # a field cleared
|
| 1082 |
+
(" self.location_id = self.other\n", False), # a suggestion
|
| 1083 |
+
])
|
| 1084 |
+
def test_a_warning_refuses_only_when_its_branch_takes_the_value_back(branch, refuses):
|
| 1085 |
+
"""Most warning dicts on core inform: "this reference already exists",
|
| 1086 |
+
"the rate is far from the previous one". The user saves past them in the
|
| 1087 |
+
form as in an import, so nothing is enforced anywhere. `self.hint = False`
|
| 1088 |
+
outside the branch does not count: it is a UI flag, not the refusal."""
|
| 1089 |
+
code = WARNING_ONCHANGE.format(branch=branch)
|
| 1090 |
+
found = semantics.run_check("onchange_tries_to_enforce", code)
|
| 1091 |
+
assert bool(found) is refuses, (branch, found)
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
# ------------------------------------------------ the value-origin trace
|
| 1095 |
#
|
| 1096 |
# Four rules were classified false for one reason: each read a variable's NAME
|