[IMP] web,calendar: improve the quality of calendar app

PURPOSE

Improve the quality of the Calendar application by updating the user interface
and using a multi-calendar like display.

SPECIFICATIONS

Prepare calendar view for the changes listed below in order to ease them and
fix some issues. Check commits for more details.

Modify the design of the event card based on the invitation status and the
owner of the event. If the status needs an action for the user or is uncertain
only the outline of the event is displayed. If the event is accepted or the
user is the owner, the display is the same as before (card is filled).
If the event is declined, the card is filled but the opacity is decreased and
the text is crossed.

Save filters of the calendar view for the user. We now save the status of each
filters to avoid the user to do the same operations if he go elsewhere in the
meantime.

Improve the way the events are displayed to the user. Now, an event is
displayed for each attendee of the event that are selected in the filter.
These events are displayed correctly based on the status of the attendee
in the event. The colors displayed for the events now represent the attendees
and not the organizer anymore.

If an attendee edits an event, it is edited for all others attendees.
Also, when an attendee that is not the organizer try to delete the event,
then the event is now declined in place of being deleted. If the organizer
deletes the event, it is deleted for all attendees.

In case of an event where all attendees have declined it but the organizer,
the organizer see now a danger icon before the name of the event and it's
outlined and not filled with color, no matter of the actual status of the
organizer in the event.

Some other changes include

  * remove organizers filter from the sidebar
  * rename "Responsible" into "Organizer"

LINKS

Task ID-2196775

closes odoo/odoo#55190

