diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py
index 9e295b98151..c6b310f3c67 100644
--- a/addons/mail/models/mail_activity.py
+++ b/addons/mail/models/mail_activity.py
@@ -4,14 +4,14 @@
import logging
import pytz
-from collections import defaultdict
+from collections import defaultdict, Counter
from datetime import date, datetime
from dateutil.relativedelta import relativedelta
from odoo import api, exceptions, fields, models, _, Command
from odoo.osv import expression
from odoo.tools import is_html_empty
-from odoo.tools.misc import clean_context, get_lang
+from odoo.tools.misc import clean_context, get_lang, groupby
_logger = logging.getLogger(__name__)
@@ -78,9 +78,15 @@ class MailActivity(models.Model):
summary = fields.Char('Summary')
note = fields.Html('Note', sanitize_style=True)
date_deadline = fields.Date('Due Date', index=True, required=True, default=fields.Date.context_today)
+ date_done = fields.Date('Done Date', compute='_compute_date_done', store=True)
automated = fields.Boolean(
'Automated activity', readonly=True,
help='Indicates this activity has been created automatically and not by any user.')
+ # Attachments are linked to a document through model / res_id and to the activity through this field.
+ attachment_ids = fields.Many2many(
+ 'ir.attachment', 'activity_attachment_rel',
+ 'activity_id', 'attachment_id',
+ string='Attachments')
# description
user_id = fields.Many2one(
'res.users', 'Assigned to',
@@ -90,7 +96,8 @@ class MailActivity(models.Model):
state = fields.Selection([
('overdue', 'Overdue'),
('today', 'Today'),
- ('planned', 'Planned')], 'State',
+ ('planned', 'Planned'),
+ ('done', 'Done')], 'State',
compute='_compute_state')
recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type")
previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type', readonly=True)
@@ -101,6 +108,7 @@ class MailActivity(models.Model):
chaining_type = fields.Selection(related='activity_type_id.chaining_type', readonly=True)
# access
can_write = fields.Boolean(compute='_compute_can_write') # used to hide buttons if the current user has no access
+ active = fields.Boolean(default=True)
_sql_constraints = [
# Required on a Many2one reference field is not sufficient as actually
@@ -122,18 +130,26 @@ class MailActivity(models.Model):
if record.previous_activity_type_id.triggered_next_type_id:
record.activity_type_id = record.previous_activity_type_id.triggered_next_type_id
+ @api.depends('active')
+ def _compute_date_done(self):
+ unarchived = self.filtered('active')
+ unarchived.date_done = False
+ # keep earliest archive date if multi archive
+ toupdate = (self - unarchived).filtered(lambda act: not act.date_done)
+ toupdate.date_done = fields.Datetime.now()
+
@api.depends('res_model', 'res_id')
def _compute_res_name(self):
for activity in self:
activity.res_name = activity.res_model and \
self.env[activity.res_model].browse(activity.res_id).display_name
- @api.depends('date_deadline')
+ @api.depends('active', 'date_deadline')
def _compute_state(self):
for record in self.filtered(lambda activity: activity.date_deadline):
tz = record.user_id.sudo().tz
date_deadline = record.date_deadline
- record.state = self._compute_state_from_date(date_deadline, tz)
+ record.state = 'done' if not record.active else self._compute_state_from_date(date_deadline, tz)
@api.model
def _compute_state_from_date(self, date_deadline, tz=False):
@@ -533,6 +549,10 @@ class MailActivity(models.Model):
mail_activity_type_id=activity.activity_type_id.id,
subtype_xmlid='mail.mt_activities',
)
+ if activity.activity_type_id.keep_done:
+ attachment_ids = (attachment_ids or []) + activity_attachments.get(activity.id, [])
+ if attachment_ids:
+ activity.attachment_ids = attachment_ids
# Moving the attachments in the message
# TODO: Fix void res_id on attachment when you create an activity with an image
@@ -551,7 +571,9 @@ class MailActivity(models.Model):
if next_activities_values:
next_activities = self.env['mail.activity'].create(next_activities_values)
- self.unlink() # will unlink activity, dont access `self` after that
+ activity_to_keep = self.filtered('activity_type_id.keep_done')
+ activity_to_keep.action_archive()
+ (self - activity_to_keep).unlink() # will unlink activity, dont access `self` after that
return messages, next_activities
@@ -572,53 +594,152 @@ class MailActivity(models.Model):
def activity_format(self):
activities = self.read()
self.mail_template_ids.fetch(['name'])
+ self.attachment_ids.fetch(['name'])
for record, activity in zip(self, activities):
activity['mail_template_ids'] = [
{'id': mail_template.id, 'name': mail_template.name}
for mail_template in record.mail_template_ids
]
+ activity['attachment_ids'] = [
+ {'id': attachment.id, 'name': attachment.name}
+ for attachment in record.attachment_ids
+ ]
return activities
@api.model
- def get_activity_data(self, res_model, domain, limit=None, offset=0):
- activity_domain = [('res_model', '=', res_model)]
- if domain or limit or offset:
- res = self.env[res_model].search(domain or [], limit=limit, offset=offset)
- activity_domain.append(('res_id', 'in', res.ids))
- grouped_activities = self.env['mail.activity']._read_group(
- activity_domain,
- ['res_id', 'activity_type_id'],
- ['id:array_agg', 'date_deadline:min', '__count'])
- # filter out unreadable records
- if not domain:
- res_ids = tuple(res_id for res_id, *_ in grouped_activities)
- res_ids_set = set(self.env[res_model].search([('id', 'in', res_ids)])._ids)
- grouped_activities = [a for a in grouped_activities if a[0] in res_ids_set]
- res_id_to_deadline = {}
- activity_data = defaultdict(dict)
- for res_id, activity_type, ids, date_deadline, count in grouped_activities:
- activity_type_id = activity_type.id
- res_id_to_deadline[res_id] = date_deadline if (res_id not in res_id_to_deadline or date_deadline < res_id_to_deadline[res_id]) else res_id_to_deadline[res_id]
- state = self._compute_state_from_date(date_deadline, self.user_id.sudo().tz)
- activity_data[res_id][activity_type_id] = {
- 'count': count,
- 'ids': ids,
- 'state': state,
- 'o_closest_deadline': date_deadline,
- }
- activity_type_infos = []
- activity_type_ids = self.env['mail.activity.type'].search(
- ['|', ('res_model', '=', res_model), ('res_model', '=', False)])
- for elem in sorted(activity_type_ids, key=lambda item: item.sequence):
- mail_template_info = []
- for mail_template_id in elem.mail_template_ids:
- mail_template_info.append({"id": mail_template_id.id, "name": mail_template_id.name})
- activity_type_infos.append([elem.id, elem.name, mail_template_info])
+ def get_activity_data(self, res_model, domain, limit=None, offset=0, fetch_done=False):
+ """ Get aggregate data about records and their activities.
+ The goal is to fetch and compute aggregated data about records and their
+ activities to display them in the activity views and the chatter. For example,
+ the activity view displays it as a table with columns and rows being respectively
+ the activity_types and the activity_res_ids, and the grouped_activities being the
+ table entries with the aggregated data.
+
+ :param str res_model: model of the records to fetch
+ :param list domain: record search domain
+ :param int limit: maximum number of records to fetch
+ :param int offset: offset of the first record to fetch
+ :param bool fetch_done: determines if "done" activities are integrated in the
+ aggregated data or not.
+ :return dict: {'activity_types': dict of activity type info
+ {id: int, name: str, mail_template: list of {id:int, name:str},
+ keep_done: bool}
+ 'activity_res_ids': list of record id ordered by closest date
+ (deadline for ongoing activities, and done date for done activities)
+ 'grouped_activities': dict
+ res_id -> activity_type_id -> aggregated info as:
+ count_by_state dict: mapping state to count (ex.: 'planned': 2)
+ ids list: activity ids for the res_id and activity_type_id
+ reporting_date str: aggregated date of the related activities as
+ oldest deadline of ongoing activities if there are any
+ or most recent date done of completed activities
+ state dict: aggregated state of the related activities
+ user_assigned_ids list: activity responsible id ordered
+ by closest deadline of the related activities
+ attachments_info: dict with information about the attachments
+ {'count': int, 'most_recent_id': int, 'most_recent_name': str}
+ }
+ """
+ user_tz = self.user_id.sudo().tz
+ DocModel = self.env[res_model]
+ Activity = self.env['mail.activity']
+
+ # 1. Retrieve all ongoing and completed activities according to the parameters
+ activity_types = self.env['mail.activity.type'].search([('res_model', 'in', (res_model, False))])
+ fetch_done = fetch_done and activity_types.filtered('keep_done')
+ activity_domain = [('res_model', '=', res_model)]
+ is_filtered = domain or limit or offset
+ if is_filtered:
+ activity_domain.append(('res_id', 'in', DocModel._search(domain or [], offset, limit) if is_filtered else []))
+ all_activities = Activity.with_context(active_test=not fetch_done).search(
+ activity_domain, order='date_done DESC, date_deadline ASC')
+ all_ongoing = all_activities.filtered('active')
+ all_completed = all_activities.filtered(lambda act: not act.active)
+
+ # 2. Get attachment of completed activities
+ if all_completed:
+ attachment_ids = all_completed.attachment_ids.ids
+ attachments_by_id = {
+ a['id']: a
+ for a in self.env['ir.attachment'].search_read([['id', 'in', attachment_ids]], ['create_date', 'name'])
+ } if attachment_ids else {}
+ else:
+ attachments_by_id = {}
+
+ # 3. Group activities per records and activity type
+ grouped_completed = {group: Activity.browse([v.id for v in values])
+ for group, values in groupby(all_completed, key=lambda a: (a.res_id, a.activity_type_id))}
+ grouped_ongoing = {group: Activity.browse([v.id for v in values])
+ for group, values in groupby(all_ongoing, key=lambda a: (a.res_id, a.activity_type_id))}
+
+ # 4. Filter out unreadable records
+ res_id_type_tuples = grouped_ongoing.keys() | grouped_completed.keys()
+ if not is_filtered:
+ filtered = set(DocModel.search([('id', 'in', [r[0] for r in res_id_type_tuples])]).ids)
+ res_id_type_tuples = list(filter(lambda r: r[0] in filtered, res_id_type_tuples))
+
+ # 5. Format data
+ res_id_to_date_done = {}
+ res_id_to_deadline = {}
+ grouped_activities = defaultdict(dict)
+ for res_id_tuple in res_id_type_tuples:
+ res_id, activity_type_id = res_id_tuple
+ ongoing = grouped_ongoing.get(res_id_tuple, Activity)
+ completed = grouped_completed.get(res_id_tuple, Activity)
+ activities = ongoing | completed
+
+ # As completed is sorted on date_done DESC, we take here the max date_done
+ date_done = completed and completed[0].date_done
+ # As ongoing is sorted on date_deadline ASC, we take here the min date_deadline
+ date_deadline = ongoing and ongoing[0].date_deadline
+ if date_deadline and (res_id not in res_id_to_deadline or date_deadline < res_id_to_deadline[res_id]):
+ res_id_to_deadline[res_id] = date_deadline
+ if date_done and (res_id not in res_id_to_date_done or date_done > res_id_to_date_done[res_id]):
+ res_id_to_date_done[res_id] = date_done
+ # As ongoing is sorted on date_deadline, we get assignees on activity with oldest deadline first
+ user_assigned_ids = ongoing.user_id.ids
+ attachments = [attachments_by_id[attach.id] for attach in completed.attachment_ids]
+ grouped_activities[res_id][activity_type_id.id] = {
+ 'count_by_state': dict(Counter(
+ self._compute_state_from_date(act.date_deadline, user_tz) if act.active else 'done'
+ for act in activities)),
+ 'ids': activities.ids,
+ 'reporting_date': ongoing and date_deadline or date_done or None,
+ 'state': self._compute_state_from_date(date_deadline, user_tz) if ongoing else 'done',
+ 'user_assigned_ids': user_assigned_ids,
+ }
+ if attachments:
+ most_recent_attachment = max(attachments, key=lambda a: (a['create_date'], a['id']))
+ grouped_activities[res_id][activity_type_id.id]['attachments_info'] = {
+ 'most_recent_id': most_recent_attachment['id'],
+ 'most_recent_name': most_recent_attachment['name'],
+ 'count': len(attachments),
+ }
+
+ # Get record ids ordered by oldest deadline (urgent one first)
+ ongoing_res_ids = sorted(res_id_to_deadline, key=lambda item: res_id_to_deadline[item])
+ # Get record ids with only completed activities ordered by date done reversed (most recently done first)
+ completed_res_ids = [
+ res_id for res_id in sorted(
+ res_id_to_date_done, key=lambda item: res_id_to_date_done[item], reverse=True
+ ) if res_id not in res_id_to_deadline
+ ]
return {
- 'activity_types': activity_type_infos,
- 'activity_res_ids': sorted(res_id_to_deadline, key=lambda item: res_id_to_deadline[item]),
- 'grouped_activities': activity_data,
+ 'activity_res_ids': ongoing_res_ids + completed_res_ids,
+ 'activity_types': [
+ {
+ 'id': activity_type.id,
+ 'keep_done': activity_type.keep_done,
+ 'name': activity_type.name,
+ 'template_ids': [
+ {'id': mail_template_id.id, 'name': mail_template_id.name}
+ for mail_template_id in activity_type.mail_template_ids
+ ],
+ }
+ for activity_type in activity_types
+ ],
+ 'grouped_activities': grouped_activities,
}
# ----------------------------------------------------------------------
diff --git a/addons/mail/models/mail_activity_mixin.py b/addons/mail/models/mail_activity_mixin.py
index 4350af7ec57..aeb1f543ba5 100644
--- a/addons/mail/models/mail_activity_mixin.py
+++ b/addons/mail/models/mail_activity_mixin.py
@@ -62,7 +62,7 @@ class MailActivityMixin(models.AbstractModel):
'Today: Activity date is today\nPlanned: Future activities.')
activity_user_id = fields.Many2one(
'res.users', 'Responsible User',
- related='activity_ids.user_id', readonly=False,
+ compute='_compute_activity_user_id', readonly=True,
search='_search_activity_user_id',
groups="base.group_user")
activity_type_id = fields.Many2one(
@@ -111,6 +111,11 @@ class MailActivityMixin(models.AbstractModel):
record.activity_exception_decoration = exception_activity_type_id and exception_activity_type_id.decoration_type
record.activity_exception_icon = exception_activity_type_id and exception_activity_type_id.icon
+ @api.depends('activity_ids.user_id')
+ def _compute_activity_user_id(self):
+ for record in self:
+ record.activity_user_id = record.activity_ids[0].user_id if record.activity_ids else False
+
def _search_activity_exception_decoration(self, operator, operand):
return [('activity_ids.activity_type_id.decoration_type', operator, operand)]
@@ -204,7 +209,7 @@ class MailActivityMixin(models.AbstractModel):
@api.model
def _search_activity_user_id(self, operator, operand):
- return [('activity_ids.user_id', operator, operand)]
+ return [('activity_ids', 'any', [('active', 'in', [True, False]), ('user_id', operator, operand)])]
@api.model
def _search_activity_type_id(self, operator, operand):
diff --git a/addons/mail/models/mail_activity_type.py b/addons/mail/models/mail_activity_type.py
index 83dedf74bbd..91a33204ec1 100644
--- a/addons/mail/models/mail_activity_type.py
+++ b/addons/mail/models/mail_activity_type.py
@@ -71,6 +71,7 @@ class MailActivityType(models.Model):
mail_template_ids = fields.Many2many('mail.template', string='Email templates')
default_user_id = fields.Many2one("res.users", string="Default User")
default_note = fields.Html(string="Default Note", translate=True)
+ keep_done = fields.Boolean(string="Keep Done", help='Keep activities marked as done in the activity view')
#Fields for display purpose only
initial_res_model = fields.Selection(selection=_get_model_selection, string='Initial model', compute="_compute_initial_res_model", store=False,
diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py
index 5ace9f997da..27b0d11badd 100644
--- a/addons/mail/models/mail_thread.py
+++ b/addons/mail/models/mail_thread.py
@@ -4183,7 +4183,7 @@ class MailThread(models.AbstractModel):
except AccessError:
pass
if 'activities' in request_list:
- res['activities'] = self.activity_ids.activity_format()
+ res['activities'] = self.with_context(active_test=True).activity_ids.activity_format()
if 'attachments' in request_list:
res['attachments'] = self._get_mail_thread_data_attachments()._attachment_format()
if 'followers' in request_list:
diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py
index f4d462b5ab4..04a1cbb35ee 100644
--- a/addons/mail/models/res_users.py
+++ b/addons/mail/models/res_users.py
@@ -282,10 +282,11 @@ class Users(models.Model):
user_activities = {}
for model_name, activities_by_record in activities_by_record_by_model_name.items():
domain = [("id", "in", list({r.id for r in activities_by_record.keys()}))]
- allowed_records = self.env[model_name].search(domain)
+ Model = self.env[model_name]
+ allowed_records = Model.search(domain)
if not allowed_records:
continue
- module = self.env[model_name]._original_module
+ module = Model._original_module
icon = module and modules.module.get_module_icon(module)
model = self.env["ir.model"]._get(model_name).with_prefetch(model_ids)
user_activities[model_name] = {
@@ -298,12 +299,7 @@ class Users(models.Model):
"today_count": 0,
"overdue_count": 0,
"planned_count": 0,
- "actions": [
- {
- "icon": "fa-clock-o",
- "name": "Summary",
- }
- ],
+ "view_type": getattr(Model, '_systray_view', 'list'),
}
for record, activities in activities_by_record.items():
if record not in allowed_records:
diff --git a/addons/mail/static/src/core/web/activity_list_popover.js b/addons/mail/static/src/core/web/activity_list_popover.js
index de77115a607..e4cc764a722 100644
--- a/addons/mail/static/src/core/web/activity_list_popover.js
+++ b/addons/mail/static/src/core/web/activity_list_popover.js
@@ -62,6 +62,10 @@ export class ActivityListPopover extends Component {
this.props.close();
}
+ get doneActivities() {
+ return this.activities.filter((activity) => activity.state === "done");
+ }
+
get overdueActivities() {
return this.activities.filter((activity) => activity.state === "overdue");
}
diff --git a/addons/mail/static/src/core/web/activity_list_popover.xml b/addons/mail/static/src/core/web/activity_list_popover.xml
index 8bf55bfa4c5..ba714911fe3 100644
--- a/addons/mail/static/src/core/web/activity_list_popover.xml
+++ b/addons/mail/static/src/core/web/activity_list_popover.xml
@@ -28,6 +28,14 @@