[MERGE] mail, various: improve activity view, allow to keep done activities

[IMP] {test_}mail: allow to keep done activities and improve activity view

We add the option to keep the completed activities to allow the user to keep
track of the past action, especially the uploaded files which are displayed
along the completed activities. This feature can be activated per activity
type and is disabled by default.

We also improve the activity view to give a better overview of what has been
done and what is still to be done and by whom.

We also add tests for those new features:
- check activity data for the views (server-side test)

* Details about the activity view improvements:

** Improvement of the column header:

- progressbar: previously, only visible activities were counted in the
progression bar. Now all are counted.
- activity counter: instead of displaying the total count (next to the
progression bar), we display now a ratio as 2 numbers (only if keep done is
enabled):

** Improvement of the activity cells:

- add assignees in activity cells of activity view (limit to 2 having the
activity with closest deadline, if more a + is displayed). Note: to avoid
spacing between avatar, we add the optional props noSpacing to the avatar
component.
- add done activity in activity cell popover
- increase by 50% the popover height
- display done activity cell in grey
- instead of displaying the activity count if greater than 1, we display a
ratio ongoing / activity count. Note: we don't display the denominator if all
activities are done or all are ongoing.

** Progress bar filter

The activity filter is slighly modified. Previously, when clicking on a color
of the progress bar, all cell with a different color where hidden (ex.: if we
were clicking on the green area to filter planned activities, red cell which
represents overdue activities were hidden even if there was also a planned
activities for this record and activity type). Now we also display cells with
other "color" if there exist for that record and that type an activity
corresponding to the filtered "color".

[IMP] crm, {test_}mail: allows to filter record having "done" activity

To allow to search records having "done" activity (archived) assigned to
someone, we have modified the search method of the field activity_user_id 
(in the activity mixin) so that it matches also responsible of archived activities.
This field is mainly used when coming from the systray to display user
activities including "done" ones but could be also used in custom filters.

We have also modified the default domain when fetching record for the activity
view so that it matches records having at least one activity, archived or not.
This prevents to load more records than necessary to display the activity view.

We also adapt the test accordingly.

[IMP] mail, various: simplify the systray

The system tray has been simplified:
- only one clickable area per activity group
- by default, a click on an activity group open the list view, except for some
model (ex.: journal entries, project, task, documents where the activity view
is opened). This can be defined per model using the _systray_view attribute on
the model.
- the clock icon providing access to the activity view has been removed. As it
is the only action, we also remove "actions" returned by systray_get_activities
in res_users.
- the KPIs are no longer clickable

The tests have been updated (for example, because we open the list view instead
of the kanban view) and one removed "activity menu widget: activity view icon"
as we only have one clickable area now and no more icons.

[IMP] various: add activity view for different model

We add the activity view for:
- event.event
- maintenance.request
- mrp.production, mrp.unbuild
- repair.order
- stock.picking

[IMP] various: improve custom activity views

The following summary view of the record in the activity views have been
improved:
- project_task: task state added
- project_project: project manager added
- event_event: responsible and the dates (date_begin_located and
date_end_located) added
- account_move: total amount, customer and state added
- sale_order: total amount and the state added
- purchase_order: total amount and the state added
- crm_lead: customer and stage added
- hr_applicant (hr_recruitment): recruiter added
- survey_survey: responsible added
- maintenance.request: added equipment and responsible
- stock.picking: added scheduled_date
- repair.order: responsible, schedule_date and product_id added
- mrp.production: added responsible
- hr_leave (hr_holidays): status is added and default deadline modified see
below

Ensures that "Schedule activity" is in one line by adding a colspan.

hr_leave activity: When a time off approval activity is created as a
consequence of the creation of a hr.leave, the deadline of the activity is set
to the date_from of the hr.leave minus activity type delay_count (default 15)
except if it leads to a date anterior to today. In that case it is set to
today. That way, in the activity view, the time off approval activities are
more or less sorted by related hr.leave date_from and the cell date is an
indication of when the time off is planned.

Technical note: on most activity view, the activity record was not occupying
all the horizontal space. To solve that problem the css has been modified and
the max-width (200) that was imposed on the sub div has been removed. And as
it was impossible to impose a max-width for a flex div (which is the common
case for the activity record), the max width is imposed on each text that might
be too long using the class o_text_block. That class has been modified to
impose a max-width. That's why that class has been added in most view.
Alternativly, we could have modified the activity compiler to add that class
when the attribute full was set (not done because not sure of the consequence).

[REM] project_todo: remove add todo from the systray

In order to simplify the systray, we remove the action "Add a To-do" from the
activity menu in the systray.

Technical note: We remove the following code in project_todo:
- static/src/web/activity: which add the "Add a To-do" in activity menu in the
systray with its logic
- controllers: which add specific controller for the "Add a To-do" in the
systray
- static/tests: which test the todo from the activity menu
- static/tests/helpers: that supports the test that we have removed (ex.:
simulate the removed controller)

Task-3300854

closes odoo/odoo#138135

