[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:
@@ -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.')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user