Related: odoo/upgrade#1532
Related: odoo/enterprise#12196
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2021-04-16 09:09:07 +02:00
committed by GitHub
32 changed files with 478 additions and 99 deletions
@@ -11,6 +11,8 @@ class Contacts(models.Model):
user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user)
partner_id = fields.Many2one('res.partner', 'Employee', required=True)
active = fields.Boolean('Active', default=True)
partner_checked = fields.Boolean('Checked', default=True,
help="This field is used to know if the partner is checked in the filter of the calendar view for the user_id.")
_sql_constraints = [
('user_id_partner_id_unique', 'UNIQUE(user_id, partner_id)', 'A user cannot have the same contact twice.')
+45 -8
View File
@@ -97,9 +97,9 @@ class Meeting(models.Model):
# description
name = fields.Char('Meeting Subject', required=True)
description = fields.Text('Description')
user_id = fields.Many2one('res.users', 'Responsible', default=lambda self: self.env.user)
user_id = fields.Many2one('res.users', 'Organizer', default=lambda self: self.env.user)
partner_id = fields.Many2one(
'res.partner', string='Responsible Contact', related='user_id.partner_id', readonly=True)
'res.partner', string='Scheduled by', related='user_id.partner_id', readonly=True)
location = fields.Char('Location', tracking=True, help="Location of Event")
videocall_location = fields.Char('Join Video Call', default=_default_videocall_location)
# visibility
@@ -119,6 +119,9 @@ class Meeting(models.Model):
that period of time.")
is_highlighted = fields.Boolean(
compute='_compute_is_highlighted', string='Is the Event Highlighted')
is_organizer_alone = fields.Boolean(compute='_compute_is_organizer_alone', string="Is the Organizer Alone",
help="""Check if the organizer is alone in the event, i.e. if the organizer is the only one that hasn't declined
the event (only if the organizer is not the only attendee)""")
# filtering
active = fields.Boolean(
'Active', default=True,
@@ -222,6 +225,20 @@ class Meeting(models.Model):
for event in self:
event.is_highlighted = False
@api.depends('partner_id', 'attendee_ids')
def _compute_is_organizer_alone(self):
"""
Check if the organizer of the event is the only one who has accepted the event.
It does not apply if the organizer is the only attendee of the event because it
would represent a personnal event.
The goal of this field is to highlight to the user that the others attendees are
not available for this event.
"""
for event in self:
organizer = event.attendee_ids.filtered(lambda a: a.partner_id == event.partner_id)
all_declined = not any((event.attendee_ids - organizer).filtered(lambda a: a.state != 'declined'))
event.is_organizer_alone = len(event.attendee_ids) > 1 and all_declined
def _compute_display_time(self):
for meeting in self:
meeting.display_time = self._get_display_time(meeting.start, meeting.stop, meeting.duration, meeting.allday)
@@ -287,11 +304,7 @@ class Meeting(models.Model):
def _compute_attendee(self):
for meeting in self:
attendee = next(
(attendee for attendee in self.attendee_ids
if attendee.partner_id == self.env.user.partner_id),
self.env['calendar.attendee']
)
attendee = meeting._find_attendee()
meeting.attendee_status = attendee.state if attendee else 'needsAction'
@api.constrains('start', 'stop', 'start_date', 'stop_date')
@@ -364,8 +377,10 @@ class Meeting(models.Model):
activity_vals['user_id'] = user_id
values['activity_ids'] = [(0, 0, activity_vals)]
# Automatically add the current partner when creating an event if there is none (happens when we quickcreate an event)
self_partner_id = [(4, self.env.user.partner_id.id)]
vals_list = [
dict(vals, attendee_ids=self._attendees_values(vals['partner_ids'])) if 'partner_ids' in vals else vals
dict(vals, attendee_ids=self._attendees_values(vals.get('partner_ids', self_partner_id)))
for vals in vals_list
]
recurrence_fields = self._get_recurrent_fields()
@@ -747,6 +762,28 @@ class Meeting(models.Model):
# TOOLS
# ------------------------------------------------------------
def _find_attendee(self):
""" Return the first attendee where the user connected has been invited
or the attendee selected in the filter that is the owner
from all the meeting_ids in parameters.
"""
self.ensure_one()
my_attendee = self.attendee_ids.filtered(lambda att: att.partner_id == self.env.user.partner_id)
if my_attendee:
return my_attendee
event_checked_attendees = self.env['calendar.contacts'].search([
('user_id', '=', self.env.user.id),
('partner_id', 'in', self.attendee_ids.partner_id.ids),
('partner_checked', '=', True)
]).mapped('partner_id')
if self.partner_id in event_checked_attendees and self.partner_id in self.attendee_ids.partner_id:
return self.attendee_ids.filtered(lambda attendee: attendee.partner_id == self.partner_id)
attendee = self.attendee_ids.filtered(lambda att: att.partner_id in event_checked_attendees and att.state != "needsAction")
return attendee
def _get_start_date(self):
"""Return the event starting date in the event's timezone.
If no starting time is assigned (yet), return today as default
+21 -16
View File
@@ -12,24 +12,29 @@ class Partner(models.Model):
calendar_last_notif_ack = fields.Datetime(
'Last notification marked as read from base Calendar', default=fields.Datetime.now)
def get_attendee_detail(self, meeting_id):
""" Return a list of tuple (id, name, status)
Used by base_calendar.js : Many2ManyAttendee
def get_attendee_detail(self, meeting_ids):
""" Return a list of dict of the given meetings with the attendees details
Used by:
- base_calendar.js : Many2ManyAttendee
- calendar_model.js (calendar.CalendarModel)
"""
datas = []
meeting = None
if meeting_id:
meeting = self.env['calendar.event'].browse(meeting_id)
attendees_details = []
meetings = self.env['calendar.event'].browse(meeting_ids)
meetings_attendees = meetings.mapped('attendee_ids')
for partner in self:
data = partner.name_get()[0]
data = [data[0], data[1], False, partner.color]
if meeting:
for attendee in meeting.attendee_ids:
if attendee.partner_id.id == partner.id:
data[2] = attendee.state
datas.append(data)
return datas
partner_info = partner.name_get()[0]
for attendee in meetings_attendees.filtered(lambda att: att.partner_id == partner):
attendee_is_organizer = self.env.user == attendee.event_id.user_id and attendee.partner_id == self.env.user.partner_id
attendees_details.append({
'id': partner_info[0],
'name': partner_info[1],
'color': partner.color,
'status': attendee.state,
'event_id': attendee.event_id.id,
'attendee_id': attendee.id,
'is_alone': attendee.event_id.is_organizer_alone and attendee_is_organizer,
})
return attendees_details
@api.model
def _set_calendar_last_notif_ack(self):
@@ -127,12 +127,10 @@ BasicModel.include({
return this._rpc({
model: 'res.partner',
method: 'get_attendee_detail',
args: [attendeeIDs, meetingID],
args: [attendeeIDs, [meetingID]],
context: context,
}).then(function (result) {
return _.map(result, function (d) {
return _.object(['id', 'display_name', 'status', 'color'], d);
});
return result;
});
},
});
@@ -40,6 +40,22 @@ odoo.define('calendar.CalendarController', function (require) {
});
},
/**
* If the event comes from the organizer we delete the event, else we the event is decline for the attendee
* @override
*/
_onDeleteRecord: function (ev) {
const event = _.find(this.model.data.data, e => e.id === ev.data.id && e.attendee_id === ev.data.event.attendee_id);
if (this.getSession().partner_id === event.attendee_id && this.getSession().partner_id === event.record.partner_id[0]) {
this._super(...arguments);
} else {
var self = this;
this.model.declineEvent(event).then(function () {
self.reload();
});
}
},
/**
* @override
* @private
@@ -14,7 +14,68 @@ odoo.define('calendar.CalendarModel', function (require) {
return _.extend({}, data, {
'recurrence_update': event.recurrenceUpdate,
});
}
},
async _getCalendarEventData(events) {
const calendarEventData = await this._super(...arguments);
const calendarEventByAttendeeData = await this._calendarEventByAttendee(calendarEventData);
return calendarEventByAttendeeData;
},
/**
* Split the events to display an event for each attendee with the correct status.
* If the all filter is activated, we don't display an event for each attendee and keep
* the previous behavior to display a single event.
*
*/
async _calendarEventByAttendee(eventsData) {
const self = this;
let eventsDataByAttendee = [];
const attendeeFilters = self.loadParams.filters.partner_ids;
const everyoneFilter = attendeeFilters && (attendeeFilters.filters.find(f => f.value === "all") || {}).active || false;
const attendeeIDs = attendeeFilters && _.filter(attendeeFilters.filters.map(partner => partner.value !== 'all' ? partner.value : false), id => id !== false);
const eventIDs = eventsData.map(event => event.id);
// Fetch the attendees' info from the partners selected in the filter to display the events
this.attendees = await self._rpc({
model: 'res.partner',
method: 'get_attendee_detail',
args: [attendeeIDs, eventIDs],
});
if (!everyoneFilter) {
eventsData.forEach(event => {
(event.record.partner_ids || []).forEach(attendee => {
if (attendeeFilters.filters.find(f => f.active && f.value == attendee)) {
let e = $.extend(true, {}, event);
e.attendee_id = attendee;
const attendee_info = self.attendees.find(a => a.id == attendee && a.event_id == e.record.id);
if (attendee_info) {
e.record.attendee_status = attendee_info.status;
e.record.is_alone = attendee_info.is_alone;
}
eventsDataByAttendee.push(e);
}
});
});
} else {
eventsData.forEach(event => {
const attendee_info = self.attendees.find(a => a.id == self.getSession().partner_id && a.event_id == event.record.id);
if (attendee_info) {
event.record.is_alone = attendee_info.is_alone;
}
});
}
return eventsDataByAttendee.length ? eventsDataByAttendee : eventsData;
},
/**
* Decline an event for the actual attendee
* @param {Integer} eventId
*/
declineEvent: function (event) {
return this._rpc({
model: 'calendar.attendee',
method: 'do_decline',
args: [this.attendees.find(attendee => attendee.event_id === event.id && attendee.id === this.getSession().partner_id).attendee_id],
});
},
});
return CalendarModel;
@@ -5,7 +5,6 @@ const CalendarRenderer = require('web.CalendarRenderer');
const CalendarPopover = require('web.CalendarPopover');
const session = require('web.session');
const AttendeeCalendarPopover = CalendarPopover.extend({
template: 'Calendar.attendee.status.popover',
events: _.extend({}, CalendarPopover.prototype.events, {
@@ -32,18 +31,24 @@ const AttendeeCalendarPopover = CalendarPopover.extend({
// Public
//--------------------------------------------------------------------------
/**
* @return {boolean}
*/
isCurrentPartnerOrganizer() {
return this.event.extendedProps.record.partner_id[0] === session.partner_id;
},
/**
* @return {boolean}
*/
isCurrentPartnerAttendee() {
return this.event.extendedProps.record.partner_ids.includes(session.partner_id);
return this.event.extendedProps.record.partner_ids.includes(session.partner_id) && this.event.extendedProps.attendee_id === session.partner_id;
},
/**
* @override
* @return {boolean}
*/
isEventDeletable() {
return this._super() && (this._isEventPrivate() ? this.isCurrentPartnerAttendee() : true);
return this._super() && this.isCurrentPartnerAttendee();
},
/**
* @override
@@ -92,6 +97,8 @@ const AttendeeCalendarPopover = CalendarPopover.extend({
self.event.extendedProps.record.attendee_status = selectedStatus; // FIXEME: Maybe we have to reload view
self.$('.o-calendar-attendee-status-text').text(self.statusInfo[selectedStatus].text);
self.$('.o-calendar-attendee-status-icon').removeClass(_.values(self.statusColors).join(' ')).addClass(self.statusInfo[selectedStatus].color);
self.$el.parent().hide();
self.trigger_up('render_event');
});
},
});
@@ -99,10 +106,35 @@ const AttendeeCalendarPopover = CalendarPopover.extend({
const AttendeeCalendarRenderer = CalendarRenderer.extend({
config: _.extend({}, CalendarRenderer.prototype.config, {
CalendarPopover: AttendeeCalendarPopover,
}),
CalendarPopover: AttendeeCalendarPopover,
eventTemplate: 'Calendar.calendar-box',
}),
/**
* Add the attendee-id attribute in order to distinct the events when there are
* several attendees in the event.
* @override
*/
_addEventAttributes: function (element, event) {
this._super(...arguments);
element.attr('data-attendee-id', event.extendedProps.attendee_id);
},
/**
* If an attendee_id has been set on the event, we check also the attendee-id attribute
* to select the good event on which the CSS class will be applied.
* @override
*/
_computeEventSelector: function (info) {
let selector = this._super(...arguments);
if (info.event.extendedProps.attendee_id) {
selector += `[data-attendee-id=${info.event.extendedProps.attendee_id}]`;
}
return selector;
},
});
return AttendeeCalendarRenderer
return {
AttendeeCalendarRenderer: AttendeeCalendarRenderer,
AttendeeCalendarPopover: AttendeeCalendarPopover,
};
});
@@ -3,7 +3,7 @@ odoo.define('calendar.CalendarView', function (require) {
var CalendarController = require('calendar.CalendarController');
var CalendarModel = require('calendar.CalendarModel');
const CalendarRenderer = require('calendar.CalendarRenderer');
const CalendarRenderer = require('calendar.CalendarRenderer').AttendeeCalendarRenderer;
var CalendarView = require('web.CalendarView');
var viewRegistry = require('web.view_registry');
@@ -62,4 +62,14 @@
</span>
</t>
<t t-name="Calendar.calendar-box">
<div t-attf-class="#{record.is_highlighted ? 'o_event_hightlight' : ''} #{typeof color === 'number' ? _.str.sprintf('o_calendar_color_%s', color) : 'o_calendar_color_1'} fc-event o_attendee_status_#{record.is_alone ? 'alone' : record.attendee_status} py-0" >
<div class="o_event_title mr-2">
<span t-if="record.is_alone" class="fa fa-exclamation-circle"/>
<t t-esc="record.display_name"/>
</div>
<span class="fc-time"/>
</div>
</t>
</template>
@@ -51,11 +51,11 @@ QUnit.module('calendar', {
"the method should only be called on res.partner");
assert.deepEqual(args.args[0], [1, 2],
"the partner ids should be passed as argument");
assert.strictEqual(args.args[1], 14,
assert.deepEqual(args.args[1], [14],
"the event id should be passed as argument");
return Promise.resolve([
[1, "Jesus", "accepted", 0],
[2, "Mahomet", "tentative", 0],
{id: 1, name: "Jesus", status: "accepted", color: 0},
{id: 2, name: "Mahomet", status: "tentative", color: 0},
]);
}
return this._super.apply(this, arguments);
+2 -2
View File
@@ -11,12 +11,12 @@ class TestEventNotifications(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.event = cls.env['calendar.event'].create({
cls.user = new_test_user(cls.env, 'xav', email='em@il.com', notification_type='inbox')
cls.event = cls.env['calendar.event'].with_user(cls.user).create({
'name': "Doom's day",
'start': datetime(2019, 10, 25, 8, 0),
'stop': datetime(2019, 10, 27, 18, 0),
}).with_context(mail_notrack=True)
cls.user = new_test_user(cls.env, 'xav', email='em@il.com', notification_type='inbox')
cls.partner = cls.user.partner_id
def test_attendee_added(self):
+6 -10
View File
@@ -163,8 +163,9 @@
<field name="alarm_ids" widget="many2many_tags" options="{'no_quick_create': True}"/>
<field name="location" />
<field name="categ_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_create_edit': True}"/>
<field name="event_tz" attrs="{'invisible': [('recurrency', '=', False)]}"/>
<field name="user_id" widget="many2one_avatar_user"/>
</group>
</group>
<label for="description"/>
<field name="description"/>
@@ -253,12 +254,6 @@
</kanban>
</field>
</page>
<page name="page_misc" string="Misc" groups="base.group_no_one">
<group>
<label for="user_id"/>
<field name="user_id" nolabel="1" widget="many2one_avatar_user"/>
</group>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
@@ -277,13 +272,14 @@
<calendar js_class="attendee_calendar" string="Meetings" date_start="start" date_stop="stop" date_delay="duration" all_day="allday"
event_open_popup="true"
event_limit="5"
color="user_id">
color="partner_ids">
<field name="location" attrs="{'invisible': [('location', '=', False)]}"/>
<field name="videocall_location" string="Join Video Call" widget="CopyClipboardURL" />
<field name="attendee_status" invisible="1"/>
<field name="user_id" string="Responsible" filters="1" widget="many2one_avatar_user"/>
<field name="partner_ids" widget="many2manyattendee" write_model="calendar.contacts" write_field="partner_id" avatar_field="image_128"/>
<field name="partner_id" string="Organizer"/>
<field name="partner_ids" filters="1" widget="many2manyattendee" write_model="calendar.contacts" write_field="partner_id" filter_field="partner_checked" avatar_field="image_128"/>
<field name="is_highlighted" invisible="1"/>
<field name="is_organizer_alone" invisible="1"/>
<field name="description" attrs="{'invisible': [('description', '=', False)]}"/>
<field name="privacy"/>
<field name="show_as"/>
+1 -2
View File
@@ -114,7 +114,7 @@ class Meeting(models.Model):
user = google_event.owner(self.env)
google_attendees += [{
'email': user.partner_id.email,
'status': {'response': 'accepted'},
'responseStatus': 'accepted',
}]
emails = [a.get('email') for a in google_attendees]
existing_attendees = self.env['calendar.attendee']
@@ -195,7 +195,6 @@ class Meeting(models.Model):
'method': "email" if alarm.alarm_type == "email" else "popup",
'minutes': alarm.duration_minutes
} for alarm in self.alarm_ids]
attendee_ids = self.attendee_ids.filtered(lambda a: a.partner_id not in self.user_id.partner_id)
values = {
'id': self.google_id,
'start': start,
@@ -5,7 +5,7 @@ var core = require('web.core');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
const CalendarView = require('calendar.CalendarView');
const CalendarRenderer = require('calendar.CalendarRenderer');
const CalendarRenderer = require('calendar.CalendarRenderer').AttendeeCalendarRenderer;
const CalendarController = require('calendar.CalendarController');
const CalendarModel = require('calendar.CalendarModel');
const viewRegistry = require('web.view_registry');
@@ -1,7 +1,7 @@
odoo.define('google_calendar.GoogleCalendarPopover', function(require) {
"use strict";
const CalendarPopover = require('web.CalendarPopover');
const CalendarPopover = require('calendar.CalendarRenderer').AttendeeCalendarPopover;
const GoogleCalendarPopover = CalendarPopover.include({
events: _.extend({}, CalendarPopover.prototype.events, {
@@ -9,7 +9,7 @@ odoo.define('google_calendar.GoogleCalendarPopover', function(require) {
}),
isGEventSyncedAndArchivable() {
return this.event.extendedProps.record.google_id;
return this.isCurrentPartnerOrganizer() && this.event.extendedProps.record.google_id;
},
isEventDeletable() {
@@ -87,7 +87,7 @@ QUnit.module('Google Calendar', {
viewOptions: {
initialDate: initialDate,
},
mockRPC: function (route, args) {
mockRPC: async function (route, args) {
if (route === '/google_calendar/sync_data') {
assert.step(route);
this.data['calendar.event'].records.push(
@@ -98,8 +98,10 @@ QUnit.module('Google Calendar', {
assert.step(route);
} else if (route === '/microsoft_calendar/sync_data') {
return Promise.resolve();
} else if (route === '/web/dataset/call_kw/res.partner/get_attendee_detail') {
return Promise.resolve([]);
}
return this._super.apply(this, arguments);
return await this._super.apply(this, arguments);
},
});
@@ -89,7 +89,7 @@ class TestSyncOdoo2Google(TestSyncGoogle):
'guestsCanModify': True,
'reminders': {'overrides': [], 'useDefault': False},
'organizer': {'email': 'odoobot@example.com', 'self': True},
'attendees': [],
'attendees': [{'email': 'odoobot@example.com', 'responseStatus': 'accepted'}],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: event.id}}
})
@@ -148,7 +148,7 @@ class TestSyncOdoo2Google(TestSyncGoogle):
'guestsCanModify': True,
'reminders': {'overrides': [], 'useDefault': False},
'organizer': {'email': 'odoobot@example.com', 'self': True},
'attendees': [],
'attendees': [{'email': 'odoobot@example.com', 'responseStatus': 'accepted'}],
'recurrence': ['RRULE:FREQ=WEEKLY;COUNT=2;BYDAY=WE'],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: recurrence.id}}
})
@@ -183,7 +183,7 @@ class TestSyncOdoo2Google(TestSyncGoogle):
'guestsCanModify': True,
'reminders': {'overrides': [], 'useDefault': False},
'organizer': {'email': 'odoobot@example.com', 'self': True},
'attendees': [],
'attendees': [{'email': 'odoobot@example.com', 'responseStatus': 'accepted'}],
'recurrence': ['RRULE:FREQ=WEEKLY;COUNT=2;BYDAY=WE'],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: event.recurrence_id.id}}
}, timeout=3)
@@ -229,7 +229,7 @@ class TestSyncOdoo2Google(TestSyncGoogle):
'location': '',
'guestsCanModify': True,
'organizer': {'email': 'odoobot@example.com', 'self': True},
'attendees': [],
'attendees': [{'email': 'odoobot@example.com', 'responseStatus': 'accepted'}],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: event.id}},
'reminders': {'overrides': [], 'useDefault': False},
'visibility': 'public',
@@ -315,7 +315,7 @@ class TestSyncOdoo2Google(TestSyncGoogle):
'location': '',
'guestsCanModify': True,
'organizer': {'email': 'odoobot@example.com', 'self': True},
'attendees': [],
'attendees': [{'email': 'odoobot@example.com', 'responseStatus': 'accepted'}],
'recurrence': ['RRULE:FREQ=WEEKLY;COUNT=2;BYDAY=WE'],
'extendedProperties': {'shared': {'%s_odoo_id' % self.env.cr.dbname: recurrence.id}},
'reminders': {'overrides': [], 'useDefault': False},
@@ -313,7 +313,7 @@ class TestCompanyLeave(TransactionCase):
})
company_leave._compute_date_from_to()
with self.assertQueryCount(__system__=739, admin=865):
with self.assertQueryCount(__system__=842, admin=865):
# Original query count: 1987
# Without tracking/activity context keys: 5154
company_leave.action_validate()
@@ -31,7 +31,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase):
self.richard_emp.generate_work_entries(date(2018, 1, 1), date(2018, 1, 2))
leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0))
with self.assertQueryCount(__system__=92, admin=99):
with self.assertQueryCount(__system__=94, admin=99):
leave.action_validate()
leave.action_refuse()
@@ -4,12 +4,24 @@
from odoo import models
from odoo.addons.microsoft_calendar.models.microsoft_sync import microsoft_calendar_token
from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService
class Attendee(models.Model):
_name = 'calendar.attendee'
_inherit = 'calendar.attendee'
def write(self, vals):
"""
Tell to resync the link microsoft/odoo of the event when the status of an attendee is modified
"""
res = super().write(vals)
if vals.get('state'):
# When the state is changed, the corresponding event must be sync with microsoft
microsoft_service = MicrosoftCalendarService(self.env['microsoft.service'])
self.event_id.filtered('microsoft_id')._sync_odoo2microsoft(microsoft_service)
return res
def _send_mail_to_attendees(self, mail_template, force_send=False):
""" Override the super method
If not synced with Microsoft Outlook, let Odoo in charge of sending emails
@@ -56,6 +56,7 @@ class MicrosoftSync(models.AbstractModel):
_description = "Synchronize a record with Microsoft Calendar"
microsoft_id = fields.Char('Microsoft Calendar Id', copy=False)
# This field helps to known when a microsoft event need to be resynced
need_sync_m = fields.Boolean(default=True, copy=False)
active = fields.Boolean(default=True)
@@ -5,7 +5,7 @@ var core = require('web.core');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
const CalendarView = require('calendar.CalendarView');
const CalendarRenderer = require('calendar.CalendarRenderer');
const CalendarRenderer = require('calendar.CalendarRenderer').AttendeeCalendarRenderer;
const CalendarController = require('calendar.CalendarController');
const CalendarModel = require('calendar.CalendarModel');
const viewRegistry = require('web.view_registry');
@@ -1,7 +1,7 @@
odoo.define('microsoft_calendar.MicrosoftCalendarPopover', function(require) {
"use strict";
const CalendarPopover = require('web.CalendarPopover');
const CalendarPopover = require('calendar.CalendarRenderer').AttendeeCalendarPopover;
const MicrosoftCalendarPopover = CalendarPopover.include({
events: _.extend({}, CalendarPopover.prototype.events, {
@@ -15,9 +15,9 @@ odoo.define('microsoft_calendar.MicrosoftCalendarPopover', function(require) {
*/
isMEventSyncedAndArchivable() {
if (this.event.extendedProps.record.google_id === undefined) {
return this.event.extendedProps.record.microsoft_id;
return this.isCurrentPartnerOrganizer() && this.event.extendedProps.record.microsoft_id;
}
return !this.event.extendedProps.record.google_id && this.event.extendedProps.record.microsoft_id
return this.isCurrentPartnerOrganizer() && !this.event.extendedProps.record.google_id && this.event.extendedProps.record.microsoft_id
},
isEventDeletable() {
@@ -246,7 +246,23 @@ var CalendarController = AbstractController.extend({
* @private
* @param {OdooEvent} event
*/
_onChangeFilter: function (event) {
_onChangeFilter: async function (event) {
if (event.data.value !== "all" && event.target.filter_field) {
const domain = [['user_id', '=', this.getSession().uid], [event.target.write_field, '=', parseInt(event.data.value)]];
const contact = await this._rpc({
model: event.target.write_model,
method: 'search',
args: [domain],
});
let val = {
[event.target.filter_field]: event.data.active,
};
await this._rpc({
model: event.target.write_model,
method: 'write',
args: [contact, val],
});
}
if (this.model.changeFilter(event.data) && !event.data.no_reload) {
this.reload();
}
@@ -499,9 +499,9 @@ return AbstractModel.extend({
fields: self.fieldNames,
domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain())
})
.then(function (events) {
.then(async function (events) {
self._parseServerData(events);
self.data.data = _.map(events, self._recordToCalendarEvent.bind(self));
self.data.data = await self._getCalendarEventData(events);
return Promise.all([
self._loadColors(self.data, self.data.data),
self._loadRecordsToFilters(self.data, self.data.data)
@@ -518,14 +518,44 @@ return AbstractModel.extend({
_loadColors: function (element, events) {
if (this.fieldColor) {
var fieldName = this.fieldColor;
var self = this;
_.each(events, function (event) {
var value = event.record[fieldName];
event.color_index = _.isArray(value) ? value[0] : value;
var filter = self.loadParams.filters[fieldName];
var color = filter && _.map(filter.filters, f => f.value) || value;
var everyoneFilter = filter && (_.find(filter.filters, f => f.value === "all") || {}).active || false;
if (!everyoneFilter) {
color = [event.attendee_id || value]
} else {
color = value.includes(self.getSession().partner_id) ? self.getSession().partner_id : value
}
event.color_index = _.isArray(color) ? self._getColorIndex(value, color[0]) : color;
});
this.model_color = this.fields[fieldName].relation || element.model;
}
return Promise.resolve();
},
/**
* Get the color index used to render the event and the filter.
* Since we have a maximum of 30 colors, we have to adapt the color
* index in case the id exceeds the number of colors available.
*
* - actual_color_list contains the list of color already used in the filter
* - color_index is the color we try to apply for the event
*
* @private
* @param {Array} actual_color_list
* @param {Integer} color_index
* @returns {Integer}
*/
_getColorIndex: function(actual_color_list, color_index) {
var actual_color_list = actual_color_list || [0];
var new_color_index = color_index % 30;
if (new_color_index !== color_index && actual_color_list.includes(new_color_index)) {
new_color_index = this._getColorIndex(actual_color_list, new_color_index + 3);
}
return new_color_index;
},
/**
* @private
* @param {any} filter
@@ -537,11 +567,15 @@ return AbstractModel.extend({
}
var field = this.fields[filter.fieldName];
var fields = [filter.write_field];
if (filter.filter_field) {
fields.push(filter.filter_field);
}
return this._rpc({
model: filter.write_model,
method: 'search_read',
domain: [["user_id", "=", session.uid]],
fields: [filter.write_field],
fields: fields,
})
.then(function (res) {
var records = _.map(res, function (record) {
@@ -554,7 +588,7 @@ return AbstractModel.extend({
'id': record.id,
'value': value,
'label': formater(_value, field),
'active': f && f.active,
'active': (f && f.active) || (filter.filter_field && record[filter.filter_field]),
};
});
records.sort(function (f1,f2) {
@@ -609,7 +643,7 @@ return AbstractModel.extend({
if (filter.write_model) {
if (field.relation === self.model_color) {
_.each(filter.filters, function (f) {
f.color_index = f.value;
f.color_index = self._getColorIndex(_.map(filter.filters, f => f.value), f.value);
});
}
return;
@@ -773,6 +807,9 @@ return AbstractModel.extend({
return r;
},
_getCalendarEventData: function (events) {
return _.map(events, this._recordToCalendarEvent.bind(this));
}
});
});
@@ -46,6 +46,7 @@ var SidebarFilter = Widget.extend(FieldManagerMixin, {
this.fieldName = options.fieldName;
this.write_model = options.write_model;
this.write_field = options.write_field;
this.filter_field = options.filter_field;
this.avatar_field = options.avatar_field;
this.avatar_model = options.avatar_model;
this.filters = options.filters;
@@ -161,10 +162,12 @@ return AbstractRenderer.extend({
template: "CalendarView",
config: {
CalendarPopover: CalendarPopover,
eventTemplate: 'calendar-box',
},
custom_events: _.extend({}, AbstractRenderer.prototype.custom_events || {}, {
edit_event: '_onEditEvent',
delete_event: '_onDeleteEvent',
render_event: '_renderEvents',
}),
/**
@@ -356,7 +359,7 @@ return AbstractRenderer.extend({
if (_.isEmpty(qweb_context.record)) {
return '';
} else {
return qweb.render("calendar-box", qweb_context);
return qweb.render(this.config.eventTemplate, qweb_context);
}
},
/**
@@ -412,7 +415,7 @@ return AbstractRenderer.extend({
eventClickInfo.jsEvent.stopPropagation();
var eventData = eventClickInfo.event;
self._unselectEvent();
$(self.calendarElement).find(`[data-event-id=${eventData.id}]`).addClass('o_cw_custom_highlight');
$(self.calendarElement).find(self._computeEventSelector(eventClickInfo)).addClass('o_cw_custom_highlight');
self._renderEventPopover(eventData, $(eventClickInfo.el));
},
selectAllow: function (event) {
@@ -446,7 +449,7 @@ return AbstractRenderer.extend({
var event = info.event;
var element = $(info.el);
var view = info.view;
element.attr('data-event-id', event.id);
self._addEventAttributes(element, event);
if (view.type === 'dayGridYear') {
const color = this.getColor(event.extendedProps.color_index);
if (typeof color === 'string') {
@@ -471,6 +474,7 @@ return AbstractRenderer.extend({
if (view.type === 'dayGridMonth' && event.extendedProps.record) {
var start = event.extendedProps.r_start || event.start;
var end = event.extendedProps.r_end || event.end;
$(this.el).find(_.str.sprintf('.fc-day[data-date="%s"]', moment(start).format('YYYY-MM-DD'))).addClass('fc-has-event');
// Detect if the event occurs in just one day
// note: add & remove 1 min to avoid issues with 00:00
var isSameDayEvent = moment(start).clone().add(1, 'minute').isSame(moment(end).clone().subtract(1, 'minute'), 'day');
@@ -503,13 +507,13 @@ return AbstractRenderer.extend({
// The css ":hover" selector can't be used because these events
// are rendered using multiple elements.
eventMouseEnter: function (mouseEnterInfo) {
$(self.calendarElement).find(`[data-event-id=${mouseEnterInfo.event.id}]`).addClass('o_cw_custom_hover');
$(self.calendarElement).find(self._computeEventSelector(mouseEnterInfo)).addClass('o_cw_custom_hover');
},
eventMouseLeave: function (mouseLeaveInfo) {
if (!mouseLeaveInfo.event.id) {
return;
}
$(self.calendarElement).find(`[data-event-id=${mouseLeaveInfo.event.id}]`).removeClass('o_cw_custom_hover');
$(self.calendarElement).find(self._computeEventSelector(mouseLeaveInfo)).removeClass('o_cw_custom_hover');
},
eventDragStart: function (mouseDragInfo) {
$(self.calendarElement).find(`[data-event-id=${mouseDragInfo.event.id}]`).addClass('o_cw_custom_hover');
@@ -547,6 +551,22 @@ return AbstractRenderer.extend({
options.plugins.push(createYearCalendarView(FullCalendar, options));
return options;
},
/**
* Compute the selector to select the correct event we are currently on
* @param {jQueryElement} info
* @returns {String}
*/
_computeEventSelector: function (info) {
return `[data-event-id=${info.event.id}]`;
},
/**
* Add attribute to the element
* @param {jQueryElement} element
* @param {Object} event
*/
_addEventAttributes: function (element, event) {
element.attr('data-event-id', event.id);
},
/**
* Initialize the main calendar
*
@@ -880,13 +900,15 @@ return AbstractRenderer.extend({
for (const event of events) {
const start = moment(event.extendedProps.r_start);
const end = moment(event.extendedProps.r_end);
const duration = moment.duration(end.diff(start)).asDays();
event.extendedProps.record.start_hour = event.extendedProps.record.allday === false && duration < 1 ? start.format("HH:mm") : "";
const key = this._getFormattedDate(start, end, false, event.extendedProps.record.allday);
if (!(key in groupedEvents)) {
groupedEvents[key] = [];
groupKeys.push({
key: key,
start: event.extendedProps.r_start,
end: event.extendedProps.r_end,
start: event.start,
end: event.end,
isSameDayEvent: start.clone().add(1, 'minute')
.isSame(end.clone().subtract(1, 'minute'), 'day'),
});
@@ -1008,7 +1030,7 @@ return AbstractRenderer.extend({
*/
_onDeleteEvent: function (event) {
this._unselectEvent();
this.trigger_up('deleteRecord', {id: parseInt(event.data.id, 10)});
this.trigger_up('deleteRecord', {id: parseInt(event.data.id, 10), event: event.target.event.extendedProps});
},
});
@@ -112,6 +112,7 @@ var CalendarView = AbstractView.extend({
};
filters[fieldName].write_model = child.attrs.write_model;
filters[fieldName].write_field = child.attrs.write_field; // can't use a x2many fields
filters[fieldName].filter_field = child.attrs.filter_field;
modelFilters.push(fields[fieldName].relation);
}
+98 -6
View File
@@ -46,7 +46,7 @@ $o-cw-filter-avatar-size: 20px;
.fc-content {
white-space: normal;
margin: 8px 4px;
margin: 0.25rem;
font-size: 1.1em;
font-weight: 500;
}
@@ -107,6 +107,20 @@ $o-cw-filter-avatar-size: 20px;
color: white;
}
.fc-more-popover {
.fc-header {
padding-left: 1rem;
.fc-title {
font-weight: bold;
}
}
.fc-body {
max-height: 500px;
overflow: auto;
}
}
.o_calendar_disabled {
background-color: $gray-200;
border-color: white !important;
@@ -215,6 +229,10 @@ $o-cw-filter-avatar-size: 20px;
padding-top: 0;
padding-bottom: 0;
}
.fc-content {
margin-top: 1px!important;
}
}
// ====== Week only
@@ -361,6 +379,37 @@ $o-cw-filter-avatar-size: 20px;
padding-left: 5px;
}
@for $i from 1 through length($o-colors-complete) {
$color: nth($o-colors-complete, $i);
.o_cw_popover_link.o_calendar_color_#{$i - 1} {
border: solid 2px;
padding-left: 5px;
border-color: $color;
margin-top: 2px;
background: $color;
color: black;
&.o_attendee_status_tentative {
color: color-yiq($color);
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba($color, 0.7) 10px,
rgba($color, 0.7) 20px) !important;
}
&.o_attendee_status_needsAction {
background-color: rgba(white, 0.9)!important;
}
&.o_attendee_status_declined {
text-decoration: line-through;
background-color: rgba(white, 0.9)!important;
}
}
}
> table {
height: 100%;
}
@@ -421,6 +470,10 @@ $o-cw-filter-avatar-size: 20px;
.fc-dayGridMonth-view {
padding-left: unset;
.fc-has-event {
background-color: #B4DFF5;
}
}
.fc-week.fc-row {
@@ -759,7 +812,13 @@ $o-cw-filter-avatar-size: 20px;
) !important;
.fc-bg {
background: rgba(white, 0.5);
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba($color, 0.5) 10px,
rgba($color, 0.5) 20px
);
}
}
.fc-bgevent.o_calendar_color_#{$i - 1} {
@@ -770,7 +829,7 @@ $o-cw-filter-avatar-size: 20px;
.fc-event.o_calendar_color_#{$i - 1} {
border-color: $color;
color: darken($color, 45%);
opacity: 0.75;
opacity: 1;
&.o_event_hightlight {
opacity: 1;
@@ -790,15 +849,48 @@ $o-cw-filter-avatar-size: 20px;
&.o_cw_custom_hover, &.o_cw_custom_highlight {
box-shadow: 0 12px 12px -5px rgba($color, 0.3);
color: color-yiq($color);
z-index: 10!important;
opacity: 1;
right: 1px!important;
left: 1px!important;
margin-right: 0!important;
}
&.o_attendee_status_needsAction, &.o_attendee_status_tentative, &.o_attendee_status_declined, &.o_attendee_status_alone {
border-width: 2px 2px 2px !important;
&.o_cw_custom_highlight {
background-color: $color;
}
}
&.o_attendee_status_tentative {
color: color-yiq($color);
.fc-bg {
background: repeating-linear-gradient(
45deg,
$color,
$color 10px,
rgba($color, 0.5) 10px,
rgba($color, 0.5) 20px
);
rgba(white, 0.4) 10px,
rgba(white, 0.4) 20px
) !important;
}
}
&.o_attendee_status_alone, &.o_attendee_status_needsAction {
background-color: rgba(white, 0.9)!important;
color: black;
.fc-bg {
background-color: rgba(white, 0.9)!important;
}
}
&.o_attendee_status_declined {
text-decoration: line-through;
background-color: rgba(white, 0.9)!important;
color: black;
.fc-bg {
background-color: rgba(white, 0.9)!important;
}
}
}
+6 -3
View File
@@ -132,10 +132,13 @@
<div class="font-weight-bold mt-2" t-esc="groupKey"/>
<t t-foreach="groupedEvents[groupKey]" t-as="event">
<t t-set="record" t-value="event.extendedProps.record"/>
<a href="#" class="o_cw_popover_link btn-link d-block"
<t t-set="color" t-value="event.extendedProps.color_index"/>
<a href="#"
t-attf-class="o_cw_popover_link o_calendar_color_#{typeof color === 'number' ? _.str.sprintf('%s', color) : '1'} o_attendee_status_#{record.is_alone ? 'alone' : record.attendee_status} btn-link d-block"
t-att-data-id="record.id"
t-att-data-title="record.display_name"
t-esc="record.display_name"/>
t-att-data-title="record.display_name">
<t t-esc="record.display_name"/> <t t-esc="record.start_hour"/>
</a>
</t>
</t>
<div class="mt-2">
@@ -116,11 +116,12 @@ QUnit.module('Views', {
id: {string: "ID", type: "integer"},
user_id: {string: "user", type: "many2one", relation: 'user'},
partner_id: {string: "partner", type: "many2one", relation: 'partner'},
partner_checked: {string: "checked", type: "boolean"}
},
records: [
{id: 1, user_id: session.uid, partner_id: 1},
{id: 2, user_id: session.uid, partner_id: 2},
{id: 3, user_id: 4, partner_id: 3}
{id: 1, user_id: session.uid, partner_id: 1, partner_checked: true},
{id: 2, user_id: session.uid, partner_id: 2, partner_checked: true},
{id: 3, user_id: 4, partner_id: 3, partner_checked: true}
]
},
};
@@ -2140,6 +2141,40 @@ QUnit.module('Views', {
assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body");
});
QUnit.test('check filters with filter_field specified', async function (assert) {
assert.expect(5);
const calendar = await createCalendarView({
View: CalendarView,
model: 'event',
data: this.data,
arch: `
<calendar class="o_calendar_test"
event_open_popup="true"
date_start="start"
date_stop="stop"
all_day="allday"
mode="week"
color="partner_id">
<field name="partner_ids" write_model="filter_partner" write_field="partner_id" filter_field="partner_checked"/>
</calendar>`,
});
assert.containsOnce(calendar, '.o_calendar_filter_item[data-id="2"] input:checked',
"checkbox should be checked");
await testUtils.dom.click(calendar.$('.o_calendar_filter_item[data-id="2"] input'));
assert.containsNone(calendar, '.o_calendar_filter_item[data-id="2"] input:checked',
"checkbox should not be checked");
assert.strictEqual(this.data.filter_partner.records.find(r => r.id === 2).partner_checked, false,
"the status of this filter should now be false");
await calendar.reload();
assert.containsNone(calendar, '.o_calendar_filter_item[data-id="2"] input:checked',
"checkbox should not be checked after the reload");
assert.strictEqual(this.data.filter_partner.records.find(r => r.id === 2).partner_checked, false,
"the status of this filter should still be false after the reload");
calendar.destroy();
});
QUnit.test('"all" filter', async function (assert) {
assert.expect(8);
@@ -3832,7 +3867,7 @@ QUnit.module('Views', {
assert.containsOnce(calendar, '.o_cw_popover');
popoverText = calendar.el.querySelector('.o_cw_popover')
.textContent.replace(/\s{2,}/g, ' ').trim();
assert.strictEqual(popoverText, 'December 12, 2016 event 2 event 3');
assert.strictEqual(popoverText, 'December 12, 2016 event 2 10:55 event 3 15:55');
await testUtils.dom.click(calendar.el.querySelector('.o_cw_popover_close'));
assert.containsNone(calendar, '.o_cw_popover');
+3 -2
View File
@@ -454,10 +454,11 @@ calendar view are:
* ``avatar_field``
only for x2many field, to display the avatar instead of the display_name
in the cards
* ``write_model`` and ``write_field``
* ``write_model`` and ``write_field`` and ``filter_field``
you can add a filter and save the result in the defined model, the
filter is added in the sidebar
filter is added in the sidebar. The ``filter_field`` is optionnal and allows
you to specify the field that will hold the status of the filter.
* ``filter`` and ``color``
use "True" to add this field in filter in the sidebar. You can specify
+1
View File
@@ -265,6 +265,7 @@
<rng:optional><rng:attribute name="avatar_field" /></rng:optional>
<rng:optional><rng:attribute name="write_model" /></rng:optional>
<rng:optional><rng:attribute name="write_field" /></rng:optional>
<rng:optional><rng:attribute name="filter_field" /></rng:optional>
<rng:optional><rng:attribute name="text" /></rng:optional>
<rng:optional><rng:attribute name="optional" /></rng:optional>
<rng:optional><rng:attribute name="decoration-bf"/></rng:optional>