diff --git a/addons/calendar/models/calendar_contact.py b/addons/calendar/models/calendar_contact.py index 6885450fa27..9a8a002872a 100644 --- a/addons/calendar/models/calendar_contact.py +++ b/addons/calendar/models/calendar_contact.py @@ -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.') diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index a13afdd3a7d..fc3f8cbfa67 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -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 diff --git a/addons/calendar/models/res_partner.py b/addons/calendar/models/res_partner.py index ec4f5ea7555..2cfe4551337 100644 --- a/addons/calendar/models/res_partner.py +++ b/addons/calendar/models/res_partner.py @@ -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): diff --git a/addons/calendar/static/src/js/base_calendar.js b/addons/calendar/static/src/js/base_calendar.js index 46362327e17..a4de081c604 100644 --- a/addons/calendar/static/src/js/base_calendar.js +++ b/addons/calendar/static/src/js/base_calendar.js @@ -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; }); }, }); diff --git a/addons/calendar/static/src/js/calendar_controller.js b/addons/calendar/static/src/js/calendar_controller.js index c87f756702c..afb48c1fbaf 100644 --- a/addons/calendar/static/src/js/calendar_controller.js +++ b/addons/calendar/static/src/js/calendar_controller.js @@ -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 diff --git a/addons/calendar/static/src/js/calendar_model.js b/addons/calendar/static/src/js/calendar_model.js index 8d90b543d0b..81d529e9bb9 100644 --- a/addons/calendar/static/src/js/calendar_model.js +++ b/addons/calendar/static/src/js/calendar_model.js @@ -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; diff --git a/addons/calendar/static/src/js/calendar_renderer.js b/addons/calendar/static/src/js/calendar_renderer.js index 8320dc84f75..3ccd924ea7b 100644 --- a/addons/calendar/static/src/js/calendar_renderer.js +++ b/addons/calendar/static/src/js/calendar_renderer.js @@ -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, +}; }); diff --git a/addons/calendar/static/src/js/calendar_view.js b/addons/calendar/static/src/js/calendar_view.js index 4454dc0e1ef..78eb7ec2752 100644 --- a/addons/calendar/static/src/js/calendar_view.js +++ b/addons/calendar/static/src/js/calendar_view.js @@ -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'); diff --git a/addons/calendar/static/src/xml/base_calendar.xml b/addons/calendar/static/src/xml/base_calendar.xml index 3f2b083477e..b87fa243da4 100644 --- a/addons/calendar/static/src/xml/base_calendar.xml +++ b/addons/calendar/static/src/xml/base_calendar.xml @@ -62,4 +62,14 @@ + +
+
+ + +
+ +
+
+ diff --git a/addons/calendar/static/tests/calendar_tests.js b/addons/calendar/static/tests/calendar_tests.js index d2626b0ab51..004e9b7ad65 100644 --- a/addons/calendar/static/tests/calendar_tests.js +++ b/addons/calendar/static/tests/calendar_tests.js @@ -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); diff --git a/addons/calendar/tests/test_attendees.py b/addons/calendar/tests/test_attendees.py index 44f7177c149..d9e1c1db4fc 100644 --- a/addons/calendar/tests/test_attendees.py +++ b/addons/calendar/tests/test_attendees.py @@ -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): diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml index 0ec4a0bac11..684495142c5 100644 --- a/addons/calendar/views/calendar_views.xml +++ b/addons/calendar/views/calendar_views.xml @@ -163,8 +163,9 @@ + + -