diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index f23fc7491b0..388bd639200 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -68,6 +68,7 @@ class AccountMove(models.Model): _check_company_auto = True _sequence_index = "journal_id" _rec_names_search = ['name', 'partner_id.name', 'ref'] + _systray_view = 'activity' @property def _sequence_monthly_regex(self): diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index f58f47797a4..e2400c3cf88 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -1286,6 +1286,30 @@ + + account.move.view.activity + account.move + + + + +
+
+ +
+ +
+
+ +
+ +
+
+ + +
+ + account.move.select account.move diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index baa383bc164..c90fbd54685 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -69,6 +69,7 @@ class Meeting(models.Model): _description = "Calendar Event" _order = "start desc" _inherit = ["mail.thread"] + _systray_view = 'calendar' DISCUSS_ROUTE = 'calendar/join_videocall' diff --git a/addons/calendar/models/res_users.py b/addons/calendar/models/res_users.py index e4467d2bd34..c576e91bec7 100644 --- a/addons/calendar/models/res_users.py +++ b/addons/calendar/models/res_users.py @@ -40,7 +40,8 @@ class Users(models.Model): def systray_get_activities(self): res = super(Users, self).systray_get_activities() - meetings_lines = self.env['calendar.event'].search_read( + EventModel = self.env['calendar.event'] + meetings_lines = EventModel.search_read( self._systray_get_calendar_event_domain(), ['id', 'start', 'name', 'allday'], order='start') @@ -51,8 +52,9 @@ class Users(models.Model): 'type': 'meeting', 'name': meeting_label, 'model': 'calendar.event', - 'icon': modules.module.get_module_icon(self.env['calendar.event']._original_module), + 'icon': modules.module.get_module_icon(EventModel._original_module), 'meetings': meetings_lines, + "view_type": EventModel._systray_view, } res.insert(0, meetings_systray) diff --git a/addons/calendar/static/tests/activity_menu_tests.js b/addons/calendar/static/tests/activity_menu_tests.js index 77e32b55964..92487a4bf04 100644 --- a/addons/calendar/static/tests/activity_menu_tests.js +++ b/addons/calendar/static/tests/activity_menu_tests.js @@ -34,7 +34,7 @@ QUnit.test("activity menu widget:today meetings", async function (assert) { assert.step("action"); }, }); - await contains(".o-mail-ActivityGroup", { text: "Today's Meetings" }); + await contains(".o-mail-ActivityGroup div[name='activityTitle']", { text: "Today's Meetings" }); await contains(".o-mail-ActivityGroup .o-calendar-meeting", { count: 2 }); await contains(".o-calendar-meeting span.fw-bold", { text: "meeting1" }); await contains(".o-calendar-meeting span:not(.fw-bold)", { text: "meeting2" }); diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index bca2e13778b..a8f3bd2dc6b 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -513,9 +513,17 @@
-
- - +
+
+ +
+ +
+
+ +
+ +
@@ -1115,7 +1123,8 @@ crm.lead tree,kanban,graph,pivot,calendar,form,activity - [('activity_ids','!=',False)] + + [("activity_ids.active", "in", [True, False])] {'default_type': 'opportunity', 'search_default_assigned_to_me': 1} diff --git a/addons/event/views/event_event_views.xml b/addons/event/views/event_event_views.xml index 84a250aef8d..332bd852f72 100644 --- a/addons/event/views/event_event_views.xml +++ b/addons/event/views/event_event_views.xml @@ -158,6 +158,26 @@ + + event.event.view.activity + event.event + + + +
+ +
+ + + + +
+
+
+
+
+
+ event.event.form.quick_create event.event @@ -295,7 +315,7 @@ Events event.event - kanban,calendar,tree,form,pivot,graph + kanban,calendar,tree,form,pivot,graph,activity

diff --git a/addons/fleet/views/fleet_vehicle_cost_views.xml b/addons/fleet/views/fleet_vehicle_cost_views.xml index 056c1e382b9..7c33302188c 100644 --- a/addons/fleet/views/fleet_vehicle_cost_views.xml +++ b/addons/fleet/views/fleet_vehicle_cost_views.xml @@ -179,7 +179,7 @@

