Skip to content
Closed
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
1 change: 1 addition & 0 deletions addons/mail/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
'demo/discuss_channel_demo.xml',
'demo/discuss/public_channel_demo.xml',
"demo/discuss/readonly_channel_demo.xml",
'demo/discuss/call_debrief_discuss_demo.xml',
"demo/mail_poll_demo.xml",
"demo/mail_canned_response_demo.xml",
],
Expand Down
95 changes: 95 additions & 0 deletions addons/mail/demo/discuss/call_debrief_discuss_demo.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<odoo>
<data noupdate="1">
<record id="mail.demo_call_channel" model="discuss.channel">
<field name="name">Demo Call Channel</field>
<field name="channel_type">channel</field>
</record>

<record id="mail.demo_call_channel_member" model="discuss.channel.member">
<field name="partner_id" ref="base.partner_admin"/>
<field name="channel_id" ref="mail.demo_call_channel"/>
</record>

<!-- Audio Call Data -->
<record id="mail.demo_call_history_audio" model="discuss.call.history">
<field name="channel_id" ref="mail.demo_call_channel"/>
<field name="start_dt" eval="(datetime.now() - relativedelta(hours=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="end_dt" eval="(datetime.now() - relativedelta(hours=2, minutes=-1)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>

<record id="mail.demo_mail_call_artifact_audio" model="mail.call.artifact">
<field name="discuss_call_history_id" ref="mail.demo_call_history_audio"/>
<field name="start_ms">0</field>
<field name="end_ms">60000</field>
</record>

<record id="mail.demo_call_attachment_audio" model="ir.attachment">
<field name="name">demo_audio_call.webm</field>
<field name="mimetype">audio/webm</field>
<field name="raw" type="bytes" file="mail/demo/discuss/fixtures/audio_opus_60s_long_3s_content.webm"/>
<field name="description">Demo Audio Recording</field>
<field name="res_model">mail.call.artifact</field>
<field name="res_id" ref="mail.demo_mail_call_artifact_audio"/>
</record>

<!-- Video Call Data -->
<record id="mail.demo_call_history_video" model="discuss.call.history">
<field name="channel_id" ref="mail.demo_call_channel"/>
<field name="start_dt" eval="(datetime.now() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="end_dt" eval="(datetime.now() - relativedelta(days=1, minutes=-1)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>

<record id="mail.demo_mail_call_artifact_video" model="mail.call.artifact">
<field name="discuss_call_history_id" ref="mail.demo_call_history_video"/>
<field name="start_ms">0</field>
<field name="end_ms">60000</field>
</record>

<record id="mail.demo_call_attachment_video" model="ir.attachment">
<field name="name">demo_video_call.webm</field>
<field name="mimetype">video/webm</field>
<field name="raw" type="bytes" file="mail/demo/discuss/fixtures/video_60s_long_3s_content_av1_opus.webm"/>
<field name="description">Demo Video Recording</field>
<field name="res_model">mail.call.artifact</field>
<field name="res_id" ref="mail.demo_mail_call_artifact_video"/>
</record>

<record id="mail.demo_call_history_no_media" model="discuss.call.history">
<field name="channel_id" ref="mail.demo_call_channel"/>
<field name="start_dt" eval="(datetime.now() - relativedelta(hours=5)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="end_dt" eval="(datetime.now() - relativedelta(hours=5, minutes=-2)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<!-- Call with disjoined audios -->
<record id="mail.call_starting_with_audio_then_silence_then_audio" model="discuss.call.history">
<field name="channel_id" ref="mail.demo_call_channel"/>
<field name="start_dt" eval="(datetime.now() - relativedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="end_dt" eval="(datetime.now() - relativedelta(hours=3, seconds=-123)).strftime('%Y-%m-%d %H:%M:%S')"/>
</record>
<record id="mail.artifact_disjoined_s1" model="mail.call.artifact">
<field name="discuss_call_history_id" ref="mail.call_starting_with_audio_then_silence_then_audio"/>
<field name="start_ms">0</field>
<field name="end_ms">20000</field>
</record>
<record id="mail.attachment_disjoined_s1" model="ir.attachment">
<field name="name">audio_physics_0s_20s_16k.mp3</field>
<field name="mimetype">audio/mpeg</field>
<field name="raw" type="bytes" file="mail/demo/discuss/fixtures/audio_physics_0s_20s_16k.mp3"/>
<field name="description">Demo Audio S1 (0-20s)</field>
<field name="res_model">mail.call.artifact</field>
<field name="res_id" ref="mail.artifact_disjoined_s1"/>
</record>
<record id="mail.artifact_disjoined_s3" model="mail.call.artifact">
<field name="discuss_call_history_id" ref="mail.call_starting_with_audio_then_silence_then_audio"/>
<field name="start_ms">40000</field>
<field name="end_ms">60000</field>
</record>
<record id="mail.attachment_disjoined_s3" model="ir.attachment">
<field name="name">audio_physics_40s_60s_16k.mp3</field>
<field name="mimetype">audio/mpeg</field>
<field name="raw" type="bytes" file="mail/demo/discuss/fixtures/audio_physics_40s_60s_16k.mp3"/>
<field name="description">Demo Audio S3 (40-60s)</field>
<field name="res_model">mail.call.artifact</field>
<field name="res_id" ref="mail.artifact_disjoined_s3"/>
</record>
</data>
</odoo>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions addons/mail/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
from . import update

# after mail specifically as discuss module depends on mail
from . import mail_call_artifact
from . import discuss

# discuss_channel_member must be loaded first
Expand Down
5 changes: 5 additions & 0 deletions addons/mail/models/discuss/discuss_call_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class DiscussCallHistory(models.Model):
_explanation = "Stores the history of internal discuss calls (audio/video), tracking the start time, end time, duration, and the associated channel."

channel_id = fields.Many2one("discuss.channel", index=True, required=True, ondelete="cascade")
artifact_ids = fields.One2many("mail.call.artifact", "discuss_call_history_id", string="Artifacts")
duration_hour = fields.Float(compute="_compute_duration_hour")
start_dt = fields.Datetime(index=True, required=True)
end_dt = fields.Datetime()
Expand All @@ -26,6 +27,10 @@ class DiscussCallHistory(models.Model):
)
_channel_id_end_dt_idx = models.Index("(channel_id, end_dt) WHERE end_dt IS NULL")

@api.ondelete(at_uninstall=False)
def _unlink_cleanup_artifacts_attachments(self):
self.artifact_ids.unlink()

@api.depends("start_dt", "end_dt")
def _compute_duration_hour(self):
for record in self:
Expand Down
5 changes: 5 additions & 0 deletions addons/mail/models/ir_attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
class IrAttachment(models.Model):
_inherit = 'ir.attachment'

_mail_call_artifact_uniq = models.UniqueIndex(
"(res_id) WHERE res_model = 'mail.call.artifact'",
message="Only one attachment per call artifact is allowed.",
)

thumbnail = fields.Image()
has_thumbnail = fields.Boolean(compute="_compute_has_thumbnail")

Expand Down
100 changes: 100 additions & 0 deletions addons/mail/models/mail_call_artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError


class MailCallArtifact(models.Model):
"""Represent a discrete product of a call (audio, transcript etc.)

For media artifacts, each record acts as a thin metadata wrapper (timing, source, etc.) for
exactly one media file, keeping the ir_attachment table lean"""

_name = "mail.call.artifact"
_description = "Call Artifact"
_order = "start_ms, id"

# required=False as artifact can also owned by other call models (ensured by constraints)
discuss_call_history_id = fields.Many2one(
"discuss.call.history",
string="Discuss Call History",
ondelete="cascade",
required=False,
index=True,
)
media_id = fields.Many2one(
"ir.attachment",
string="Media Attachment",
compute="_compute_media_id",
)
start_ms = fields.Integer(
string="Start (ms)",
default=0,
required=True,
index=True,
help="Offset from the start of the call in milliseconds",
)
end_ms = fields.Integer(
string="End (ms)",
default=0,
required=True,
help="Offset from the start of the call in milliseconds",
)

# ---------------------------------------------------------------------
# Constraints

_start_before_end = models.Constraint(
"CHECK(start_ms <= end_ms)", "End time must be after or equal to the start time.",
)
_artifact_has_possessor = models.Constraint(
"CHECK(num_nonnulls(discuss_call_history_id) = 1)", "Artifact must be linked to a call source.",
)

@api.constrains("start_ms", "end_ms", "discuss_call_history_id")
def _constrains_artifacts_overlap(self):
"""Check that artifacts within the same call do not overlap."""
grouped_artifacts = self._get_artifacts_grouped_by_call()
for artifacts in grouped_artifacts.values():
self._check_artifacts_overlap(artifacts)

def _get_artifacts_grouped_by_call(self):
"""Return a dict mapping call records to their respective artifact recordsets.

This hook allows other modules to include artifacts linked to different
call models (e.g., voip.call) in the overlap validation"""
all_artifacts = self.discuss_call_history_id.artifact_ids
return all_artifacts.grouped('discuss_call_history_id')

def _is_overlap_candidate(self):
"""Determine if self should be checked for overlap"""
return True

def _check_artifacts_overlap(self, artifacts):
"""Check if the provided artifacts overlap in time"""
candidates = artifacts.filtered(lambda a: a._is_overlap_candidate()).sorted('start_ms')
for i in range(len(candidates) - 1):
if candidates[i].end_ms > candidates[i + 1].start_ms:
raise ValidationError(self.env._("Media artifacts overlap."))

# ---------------------------------------------------------------------
# Computes

def _compute_media_id(self):
attachments = self.env["ir.attachment"].search([
("res_model", "=", self._name),
("res_id", "in", self.ids),
])
attachment_by_res_id = attachments.grouped('res_id')
for artifact in self:
artifact.media_id = attachment_by_res_id.get(artifact.id)

# ---------------------------------------------------------------------
# Methods

def _get_related_call(self):
"""Return the parent call record (discuss.call.history, voip.call, etc.)"""
self.ensure_one()
return self.discuss_call_history_id

@api.ondelete(at_uninstall=False)
def _unlink_cleanup_media_attachment(self):
self.media_id.sudo().unlink()
2 changes: 2 additions & 0 deletions addons/mail/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,5 @@ access_mail_scheduled_message,access.mail.scheduled.message,model_mail_scheduled
access_mail_poll_erp_manager,access.mail.poll.erp_manager,model_mail_poll,base.group_erp_manager,1,1,1,1
access_mail_poll_option_erp_manager,access.mail.poll.option.erp_manager,model_mail_poll_option,base.group_erp_manager,1,1,1,1
access_mail_poll_vote_erp_manager,access.mail.poll.vote.erp_manager,model_mail_poll_vote,base.group_erp_manager,1,1,1,1
access_mail_call_artifact_user,call.artifact.user,model_mail_call_artifact,base.group_user,1,0,0,0
access_mail_call_artifact_admin,call.artifact.admin,model_mail_call_artifact,base.group_erp_manager,1,1,1,1
18 changes: 18 additions & 0 deletions addons/mail/security/mail_security.xml
Original file line number Diff line number Diff line change
Expand Up @@ -462,4 +462,22 @@
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>

<record id="mail_call_artifact_rule_user" model="ir.rule">
<field name="name">Call Artifact: User Access</field>
<field name="model_id" ref="model_mail_call_artifact"/>
<field name="groups" eval="[Command.link(ref('base.group_user'))]"/>
<field name="domain_force">[('discuss_call_history_id', 'access', 'read')]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>

<record id="mail_call_artifact_rule_admin" model="ir.rule">
<field name="name">Call Artifact: Admin Full Access</field>
<field name="model_id" ref="model_mail_call_artifact"/>
<field name="groups" eval="[Command.link(ref('base.group_erp_manager'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
</odoo>
Loading