Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 127 additions & 12 deletions message_center_compassion/models/field_to_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
##############################################################################
import logging

from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools.safe_eval import safe_eval, wrap_module

_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -67,6 +68,35 @@ class FieldToJson(models.Model):
"record. If not specified, it will assume a single value is given and "
"will search according the relational_field set."
)
relational_domain_restrict = fields.Char(
help="Optional domain (Odoo domain syntax) added to the search "
"performed for an existing relational record. Use it to disambiguate "
"between several records that could otherwise match the same "
"search value."
)
relational_comodel_name = fields.Char(
related="relational_field_id.relation",
help="Technical field holding the model name of the relational field, "
"used to know which model relational_domain_restrict applies to.",
)
relational_ttype = fields.Selection(
related="relational_field_id.ttype",
help="Technical field holding the type (many2one, many2many, ...) "
"of the relational field, used to show Many2one-specific options "
"in the view.",
)
many2one_multiple_match_policy = fields.Selection(
[
("first_match", "Take First Match"),
("raise", "Raise an Error"),
],
default="first_match",
help="When the search for an existing record on a Many2one field "
"returns several matches, decide whether to silently take the "
"first one found (legacy behavior) or raise an error so the "
"ambiguity can be resolved manually, e.g. with a domain "
"restriction.",
)
allow_relational_creation = fields.Boolean(
help="If set to true, new records will be created if no matching "
"records are found with the given JSON values",
Expand Down Expand Up @@ -236,18 +266,38 @@ def _search_for_relational_values(self, value, field, relational_model, orm_vals
# In that case we receive several values for the relation record
# and use one value in particular to find a matching record.
search_val = val.get(search_field)
records = (
relational_model.search(
[
"|",
(search_field, "=", search_val),
(search_field, "=ilike", str(search_val)),
]
)
if search_val
else relational_model
)
if search_val:
domain = [
"|",
(search_field, "=", search_val),
(search_field, "=ilike", str(search_val)),
]
if self.relational_domain_restrict:
domain = expression.AND(
[domain, safe_eval(self.relational_domain_restrict)]
)
records = relational_model.search(domain)
else:
records = relational_model
if self.relational_field_id.ttype == "many2one":
if len(records) > 1 and self.many2one_multiple_match_policy == "raise":
raise UserError(
_(
"Found %(count)s records matching value %(value)r "
"for field %(field)s (mapping %(mapping)s), but "
"expected at most one. Restrict the search with a "
"domain or allow taking the first match on the "
"field mapping configuration. Matching records: "
"%(records)s"
)
% {
"count": len(records),
"value": search_val,
"field": self.odoo_field,
"mapping": self.mapping_id.name,
"records": records,
}
)
record = records[:1] # Only take one relation
if not record and self.allow_relational_creation:
to_create.append(val)
Expand Down Expand Up @@ -364,3 +414,68 @@ def _get_relational_creation_values(self, field_values):
}
)
return [(0, 0, values) for values in record_values]

def action_check_duplicate_matches(self):
"""
Diagnostic action for the "Check for duplicates" button: scans the
actual current data of the relational model targeted by this field
mapping, and reports which search values currently match more than
one record - i.e. the exact ambiguity a Many2one lookup could hit
when converting an incoming GMC message (see T3276).

This lets a non-technical user self-serve the same check a
developer would otherwise have to run manually against the
database.
"""
self.ensure_one()
if (
self.relational_field_id.ttype != "many2one"
or not self.search_relational_record
):
raise UserError(
_(
"This check only applies to a Many2one field configured "
"with 'Search Relational Record' enabled."
)
)
target_model = self.relational_comodel_name
search_field = self.search_key or self.field_id.name
if not target_model or not search_field:
raise UserError(_("Cannot determine the target model/field to check."))

duplicate_groups = self.env[target_model]._read_group(
domain=[(search_field, "!=", False)],
groupby=[search_field],
aggregates=["__count"],
having=[("__count", ">", 1)],
)
Comment on lines +446 to +451

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Duplicate check does not match runtime lookup

The duplicate-check action groups exact target values and only filters out falsy values. Runtime resolution also uses a case-insensitive =ilike fallback and applies relational_domain_restrict. As a result, values such as ABC and abc are not shown as duplicates even though one runtime lookup matches both records, while an exact duplicate that the configured restriction narrows to one record is still reported. Apply the same effective domain and case-insensitive matching semantics in the diagnostic so its review results reflect the records conversion can actually resolve.

Artifacts

Focused executable source-pinned check for the diagnostic and runtime lookup mismatch

  • The authored minimal Python check asserts the reviewed source contains both query shapes and executes case-only and domain-restriction reproductions, confirming the mismatch.

Output from the focused PR 2128 diagnostic and runtime check

  • Running the focused check exited 0 and shows the diagnostic disagrees with runtime for both case-only values and a domain-excluded duplicate, confirming the defect.

View artifacts

T-Rex Ran code and verified through T-Rex