Related: odoo/upgrade#5264
Related: odoo/enterprise#48685
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2023-10-19 19:08:12 +00:00
committed by Odoo Online
81 changed files with 1158 additions and 727 deletions
+1
View File
@@ -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):
@@ -1286,6 +1286,30 @@
</field>
</record>
<record id="account_move_view_activity" model="ir.ui.view">
<field name="name">account.move.view.activity</field>
<field name="model">account.move</field>
<field name="arch" type="xml">
<activity string="Account Entry">
<field name="currency_id"/>
<templates>
<div t-name="activity-box" class="d-block">
<div class="d-flex justify-content-between">
<field name="name" string="Entry Name" class="o_text_block o_text_bold"/>
<div class="m-1"/>
<field name="amount_total" widget="monetary"/>
</div>
<div class="d-flex justify-content-between">
<field name="commercial_partner_id" string="Commercial Entity" class="o_text_block"/>
<div class="m-1"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'posted'"/>
</div>
</div>
</templates>
</activity>
</field>
</record>
<record id="view_account_move_filter" model="ir.ui.view">
<field name="name">account.move.select</field>
<field name="model">account.move</field>
+1
View File
@@ -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'
+4 -2
View File
@@ -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)
@@ -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" });
+13 -4
View File
@@ -513,9 +513,17 @@
<templates>
<div t-name="activity-box">
<field name="user_id" widget="many2one_avatar_user" domain="[('share', '=', False)]"/>
<div>
<field name="name" display="full"/>
<field name="expected_revenue" widget="monetary" display="full" muted="1"/>
<div class="flex-grow-1">
<div class="d-flex justify-content-between">
<field name="name" display="full" class="o_text_block o_text_bold"/>
<div class="m-1"/>
<field name="expected_revenue" widget='monetary' display="full" muted="1"/>
</div>
<div class="d-flex justify-content-between">
<field name="partner_id" muted="1" display="full" class="o_text_block"/>
<div class="m-1"/>
<field name="stage_id" widget="badge"/>
</div>
</div>
</div>
</templates>
@@ -1115,7 +1123,8 @@
<field name="res_model">crm.lead</field>
<field name="view_mode">tree,kanban,graph,pivot,calendar,form,activity</field>
<field name="view_id" ref="crm_lead_view_list_activities"/>
<field name="domain">[('activity_ids','!=',False)]</field>
<!-- Ensure that only records with at least one activity, "done" (archived) or not, are fetched. -->
<field name="domain">[("activity_ids.active", "in", [True, False])]</field>
<field name="search_view_id" ref="crm.view_crm_case_my_activities_filter"/>
<field name="context">{'default_type': 'opportunity',
'search_default_assigned_to_me': 1}
+21 -1
View File
@@ -158,6 +158,26 @@
</field>
</record>
<record id="event_event_view_activity" model="ir.ui.view">
<field name="name">event.event.view.activity</field>
<field name="model">event.event</field>
<field name="arch" type="xml">
<activity string="Event">
<templates>
<div t-name="activity-box">
<field name="user_id" widget="many2one_avatar_user"/>
<div class="flex-grow-1">
<field name="name" string="Event Name" class="o_text_block o_text_bold"/>
<field name="date_begin"/>
<i class="fa fa-long-arrow-right mx-2" aria-label="Arrow icon" title="Arrow" />
<field name="date_end"/>
</div>
</div>
</templates>
</activity>
</field>
</record>
<record id="event_event_view_form_quick_create" model="ir.ui.view">
<field name="name">event.event.form.quick_create</field>
<field name="model">event.event</field>
@@ -295,7 +315,7 @@
<record model="ir.actions.act_window" id="action_event_view">
<field name="name">Events</field>
<field name="res_model">event.event</field>
<field name="view_mode">kanban,calendar,tree,form,pivot,graph</field>
<field name="view_mode">kanban,calendar,tree,form,pivot,graph,activity</field>
<field name="search_view_id" ref="view_event_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -179,7 +179,7 @@
<div t-name="activity-box">
<img t-att-src="activity_image('res.partner', 'avatar_128', record.purchaser_id.raw_value)" t-att-title="record.purchaser_id.value" t-att-alt="record.purchaser_id.value"/>
<div class="ms-2">
<field name="vehicle_id" display="full"/>
<field name="vehicle_id" display="full" class="o_text_block"/>
</div>
</div>
</templates>
@@ -356,8 +356,8 @@
<div t-name="activity-box">
<img t-att-src="activity_image('fleet.vehicle', 'image_128', record.vehicle_id.raw_value)" role="img" t-att-title="record.vehicle_id.value" t-att-alt="record.vehicle_id.value"/>
<div class="ms-2">
<field name="vehicle_id"/>
<field t-if="record.description.raw_value" name="description" muted="1"/>
<field name="vehicle_id" class="o_text_block"/>
<field t-if="record.description.raw_value" name="description" muted="1" class="o_text_block"/>
</div>
</div>
</templates>
+2 -2
View File
@@ -338,8 +338,8 @@
<div t-name="activity-box">
<img class="rounded-circle" t-att-src="activity_image('fleet.vehicle', 'image_128', record.id.raw_value)" role="img" t-att-title="record.id.value" t-att-alt="record.id.value"/>
<div class="ms-2">
<field name="license_plate" display="full"/>
<field name="model_id" muted="1"/>
<field name="license_plate" display="full" class="o_text_block"/>
<field name="model_id" muted="1" class="o_text_block"/>
</div>
</div>
</templates>
+2 -2
View File
@@ -331,8 +331,8 @@
<div t-name="activity-box">
<img class="rounded" t-att-src="activity_image('hr.employee', 'avatar_128', record.id.raw_value)" role="img" t-att-title="record.id.value" t-att-alt="record.id.value"/>
<div class="ms-2">
<field name="name" display="full"/>
<field name="job_id" muted="1" display="full"/>
<field name="name" display="full" class="o_text_block"/>
<field name="job_id" muted="1" display="full" class="o_text_block"/>
</div>
</div>
</templates>
@@ -355,8 +355,8 @@
<div t-name="activity-box">
<img class="rounded" t-att-src="activity_image('hr.employee', 'avatar_128', record.employee_id.raw_value)" t-att-title="record.employee_id.value" t-att-alt="record.employee_id.value"/>
<div class="ms-2">
<field name="name" display="full"/>
<field name="job_id" muted="1" display="full"/>
<field name="name" display="full" class="o_text_block"/>
<field name="job_id" muted="1" display="full" class="o_text_block"/>
</div>
</div>
</templates>
+2 -2
View File
@@ -396,7 +396,7 @@
<div t-name="activity-box">
<img class="rounded" t-att-src="activity_image('hr.employee', 'avatar_128', record.employee_id.raw_value)" t-att-title="record.employee_id.value" t-att-alt="record.employee_id.value"/>
<div class="ms-2">
<field name="name" display="full"/>
<field name="name" display="full" class="o_text_block"/>
<field name="total_amount" widget="monetary" muted="1" display="full"/>
</div>
</div>
@@ -1025,7 +1025,7 @@
<div t-name="activity-box">
<img class="rounded" t-att-src="activity_image('hr.employee', 'avatar_128', record.employee_id.raw_value)" t-att-title="record.employee_id.value" t-att-alt="record.employee_id.value"/>
<div class="ms-2">
<field name="name" display="full"/>
<field name="name" display="full" class="o_text_block"/>
<field name="total_amount" widget="monetary" muted="1" display="full"/>
<field name="state" display="right"/>
</div>
@@ -6,6 +6,7 @@
<field name="name">Time Off Approval</field>
<field name="icon">fa-sun-o</field>
<field name="res_model">hr.leave</field>
<field name="delay_count">15</field>
</record>
<record id="mail_act_leave_second_approval" model="mail.activity.type">
<field name="name">Time Off Second Approve</field>
+11 -1
View File
@@ -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,
@@ -431,7 +431,7 @@
<div t-name="activity-box">
<img class="rounded" t-att-src="activity_image('hr.employee', 'avatar_128', record.employee_id.raw_value)" t-att-title="record.employee_id.value" t-att-alt="record.employee_id.value"/>
<div class="ms-2">
<field name="employee_id" bold="1"/> <span class="text-muted">(<field name="number_of_days"/> days)</span>
<field name="employee_id" class="o_text_block o_text_bold"/> <span class="text-muted">(<field name="number_of_days"/> days)</span>
<field name="holiday_status_id" muted="1" display="full"/>
</div>
</div>
+7 -6
View File
@@ -184,15 +184,16 @@
<field name="arch" type="xml">
<activity string="Time Off Request">
<field name="employee_id"/>
<field name="date_from"/>
<field name="date_to"/>
<field name="number_of_days"/>
<templates>
<div t-name="activity-box">
<img class="rounded" t-att-src="activity_image('hr.employee', 'avatar_128', record.employee_id.raw_value)" t-att-title="record.employee_id.value" t-att-alt="record.employee_id.value" width="50" height="50"/>
<div class="ms-2">
<field name="name" bold="1"/> <span class="text-muted">(<field name="number_of_days"/> days)</span>
<img class="rounded" t-att-src="activity_image('hr.employee', 'avatar_128', record.employee_id.raw_value)" t-att-title="record.employee_id.value" t-att-alt="record.employee_id.value"/>
<div class="ms-2 flex-grow-1">
<div class="d-flex justify-content-between">
<field name="name" class="o_text_block o_text_bold"/>
<div class="text-muted text-nowrap">(<field name="number_of_days"/> days)</div>
</div>
<div class="text-muted">
<field name="holiday_status_id" display="full"/>
<field name="date_from" widget="date"/> <i class="fa fa-long-arrow-right" title="to" /> <field name="date_to" widget="date"/>
</div>
</div>
@@ -407,9 +407,10 @@
<activity string="Applicants">
<templates>
<div t-name="activity-box">
<div class="ms-2">
<field name="name" display="full"/>
<field name="partner_name" muted="1" display="full"/>
<field name="user_id" widget="many2one_avatar_user"/>
<div class="flex-grow-1">
<field name="name" display="full" class="o_text_block"/>
<field name="partner_name" muted="1" display="full" class="o_text_block"/>
</div>
</div>
</templates>
+164 -43
View File
@@ -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<int> of record id ordered by closest date
(deadline for ongoing activities, and done date for done activities)
'grouped_activities': dict<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,
}
# ----------------------------------------------------------------------
+7 -2
View File
@@ -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):
+1
View File
@@ -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,
+1 -1
View File
@@ -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:
+4 -8
View File
@@ -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:
@@ -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");
}
@@ -28,6 +28,14 @@
</div>
<ActivityListPopoverItem t-foreach="plannedActivities" t-as="activity" t-key="activity.id" activity="activity" onActivityChanged="props.onActivityChanged" onClickEditActivityButton="props.close" onClickDoneAndScheduleNext="props.close"/>
</t>
<t t-if="doneActivities.length > 0">
<div class="o-mail-ActivityListPopover-done d-flex bg-100 py-2 border-bottom">
<span class="fw-bold mx-3">Done</span>
<span class="flex-grow-1"/>
<span class="badge rounded-pill text-bg-secondary mx-3 align-self-center" t-esc="doneActivities.length"/>
</div>
<ActivityListPopoverItem t-foreach="doneActivities" t-as="activity" t-key="activity.id" activity="activity"/>
</t>
</div>
<button class="btn btn-secondary p-3 text-center" t-on-click="onClickAddActivityButton">
<t t-if="props.resIds"><i class="fa fa-plus fa-fw"/><strong>Schedule an activity on selected records</strong></t>
@@ -24,9 +24,9 @@ export class ActivityListPopoverItem extends Component {
static components = { ActivityMailTemplate, ActivityMarkAsDone, FileUploader };
static props = [
"activity",
"onActivityChanged",
"onActivityChanged?",
"onClickDoneAndScheduleNext?",
"onClickEditActivityButton",
"onClickEditActivityButton?",
];
static template = "mail.ActivityListPopoverItem";
@@ -64,15 +64,19 @@ export class ActivityListPopoverItem extends Component {
}
get hasEditButton() {
return this.props.activity.chaining_type === "suggest" && this.props.activity.can_write;
const activity = this.props.activity;
return (
activity.state !== "done" && activity.chaining_type === "suggest" && activity.can_write
);
}
get hasFileUploader() {
return this.props.activity.activity_category === "upload_file";
const activity = this.props.activity;
return activity.state !== "done" && activity.activity_category === "upload_file";
}
get hasMarkDoneButton() {
return !this.hasFileUploader;
return this.props.activity.state !== "done" && !this.hasFileUploader;
}
onClickEditActivityButton() {
@@ -99,4 +103,16 @@ export class ActivityListPopoverItem extends Component {
model: "res.users",
});
}
get dateDeadlineFormatted() {
return luxon.DateTime.fromISO(this.props.activity.date_deadline).toLocaleString(
luxon.DateTime.DATE_SHORT
);
}
get dateDoneFormatted() {
return luxon.DateTime.fromISO(this.props.activity.dateDone).toLocaleString(
luxon.DateTime.DATE_SHORT
);
}
}
@@ -33,9 +33,18 @@
<small class="text-truncate" t-esc="props.activity.user_id[1]"/>
<small t-if="props.activity.state !== 'today'" class="mx-1">-</small>
</t>
<small t-if="props.activity.state !== 'today'" class="fw-bold" t-att-title="props.activity.dateDeadline" t-esc="delayLabel"/>
<small t-if="['overdue', 'planned'].includes(props.activity.state)" class="fw-bold" t-att-title="dateDeadlineFormatted" t-esc="delayLabel"/>
<small t-if="props.activity.state === 'done'" class="fw-bold" t-att-title="dateDoneFormatted" t-esc="dateDoneFormatted"/>
</div>
</div>
<div t-if="props.activity.attachment_ids?.length" class="mx-5">
<t t-foreach="props.activity.attachment_ids" t-as="attachment" t-key="attachment.id">
<a t-attf-href="/web/content/#{attachment.id}?download=true"
t-on-click.stop="" t-out="attachment.name"
class="d-inline-block text-truncate" style="max-width: 120px;"/>
<br t-if="!attachment_last"/>
</t>
</div>
<ActivityMarkAsDone t-if="state.hasMarkDoneView" activity="props.activity" close="closeMarkAsDone" onClickDoneAndScheduleNext="props.onClickDoneAndScheduleNext" reload="props.onActivityChanged"/>
<div t-if="props.activity.mail_template_ids.length > 0" class="mx-3 mt-2">
<ActivityMailTemplate activity="props.activity" onClickButtons="props.onClickEditActivityButton" onUpdate="props.onActivityChanged"/>
@@ -1,4 +1,4 @@
.o-mail-ActivityListPopover {
width: #{"min(95vw, 350px)"};
max-height: #{"min(95vh, 350px)"};
max-height: #{"min(95vh, 525px)"};
}
@@ -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,
}
);
}
}
@@ -14,26 +14,25 @@
</div>
<div class="d-flex flex-column list-group-flush" name="activityGroups">
<t t-foreach="store.activityGroups" t-as="group" t-key="group_index" name="activityGroupLoop">
<button class="o-mail-ActivityGroup list-group-item list-group-item-action d-flex p-2" t-att-data-model_name="group.model" t-on-click="() => this.openActivityGroup(group)">
<div class="o-mail-ActivityGroup list-group-item list-group-item-action d-flex p-2 cursor-pointer"
t-att-data-model_name="group.model" t-on-click="() => this.openActivityGroup(group)">
<img alt="Activity" t-att-src="group.icon"/>
<div class="flex-grow-1 overflow-hidden">
<div class="d-flex px-2" name="activityTitle">
<span class="flex-grow-1"><t t-esc="group.name"/></span>
<t t-foreach="group.actions or []" t-as="action" t-key="action_index">
<button class="btn btn-link text-action fa p-0" type="button" t-att-title="action.name" t-att-class="action.icon" t-on-click.stop="() => this.onClickAction(action, group)"/>
</t>
</div>
<div class="d-flex px-2" name="activityTitle" t-out="group.name"/>
<div t-if="group.type === 'activity'" class="d-flex">
<button t-if="group.overdue_count" type="button" class="btn btn-link text-action py-0 px-2" t-on-click.stop="() => this.openActivityGroup(group, 'overdue')"><t t-esc="group.overdue_count"/> Late</button>
<em t-else="" class="text-muted py-0 px-2">0 Late</em>
<button t-if="group.today_count" type="button" class="btn btn-link text-action py-0 px-2" t-on-click.stop="() => this.openActivityGroup(group, 'today')"><t t-esc="group.today_count"/> Today</button>
<em t-else="" class="text-muted py-0 px-2">0 Today</em>
<span t-attf-class="#{group.overdue_count ? '' : 'text-muted'} py-0 px-2">
<t t-out="group.overdue_count"/> Late
</span>
<span t-attf-class="#{group.today_count ? '' : 'text-muted'} py-0 px-2">
<t t-out="group.today_count"/> Today
</span>
<span class="flex-grow-1"/>
<button t-if="group.planned_count" type="button" class="btn btn-link text-action py-0 px-2" t-on-click.stop="() => this.openActivityGroup(group, 'upcoming_all')"><t t-esc="group.planned_count"/> Future</button>
<em t-else="" class="text-muted py-0 px-2">0 Future</em>
<span t-attf-class="#{group.planned_count ? '' : 'text-muted'} py-0 px-2">
<t t-out="group.planned_count"/> Future
</span>
</div>
</div>
</button>
</div>
</t>
</div>
</div>
@@ -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;
@@ -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";
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mail.ColumnProgress" t-inherit="web.ColumnProgress" t-inherit-mode="primary">
<AnimatedNumber position="after">
<t t-if="props.aggregateOn">
<span class="text-900 text-nowrap cursor-default"
t-att-title="props.aggregateOn.title">
/<span class="o_column_progress_aggregated_on" t-out="props.aggregateOn.value"/>
</span>
</t>
</AnimatedNumber>
</t>
</templates>
@@ -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() {
@@ -1,3 +0,0 @@
.o-mail-ActivityCell-counter {
@include o-position-absolute($bottom: 0, $right: 0);
}
@@ -2,10 +2,41 @@
<templates xml:space="preserve">
<t t-name="mail.ActivityCell">
<div class="h-100 cursor-pointer p-3" t-on-click="onClick">
<div class="h-100 d-flex align-items-center justify-content-center position-relative" t-ref="content">
<div class="o-mail-ActivityCell-deadline text-white" t-out="closestDeadlineFormatted"/>
<span t-if="props.activityIds.length > 1" class="o-mail-ActivityCell-counter badge bg-light rounded-pill border-0" t-out="props.activityIds.length"/>
<div class="h-100 cursor-pointer p-1 d-flex flex-column justify-content-between" t-on-click="onClick">
<div class="d-flex align-items-center justify-content-center position-relative" t-ref="content">
<div class="o-mail-ActivityCell-deadline" t-out="reportingDateFormatted"/>
</div>
<div class="d-flex justify-content-between">
<div t-if="props.userAssignedIds" class="d-flex justify-content-start">
<Avatar t-if="props.userAssignedIds.length > 0"
resModel="'res.users'" resId="props.userAssignedIds[0]"
displayName="''" noSpacing="true"/>
<Avatar t-if="props.userAssignedIds.length > 1"
resModel="'res.users'" resId="props.userAssignedIds[1]"
displayName="''" noSpacing="true"/>
<t t-set="nAdditionalAssignee"
t-value="props.userAssignedIds.length - 2"/>
<span t-if="nAdditionalAssignee > 0">+<t t-out="nAdditionalAssignee"/></span>
</div>
<div t-else=""/>
<div t-if="props.attachmentsInfo and ongoingActivityCount == 0"
class="d-flex w-100 justify-content-center gap-1 px-2">
<a t-attf-href="/web/content/#{props.attachmentsInfo.most_recent_id}?download=true" t-on-click.stop=""
t-out="props.attachmentsInfo.most_recent_name"
class="d-inline-block text-truncate" style="max-width: 120px;"/>
<div t-if="props.attachmentsInfo.count > 1" class="text-nowrap">
+<t t-out="props.attachmentsInfo.count - 1"/>
</div>
</div>
<div t-if="totalActivityCount > 1"
class="o-mail-ActivityCell-counter badge bg-light rounded-pill border-0 m-1">
<t t-if="totalActivityCount == ongoingActivityCount or ongoingActivityCount == 0"
t-out="totalActivityCount"/>
<t t-else="">
<t t-out="ongoingActivityCount"/> / <t t-out="totalActivityCount"/>
</t>
</div>
<div t-else=""/>
</div>
</div>
</t>
@@ -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;
}
}
@@ -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,
});
}
}
@@ -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;
}
}
}
@@ -4,18 +4,18 @@
<thead>
<tr>
<th></th>
<th t-foreach="activeColumns" t-as="type" t-key="type[0]"
class="o_activity_type_cell p-3" t-attf-class="{{ activeFilter.activityTypeId === type[0] ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}"
<th t-foreach="activeColumns" t-as="type" t-key="type.id"
class="o_activity_type_cell p-3" t-attf-class="{{ activeFilter.activityTypeId === type.id ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}"
t-attf-width="{{100/activeColumns.length}}%">
<div>
<span t-esc="type[1]"/>
<span t-if="type[2].length > 0" class="dropdown float-end">
<span t-esc="type.name"/>
<span t-if="type.template_ids.length > 0" class="dropdown float-end">
<i class="fa fa-ellipsis-v fa-fw" data-bs-toggle="dropdown"/>
<div class="dropdown-menu">
<t t-foreach="type[2]" t-as="template" t-key="template.id">
<t t-foreach="type.template_ids" t-as="template" t-key="template.id">
<div data-tooltip="This action will send an email."
class="o_template_element o_send_mail_template cursor-pointer"
t-on-click="() => props.onSendMailTemplate(template.id, type[0])">
t-on-click="() => props.onSendMailTemplate(template.id, type.id)">
<i class="fa fa-envelope fa-fw"/> <t t-esc="template.name"/>
</div>
</t>
@@ -24,7 +24,7 @@
</div>
<div class="o_activity_counter d-flex align-items-center justify-content-between mb-3 h-0 mt24">
<t t-set="group" t-value="getGroupInfo(type)"/>
<ColumnProgress t-if="group.data.count" group="group.data" aggregate="group.aggregate" progressBar="group.data.progressBar" onBarClicked.bind="group.data.filterProgressValue" />
<ColumnProgress t-if="group.data.count" group="group.data" aggregate="group.aggregate" aggregateOn="group.aggregateOn" progressBar="group.data.progressBar" onBarClicked.bind="group.data.filterProgressValue" />
</div>
</th>
<th class="align-middle" style="width: 32px; min-width: 32px">
@@ -38,13 +38,13 @@
</t>
<t t-foreach="props.activityTypes" t-as="type" t-key="type_index">
<DropdownItem parentClosingMode="'none'" onSelected="() => this.toggleDisplayColumn(type[0])">
<DropdownItem parentClosingMode="'none'" onSelected="() => this.toggleDisplayColumn(type.id)">
<CheckBox
onChange="() => this.toggleDisplayColumn(type[0])"
value="storageActiveColumns[type[0]]"
name="type[1]"
onChange="() => this.toggleDisplayColumn(type.id)"
value="storageActiveColumns[type.id]"
name="type.name"
>
<t t-esc="type[1]"/>
<t t-esc="type.name"/>
</CheckBox>
</DropdownItem>
</t>
@@ -65,9 +65,9 @@
<t t-name="mail.ActivityViewRow">
<t t-set="record" t-value="getRecord(resId)"/>
<t t-if="record">
<tr class="o_data_row h-100" t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}">
<tr class="o_data_row h-100" t-attf-class="{{ activeFilter.resIds.has(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}">
<ActivityRecord archInfo="props.archInfo" record="record" openRecord="props.openRecord" />
<t t-foreach="activeColumns" t-as="type" t-key="type[0]">
<t t-foreach="activeColumns" t-as="type" t-key="type.id">
<t t-call="mail.ActivityViewCell"/>
</t>
<td/>
@@ -76,16 +76,23 @@
</t>
<t t-name="mail.ActivityViewCell">
<t t-set="activityGroup" t-value="props.groupedActivities[resId] and props.groupedActivities[resId][type[0]] or {count: 0, ids: [], state: false}"/>
<t t-set="isCellHidden" t-value="activeFilter.resIds.length and !activeFilter.resIds.includes(resId) and activeFilter.activityTypeId === type[0]"/>
<t t-set="activityGroup" t-value="props.groupedActivities[resId] and props.groupedActivities[resId][type.id] or {count: 0, ids: [], state: false}"/>
<t t-set="isCellHidden" t-value="!activeFilter.resIds.has(resId) and activeFilter.activityTypeId === type.id"/>
<td t-if="activityGroup.state and !isCellHidden"
t-attf-class="o_activity_summary_cell p-0 h-100 {{activityGroup.state}} {{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}">
<ActivityCell activityIds="activityGroup.ids" activityTypeId="type[0]" closestDeadline="activityGroup.o_closest_deadline" reloadFunc="props.onReloadData" resId="record.resId" resModel="record.resModel"/>
t-attf-class="o_activity_summary_cell p-0 h-100 {{activityGroup.state}} {{ activeFilter.resIds.has(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}">
<ActivityCell activityIds="activityGroup.ids" activityTypeId="type.id"
attachmentsInfo="activityGroup.attachments_info"
reportingDate="activityGroup.reporting_date"
countByState="activityGroup.count_by_state"
reloadFunc="props.onReloadData"
resId="record.resId" resModel="record.resModel"
userAssignedIds="activityGroup.user_assigned_ids"
/>
</td>
<td t-else=""
class="o_activity_summary_cell o_activity_empty_cell cursor-pointer"
t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}"
t-on-click.prevent.stop="() => props.onEmptyCell(resId, type[0])">
t-attf-class="{{ activeFilter.resIds.has(resId) ? 'o_activity_filter_' + activeFilter.progressValue.active : '' }}"
t-on-click.prevent.stop="() => props.onEmptyCell(resId, type.id)">
<i data-tooltip="Create" class="text-center fa fa-plus mt-2 align-items-center justify-content-center h-100"/>
</td>
</t>
@@ -93,7 +100,7 @@
<t t-name="mail.ActivityViewFooter">
<tfoot>
<tr class="o_data_row">
<td class="o_record_selector p-3 cursor-pointer" t-on-click.prevent.stop="props.scheduleActivity">
<td class="o_record_selector p-3 cursor-pointer" colspan="3" t-on-click.prevent.stop="props.scheduleActivity">
<span class="btn btn-link"><i class="fa fa-plus pe-2"/><span>Schedule activity</span></span>
</td>
</tr>
@@ -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 },
};
@@ -3,11 +3,11 @@
<t t-name="mail.Avatar">
<div class="o-mail-Avatar o_avatar d-flex align-items-center">
<img class="rounded me-2"
<img t-attf-class="rounded {{props.noSpacing ? '' : 'me-2'}}"
t-attf-src="/web/image/{{props.resModel}}/{{props.resId}}/avatar_128"
t-on-click.stop.prevent="onClickAvatar"
/>
<span class="text-truncate w-0 flex-grow-1" t-esc="props.displayName"/>
<span t-if="props.displayName" class="text-truncate w-0 flex-grow-1" t-esc="props.displayName"/>
</div>
</t>
@@ -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,
};
},
@@ -23,6 +23,7 @@
<field name="summary" placeholder="e.g. &quot;Discuss proposal&quot;"/>
<field name="icon" groups="base.group_no_one"/>
<field name="decoration_type" groups="base.group_no_one"/>
<field name="keep_done"/>
</group>
<group name="activity_planning" string="Next Activity">
<field name="chaining_type" invisible="category == 'upload_file'"/>
+2 -2
View File
@@ -81,8 +81,8 @@
<div t-name="activity-box">
<img t-att-src="activity_image('res.partner', 'avatar_128', record.id.raw_value)" role="img" t-att-title="record.name.value" t-att-alt="record.name.value"/>
<div class="ms-2">
<field name="name" display="full"/>
<field name="parent_id" muted="1" display="full"/>
<field name="name" display="full" class="o_text_block"/>
<field name="parent_id" muted="1" display="full" class="o_text_block"/>
</div>
</div>
</templates>
+27 -6
View File
@@ -52,6 +52,27 @@
</field>
</record>
<record id="maintenance_request_view_activity" model="ir.ui.view">
<field name="name">maintenance.request.view.activity</field>
<field name="model">maintenance.request</field>
<field name="arch" type="xml">
<activity string="Maintenance Request">
<field name="user_id"/>
<templates>
<div t-name="activity-box">
<field name="user_id" widget="many2one_avatar_user" domain="[('share', '=', False)]"/>
<div class="flex-grow-1 d-block">
<div class="d-flex justify-content-between">
<field name="name" display="full" class="o_text_block o_text_bold"/>
</div>
<field name="equipment_id" muted="1" display="full" class="o_text_block"/>
</div>
</div>
</templates>
</activity>
</field>
</record>
<record id="hr_equipment_request_view_form" model="ir.ui.view">
<field name="name">equipment.request.form</field>
<field name="model">maintenance.request</field>
@@ -264,7 +285,7 @@
<record id="hr_equipment_request_action" model="ir.actions.act_window">
<field name="name">Maintenance Requests</field>
<field name="res_model">maintenance.request</field>
<field name="view_mode">kanban,tree,form,pivot,graph,calendar</field>
<field name="view_mode">kanban,tree,form,pivot,graph,calendar,activity</field>
<field name="view_id" ref="hr_equipment_request_view_kanban"/>
<field name="context">{
'search_default_active': True,
@@ -282,7 +303,7 @@
<record id="hr_equipment_request_action_link" model="ir.actions.act_window">
<field name="name">Maintenance Requests</field>
<field name="res_model">maintenance.request</field>
<field name="view_mode">kanban,tree,form,pivot,graph,calendar</field>
<field name="view_mode">kanban,tree,form,pivot,graph,calendar,activity</field>
<field name="search_view_id" ref="hr_equipment_request_view_search"/>
<field name="view_id" ref="hr_equipment_request_view_kanban"/>
<field name="context">{
@@ -303,7 +324,7 @@
<field name="name">Maintenance Requests</field>
<field name="res_model">maintenance.request</field>
<field name="binding_model_id" ref="maintenance.model_maintenance_equipment"/>
<field name="view_mode">kanban,tree,form,pivot,graph,calendar</field>
<field name="view_mode">kanban,tree,form,pivot,graph,calendar,activity</field>
<field name="context">{
'search_default_active': True,
'default_equipment_id': active_id,
@@ -321,7 +342,7 @@
<record id="hr_equipment_todo_request_action_from_dashboard" model="ir.actions.act_window">
<field name="name">Maintenance Requests</field>
<field name="res_model">maintenance.request</field>
<field name="view_mode">kanban,tree,form,pivot,graph,calendar</field>
<field name="view_mode">kanban,tree,form,pivot,graph,calendar,activity</field>
<field name="context">{
'search_default_active': True,
'search_default_maintenance_team_id': active_id,
@@ -340,7 +361,7 @@
<record id="hr_equipment_request_action_cal" model="ir.actions.act_window">
<field name="name">Maintenance Requests</field>
<field name="res_model">maintenance.request</field>
<field name="view_mode">calendar,kanban,tree,form,pivot,graph</field>
<field name="view_mode">calendar,kanban,tree,form,pivot,graph,activity</field>
<field name="view_id" ref="hr_equipment_view_calendar"/>
<field name="context">{
'search_default_active': True,
@@ -358,7 +379,7 @@
<record id="maintenance_request_action_reports" model="ir.actions.act_window">
<field name="name">Maintenance Requests</field>
<field name="res_model">maintenance.request</field>
<field name="view_mode">graph,pivot,kanban,tree,form,calendar</field>
<field name="view_mode">graph,pivot,kanban,tree,form,calendar,activity</field>
<field name="context">{
'search_default_active': True,
}</field>
+1
View File
@@ -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):
+2 -1
View File
@@ -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()))
@@ -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');
}
},
+19 -1
View File
@@ -19,6 +19,24 @@
<field name="target">fullscreen</field>
</record>
<record id="mrp_production_view_activity" model="ir.ui.view">
<field name="name">mrp.production.view.activity</field>
<field name="model">mrp.production</field>
<field name="arch" type="xml">
<activity string="Manufacturing Orders">
<templates>
<div t-name="activity-box" class="d-flex w-100">
<field name="user_id" widget="many2one_avatar_user"/>
<div class="flex-grow-1">
<field name="name" class="o_text_block"/>
<field name="date_start" widget="date" class="d-block"/>
</div>
</div>
</templates>
</activity>
</field>
</record>
<record id="mrp_production_tree_view" model="ir.ui.view">
<field name="name">mrp.production.tree</field>
<field name="model">mrp.production</field>
@@ -616,7 +634,7 @@
<record id="mrp_production_action" model="ir.actions.act_window">
<field name="name">Manufacturing Orders</field>
<field name="res_model">mrp.production</field>
<field name="view_mode">tree,kanban,form,calendar,pivot,graph</field>
<field name="view_mode">tree,kanban,form,calendar,pivot,graph,activity</field>
<field name="view_id" eval="False"/>
<field name="search_view_id" ref="view_mrp_production_filter"/>
<field name="context">{'search_default_todo': True, 'default_company_id': allowed_company_ids[0]}</field>
+1 -1
View File
@@ -196,7 +196,7 @@
<record model="ir.actions.act_window" id="mrp_unbuild">
<field name="name">Unbuild Orders</field>
<field name="res_model">mrp.unbuild</field>
<field name="view_mode">tree,kanban,form</field>
<field name="view_mode">tree,kanban,form,activity</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No unbuild order found
+1 -1
View File
@@ -144,7 +144,7 @@
<record id="action_picking_tree_mrp_operation" model="ir.actions.act_window">
<field name="name">Manufacturings</field>
<field name="res_model">mrp.production</field>
<field name="view_mode">tree,kanban,form,calendar</field>
<field name="view_mode">tree,kanban,form,calendar,activity</field>
<field name="domain"></field>
<field name="context">{'default_company_id': allowed_company_ids[0]}</field>
<field name="search_view_id" ref="mrp.view_mrp_production_filter"/>
@@ -131,8 +131,8 @@
<div t-name="activity-box">
<img t-att-src="activity_image('product.template', 'image_128', record.id.raw_value)" role="img" t-att-title="record.id.value" t-att-alt="record.id.value"/>
<div class="ms-2">
<field name="name" display="full"/>
<div t-if="record.default_code.value" class="text-muted">
<field name="name" display="full" class="o_text_block"/>
<div t-if="record.default_code.value" class="o_text_block text-muted">
[<field name="default_code"/>]
</div>
</div>
+2 -2
View File
@@ -483,8 +483,8 @@
<div t-name="activity-box">
<img t-att-src="activity_image('product.product', 'image_128', record.id.raw_value)" role="img" t-att-title="record.id.value" t-att-alt="record.id.value"/>
<div class="ms-2">
<field name="name" display="full"/>
<div t-if="record.default_code.value" class="text-muted">
<field name="name" display="full" class="o_text_block"/>
<div t-if="record.default_code.value" class="o_text_block text-muted">
[<field name="default_code"/>]
</div>
</div>
+1
View File
@@ -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(
+1
View File
@@ -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
@@ -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];
@@ -13,7 +13,7 @@
</xpath>
<!-- Waiting state button -->
<xpath expr="//t[@t-if='props.readonly']" position="after">
<t t-elif="currentValue == '04_waiting_normal' and isView(['kanban', 'list', 'calendar'])">
<t t-elif="currentValue == '04_waiting_normal' and isView(['activity', 'kanban', 'list', 'calendar'])">
<button class="d-flex align-items-center btn fw-normal p-0 justify-content-center " title="This task is blocked by another unfinished task" t-att-class="{'o_task_state_list_view': isView(['list'])}">
<i class="fa fa-lg fa-hourglass-o"></i>
</button>
@@ -22,13 +22,13 @@
<!-- The toggle mark as done button -->
<xpath expr="//t[@t-if='props.readonly']" position="after">
<t t-elif="isToggleMode and currentValue == '01_in_progress'">
<button t-if="isView(['kanban', 'list', 'calendar']) or this.env.isSmall"
<button t-if="isView(['activity', 'kanban', 'list', 'calendar']) or this.env.isSmall"
class="d-flex align-items-center btn fw-normal p-0"
tabindex="-1"
t-att-class="{'o_task_state_list_view': isView(['list'])}"
t-on-click.stop="toggleState"
>
<i t-attf-class="{{ stateIcon(currentValue) }} {{ statusColor(currentValue) }} {{ ['1_done', '1_canceled'].includes(currentValue) and isView(['kanban']) ? 'opacity-50' : '' }}"></i>
<i t-attf-class="{{ stateIcon(currentValue) }} {{ statusColor(currentValue) }} {{ ['1_done', '1_canceled'].includes(currentValue) and isView(['activity', 'kanban']) ? 'opacity-50' : '' }}"></i>
</button>
<button t-else="" class="o_state_button btn oe_highlight rounded-pill" style="white-space: nowrap;" tabindex="-1"
t-attf-class="#{currentValue == '1_done' ? 'btn-success' : 'btn-outline-secondary'}"
@@ -54,9 +54,9 @@
<!-- Normal dropdown mode toggle button (displayed on the card/record by default) -->
<xpath expr="//t[@t-set-slot='toggler']/div" position="replace">
<div t-if="isView(['kanban', 'list', 'calendar']) or this.env.isSmall" class="d-flex align-items-center" t-att-class="{'o_task_state_list_view': isView(['list'])}" t-att-title="label">
<div t-if="isView(['activity', 'kanban', 'list', 'calendar']) or this.env.isSmall" class="d-flex align-items-center" t-att-class="{'o_task_state_list_view': isView(['list'])}" t-att-title="label">
<i t-if="currentValue == '04_waiting_normal'" t-attf-class="{{ stateIcon(currentValue) }} {{ statusColor(currentValue) }}" style="color: #4A4F59;"/>
<i t-else="" t-attf-class="{{ stateIcon(currentValue) }} {{ statusColor(currentValue) }} {{ ['1_done', '1_canceled'].includes(currentValue) and isView(['kanban']) ? 'opacity-50' : '' }}"/>
<i t-else="" t-attf-class="{{ stateIcon(currentValue) }} {{ statusColor(currentValue) }} {{ ['1_done', '1_canceled'].includes(currentValue) and isView(['activity', 'kanban']) ? 'opacity-50' : '' }}"/>
</div>
<div t-else="" class="d-flex align-items-center">
<t t-if="currentValue == '04_waiting_normal'">
@@ -12,3 +12,7 @@
.o_form_project_project .o_setting_box {
margin-left: 1.5rem;
}
.o_many2many_avatar_user_no_wrap div {
flex-wrap: nowrap !important;
}
@@ -1,5 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_project_view_activity" model="ir.ui.view">
<field name="name">project.project.view.activity</field>
<field name="model">project.project</field>
<field name="arch" type="xml">
<activity string="Project">
<templates>
<div t-name="activity-box" class="d-flex">
<field name="user_id" widget="many2one_avatar_user"/>
<field name="name" string="Project Name" class="flex-grow-1 o_text_block"/>
</div>
</templates>
</activity>
</field>
</record>
<record id="action_send_mail_project_project" model="ir.actions.act_window">
<field name="name">Send Email</field>
<field name="res_model">mail.compose.message</field>
+12 -10
View File
@@ -803,17 +803,19 @@
<field name="user_ids"/>
<field name="project_id"/>
<templates>
<div class="w-100" t-name="activity-box" style="display: inline-grid; grid-template-columns: auto max-content;">
<div>
<span t-att-title="record.name.value">
<field name="name" display="full" class="w-100 text-truncate"/>
</span>
<span t-att-title="record.project_id.value" invisible="context.get('default_project_id', False)">
<field t-if="record.project_id.value" name="project_id" muted="1" class="w-100 text-truncate"/>
<span t-else="" class="fst-italic text-muted"><i class="fa fa-lock"></i> Private</span>
</span>
<div t-name="activity-box" class="justify-content-between gap-2">
<div class="flex-shrink-1">
<field name="name" display="full" class="o_text_block"/>
<div t-att-title="record.project_id.value" invisible="context.get('default_project_id', False)">
<field t-if="record.project_id.value" name="project_id" muted="1" class="o_text_block"/>
<div t-else="" class="fst-italic text-muted"><i class="fa fa-lock"/> Private</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2 flex-grow-1">
<field name="state" widget="project_task_state_selection" class="align-self-center"
options="{'hide_label': 1, 'is_toggle_mode': false}"/>
<field name="user_ids" widget="many2many_avatar_user" class="o_many2many_avatar_user_no_wrap"/>
</div>
<field name="user_ids" widget="many2many_avatar_user"/>
</div>
</templates>
</activity>
-1
View File
@@ -1,4 +1,3 @@
from . import controllers
from . import models
from . import wizard
-4
View File
@@ -28,10 +28,6 @@
'project_todo/static/src/components/**/*',
'project_todo/static/src/scss/todo.scss',
'project_todo/static/src/views/**/*',
'project_todo/static/src/web/**/*',
],
'web.qunit_suite_tests': [
'project_todo/static/tests/**/*',
],
},
'license': 'LGPL-3',
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import project_todo
@@ -1,18 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import Controller, request, route
class TodoController(Controller):
@route('/project_todo/new', type='json', auth='user')
def todo_new_from_systray(self, todo_description, activity_type_id=None, date_deadline=None):
""" Route to create todo and their activity directly from the systray """
todo = request.env['project.task'].create({'description': todo_description})
if date_deadline:
todo.activity_schedule(
activity_type_id=activity_type_id or request.env['mail.activity.type'].search([('category', '=', 'reminder')], limit=1).id,
note=todo.description,
date_deadline=date_deadline
)
return todo.id
@@ -1,113 +0,0 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { onExternalClick } from "@mail/utils/common/hooks";
import { ActivityMenu } from "@mail/core/web/activity_menu";
import { useEffect, useRef, useState } from "@odoo/owl";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { useCommand } from "@web/core/commands/command_hook";
import { useService } from "@web/core/utils/hooks";
import { patch } from "@web/core/utils/patch";
import { registry } from "@web/core/registry";
const { DateTime } = luxon;
// Add a to-do category for the command palette
registry.category("command_categories").add("to-do", {}, { sequence: 105 });
patch(ActivityMenu.prototype, {
setup() {
super.setup(...arguments);
this.rpc = useService("rpc");
this.orm = useService("orm");
this.dialogService = useService("dialog");
this.state = useState({ addingTodo: false });
this.todoInputRef = useRef("todoInput");
this.addingTodoDate = false;
this.notification = useService("notification");
useEffect(
(addingTodo) => {
if (addingTodo) {
this.todoInputRef.el.focus();
}
},
() => [this.state.addingTodo]
);
onExternalClick("todoInput", (ev) => {
if (
ev.target.closest(".o-mail-ActivityMenu-show") ||
ev.target.closest(".o_datetime_picker")
) {
return;
}
this.state.addingTodo = false;
});
useCommand(
_t("Add a To-Do"),
() => {
document.body.click(); // hack to close command palette
this.createActivityTodo();
},
{
category: "to-do",
hotkey: "alt+l",
global: true,
}
);
},
sortActivityGroups() {
super.sortActivityGroups();
this.store.activityGroups.sort((g1, g2) => {
if (g1.model === "project.task" ? true : false) {
return -1;
}
if (g2.model === "project.task" ? true : false) {
return 1;
}
});
},
onKeydownTodoInput(ev) {
if (ev.key === "Enter") {
this.saveTodo();
}
},
async createActivityTodo() {
const wizard = await this.orm.call("mail.activity.todo.create", "create", [{
"user_id": this.userId,
}]);
this.dialogService.add(FormViewDialog, {
title: "Add a To-Do",
resModel: "mail.activity.todo.create",
resId: wizard,
preventCreate: true,
size: "md",
});
},
async saveTodo() {
const urlRegExp = /http(s)?:\/\/(www\.)?[a-zA-Z0-9@:%_+~#=~#?&/=\-;!.]{3,2000}/g;
const todo = this.todoInputRef.el.value.replace(urlRegExp, '<a href="$&">$&</a>').trim();
if (!todo) {
return;
}
await this.rpc("/project_todo/new", {
todo_description: todo,
date_deadline: this.addingTodoDate ? this.addingTodoDate : DateTime.local(),
});
document.body.click(); // hack to close dropdown
this.notification.add(
_t("Your to-do has been successfully added to your tasks and scheduled for completion."),
{ type: "success" },
);
this.state.addingTodo = false;
},
});
ActivityMenu.components = {
...ActivityMenu.components,
DateTimeInput,
};
@@ -1,11 +0,0 @@
.o-mail-ActivityMenu-text {
flex: 1 1 100%;
}
.o-mail-ActivityMenu-input {
flex: 1 1 auto;
}
.o-mail-ActivityMenu-save {
font-size: 11px;
}
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.ActivityMenu" t-inherit-mode="extension">
<xpath expr="//*[@name='activityGroupLoop']" position="after">
<div t-if="!state.addingTodo" class="d-grid" t-on-click="() => this.state.addingTodo = true">
<button type="button" class="btn text-center" t-on-click="() => this.state.addingTodo = true">Add a To-do</button>
</div>
<div t-if="state.addingTodo" class="o-mail-ActivityMenu-show d-flex p-2 border-bottom">
<img src="/project_todo/static/description/icon.png" alt="AddTodo"/>
<div class="flex-grow-1">
<div class="d-flex px-2">
<span class="o-mail-ActivityMenu-text flex-grow-1"><strong>Add a To-do</strong></span>
<DateTimeInput
type="'date'"
value="addingTodoDate"
onChange="(value) => (this.addingTodoDate = value || false)"
placeholder="'Today'"
/>
</div>
<div class="d-flex">
<input class="o-mail-ActivityMenu-input py-0 px-2 border-0" type="text" placeholder="Reminder to..." t-on-keydown="onKeydownTodoInput" t-ref="todoInput"/>
<span class="flex-grow-1"/>
<span class="ml8 mr4">
<button type="button" class="o-mail-ActivityMenu-save fw-bold btn btn-link py-0 px-2" t-on-click="saveTodo">SAVE</button>
</span>
</div>
</div>
</div>
</xpath>
</t>
</templates>
@@ -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" });
});
@@ -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,
});
}
},
});
@@ -1,5 +0,0 @@
/** @odoo-module **/
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
addModelNamesToFetch(['project.task']);
@@ -198,7 +198,7 @@
<div class="w-100" t-name="activity-box" style="display: inline-grid; grid-template-columns: auto max-content;">
<div>
<span t-att-title="record.name.value">
<field name="name" display="full" class="w-100 text-truncate align-middle"/>
<field name="name" display="full" class="w-100 o_text_block align-middle"/>
</span>
</div>
<field name="user_ids" widget="many2many_avatar_user"/>
+14 -4
View File
@@ -654,11 +654,21 @@
<field name="model">purchase.order</field>
<field name="arch" type="xml">
<activity string="Purchase Order">
<field name="currency_id"/>
<templates>
<div t-name="activity-box">
<div>
<field name="name" display="full"/>
<field name="partner_id" muted="1" display="full"/>
<div t-name="activity-box" class="d-block">
<div class="d-flex justify-content-between">
<field name="name" display="full" class="o_text_block o_text_bold"/>
<div class="m-1"/>
<field name="amount_total" widget="monetary"/>
</div>
<div class="d-flex justify-content-between">
<field name="partner_id" muted="1" display="full" class="o_text_block"/>
<div class="m-1"/>
<field name="state" widget="badge"
decoration-success="state == 'purchase' or state == 'done'"
decoration-warning="state == 'to approve'"
decoration-info="state == 'draft' or state == 'sent'"/>
</div>
</div>
</templates>
+20 -1
View File
@@ -3,6 +3,25 @@
<data>
<record id="repair_order_view_activity" model="ir.ui.view">
<field name="name">repair.order.view.activity</field>
<field name="model">repair.order</field>
<field name="arch" type="xml">
<activity string="Activity view">
<templates>
<div t-name="activity-box">
<field name="user_id" widget="many2one_avatar_user" domain="[('share', '=', False)]"/>
<div>
<field name="name" display="full" class="o_text_block o_text_bold"/>
<field name="product_id" class="o_text_block"/>
<field name="schedule_date" widget="date" class="d-block"/>
</div>
</div>
</templates>
</activity>
</field>
</record>
<record id="view_repair_order_tree" model="ir.ui.view">
<field name="name">repair.tree</field>
<field name="model">repair.order</field>
@@ -298,7 +317,7 @@
<record id="action_repair_order_tree" model="ir.actions.act_window">
<field name="name">Repair Orders</field>
<field name="res_model">repair.order</field>
<field name="view_mode">tree,kanban,graph,pivot,form</field>
<field name="view_mode">tree,kanban,graph,pivot,form,activity</field>
<field name="search_view_id" ref="view_repair_order_form_filter"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
+13 -5
View File
@@ -7,12 +7,20 @@
<field name="name">sale.order.activity</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<activity string="Sales Orders">
<activity string="Sales Order">
<field name="currency_id"/>
<templates>
<div t-name="activity-box">
<div>
<field name="name" display="full"/>
<field name="partner_id" muted="1" display="full"/>
<div t-name="activity-box" class="d-block">
<div class="d-flex justify-content-between">
<field name="name" display="full" class="o_text_block o_text_bold"/>
<div class="m-1"/>
<field name="amount_total" widget="monetary"/>
</div>
<div class="d-flex justify-content-between">
<field name="partner_id" muted="1" display="full" class="o_text_block"/>
<div class="m-1"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'" decoration-success="state == 'sale'"/>
</div>
</div>
</templates>
+17 -2
View File
@@ -1,5 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="stock_picking_view_activity" model="ir.ui.view">
<field name="name">stock.picking.view.activity</field>
<field name="model">stock.picking</field>
<field name="arch" type="xml">
<activity string="Activity view">
<templates>
<div t-name="activity-box" class="d-block">
<field name="name" display="full" class="o_text_block o_text_bold"/>
<field name="scheduled_date" widget="date"/>
</div>
</templates>
</activity>
</field>
</record>
<record model="ir.ui.view" id="stock_picking_calendar">
<field name="name">stock.picking.calendar</field>
<field name="model">stock.picking</field>
@@ -436,7 +451,7 @@
<record id="action_picking_tree_incoming" model="ir.actions.act_window">
<field name="name">Receipts</field>
<field name="res_model">stock.picking</field>
<field name="view_mode">tree,kanban,form,calendar</field>
<field name="view_mode">tree,kanban,form,calendar,activity</field>
<field name="domain"></field>
<field name="context">{'contact_display': 'partner_address', 'default_company_id': allowed_company_ids[0], 'restricted_picking_type_code': 'incoming', 'search_default_reception': 1}</field>
<field name="search_view_id" ref="view_picking_internal_search"/>
@@ -452,7 +467,7 @@
<record id="action_picking_tree_outgoing" model="ir.actions.act_window">
<field name="name">Deliveries</field>
<field name="res_model">stock.picking</field>
<field name="view_mode">tree,kanban,form,calendar</field>
<field name="view_mode">tree,kanban,form,calendar,activity</field>
<field name="domain"></field>
<field name="context">{'contact_display': 'partner_address', 'default_company_id': allowed_company_ids[0], 'restricted_picking_type_code': 'outgoing', 'search_default_delivery': 1}</field>
<field name="search_view_id" ref="view_picking_internal_search"/>
@@ -371,6 +371,24 @@
</kanban>
</field>
</record>
<record id="survey_survey_view_activity" model="ir.ui.view">
<field name="name">survey.survey.view.activity</field>
<field name="model">survey.survey</field>
<field name="arch" type="xml">
<activity string="Survey">
<templates>
<div t-name="activity-box" class="d-flex w-100">
<field name="user_id" widget="many2one_avatar_user"/>
<div class="flex-grow-1">
<field name="title" class="o_text_block"/>
</div>
</div>
</templates>
</activity>
</field>
</record>
<record id="survey_survey_view_search" model="ir.ui.view">
<field name="name">survey.survey.search</field>
<field name="model">survey.survey</field>
+6
View File
@@ -36,5 +36,11 @@
<field name="chaining_type">trigger</field>
<field name="triggered_next_type_id" ref="test_mail.mail_act_test_chained_2"/>
</record>
<record id="mail_act_test_upload_document" model="mail.activity.type">
<field name="name">Upload Document</field>
<field name="delay_count">5</field>
<field name="category">upload_file</field>
<field name="res_model">mail.test.activity</field>
</record>
</odoo>
@@ -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
]);
});
@@ -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 () => {
+161 -1
View File
@@ -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"""
@@ -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",
}
)