- +
@@ -356,8 +356,8 @@
- - + +
diff --git a/addons/fleet/views/fleet_vehicle_views.xml b/addons/fleet/views/fleet_vehicle_views.xml index 84d94c60811..a736ea658d7 100644 --- a/addons/fleet/views/fleet_vehicle_views.xml +++ b/addons/fleet/views/fleet_vehicle_views.xml @@ -338,8 +338,8 @@
- - + +
diff --git a/addons/hr/views/hr_employee_views.xml b/addons/hr/views/hr_employee_views.xml index 7a61d658452..4978fd1a009 100644 --- a/addons/hr/views/hr_employee_views.xml +++ b/addons/hr/views/hr_employee_views.xml @@ -331,8 +331,8 @@
- - + +
diff --git a/addons/hr_contract/views/hr_contract_views.xml b/addons/hr_contract/views/hr_contract_views.xml index dc09d32485a..9ee2d1cdcd9 100644 --- a/addons/hr_contract/views/hr_contract_views.xml +++ b/addons/hr_contract/views/hr_contract_views.xml @@ -355,8 +355,8 @@
- - + +
diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index b6e841338b4..f8747a39284 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -396,7 +396,7 @@
- +
@@ -1025,7 +1025,7 @@
- +
diff --git a/addons/hr_holidays/data/mail_activity_type_data.xml b/addons/hr_holidays/data/mail_activity_type_data.xml index 19858334853..89a5d483f35 100644 --- a/addons/hr_holidays/data/mail_activity_type_data.xml +++ b/addons/hr_holidays/data/mail_activity_type_data.xml @@ -6,6 +6,7 @@ Time Off Approval fa-sun-o hr.leave + 15 Time Off Second Approve diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index a525720fa84..a62173d7dd8 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -9,6 +9,7 @@ import pytz from collections import namedtuple, defaultdict from datetime import datetime, timedelta, time +from dateutil.relativedelta import relativedelta from pytz import timezone, UTC from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG @@ -1460,10 +1461,19 @@ class HolidaysRequest(models.Model): elif holiday.state == 'confirm': if holiday.holiday_status_id.leave_validation_type != 'no_validation': user_ids = holiday.sudo()._get_responsible_for_approval().ids or self.env.user.ids + today = fields.Date.today() + activity_type = self.env.ref('hr_holidays.mail_act_leave_approval') for user_id in user_ids: + date_deadline = ( + (holiday.date_from - + relativedelta(**{activity_type.delay_unit: activity_type.delay_count or 0})).date() + if holiday.date_from else today) + if date_deadline < today: + date_deadline = today activity_vals.append({ - 'activity_type_id': self.env.ref('hr_holidays.mail_act_leave_approval').id, + 'activity_type_id': activity_type.id, 'automated': True, + 'date_deadline': date_deadline, 'note': note, 'user_id': user_id, 'res_id': holiday.id, diff --git a/addons/hr_holidays/views/hr_leave_allocation_views.xml b/addons/hr_holidays/views/hr_leave_allocation_views.xml index 38853ca7e34..34ddcdbcc3b 100644 --- a/addons/hr_holidays/views/hr_leave_allocation_views.xml +++ b/addons/hr_holidays/views/hr_leave_allocation_views.xml @@ -431,7 +431,7 @@
- ( days) + ( days)
diff --git a/addons/hr_holidays/views/hr_leave_views.xml b/addons/hr_holidays/views/hr_leave_views.xml index 4398b0169dd..88d7bab74c2 100644 --- a/addons/hr_holidays/views/hr_leave_views.xml +++ b/addons/hr_holidays/views/hr_leave_views.xml @@ -184,15 +184,16 @@ - - -
- -
- ( days) + +
+
+ +
( days)
+
+
diff --git a/addons/hr_recruitment/views/hr_applicant_views.xml b/addons/hr_recruitment/views/hr_applicant_views.xml index d38b90fa788..c871581e0b6 100644 --- a/addons/hr_recruitment/views/hr_applicant_views.xml +++ b/addons/hr_recruitment/views/hr_applicant_views.xml @@ -407,9 +407,10 @@
-
- - + +
+ +
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 @@
+ +
+ Done + + +
+ +
+
diff --git a/addons/mail/static/src/core/web/activity_list_view.scss b/addons/mail/static/src/core/web/activity_list_view.scss index e8faa786514..9c8e946fe13 100644 --- a/addons/mail/static/src/core/web/activity_list_view.scss +++ b/addons/mail/static/src/core/web/activity_list_view.scss @@ -1,4 +1,4 @@ .o-mail-ActivityListPopover { width: #{"min(95vw, 350px)"}; - max-height: #{"min(95vh, 350px)"}; + max-height: #{"min(95vh, 525px)"}; } diff --git a/addons/mail/static/src/core/web/activity_menu.js b/addons/mail/static/src/core/web/activity_menu.js index 7f341c1d4e6..72aa7b2e191 100644 --- a/addons/mail/static/src/core/web/activity_menu.js +++ b/addons/mail/static/src/core/web/activity_menu.js @@ -48,42 +48,14 @@ export class ActivityMenu extends Component { ]; } - onClickAction(action, group) { - document.body.click(); // hack to close dropdown - if (action.action_xmlid) { - this.env.services.action.doAction(action.action_xmlid); - } else { - let domain = [["activity_ids.user_id", "=", this.userId]]; - if (group.domain) { - domain = Domain.and([domain, group.domain]).toList(); - } - this.action.doAction( - { - domain, - name: group.name, - res_model: group.model, - type: "ir.actions.act_window", - views: this.availableViews(group), - }, - { clearBreadcrumbs: true, viewType: "activity" } - ); - } - } - - openActivityGroup(group, filter = "all") { + openActivityGroup(group) { document.body.click(); // hack to close dropdown const context = { // Necessary because activity_ids of mail.activity.mixin has auto_join // So, duplicates are faking the count and "Load more" doesn't show up force_search_count: 1, }; - if (filter === "all") { - context.search_default_activities_overdue = 1; - context.search_default_activities_today = 1; - } else { - context["search_default_activities_" + filter] = 1; - } - let domain = [["activity_ids.user_id", "=", this.userId]]; + let domain = [["activity_user_id", "=", this.userId]]; if (group.domain) { domain = Domain.and([domain, group.domain]).toList(); } @@ -99,7 +71,10 @@ export class ActivityMenu extends Component { type: "ir.actions.act_window", views, }, - { clearBreadcrumbs: true } + { + clearBreadcrumbs: true, + viewType: group.view_type, + } ); } } diff --git a/addons/mail/static/src/core/web/activity_menu.xml b/addons/mail/static/src/core/web/activity_menu.xml index c381735eeb6..77a351b2fc9 100644 --- a/addons/mail/static/src/core/web/activity_menu.xml +++ b/addons/mail/static/src/core/web/activity_menu.xml @@ -14,26 +14,25 @@
-
+
- - 0 Late - - 0 Today + + Late + + + Today + - - 0 Future + + Future +
- +
diff --git a/addons/mail/static/src/core/web/activity_model.js b/addons/mail/static/src/core/web/activity_model.js index 334cda6104b..083f8a0401b 100644 --- a/addons/mail/static/src/core/web/activity_model.js +++ b/addons/mail/static/src/core/web/activity_model.js @@ -13,6 +13,7 @@ import { assignDefined } from "@mail/utils/common/misc"; * @property {string} create_date * @property {[number, string]} create_uid * @property {string} date_deadline + * @property {string} date_done * @property {string} display_name * @property {boolean} has_recommended_activities * @property {string} icon @@ -66,12 +67,16 @@ export class Activity extends Record { return activity; } + /** @type {boolean} */ + active; /** @type {string} */ activity_category; /** @type {[number, string]} */ activity_type_id; /** @type {string|false} */ activity_decoration; + /** @type {Object[]} */ + attachment_ids; /** @type {boolean} */ can_write; /** @type {'suggest'|'trigger'} */ @@ -83,6 +88,8 @@ export class Activity extends Record { /** @type {string} */ date_deadline; /** @type {string} */ + date_done; + /** @type {string} */ display_name; /** @type {boolean} */ has_recommended_activities; diff --git a/addons/mail/static/src/core/web/mail_column_progress.js b/addons/mail/static/src/core/web/mail_column_progress.js new file mode 100644 index 00000000000..b227917df7d --- /dev/null +++ b/addons/mail/static/src/core/web/mail_column_progress.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import { ColumnProgress } from "@web/views/view_components/column_progress"; + + +export class MailColumnProgress extends ColumnProgress { + static props = { + ...ColumnProgress.props, + aggregateOn: { type: Object, optional: true }, + }; + static template = "mail.ColumnProgress"; +} diff --git a/addons/mail/static/src/core/web/mail_column_progress.xml b/addons/mail/static/src/core/web/mail_column_progress.xml new file mode 100644 index 00000000000..408c67969d8 --- /dev/null +++ b/addons/mail/static/src/core/web/mail_column_progress.xml @@ -0,0 +1,13 @@ + + + + + + + / + + + + + diff --git a/addons/mail/static/src/views/web/activity/activity_cell.js b/addons/mail/static/src/views/web/activity/activity_cell.js index 5b9a8024177..41c2129b524 100644 --- a/addons/mail/static/src/views/web/activity/activity_cell.js +++ b/addons/mail/static/src/views/web/activity/activity_cell.js @@ -1,22 +1,32 @@ /* @odoo-module */ import { ActivityListPopover } from "@mail/core/web/activity_list_popover"; +import { Avatar } from "@mail/views/web/fields/avatar/avatar"; import { Component, useRef } from "@odoo/owl"; import { usePopover } from "@web/core/popover/popover_hook"; export class ActivityCell extends Component { + static components = { + Avatar, + }; static props = { activityIds: { type: Array, elements: Number, }, + attachmentsInfo: { + optional: true, + type: Object, + }, activityTypeId: Number, - closestDeadline: String, + reportingDate: String, + countByState: Object, reloadFunc: Function, resId: Number, resModel: String, + userAssignedIds: Array, }; static template = "mail.ActivityCell"; @@ -25,21 +35,22 @@ export class ActivityCell extends Component { this.contentRef = useRef("content"); } - get closestDeadlineFormatted() { - const date = luxon.DateTime.fromISO(this.props.closestDeadline); - // To remove year only if current year - if (luxon.DateTime.now().year === date.year) { - return date.toLocaleString({ - day: "numeric", - month: "short", - }); - } else { - return date.toLocaleString({ - day: "numeric", - month: "short", - year: "numeric", - }); - } + get reportingDateFormatted() { + return luxon.DateTime.fromISO(this.props.reportingDate).toLocaleString( + luxon.DateTime.DATE_SHORT + ); + } + + get ongoingActivityCount() { + return ( + (this.props.countByState?.planned ?? 0) + + (this.props.countByState?.today ?? 0) + + (this.props.countByState?.overdue ?? 0) + ); + } + + get totalActivityCount() { + return this.ongoingActivityCount + (this.props.countByState?.done ?? 0); } onClick() { diff --git a/addons/mail/static/src/views/web/activity/activity_cell.scss b/addons/mail/static/src/views/web/activity/activity_cell.scss deleted file mode 100644 index 4bbe0988b6b..00000000000 --- a/addons/mail/static/src/views/web/activity/activity_cell.scss +++ /dev/null @@ -1,3 +0,0 @@ -.o-mail-ActivityCell-counter { - @include o-position-absolute($bottom: 0, $right: 0); -} diff --git a/addons/mail/static/src/views/web/activity/activity_cell.xml b/addons/mail/static/src/views/web/activity/activity_cell.xml index 527c98bcc78..c82eadf53ba 100644 --- a/addons/mail/static/src/views/web/activity/activity_cell.xml +++ b/addons/mail/static/src/views/web/activity/activity_cell.xml @@ -2,10 +2,41 @@ -
-
-
- +
+
+
+
+
+
+ + + + + +
+
diff --git a/addons/mail/static/src/views/web/activity/activity_controller.scss b/addons/mail/static/src/views/web/activity/activity_controller.scss index 0bdc8f1c74d..b75cf15dca0 100644 --- a/addons/mail/static/src/views/web/activity/activity_controller.scss +++ b/addons/mail/static/src/views/web/activity/activity_controller.scss @@ -5,14 +5,47 @@ } } .o_activity_summary_cell { + .o-mail-ActivityCell-counter { + color: map-get($theme-colors, 'dark'); + } &.planned { background-color: map-get($theme-colors, 'success'); + color: map-get($theme-colors, 'light'); + a { + color: map-get($theme-colors, 'light'); + &:hover { + text-decoration: underline; + } + } } &.overdue { background-color: map-get($theme-colors, 'danger'); + color: map-get($theme-colors, 'light'); + a { + color: map-get($theme-colors, 'light'); + &:hover { + text-decoration: underline; + } + } } &.today { background-color: map-get($theme-colors, 'warning'); + color: map-get($theme-colors, 'light'); + a { + color: map-get($theme-colors, 'light'); + &:hover { + text-decoration: underline; + } + } + } + &.done { + background-color: map-get($theme-colors, 'secondary'); + a { + color: map-get($theme-colors, 'dark'); + &:hover { + text-decoration: underline; + } + } } &.o_activity_empty_cell { > i { @@ -57,20 +90,12 @@ font-size: large; } - > div { - max-width: 200px; - - .o_text_block { - @include o-text-overflow; - display: block; - } - } - .o_text_bold { - font-weight: bold; + font-weight: $font-weight-bold; } .o_text_block { + @include o-text-overflow($max-width: 15rem); display: block; } } diff --git a/addons/mail/static/src/views/web/activity/activity_model.js b/addons/mail/static/src/views/web/activity/activity_model.js index 306d411ae25..489eabac05a 100644 --- a/addons/mail/static/src/views/web/activity/activity_model.js +++ b/addons/mail/static/src/views/web/activity/activity_model.js @@ -7,7 +7,9 @@ export class ActivityModel extends RelationalModel { async load(params = {}) { this.originalDomain = params.domain ? [...params.domain] : []; - params.domain?.push(["activity_ids", "!=", false]); + // Ensure that only (active) records with at least one activity, "done" (archived) or not, are fetched. + // We don't use active_test=false in the context because otherwise we would also get archived records. + params.domain?.push(["activity_ids.active", "in", [true, false]]); if (params && "groupBy" in params) { params.groupBy = []; } @@ -20,6 +22,7 @@ export class ActivityModel extends RelationalModel { domain: params.domain || this.env.searchModel._domain, limit: params.limit || this.initialLimit, offset: params.offset || 0, + fetch_done: true, }); } } diff --git a/addons/mail/static/src/views/web/activity/activity_renderer.js b/addons/mail/static/src/views/web/activity/activity_renderer.js index e7cdd0e7ad5..dbc8dbd5e5d 100644 --- a/addons/mail/static/src/views/web/activity/activity_renderer.js +++ b/addons/mail/static/src/views/web/activity/activity_renderer.js @@ -1,5 +1,6 @@ /* @odoo-module */ +import { MailColumnProgress } from "@mail/core/web/mail_column_progress"; import { ActivityCell } from "@mail/views/web/activity/activity_cell"; import { ActivityRecord } from "@mail/views/web/activity/activity_record"; @@ -9,19 +10,19 @@ import { browser } from "@web/core/browser/browser"; import { CheckBox } from "@web/core/checkbox/checkbox"; import { Dropdown } from "@web/core/dropdown/dropdown"; import { DropdownItem } from "@web/core/dropdown/dropdown_item"; -import { ColumnProgress } from "@web/views/view_components/column_progress"; +import { _t } from "@web/core/l10n/translation"; export class ActivityRenderer extends Component { static components = { ActivityCell, ActivityRecord, - ColumnProgress, + ColumnProgress: MailColumnProgress, Dropdown, DropdownItem, CheckBox, }; static props = { - activityTypes: { type: Array }, + activityTypes: { type: Object }, activityResIds: { type: Array }, fields: { type: Object }, resModel: { type: String }, @@ -42,7 +43,7 @@ export class ActivityRenderer extends Component { active: null, }, activityTypeId: null, - resIds: [], + resIds: new Set(Object.keys(this.props.groupedActivities)), }); this.storageKey = ["activity_columns", this.props.resModel, this.env.config.viewId]; @@ -56,34 +57,52 @@ export class ActivityRenderer extends Component { */ get activityResIds() { return [...this.props.activityResIds].sort((a) => - this.activeFilter.resIds.includes(a) ? -1 : 0 + this.activeFilter.resIds.has(a) ? -1 : 0 ); } - getGroupInfo(group) { + getGroupInfo(activityType) { const types = { + done: { + color: "secondary", + inProgressBar: false, + label: _t("done"), // activity_mixin.activity_state has no done state, so we add it manually here + value: 0, + }, planned: { color: "success", + inProgressBar: true, value: 0, }, today: { color: "warning", + inProgressBar: true, value: 0, }, overdue: { - value: 0, color: "danger", + inProgressBar: true, + value: 0, }, }; - const typeId = group[0]; - const isColumnFiltered = this.activeFilter.activityTypeId === group[0]; + for (const [type, label] of this.props.fields.activity_state.selection) { + types[type].label = label; + } + const typeId = activityType.id; + const isColumnFiltered = this.activeFilter.activityTypeId === activityType.id; const progressValue = isColumnFiltered ? this.activeFilter.progressValue : { active: null }; - let totalCount = 0; + let totalCountWithoutDone = 0; for (const activities of Object.values(this.props.groupedActivities)) { if (typeId in activities) { - types[activities[typeId].state].value += 1; - totalCount++; + for (const [state, stateCount] of Object.entries( + activities[typeId].count_by_state + )) { + types[state].value += stateCount; + if (state !== "done") { + totalCountWithoutDone += stateCount; + } + } } } @@ -92,21 +111,34 @@ export class ActivityRenderer extends Component { activeBar: isColumnFiltered ? this.activeFilter.progressValue.active : null, }; for (const [value, count] of Object.entries(types)) { - progressBar.bars.push({ - count: count.value, - value, - string: this.props.fields.activity_state.selection.find((e) => e[0] === value)[1], - color: count.color, - }); + if (count.inProgressBar) { + progressBar.bars.push({ + count: count.value, + value, + string: types[value].label, + color: count.color, + }); + } } + const ongoingActivityCount = types.overdue.value + types.today.value + types.planned.value; + const ongoingAndDoneCount = ongoingActivityCount + types.done.value; + const labelAggregate = `${types.overdue.label} + ${types.today.label} + ${types.planned.label}`; + const aggregateOn = + ongoingAndDoneCount && this.isTypeDisplayDone(typeId) + ? { + title: `${types.done.label} + ${labelAggregate}`, + value: ongoingAndDoneCount, + } + : undefined; return { aggregate: { - title: group[1], - value: isColumnFiltered ? types[progressValue.active].value : totalCount, + title: labelAggregate, + value: isColumnFiltered ? types[progressValue.active].value : ongoingActivityCount, }, + aggregateOn: aggregateOn, data: { - count: totalCount, + count: totalCountWithoutDone, filterProgressValue: (name) => this.onSetProgressBarState(typeId, name), progressBar, progressValue, @@ -118,24 +150,30 @@ export class ActivityRenderer extends Component { return this.props.records.find((r) => r.resId === resId); } + isTypeDisplayDone(typeId) { + return this.props.activityTypes.find((a) => a.id === typeId).keep_done; + } + onSetProgressBarState(typeId, bar) { const name = bar.value; if (this.activeFilter.progressValue.active === name) { this.activeFilter.progressValue.active = null; this.activeFilter.activityTypeId = null; - this.activeFilter.resIds = []; + this.activeFilter.resIds = new Set(Object.keys(this.props.groupedActivities)); } else { this.activeFilter.progressValue.active = name; this.activeFilter.activityTypeId = typeId; - this.activeFilter.resIds = Object.entries(this.props.groupedActivities) - .filter(([, resIds]) => typeId in resIds && resIds[typeId].state === name) - .map(([key]) => parseInt(key)); + this.activeFilter.resIds = new Set( + Object.entries(this.props.groupedActivities) + .filter(([, resIds]) => typeId in resIds && name in resIds[typeId].count_by_state) + .map(([key]) => parseInt(key)) + ); } } get activeColumns() { return this.props.activityTypes.filter( - (activity) => this.storageActiveColumns[activity[0]] + (activityType) => this.storageActiveColumns[activityType.id] ); } @@ -145,11 +183,11 @@ export class ActivityRenderer extends Component { this.storageActiveColumns = useState({}); for (const activityType of this.props.activityTypes) { if (storageActiveColumnsList) { - this.storageActiveColumns[activityType[0]] = storageActiveColumnsList.includes( - activityType[0].toString() + this.storageActiveColumns[activityType.id] = storageActiveColumnsList.includes( + activityType.id.toString() ); } else { - this.storageActiveColumns[activityType[0]] = true; + this.storageActiveColumns[activityType.id] = true; } } } diff --git a/addons/mail/static/src/views/web/activity/activity_renderer.xml b/addons/mail/static/src/views/web/activity/activity_renderer.xml index a26cf368076..1c9ec4786cc 100644 --- a/addons/mail/static/src/views/web/activity/activity_renderer.xml +++ b/addons/mail/static/src/views/web/activity/activity_renderer.xml @@ -4,18 +4,18 @@ -
- - + + @@ -38,13 +38,13 @@ - + - + @@ -65,9 +65,9 @@ - + - + @@ -76,16 +76,23 @@ - - + + - + t-attf-class="o_activity_summary_cell p-0 h-100 {{activityGroup.state}} {{ activeFilter.resIds.has(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}"> + + t-attf-class="{{ activeFilter.resIds.has(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}" + t-on-click.prevent.stop="() => props.onEmptyCell(resId, type.id)"> @@ -93,7 +100,7 @@ - + Schedule activity diff --git a/addons/mail/static/src/views/web/fields/avatar/avatar.js b/addons/mail/static/src/views/web/fields/avatar/avatar.js index fc38e808918..99bbd552afe 100644 --- a/addons/mail/static/src/views/web/fields/avatar/avatar.js +++ b/addons/mail/static/src/views/web/fields/avatar/avatar.js @@ -14,4 +14,9 @@ export class Avatar extends Component { } } Avatar.template = "mail.Avatar"; -Avatar.props = ["resModel", "resId", "displayName"]; +Avatar.props = { + resModel: { type: String }, + resId: { type: Number }, + displayName: { type: String }, + noSpacing: { type: Boolean, optional: true }, +}; diff --git a/addons/mail/static/src/views/web/fields/avatar/avatar.xml b/addons/mail/static/src/views/web/fields/avatar/avatar.xml index a845d66e572..0bc795e432b 100644 --- a/addons/mail/static/src/views/web/fields/avatar/avatar.xml +++ b/addons/mail/static/src/views/web/fields/avatar/avatar.xml @@ -3,11 +3,11 @@
- - +
diff --git a/addons/mail/static/tests/helpers/mock_server/models/mail_activity.js b/addons/mail/static/tests/helpers/mock_server/models/mail_activity.js index 915c733c927..d74a24ceace 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/mail_activity.js +++ b/addons/mail/static/tests/helpers/mock_server/models/mail_activity.js @@ -1,9 +1,10 @@ /* @odoo-module */ -import { sortBy } from "@web/core/utils/arrays"; +import { Domain } from "@web/core/domain"; +import { groupBy, sortBy, unique } from "@web/core/utils/arrays"; import { patch } from "@web/core/utils/patch"; import { MockServer } from "@web/../tests/helpers/mock_server"; -import { serializeDate, deserializeDate } from "@web/core/l10n/dates"; +import { deserializeDate } from "@web/core/l10n/dates"; const { DateTime} = luxon; @@ -26,7 +27,14 @@ patch(MockServer.prototype, { const domain = args.args[1] || args.kwargs.domain; const limit = args[2] || args.kwargs.limit || 0; const offset = args[3] || args.kwargs.offset || 0; - return this._mockMailActivityGetActivityData(res_model, domain, limit, offset); + const fetch_done = args[4] || args.kwargs.fetch_done || false; + return this._mockMailActivityGetActivityData( + res_model, + domain, + limit, + offset, + fetch_done + ); } return super._performRPC(route, args); }, @@ -87,7 +95,7 @@ patch(MockServer.prototype, { * @param {integer[]} ids * @returns {Object} */ - _mockMailActivityActionFeedback(ids) { + _mockMailActivityActionFeedback(ids, attachment_ids= null) { this._mockMailActivityActionDone(ids); }, /** @@ -118,63 +126,163 @@ patch(MockServer.prototype, { * @param {number} offset * @returns {Object} */ - _mockMailActivityGetActivityData(res_model, domain, limit = 0, offset = 0) { + /** + * Simulate partially (time zone not supported) `_compute_state_from_date` on `mail.activity`. + * + * @param {DateTime} date_deadline to convert into state + * @returns {string} dateline status (today, planned or overdue) + * @private + */ + _mockComputeStateFromDate(date_deadline) { + const now = DateTime.now(); + if (date_deadline.hasSame(now, "day")) { + return "today"; + } else if (date_deadline > now) { + return "planned"; + } + return "overdue"; + }, + /** + * Simulates `get_activity_data` on `mail.activity`. + * + * @private + * @param {string} res_model + * @param {string} domain + * @param {number} limit + * @param {number} offset + * @param {boolean} fetch_done + * @returns {Object} + */ + _mockMailActivityGetActivityData(res_model, domain, limit = 0, offset = 0, fetch_done = false) { const self = this; - const allRecords = this.getRecords(res_model, domain); + + // 1. Retrieve all ongoing and completed activities according to the parameters + const activityTypes = this.getRecords("mail.activity.type", [ + "|", + ["res_model", "=", res_model], + ["res_model", "=", false], + ]); + // Remove domain term used to filter record having "done" activities (not understood by the getRecords mock) + domain = Domain.removeDomainLeaves(new Domain(domain ?? []).toList(), [ + "activity_ids.active", + ]).toList(); + const allRecords = this.getRecords(res_model, domain ?? []); const records = limit ? allRecords.slice(offset, offset + limit) : allRecords; + const activityDomain = [["res_model", "=", res_model]]; + const isFiltered = domain || limit || offset; + const domainResIds = records.map((r) => r.id); + if (isFiltered) { + activityDomain.push(["res_id", "in", domainResIds]); + } + const allActivities = this.getRecords("mail.activity", activityDomain, { + active_test: !fetch_done, + }); + const allOngoing = allActivities.filter((a) => a.active); + const allCompleted = allActivities.filter((a) => !a.active); - const activityTypes = this.getRecords("mail.activity.type", []); - const activityIds = records.map((x) => x.activity_ids).flat(); + // 2. Get attachment of completed activities + let attachmentsById; + if (allCompleted.length) { + const attachmentIds = allCompleted.map((a) => a.attachment_ids).flat(); + attachmentsById = attachmentIds.length + ? Object.fromEntries( + this.getRecords("ir.attachment", [["id", "in", attachmentIds]]).map((a) => [a.id, a]) + ) + : {}; + } else { + attachmentsById = {}; + } - const groupedActivities = {}; + // 3. Group activities per records and activity type + const groupedCompleted = groupBy(allCompleted, (a) => [a.res_id, a.activity_type_id]); + const groupedOngoing = groupBy(allOngoing, (a) => [a.res_id, a.activity_type_id]); + + // 4. Format data const resIdToDeadline = {}; - const groups = self.mockReadGroup("mail.activity", { - domain: [["id", "in", activityIds]], - fields: [ - "res_id", - "activity_type_id", - "ids:array_agg(id)", - "date_deadline:min(date_deadline)", - ], - groupby: ["res_id", "activity_type_id"], - lazy: false, - }); - groups.forEach(function (group) { - // mockReadGroup doesn't correctly return all asked fields - const activites = self.getRecords("mail.activity", group.__domain); - group.activity_type_id = group.activity_type_id[0]; - let minDate; - activites.forEach(function (activity) { - if (!minDate || deserializeDate(activity.date_deadline) < deserializeDate(minDate)) { - minDate = activity.date_deadline; - } - }); - group.date_deadline = minDate; - resIdToDeadline[group.res_id] = minDate; - let state; - if (group.date_deadline === serializeDate(DateTime.now())) { - state = "today"; - } else if (deserializeDate(group.date_deadline) > DateTime.now()) { - state = "planned"; - } else { - state = "overdue"; + const resIdToDateDone = {}; + const groupedActivities = {}; + for (const resIdStrTuple of new Set([ + ...Object.keys(groupedCompleted), + ...Object.keys(groupedOngoing), + ])) { + const [resId, activityTypeId] = resIdStrTuple.split(",").map((n) => Number(n)); + const ongoing = groupedOngoing[resIdStrTuple] || []; + const completed = groupedCompleted[resIdStrTuple] || []; + const dateDone = completed.length + ? DateTime.max(...completed.map((a) => deserializeDate(a.date_done))) + : false; + const dateDeadline = ongoing.length + ? DateTime.min(...ongoing.map((a) => deserializeDate(a.date_deadline))) + : false; + if ( + dateDeadline && + (resIdToDeadline[resId] === undefined || dateDeadline < resIdToDeadline[resId]) + ) { + resIdToDeadline[resId] = dateDeadline; } - if (!groupedActivities[group.res_id]) { - groupedActivities[group.res_id] = {}; + if ( + dateDone && + (resIdToDateDone[resId] === undefined || dateDone > resIdToDateDone[resId]) + ) { + resIdToDateDone[resId] = dateDone; } - groupedActivities[group.res_id][group.activity_type_id] = { - count: group.__count, - state: state, - o_closest_deadline: group.date_deadline, - ids: activites.map((x) => x.id), + const userAssignedIds = unique( + sortBy( + ongoing.filter((a) => a.user_id), + (a) => a.date_deadline + ).map((a) => a.user_id) + ); + const reportingDate = ongoing.length ? dateDeadline : dateDone; + const attachments = completed + .map((act) => act.attachment_ids) + .flat() + .map((attachmentId) => attachmentsById[attachmentId]); + const attachmentsInfo = {}; + if (attachments.length) { + const lastAttachmentCreateDate = DateTime.max( + ...attachments.map((a) => deserializeDate(a.create_date)) + ); + const mostRecentAttachment = attachments.find((a) => + lastAttachmentCreateDate.equals(deserializeDate(a.create_date)) + ); + attachmentsInfo.attachments = { + most_recent_id: mostRecentAttachment.id, + most_recent_name: mostRecentAttachment.name, + count: attachments.length, + }; + } + if (!(resId in groupedActivities)) { + groupedActivities[resId] = {}; + } + groupedActivities[resId][activityTypeId] = { + count_by_state: { + ...Object.fromEntries( + Object.entries( + groupBy(ongoing, (a) => + self._mockComputeStateFromDate(deserializeDate(a.date_deadline)) + ) + ).map(([state, activities]) => [state, activities.length]) + ), + ...(completed.length ? { done: completed.length } : {}), + }, + ids: ongoing.map((a) => a.id).concat(completed.map((a) => a.id)), + reporting_date: reportingDate ? reportingDate.toFormat("yyyy-LL-dd") : false, + state: ongoing.length ? self._mockComputeStateFromDate(dateDeadline) : "done", + user_assigned_ids: userAssignedIds, + ...attachmentsInfo, }; - }); + } + const ongoingResIds = sortBy(Object.keys(resIdToDeadline), (item) => resIdToDeadline[item]); + const completedResIds = sortBy( + Object.keys(resIdToDateDone).filter((resId) => !(resId in resIdToDeadline)), + (item) => resIdToDateDone[item] + ); return { activity_types: activityTypes.map((type) => { - let mailTemplates = []; + let templates = []; if (type.mail_template_ids) { - mailTemplates = type.mail_template_ids.map((template_id) => { + templates = type.mail_template_ids.map((template_id) => { const template = this.getRecords("mail.template", [ ["id", "=", template_id], ])[0]; @@ -184,12 +292,14 @@ patch(MockServer.prototype, { }; }); } - return [type.id, type.display_name, mailTemplates]; + return { + id: type.id, + name: type.display_name, + template_ids: templates, + keep_done: type.keep_done, + }; }), - activity_res_ids: sortBy( - records.map((x) => x.id), - (id) => deserializeDate(resIdToDeadline[id]) - ), + activity_res_ids: ongoingResIds.concat(completedResIds).map((idStr) => Number(idStr)), grouped_activities: groupedActivities, }; }, diff --git a/addons/mail/views/mail_activity_views.xml b/addons/mail/views/mail_activity_views.xml index 7ec2da2fbcd..be0a6340b98 100644 --- a/addons/mail/views/mail_activity_views.xml +++ b/addons/mail/views/mail_activity_views.xml @@ -23,6 +23,7 @@ + diff --git a/addons/mail/views/res_partner_views.xml b/addons/mail/views/res_partner_views.xml index 50ce11019bc..9a531c8cf64 100644 --- a/addons/mail/views/res_partner_views.xml +++ b/addons/mail/views/res_partner_views.xml @@ -81,8 +81,8 @@
- - + +
diff --git a/addons/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml index 399398a7faa..8b4136c139f 100644 --- a/addons/maintenance/views/maintenance_views.xml +++ b/addons/maintenance/views/maintenance_views.xml @@ -52,6 +52,27 @@
+ + maintenance.request.view.activity + maintenance.request + + + + +
+ +
+
+ +
+ +
+
+
+
+
+
+ equipment.request.form maintenance.request @@ -264,7 +285,7 @@ Maintenance Requests maintenance.request - kanban,tree,form,pivot,graph,calendar + kanban,tree,form,pivot,graph,calendar,activity { 'search_default_active': True, @@ -282,7 +303,7 @@ Maintenance Requests maintenance.request - kanban,tree,form,pivot,graph,calendar + kanban,tree,form,pivot,graph,calendar,activity { @@ -303,7 +324,7 @@ Maintenance Requests maintenance.request - kanban,tree,form,pivot,graph,calendar + kanban,tree,form,pivot,graph,calendar,activity { 'search_default_active': True, 'default_equipment_id': active_id, @@ -321,7 +342,7 @@ Maintenance Requests maintenance.request - kanban,tree,form,pivot,graph,calendar + kanban,tree,form,pivot,graph,calendar,activity { 'search_default_active': True, 'search_default_maintenance_team_id': active_id, @@ -340,7 +361,7 @@ Maintenance Requests maintenance.request - calendar,kanban,tree,form,pivot,graph + calendar,kanban,tree,form,pivot,graph,activity { 'search_default_active': True, @@ -358,7 +379,7 @@ Maintenance Requests maintenance.request - graph,pivot,kanban,tree,form,calendar + graph,pivot,kanban,tree,form,calendar,activity { 'search_default_active': True, } diff --git a/addons/mass_mailing/models/mailing.py b/addons/mass_mailing/models/mailing.py index 938d493b880..dbe302f8bd7 100644 --- a/addons/mass_mailing/models/mailing.py +++ b/addons/mass_mailing/models/mailing.py @@ -46,6 +46,7 @@ class MassMailing(models.Model): ] _order = 'calendar_date DESC' _rec_name = "subject" + _systray_view = 'list' @api.model def default_get(self, fields_list): diff --git a/addons/mass_mailing_sms/models/res_users.py b/addons/mass_mailing_sms/models/res_users.py index 8e5a4eb730a..d6f569bbd98 100644 --- a/addons/mass_mailing_sms/models/res_users.py +++ b/addons/mass_mailing_sms/models/res_users.py @@ -17,6 +17,7 @@ class Users(models.Model): doing a new query to split them by mailing_type. """ activities = super(Users, self).systray_get_activities() + view_type = self.env['mailing.mailing']._systray_view for activity in activities: if activity.get('model') == 'mailing.mailing': activities.remove(activity) @@ -56,6 +57,7 @@ class Users(models.Model): 'icon': icon, 'total_count': 0, 'today_count': 0, 'overdue_count': 0, 'planned_count': 0, 'res_ids': res_ids, + "view_type": view_type, } user_activities[act['mailing_type']]['res_ids'].add(act['res_id']) user_activities[act['mailing_type']]['%s_count' % act['states']] += act['count'] @@ -64,7 +66,6 @@ class Users(models.Model): for mailing_type in user_activities.keys(): user_activities[mailing_type].update({ - 'actions': [{'icon': 'fa-clock-o', 'name': 'Summary',}], 'domain': json.dumps([['activity_ids.res_id', 'in', list(user_activities[mailing_type]['res_ids'])]]) }) activities.extend(list(user_activities.values())) diff --git a/addons/mass_mailing_sms/static/tests/tours/mailing_activities_split.js b/addons/mass_mailing_sms/static/tests/tours/mailing_activities_split.js index 43299702823..cd8c2d2da7a 100644 --- a/addons/mass_mailing_sms/static/tests/tours/mailing_activities_split.js +++ b/addons/mass_mailing_sms/static/tests/tours/mailing_activities_split.js @@ -14,9 +14,9 @@ registry.category("web_tour.tours").add('mailing_activities_split', { trigger: '.o-mail-ActivityGroup:contains("Email Marketing")', }, { content: 'Open Email Marketing record in the kanban view', - trigger: '.o_kanban_record:contains("New Email!")', + trigger: '.o_list_renderer .o_data_cell:contains("New Email!")', run: () => { - if ($(".o_kanban_record:contains(New SMS!)").length !== 0) { + if ($('.o_list_renderer .o_data_cell:contains("New SMS!")').length !== 0) { console.error('SMS Marketing record should not appear in this view'); } }, @@ -28,9 +28,9 @@ registry.category("web_tour.tours").add('mailing_activities_split', { trigger: '.o-mail-ActivityGroup:contains("SMS Marketing")', }, { content: 'Open SMS Marketing record in the kanban view', - trigger: '.o_kanban_record:contains("New SMS!")', + trigger: '.o_list_renderer .o_data_cell:contains("New SMS!")', run: () => { - if ($(".o_kanban_record:contains(New Email!)").length !== 0) { + if ($('.o_list_renderer .o_data_cell:contains("New Email!")').length !== 0) { console.error('Email Marketing record should not appear in this view'); } }, diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 207c412fefd..9367bef5bf9 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -19,6 +19,24 @@ fullscreen + + mrp.production.view.activity + mrp.production + + + +
+ +
+ + +
+
+
+
+
+
+ mrp.production.tree mrp.production @@ -616,7 +634,7 @@ Manufacturing Orders mrp.production - tree,kanban,form,calendar,pivot,graph + tree,kanban,form,calendar,pivot,graph,activity {'search_default_todo': True, 'default_company_id': allowed_company_ids[0]} diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml index bd88f70df1d..7887ae849bb 100644 --- a/addons/mrp/views/mrp_unbuild_views.xml +++ b/addons/mrp/views/mrp_unbuild_views.xml @@ -196,7 +196,7 @@ Unbuild Orders mrp.unbuild - tree,kanban,form + tree,kanban,form,activity

No unbuild order found diff --git a/addons/mrp/views/stock_picking_views.xml b/addons/mrp/views/stock_picking_views.xml index b27e0b8a257..cdaf45c8130 100644 --- a/addons/mrp/views/stock_picking_views.xml +++ b/addons/mrp/views/stock_picking_views.xml @@ -144,7 +144,7 @@ Manufacturings mrp.production - tree,kanban,form,calendar + tree,kanban,form,calendar,activity {'default_company_id': allowed_company_ids[0]} diff --git a/addons/product/views/product_template_views.xml b/addons/product/views/product_template_views.xml index f154b45449e..50a8ab5e877 100644 --- a/addons/product/views/product_template_views.xml +++ b/addons/product/views/product_template_views.xml @@ -131,8 +131,8 @@

- -
+ +
[]
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index 43ecf8a1809..cb51ad41da3 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -483,8 +483,8 @@
- -
+ +
[]
diff --git a/addons/project/models/project_project.py b/addons/project/models/project_project.py index 42e0d21373c..5d2b15366c6 100644 --- a/addons/project/models/project_project.py +++ b/addons/project/models/project_project.py @@ -19,6 +19,7 @@ class Project(models.Model): _inherit = ['portal.mixin', 'mail.alias.mixin', 'rating.parent.mixin', 'mail.thread', 'mail.activity.mixin'] _order = "sequence, name, id" _rating_satisfaction_days = 30 # takes 30 days by default + _systray_view = 'activity' def _compute_attached_docs_count(self): self.env.cr.execute( diff --git a/addons/project/models/project_task.py b/addons/project/models/project_task.py index 3166c3b351a..e381bb7879b 100644 --- a/addons/project/models/project_task.py +++ b/addons/project/models/project_task.py @@ -77,6 +77,7 @@ class Task(models.Model): _mail_post_access = 'read' _order = "priority desc, sequence, date_deadline asc, id desc" _primary_email = 'email_from' + _systray_view = 'activity' _track_duration_field = 'stage_id' @api.model diff --git a/addons/project/static/src/components/project_task_state_selection/project_task_state_selection.js b/addons/project/static/src/components/project_task_state_selection/project_task_state_selection.js index f04570d5c37..c7ed3ef40de 100644 --- a/addons/project/static/src/components/project_task_state_selection/project_task_state_selection.js +++ b/addons/project/static/src/components/project_task_state_selection/project_task_state_selection.js @@ -131,14 +131,14 @@ export class ProjectTaskStateSelection extends StateSelectionField { } getDropdownPosition() { - if (this.isView(['kanban', 'list', 'calendar']) || this.env.isSmall) { + if (this.isView(['activity', 'kanban', 'list', 'calendar']) || this.env.isSmall) { return ''; } return 'bottom-end'; } getTogglerClass(currentValue) { - if (this.isView(['kanban', 'list', 'calendar']) || this.env.isSmall) { + if (this.isView(['activity', 'kanban', 'list', 'calendar']) || this.env.isSmall) { return 'btn btn-link d-flex p-0'; } return 'o_state_button btn rounded-pill ' + this.colorButton[currentValue]; diff --git a/addons/project/static/src/components/project_task_state_selection/project_task_state_selection.xml b/addons/project/static/src/components/project_task_state_selection/project_task_state_selection.xml index 4dcd2bcdbff..b04e70db622 100644 --- a/addons/project/static/src/components/project_task_state_selection/project_task_state_selection.xml +++ b/addons/project/static/src/components/project_task_state_selection/project_task_state_selection.xml @@ -13,7 +13,7 @@ - + @@ -22,13 +22,13 @@ -
-
-
- AddTodo -
-
- Add a To-do - -
-
- - - - - -
-
-
- - - diff --git a/addons/project_todo/static/tests/activity_menu_tests.js b/addons/project_todo/static/tests/activity_menu_tests.js deleted file mode 100644 index 3ba0469e7b6..00000000000 --- a/addons/project_todo/static/tests/activity_menu_tests.js +++ /dev/null @@ -1,77 +0,0 @@ -/* @odoo-module */ - -import { start } from "@mail/../tests/helpers/test_utils"; - -import { editInput, patchDate } from "@web/../tests/helpers/utils"; -import { click, contains, insertText } from "@web/../tests/utils"; - -const { DateTime } = luxon; - -QUnit.test("create todo from activity menu without date", async function () { - await start(); - await contains(".o_menu_systray i[aria-label='Activities']"); - await contains(".o-mail-ActivityMenu-counter", { count: 0 }); - await click(".o_menu_systray i[aria-label='Activities']"); - await contains(".o-mail-ActivityMenu", { - text: "Congratulations, you're done with your activities.", - }); - await click(".btn", { text: "Add a To-do" }); - await contains(".btn", { count: 0, text: "Add a To-do" }); - - await contains(".o-mail-ActivityMenu-show"); - await insertText("input.o-mail-ActivityMenu-input", "New To-do"); - await click(".btn", { text: "SAVE" }); - await contains(".o_notification", { - text: "Your to-do has been successfully added to your tasks and scheduled for completion.", - }); - // Need to reopen systray as it automatically closes when a todo is created - await click(".o_menu_systray i[aria-label='Activities']"); - await contains(".o-mail-ActivityMenu-counter", { text: "1" }); - await contains(".o-mail-ActivityGroup", { text: "project.task" }); - await contains(".btn", { text: "1 Today" }); - await contains(".btn", { text: "Add a To-do" }); - await contains(".o-mail-ActivityMenu-show", { count: 0 }); - await contains(".o-mail-ActivityMenu-input", { count: 0 }); - await click(".btn", { text: "Add a To-do" }); - await contains(".btn", { count: 0, text: "Add a To-do" }); - - await insertText("input.o-mail-ActivityMenu-input", "New To-do"); - await click(".btn", { text: "SAVE" }); - // Need to reopen systray as it automatically closes when a todo is created - await contains(".o_notification", { - count: 2, - text: "Your to-do has been successfully added to your tasks and scheduled for completion.", - }); - await click(".o_menu_systray i[aria-label='Activities']"); - await contains(".o-mail-ActivityMenu-counter", { text: "2" }); - await contains(".btn", { text: "2 Today" }); -}); - -QUnit.test("create to-do from activity menu with date", async function () { - patchDate(2023, 1, 1, 6, 0, 0); - const futureDay = DateTime.utc().plus({ days: 2 }); - await start(); - await contains(".o_menu_systray i[aria-label='Activities']"); - await contains(".o-mail-ActivityMenu-counter", { count: 0 }); - - await click(".o_menu_systray i[aria-label='Activities']"); - await click(".btn", { text: "Add a To-do" }); - await insertText("input.o-mail-ActivityMenu-input", "New To-do"); - await editInput( - document.body, - "input.o_datetime_input", - futureDay.toString(luxon.DateTime.DATE_SHORT), - { - replace: true, - } - ); - await click(".btn", { text: "SAVE" }); - await contains(".o_notification", { - text: "Your to-do has been successfully added to your tasks and scheduled for completion.", - }); - // Need to reopen systray as it automatically closes when a todo is created - await click(".o_menu_systray i[aria-label='Activities']"); - await contains(".o-mail-ActivityMenu-counter", { text: "1" }); - await contains(".o-mail-ActivityGroup", { text: "project.task" }); - await contains(".btn", { text: "1 Future" }); -}); diff --git a/addons/project_todo/static/tests/helpers/mock_server/controller/project_todo.js b/addons/project_todo/static/tests/helpers/mock_server/controller/project_todo.js deleted file mode 100644 index e4d8fbefb55..00000000000 --- a/addons/project_todo/static/tests/helpers/mock_server/controller/project_todo.js +++ /dev/null @@ -1,44 +0,0 @@ -/** @odoo-module **/ - -import { patch } from "@web/core/utils/patch"; -import { serializeDate } from "@web/core/l10n/dates"; -import { MockServer } from '@web/../tests/helpers/mock_server'; - -const { DateTime } = luxon; - -patch(MockServer.prototype, { - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - */ - async _performRPC(route, args) { - if (route === '/project_todo/new') { - return this._mockRouteTodoNew(args); - } - return super._performRPC(...arguments); - }, - - //-------------------------------------------------------------------------- - // Private Mocked Routes - //-------------------------------------------------------------------------- - - /** - * Simulates the `/project_todo/new` route. - * - * @private - */ - _mockRouteTodoNew(values) { - const taskId = this.pyEnv['project.task'].create({ description: values['todo_description'] }); - if (values['date_deadline']) { - this.pyEnv['mail.activity'].create({ - date_deadline: serializeDate(DateTime.fromISO(values['date_deadline'])), - note: values['todo_description'], - res_model: 'project.task', - res_id: taskId, - }); - } - }, -}); diff --git a/addons/project_todo/static/tests/helpers/model_definitions_setup.js b/addons/project_todo/static/tests/helpers/model_definitions_setup.js deleted file mode 100644 index d0fb9c5300a..00000000000 --- a/addons/project_todo/static/tests/helpers/model_definitions_setup.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @odoo-module **/ - -import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers'; - -addModelNamesToFetch(['project.task']); diff --git a/addons/project_todo/views/project_task_views.xml b/addons/project_todo/views/project_task_views.xml index a8b6b0d21e8..b03334f9dc9 100644 --- a/addons/project_todo/views/project_task_views.xml +++ b/addons/project_todo/views/project_task_views.xml @@ -198,7 +198,7 @@
- +
diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index d327deab2f3..520f4e11f25 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -654,11 +654,21 @@ purchase.order + -
-
- - +
+
+ +
+ +
+
+ +
+
diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml index 20a71f39a6a..137502f8398 100644 --- a/addons/repair/views/repair_views.xml +++ b/addons/repair/views/repair_views.xml @@ -3,6 +3,25 @@ + + repair.order.view.activity + repair.order + + + +
+ +
+ + + +
+
+
+
+
+
+ repair.tree repair.order @@ -298,7 +317,7 @@ Repair Orders repair.order - tree,kanban,graph,pivot,form + tree,kanban,graph,pivot,form,activity

diff --git a/addons/sale/views/sale_order_views.xml b/addons/sale/views/sale_order_views.xml index ab572cd007e..11acd41b6b7 100644 --- a/addons/sale/views/sale_order_views.xml +++ b/addons/sale/views/sale_order_views.xml @@ -7,12 +7,20 @@ sale.order.activity sale.order - + + -

-
- - +
+
+ +
+ +
+
+ +
+
diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index 16ad4d8790c..6e59f3e393d 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -1,5 +1,20 @@ + + stock.picking.view.activity + stock.picking + + + +
+ + +
+
+
+
+
+ stock.picking.calendar stock.picking @@ -436,7 +451,7 @@ Receipts stock.picking - tree,kanban,form,calendar + tree,kanban,form,calendar,activity {'contact_display': 'partner_address', 'default_company_id': allowed_company_ids[0], 'restricted_picking_type_code': 'incoming', 'search_default_reception': 1} @@ -452,7 +467,7 @@ Deliveries stock.picking - tree,kanban,form,calendar + tree,kanban,form,calendar,activity {'contact_display': 'partner_address', 'default_company_id': allowed_company_ids[0], 'restricted_picking_type_code': 'outgoing', 'search_default_delivery': 1} diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml index 2dfd3d490e4..3c5daad9337 100644 --- a/addons/survey/views/survey_survey_views.xml +++ b/addons/survey/views/survey_survey_views.xml @@ -371,6 +371,24 @@ + + + survey.survey.view.activity + survey.survey + + + +
+ +
+ +
+
+
+
+
+
+ survey.survey.search survey.survey diff --git a/addons/test_mail/data/data.xml b/addons/test_mail/data/data.xml index 1e4c55f59f0..f55a629e4b0 100644 --- a/addons/test_mail/data/data.xml +++ b/addons/test_mail/data/data.xml @@ -36,5 +36,11 @@ trigger + + Upload Document + 5 + upload_file + mail.test.activity +
diff --git a/addons/test_mail/static/tests/activity_tests.js b/addons/test_mail/static/tests/activity_tests.js index a07528a6bc5..cde4946c552 100644 --- a/addons/test_mail/static/tests/activity_tests.js +++ b/addons/test_mail/static/tests/activity_tests.js @@ -6,7 +6,10 @@ import { ActivityModel } from "@mail/views/web/activity/activity_model"; import { ActivityRenderer } from "@mail/views/web/activity/activity_renderer"; import { start } from "@mail/../tests/helpers/test_utils"; +import { RelationalModel } from "@web/model/relational_model/relational_model"; +import { Domain } from "@web/core/domain"; import { serializeDate } from "@web/core/l10n/dates"; +import { deepEqual } from "@web/core/utils/objects"; import { session } from "@web/session"; import testUtils from "@web/../tests/legacy/helpers/test_utils"; import { editInput, patchWithCleanup, click, patchDate } from "@web/../tests/helpers/utils"; @@ -23,6 +26,28 @@ QUnit.module("test_mail", {}, function () { QUnit.module("activity view", { async beforeEach() { patchDate(2023, 4, 8, 10, 0, 0); + patchWithCleanup(RelationalModel.prototype, { + async load(params) { + if (params.domain) { + // Remove domain term used to filter record having "done" activities (not understood by the getRecords mock) + const domain = new Domain(params.domain); + const newDomain = Domain.removeDomainLeaves(domain.toList(), [ + "activity_ids.active", + ]); + if (!deepEqual(domain.toList(), newDomain.toList())) { + return super.load({ + ...params, + domain: newDomain.toList(), + context: params.context + ? { ...params.context, active_test: false } + : { active_test: false }, + }); + } + return super.load(params); + } + return super.load(params); + }, + }); pyEnv = await startServer(); const mailTemplateIds = pyEnv["mail.template"].create([ { name: "Template1" }, @@ -186,7 +211,7 @@ QUnit.module("test_mail", {}, function () { 'should contain "Meeting Room Furnitures" in first colum of second row' ); - const today = DateTime.now().toLocaleString({ day: "numeric", month: "short" }); + const today = DateTime.now().toLocaleString(luxon.DateTime.DATE_SHORT); assert.ok( $activity.find( @@ -380,8 +405,8 @@ QUnit.module("test_mail", {}, function () { }); assert.verifySteps([ - JSON.stringify([["activity_ids", "!=", false]]), - JSON.stringify([["activity_ids", "!=", false]]), + JSON.stringify([["activity_ids.active", "in", [true, false]]]), + '[[1,"=",1]]', // Due to the patch above that removes it ]); }); diff --git a/addons/test_mail/static/tests/systray_activity_menu_tests.js b/addons/test_mail/static/tests/systray_activity_menu_tests.js index fa398e250af..16a2638ae34 100644 --- a/addons/test_mail/static/tests/systray_activity_menu_tests.js +++ b/addons/test_mail/static/tests/systray_activity_menu_tests.js @@ -45,6 +45,12 @@ QUnit.test("do not show empty text when at least some future activities", async }); QUnit.test("activity menu widget: activity menu with 2 models", async (assert) => { + function selectContaining(domElement, selector, containings) { + return Array.from(domElement.querySelectorAll(selector)).filter((sel) => + containings.every((containing) => sel.textContent.includes(containing)) + ); + } + const tomorrow = today().plus({ days: 1 }); const yesterday = today().plus({ days: -1 }); const pyEnv = await startServer(); @@ -73,102 +79,46 @@ QUnit.test("activity menu widget: activity menu with 2 models", async (assert) = await contains(".o_menu_systray i[aria-label='Activities']"); await contains(".o-mail-ActivityMenu-counter"); await contains(".o-mail-ActivityMenu-counter", { text: "5" }); - let context = {}; + const actionChecks = { + context: { force_search_count: 1 }, + domain: [["activity_user_id", "=", session.uid]], + }; patchWithCleanup(env.services.action, { doAction(action) { - assert.deepEqual(action.context, context); + Object.entries(actionChecks).forEach(([key, value]) => { + assert.deepEqual(action[key], value); + }); + assert.step("do_action:" + action.name); }, }); - context = { - force_search_count: 1, - search_default_activities_overdue: 1, - }; await click(".o_menu_systray i[aria-label='Activities']"); await contains(".o-mail-ActivityMenu"); await contains(".o-mail-ActivityMenu .o-mail-ActivityGroup", { count: 2 }); - await click(".o-mail-ActivityMenu .o-mail-ActivityGroup button", { text: "1 Late" }); + assert.ok( + selectContaining(document, ".o-mail-ActivityMenu .o-mail-ActivityGroup", ["res.partner", "0 Late"]) + ); + assert.ok( + selectContaining(document, ".o-mail-ActivityMenu .o-mail-ActivityGroup", ["res.partner", "1 Today"]) + ); + assert.ok( + selectContaining(document, ".o-mail-ActivityMenu .o-mail-ActivityGroup", ["res.partner", "0 Future"]) + ); + assert.ok( + selectContaining(document, ".o-mail-ActivityMenu .o-mail-ActivityGroup", ["mail.test.activity", "1 Late"]) + ); + assert.ok( + selectContaining(document, ".o-mail-ActivityMenu .o-mail-ActivityGroup", ["mail.test.activity", "1 Today"]) + ); + assert.ok( + selectContaining(document, ".o-mail-ActivityMenu .o-mail-ActivityGroup", ["mail.test.activity", "2 Future"]) + ); + actionChecks.res_model = "res.partner"; + await click(".o-mail-ActivityMenu .o-mail-ActivityGroup", { text: "res.partner" }); await contains(".o-mail-ActivityMenu", { count: 0 }); - context = { - force_search_count: 1, - search_default_activities_today: 1, - }; - await click(".o_menu_systray .dropdown-toggle:has(i[aria-label='Activities'])"); - await click(":nth-child(1 of .o-mail-ActivityGroup) button", { text: "1 Today" }); - await contains(".o-mail-ActivityMenu", { count: 0 }); - context = { - force_search_count: 1, - search_default_activities_upcoming_all: 1, - }; - await click(".o_menu_systray i[aria-label='Activities']"); - await click(".o-mail-ActivityMenu .o-mail-ActivityGroup button", { text: "2 Future" }); - await contains(".o-mail-ActivityMenu", { count: 0 }); - context = { - force_search_count: 1, - search_default_activities_overdue: 1, - search_default_activities_today: 1, - }; await click(".o_menu_systray i[aria-label='Activities']"); + actionChecks.res_model = "mail.test.activity"; await click(".o-mail-ActivityMenu .o-mail-ActivityGroup", { text: "mail.test.activity" }); - await contains(".o-mail-ActivityMenu", { count: 0 }); -}); - -QUnit.test("activity menu widget: activity view icon", async (assert) => { - const tomorrow = today().plus({ days: 1 }); - const yesterday = today().plus({ days: -1 }); - const pyEnv = await startServer(); - const partnerId = pyEnv["res.partner"].create({}); - const activityIds = pyEnv["mail.test.activity"].create([{}, {}, {}, {}]); - pyEnv["mail.activity"].create([ - { res_id: partnerId, res_model: "res.partner" }, - { res_id: activityIds[0], res_model: "mail.test.activity" }, - { - date_deadline: serializeDate(tomorrow), - res_id: activityIds[1], - res_model: "mail.test.activity", - }, - { - date_deadline: serializeDate(tomorrow), - res_id: activityIds[2], - res_model: "mail.test.activity", - }, - { - date_deadline: serializeDate(yesterday), - res_id: activityIds[3], - res_model: "mail.test.activity", - }, - ]); - const { env } = await start(); - await click(".o_menu_systray i[aria-label='Activities']"); - await contains("button[title='Summary']", { count: 2 }); - await contains(".o-mail-ActivityGroup", { - text: "res.partner", - contains: ["button[title='Summary'].fa-clock-o"], - }); - await contains(".o-mail-ActivityGroup", { - text: "mail.test.activity", - contains: ["button[title='Summary'].fa-clock-o"], - }); - await contains(".show .dropdown-menu"); - patchWithCleanup(env.services.action, { - doAction(action) { - if (action.name) { - assert.ok(action.domain); - assert.deepEqual(action.domain, [["activity_ids.user_id", "=", session.uid]]); - assert.step("do_action:" + action.name); - } else { - assert.step("do_action:" + action); - } - }, - }); - await click("button[title='Summary']", { - parent: [".o-mail-ActivityGroup", { text: "mail.test.activity" }], - }); - await contains(".dropdown-menu", { count: 0 }); - await click(".o_menu_systray i[aria-label='Activities']"); - await click("button[title='Summary']", { - parent: [".o-mail-ActivityGroup", { text: "res.partner" }], - }); - assert.verifySteps(["do_action:mail.test.activity", "do_action:res.partner"]); + assert.verifySteps(["do_action:res.partner", "do_action:mail.test.activity"]); }); QUnit.test("activity menu widget: close on messaging menu click", async () => { diff --git a/addons/test_mail/tests/test_mail_activity.py b/addons/test_mail/tests/test_mail_activity.py index b19314b9159..f64a2ba6edd 100644 --- a/addons/test_mail/tests/test_mail_activity.py +++ b/addons/test_mail/tests/test_mail_activity.py @@ -23,7 +23,9 @@ class TestActivityCommon(MailCommon): @classmethod def setUpClass(cls): super(TestActivityCommon, cls).setUpClass() - cls.test_record = cls.env['mail.test.activity'].with_context(cls._test_context).create({'name': 'Test'}) + cls.test_record, cls.test_record_2 = cls.env['mail.test.activity'].with_context(cls._test_context).create([ + {'name': 'Test'}, {'name': 'Test_2'}, + ]) # reset ctx cls._reset_mail_context(cls.test_record) @@ -341,6 +343,7 @@ class TestActivityMixin(TestActivityCommon): user_id=self.user_admin.id, feedback='Test feedback',) self.assertEqual(self.test_record.activity_ids, act2 | act3) + self.assertFalse(act1.exists()) # Reschedule all activities, should update the record state self.assertEqual(self.test_record.activity_state, 'overdue') @@ -354,6 +357,7 @@ class TestActivityMixin(TestActivityCommon): self.test_record.activity_feedback( ['test_mail.mail_act_test_todo'], feedback='Test feedback') + self.assertFalse(act3.exists()) # Setting activities as done should delete them and post messages self.assertEqual(self.test_record.activity_ids, act2) @@ -366,6 +370,8 @@ class TestActivityMixin(TestActivityCommon): # Canceling activities should simply remove them self.assertEqual(self.test_record.activity_ids, self.env['mail.activity']) self.assertEqual(len(self.test_record.message_ids), 2) + self.assertFalse(self.test_record.activity_state) + self.assertFalse(act2.exists()) @mute_logger('odoo.addons.mail.models.mail_mail') def test_activity_mixin_archive(self): @@ -717,6 +723,160 @@ class TestActivityMixin(TestActivityCommon): self.assertEqual(test_record_1, record) +@tests.tagged('mail_activity') +class TestActivityViewHelpers(TestActivityCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.type_todo = cls.env.ref('test_mail.mail_act_test_todo') + cls.type_call = cls.env.ref('test_mail.mail_act_test_call') + cls.type_upload = cls.env.ref('test_mail.mail_act_test_upload_document') + cls.user_employee_2 = mail_new_test_user( + cls.env, + name='Employee2', + login='employee2', + ) + cls.attachment_1, cls.attachment_2 = cls.env['ir.attachment'].create([{ + 'name': f"Uploaded doc_{idx + 1}", + 'raw': b'bar', + 'res_model': cls.test_record_2._name, + 'res_id': cls.test_record_2.id, + } for idx in range(2)]) + cls.upload_type = cls.env.ref('test_mail.mail_act_test_upload_document') + cls.upload_type.sudo().keep_done = True + cls.user_employee.tz = cls.user_admin.tz + + @freeze_time("2023-10-18 06:00:00") + def test_get_activity_data(self): + get_activity_data = self.env['mail.activity'].get_activity_data + with self.with_user('employee'): + # Setup activities: 3 for the first record, 2 "done" and 2 ongoing for the second + test_record, test_record_2 = self.env['mail.test.activity'].browse( + (self.test_record + self.test_record_2).ids + ) + now_utc = datetime.now(pytz.UTC) + now_user = now_utc.astimezone(pytz.timezone(self.env.user.tz or 'UTC')) + today_user = now_user.date() + + for days, user_id in ((-1, self.user_employee_2), (0, self.user_employee), (1, self.user_admin)): + test_record.activity_schedule( + 'test_mail.mail_act_test_upload_document', + today_user + relativedelta(days=days), + user_id=user_id.id) + for days, user_id in ((-2, self.user_admin), (0, self.user_employee), (2, self.user_employee_2), + (3, self.user_admin)): + test_record_2.activity_schedule( + 'test_mail.mail_act_test_upload_document', + today_user + relativedelta(days=days), + user_id=user_id.id) + record_activities = test_record.activity_ids + record_2_activities = test_record_2.activity_ids + record_2_activities[0].action_feedback(feedback='Done', attachment_ids=self.attachment_1.ids) + record_2_activities[1].action_feedback(feedback='Done', attachment_ids=self.attachment_2.ids) + + # Check get activity data + activity_data = get_activity_data('mail.test.activity', None, fetch_done=True) + self.assertEqual(activity_data['activity_res_ids'], [test_record.id, test_record_2.id]) + self.assertDictEqual( + next((t for t in activity_data['activity_types'] if t['id'] == self.upload_type.id), {}), + { + 'id': self.upload_type.id, + 'name': 'Upload Document', + 'template_ids': [], + 'keep_done': True, + }) + + grouped = activity_data['grouped_activities'][test_record.id][self.upload_type.id] + grouped['ids'] = set(grouped['ids']) # ids order doesn't matter + self.assertDictEqual(grouped, { + 'state': 'overdue', + 'count_by_state': {'overdue': 1, 'planned': 1, 'today': 1}, + 'ids': set(record_activities.ids), + 'reporting_date': record_activities[0].date_deadline, + 'user_assigned_ids': record_activities.user_id.ids, + }) + + grouped = activity_data['grouped_activities'][test_record_2.id][self.upload_type.id] + grouped['ids'] = set(grouped['ids']) + self.assertDictEqual(grouped, { + 'state': 'planned', + 'count_by_state': {'done': 2, 'planned': 2}, + 'ids': set(record_2_activities.ids), + 'reporting_date': record_2_activities[2].date_deadline, + 'user_assigned_ids': record_2_activities[2:].user_id.ids, + 'attachments_info': { + 'count': 2, 'most_recent_id': self.attachment_2.id, 'most_recent_name': 'Uploaded doc_2'} + }) + + # Mark all first record activities as "done" and check activity data + record_activities.action_feedback(feedback='Done', attachment_ids=self.attachment_1.ids) + self.assertEqual(record_activities[2].date_done, date.today()) # Thanks to freeze_time + activity_data = get_activity_data('mail.test.activity', None, fetch_done=True) + grouped = activity_data['grouped_activities'][test_record.id][self.upload_type.id] + grouped['ids'] = set(grouped['ids']) + self.assertDictEqual(grouped, { + 'state': 'done', + 'count_by_state': {'done': 3}, + 'ids': set(record_activities.ids), + 'reporting_date': record_activities[2].date_done, + 'user_assigned_ids': [], + 'attachments_info': { + 'count': 1, # 1 instead of 3 because all attachments are the same one + 'most_recent_id': self.attachment_1.id, + 'most_recent_name': self.attachment_1.name, + } + }) + self.assertEqual(activity_data['activity_res_ids'], [test_record_2.id, test_record.id]) + + # Check filters (domain, pagination and fetch_done) + self.assertEqual( + get_activity_data('mail.test.activity', domain=[('id', 'in', test_record.ids)], + fetch_done=True)['activity_res_ids'], + [test_record.id]) + self.assertEqual(get_activity_data('mail.test.activity', None, fetch_done=False)['activity_res_ids'], + [test_record_2.id]) + # Note that the records are ordered by ids not by deadline (so we get the "wrong" order) + self.assertEqual( + get_activity_data('mail.test.activity', None, offset=1, fetch_done=True)['activity_res_ids'], + [test_record_2.id]) + self.assertEqual( + get_activity_data('mail.test.activity', None, limit=1, fetch_done=True)['activity_res_ids'], + [test_record.id]) + + # Unset keep done and check activity data: record with only "done" activities must not be returned + self.upload_type.sudo().keep_done = False + activity_data = get_activity_data('mail.test.activity', None, fetch_done=True) + self.assertDictEqual( + next((t for t in activity_data['activity_types'] if t['id'] == self.upload_type.id), {}), + { + 'id': self.upload_type.id, + 'name': 'Upload Document', + 'template_ids': [], + 'keep_done': False, + }) + self.assertEqual(activity_data['activity_res_ids'], [test_record_2.id]) + + # Unarchiving activities should restore the activity + record_activities.action_unarchive() + self.assertFalse(any(act.date_done for act in record_activities)) + self.assertTrue(all(act.date_deadline for act in record_activities)) + self.upload_type.sudo().keep_done = True + activity_data = get_activity_data('mail.test.activity', None, fetch_done=True) + grouped = activity_data['grouped_activities'][test_record.id][self.upload_type.id] + self.assertEqual(grouped['state'], 'overdue') + self.assertEqual(grouped['count_by_state'], {'overdue': 1, 'planned': 1, 'today': 1}) + self.assertEqual(grouped['reporting_date'], record_activities[0].date_deadline) + self.assertEqual(activity_data['activity_res_ids'], [test_record.id, test_record_2.id]) + grouped['ids'] = set(grouped['ids']) + self.assertDictEqual(grouped, { + 'state': 'overdue', + 'count_by_state': {'overdue': 1, 'planned': 1, 'today': 1}, + 'ids': set(record_activities.ids), + 'reporting_date': record_activities[0].date_deadline, + 'user_assigned_ids': record_activities.user_id.ids, + }) + + @tests.tagged('mail_activity') class TestORM(TestActivityCommon): """Test for read_progress_bar""" diff --git a/addons/test_mail/tests/test_mail_multicompany.py b/addons/test_mail/tests/test_mail_multicompany.py index 32702e2f0fd..1c05575c648 100644 --- a/addons/test_mail/tests/test_mail_multicompany.py +++ b/addons/test_mail/tests/test_mail_multicompany.py @@ -271,7 +271,6 @@ class TestMultiCompanySetup(TestMailMCCommon): self.assertEqual( test_activity, { - "actions": [{"icon": "fa-clock-o", "name": "Summary"}], "icon": "/base/static/description/icon.png", "id": self.env["ir.model"]._get_id("mail.test.multi.company.with.activity"), "model": "mail.test.multi.company.with.activity", @@ -281,6 +280,7 @@ class TestMultiCompanySetup(TestMailMCCommon): "today_count": 2, "total_count": 2, "type": "activity", + "view_type": "list", } ) @@ -291,7 +291,6 @@ class TestMultiCompanySetup(TestMailMCCommon): self.assertEqual( test_activity, { - "actions": [{"icon": "fa-clock-o", "name": "Summary"}], "icon": "/base/static/description/icon.png", "id": self.env["ir.model"]._get_id("mail.test.multi.company.with.activity"), "model": "mail.test.multi.company.with.activity", @@ -301,6 +300,7 @@ class TestMultiCompanySetup(TestMailMCCommon): "today_count": 1, "total_count": 1, "type": "activity", + "view_type": "list", } )