if not duplicate_groups:
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("No ambiguity found"),
"message": _(
"No duplicate values currently exist on %(model)s.%(field)s. "
"This lookup is safe for now, but new data could still "
"introduce a duplicate later."
)
% {"model": target_model, "field": search_field},
"type": "success",
"sticky": False,
},
}
raw_values = [group[0] for group in duplicate_groups]
if raw_values and isinstance(raw_values[0], models.BaseModel):
domain_values = [value.id for value in raw_values]
else:
domain_values = raw_values
return {
"type": "ir.actions.act_window",
"name": _("%(count)s value(s) of %(field)s match several records")
% {"count": len(domain_values), "field": search_field},
"res_model": target_model,
"view_mode": "list,form",
"domain": [(search_field, "in", domain_values)],
"context": {"group_by": [search_field]},
}
61 changes: 61 additions & 0 deletions message_center_compassion/views/compassion_mapping_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,32 @@
<field name="json_name" />
<field name="odoo_field" />
<field name="relational_field" invisible="1" />
<field name="relational_comodel_name" invisible="1" />
<field name="relational_ttype" invisible="1" />
<field
name="search_relational_record"
invisible="not relational_field and not sub_mapping_id"
/>
<field
name="search_key"
invisible="not search_relational_record and not sub_mapping_id"
/>
<field
name="relational_domain_restrict"
widget="domain"
options="{'model': 'relational_comodel_name'}"
invisible="not search_relational_record and not sub_mapping_id"
/>
<field
name="many2one_multiple_match_policy"
invisible="relational_ttype != 'many2one' or (not search_relational_record and not sub_mapping_id)"
/>
<button
name="action_check_duplicate_matches"
type="object"
string="Check for duplicates"
class="btn-secondary"
invisible="relational_ttype != 'many2one' or not search_relational_record"
/>
<field
name="allow_null"
Expand Down Expand Up @@ -72,9 +91,51 @@
<field name="json_name" />
<field name="odoo_field" />
<field name="sub_mapping_id" />
<field name="relational_domain_restrict" optional="hide" />
<field name="many2one_multiple_match_policy" optional="hide" />
</list>
</field>
</record>
<record id="view_field_to_json_many2one_review_list" model="ir.ui.view">
<field name="name">field.to.json.many2one.review.list</field>
<field name="model">compassion.field.to.json</field>
<field name="arch" type="xml">
<list>
<field name="mapping_id" />
<field name="model" string="Source Model" />
<field name="json_name" />
<field name="odoo_field" />
<field name="relational_comodel_name" string="Target Model" />
<field name="relational_domain_restrict" />
<field name="many2one_multiple_match_policy" />
<button
name="action_check_duplicate_matches"
type="object"
string="Check for duplicates"
class="btn-secondary"
/>
</list>
</field>
</record>

<record id="action_field_to_json_many2one_review" model="ir.actions.act_window">
<field name="name">Many2one Fields to Review</field>
<field name="res_model">compassion.field.to.json</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_field_to_json_many2one_review_list" />
<field
name="domain"
>[('search_relational_record', '=', True), ('relational_ttype', '=', 'many2one')]</field>
</record>

<menuitem
id="menu_field_to_json_many2one_review"
parent="menu_message_config"
name="Many2one Fields to Review"
action="action_field_to_json_many2one_review"
sequence="40"
/>

<record id="view_compassion_mapping_tree" model="ir.ui.view">
<field name="name">compassion.mapping.list</field>
<field name="model">compassion.mapping</field>
Expand Down
1 change: 0 additions & 1 deletion sponsorship_compassion/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ access_recurring_contract,Full access on recurring.contract,recurring_contract.m
access_recurring_contract_line,Full access on recurring.contract.line,recurring_contract.model_recurring_contract_line,child_compassion.group_sponsorship,1,1,1,1
access_recurring_contract_group,Full access on recurring.contract.group,recurring_contract.model_recurring_contract_group,child_compassion.group_sponsorship,1,1,1,1
access_recurring_contract_origin,Full access on recurring.contract.origin,model_recurring_contract_origin,child_compassion.group_sponsorship,1,1,1,1
access_recurring_invoicer,Full access on recurring.invoicer,recurring_contract.model_recurring_invoicer,child_compassion.group_sponsorship,1,1,1,1
access_account_move,Full access on account.move,account.model_account_move,child_compassion.group_sponsorship,1,1,1,1
access_account_move_line,Full access on account.move.line,account.model_account_move_line,child_compassion.group_sponsorship,1,1,1,1
access_account_journal,Read access on account.journal,account.model_account_journal,child_compassion.group_sponsorship,1,0,0,0
Expand Down
7 changes: 0 additions & 7 deletions sponsorship_compassion/views/sponsorship_contract_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -358,13 +358,6 @@
sequence="12"
action="recurring_contract.action_invoice_automatic_generation"
/>
<menuitem
id="menu_recurring_invoicer_form"
name="Generated invoices"
parent="account.menu_finance_receivables"
sequence="13"
action="recurring_contract.action_recurring_invoicer_form"
/>

<!-- Move the Sponsorships Menu to the Sponsorship Section -->
<menuitem
Expand Down
Loading