[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:
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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. "Discuss proposal""/>
|
||||
<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'"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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];
|
||||
|
||||
+5
-5
@@ -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>
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user