Skip to content
Merged
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
7 changes: 6 additions & 1 deletion eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ const config = [{
},

}, {
files: ["**/*.esm.js"],
files: ["**/*.js"],

languageOptions: {
ecmaVersion: 2024,
Expand All @@ -201,6 +201,11 @@ const config = [{
"setTimeout": "readonly",
"clearTimeout": "readonly",
"fetch": "readonly",
"location": "readonly",
"sessionStorage": "readonly",
"File": "readonly",
"DataTransfer": "readonly",
"Event": "readonly",
}
},
}];
Expand Down
3 changes: 3 additions & 0 deletions interaction_resume/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"interaction_resume/static/src/xml/**/*.xml",
"interaction_resume/static/src/js/**/*.js",
],
"web.assets_tests": [
"interaction_resume/static/tests/tours/interaction_resume.js",
],
},
"external_dependencies": {
"python": [],
Expand Down
8 changes: 8 additions & 0 deletions interaction_resume/models/abstract_interaction_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ def _get_interaction_data(self, partner_id):
for rec in self
]

def _interaction_discriminator(self, vals):
"""What tells apart the several resume entries built from one record.

Nothing for a source that builds a single entry per record: the
record it was built from already tells its entry from any other.
"""
return ()

def create(self, vals_list):
res = super().create(vals_list)
for partner in res.mapped("partner_id"):
Expand Down
1 change: 1 addition & 0 deletions interaction_resume/models/crm_phonecall.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def _get_interaction_data(self, partner_id):
"communication_type": "Phone",
"subject": rec.name,
"body": rec.description or rec.name,
"has_attachment": bool(rec.message_attachment_count),
"tracking_status": TRACKING_STATUS_MAPPING.get(rec.state),
"user_id": rec.user_id.id,
}
Expand Down
10 changes: 9 additions & 1 deletion interaction_resume/models/crm_request.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from datetime import timedelta

from odoo import api, models
from odoo import api, fields, models
from odoo.tools.mail import html2plaintext


Expand Down Expand Up @@ -66,6 +66,14 @@ def _get_interaction_data(self, partner_id):
)
return res

def _interaction_discriminator(self, vals):
# A claim yields one entry per message of its thread, plus one for
# the form it came from, all built from the claim itself.
return (
fields.Datetime.to_datetime(vals.get("date")) or False,
vals.get("subject") or False,
)
Comment on lines +72 to +75

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 Preserve each CRM message

A claim can contain separate thread messages with the same timestamp and subject. This discriminator uses only those values with the claim identity, so both messages resolve to the same identity and batch creation drops the second one. The refreshed interaction timeline therefore omits a real CRM message. Include an immutable per-message value, such as the source mail.message ID. This must be resolved before merging.

Artifacts

Evidence from the check

  • The authored executable loads the targeted source with a minimal Odoo stub and submits two distinct same-date, same-subject CRM claim message values to the real identity and batch-create logic, demonstrating the deduplication condition.

Command output from the check

  • The baseline command ran against origin/18.0 and shows that same-claim messages already collapsed without a discriminator, establishing the comparison scope.

Command output from the check

  • The HEAD command ran successfully and shows equal full identities, one created entry, and one omitted message, confirming the PR does not distinguish this collision.

Command output from the check

  • Python compiled the authored validation script successfully before execution, confirming the recorded reproduction used syntactically valid test code.

View artifacts

T-Rex Ran code and verified through T-Rex


def _get_interaction_partner_domain(self, partner):
if not partner.email:
return [("partner_id", "=", partner.id)]
Expand Down
89 changes: 57 additions & 32 deletions interaction_resume/models/interaction_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,46 +77,71 @@ def open_related_action(self):
}

def action_refresh(self):
partner = self.mapped("partner_id")[:1]
partner.fetch_interactions()
self.mapped("partner_id")[:1].refresh_interactions()
return True

def fetch_more(self):
partner = self.mapped("partner_id")[:1]
partner.fetch_interactions(page=partner.last_interaction_fetch_page + 1)
return True

def _identity_of(self, vals):
"""What tells one entry of a resume from another."""
source = self.env[vals["res_model"]]
return (
vals.get("partner_id") or False,
source._name,
vals.get("res_id") or False,
) + source._interaction_discriminator(vals)

def _identity(self):
self.ensure_one()
return self._identity_of(
{
"partner_id": self.partner_id.id,
"res_model": self.res_model,
"res_id": self.res_id,
"date": self.date,
"subject": self.subject,
}
)

def _update_from_source(self, vals):
self.ensure_one()
changed = {
field: value
for field, value in vals.items()
if self._fields[field].convert_to_write(self[field], self) != value
}
if changed:
self.write(changed)
return self

@api.model_create_multi
def create(self, vals_list):
# Avoid duplicates
res = self.env[self._name]
for vals in vals_list:
subject = vals.get("subject")
if not subject:
existing_interaction = self.search(
[
("partner_id", "=", vals.get("partner_id")),
("direction", "=", vals.get("direction")),
("date", "=", vals.get("date")),
("subject", "=", False),
],
limit=1,
)
if not existing_interaction:
res += super().create(vals)
else:
res += existing_interaction
continue
existing_interaction = self.search(
if not vals_list:
return self.browse()
partners = {vals.get("partner_id") for vals in vals_list}
res_models = {vals.get("res_model") for vals in vals_list}
listed = {
entry._identity(): entry
for entry in self.search(
[
("partner_id", "=", vals.get("partner_id")),
("direction", "=", vals.get("direction")),
("date", "=", vals.get("date")),
("subject", "=", subject),
],
limit=1,
("partner_id", "in", list(partners)),
("res_model", "in", list(res_models)),
]
)
if not existing_interaction:
existing_interaction = super().create(vals)
res += existing_interaction
return res
}
res = self.browse()
to_create = []
for vals in vals_list:
identity = self._identity_of(vals)
entry = listed.get(identity)
if entry:
res += entry._update_from_source(vals)
elif identity not in listed:
# Mark it as taken, so that a duplicate later in the same
# batch does not create a second entry for it.
listed[identity] = None
to_create.append(vals)
return res + super().create(to_create)
5 changes: 3 additions & 2 deletions interaction_resume/models/other_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@ def _get_interaction_data(self, partner_id):
"body": html2plaintext(rec.body).replace("\n\n", "\n"),
"subject": rec.subject,
"other_type": rec.other_type,
"has_attachment": bool(rec.message_attachment_count),
"user_id": rec.create_uid.id,
}
for rec in self
]

def write(self, vals):
res = super().write(vals)
# Refresh interaction resume
self.mapped("partner_id").reset_interactions()
if not self._transient:
self.mapped("partner_id").refresh_interactions()
return res
7 changes: 7 additions & 0 deletions interaction_resume/models/res_partner.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ def fetch_interactions(
self.last_interaction_fetch_page = page
return True

def refresh_interactions(self):
"""Fetch again the interactions of every page already loaded"""
for partner in self:
for page in range(partner.last_interaction_fetch_page + 1):
partner.fetch_interactions(page=page)
return True

def reset_interactions(self):
"""Reset the interaction resume for this partner"""
self.mapped("interaction_resume_ids").unlink()
Expand Down
Loading
Loading