odoo.define('web.CalendarModel', function (require) { "use strict"; var AbstractModel = require('web.AbstractModel'); var Context = require('web.Context'); var core = require('web.core'); var fieldUtils = require('web.field_utils'); var session = require('web.session'); var _t = core._t; function dateToServer (date) { return date.clone().utc().locale('en').format('YYYY-MM-DD HH:mm:ss'); } return AbstractModel.extend({ /** * @override */ init: function () { this._super.apply(this, arguments); this.end_date = null; var week_start = _t.database.parameters.week_start; // calendar uses index 0 for Sunday but Odoo stores it as 7 this.week_start = week_start !== undefined && week_start !== false ? week_start % 7 : moment().startOf('week').day(); this.week_stop = this.week_start + 6; }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Transform fullcalendar event object to OpenERP Data object */ calendarEventToRecord: function (event) { // Normalize event_end without changing fullcalendars event. var data = {'name': event.title}; var start = event.start.clone(); var end = event.end && event.end.clone(); // Set end date if not existing if (!end || end.diff(start) < 0) { // undefined or invalid end date if (event.allDay) { end = start.clone(); } else { // in week mode or day mode, convert allday event to event end = start.clone().add(2, 'h'); } } else if (event.allDay) { // For an "allDay", FullCalendar gives the end day as the // next day at midnight (instead of 23h59). end.add(-1, 'days'); } var isDateEvent = this.fields[this.mapping.date_start].type === 'date'; // An "allDay" event without the "all_day" option is not considered // as a 24h day. It's just a part of the day (by default: 7h-19h). if (event.allDay) { if (!this.mapping.all_day && !isDateEvent) { if (event.r_start) { start.hours(event.r_start.hours()) .minutes(event.r_start.minutes()) .seconds(event.r_start.seconds()) .utc(); end.hours(event.r_end.hours()) .minutes(event.r_end.minutes()) .seconds(event.r_end.seconds()) .utc(); } else { // default hours in the user's timezone start.hours(7); end.hours(19); } start.add(-this.getSession().getTZOffset(start), 'minutes'); end.add(-this.getSession().getTZOffset(end), 'minutes'); } } else { start.add(-this.getSession().getTZOffset(start), 'minutes'); end.add(-this.getSession().getTZOffset(end), 'minutes'); } if (this.mapping.all_day) { if (event.record) { data[this.mapping.all_day] = (this.data.scale !== 'month' && event.allDay) || event.record[this.mapping.all_day] && end.diff(start) < 10 || false; } else { data[this.mapping.all_day] = event.allDay; } } data[this.mapping.date_start] = start; if (this.mapping.date_stop) { data[this.mapping.date_stop] = end; } if (this.mapping.date_delay) { if (this.data.scale !== 'month' || (this.data.scale === 'month' && !event.drop)) { data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; } } return data; }, /** * @param {Object} filter * @returns {boolean} */ changeFilter: function (filter) { var Filter = this.data.filters[filter.fieldName]; if (filter.value === 'all') { Filter.all = filter.active; } var f = _.find(Filter.filters, function (f) { return f.value === filter.value; }); if (f) { if (f.active !== filter.active) { f.active = filter.active; } else { return false; } } else if (filter.active) { Filter.filters.push({ value: filter.value, active: true, }); } return true; }, /** * @param {OdooEvent} event */ createRecord: function (event) { var data = this.calendarEventToRecord(event.data.data); for (var k in data) { if (data[k] && data[k]._isAMomentObject) { data[k] = dateToServer(data[k]); } } return this._rpc({ model: this.modelName, method: 'create', args: [data], context: event.data.options.context, }); }, /** * @todo I think this is dead code * * @param {any} ids * @param {any} model * @returns */ deleteRecords: function (ids, model) { return this._rpc({ model: model, method: 'unlink', args: [ids], context: session.user_context, // todo: combine with view context }); }, /** * @override * @returns {Object} */ __get: function () { return _.extend({}, this.data, { fields: this.fields }); }, /** * @override * @param {any} params * @returns {Promise} */ __load: function (params) { var self = this; this.modelName = params.modelName; this.fields = params.fields; this.fieldNames = params.fieldNames; this.fieldsInfo = params.fieldsInfo; this.mapping = params.mapping; this.mode = params.mode; // one of month, week or day this.scales = params.scales; // one of month, week or day this.scalesInfo = params.scalesInfo; // Check whether the date field is editable (i.e. if the events can be // dragged and dropped) this.editable = params.editable; this.creatable = params.creatable; // display more button when there are too much event on one day this.eventLimit = params.eventLimit; // fields to display color, e.g.: user_id.partner_id this.fieldColor = params.fieldColor; if (!this.preloadPromise) { this.preloadPromise = new Promise(function (resolve, reject) { Promise.all([ self._rpc({model: self.modelName, method: 'check_access_rights', args: ["write", false]}), self._rpc({model: self.modelName, method: 'check_access_rights', args: ["create", false]}) ]).then(function (result) { var write = result[0]; var create = result[1]; self.write_right = write; self.create_right = create; resolve(); }).guardedCatch(reject); }); } this.data = { domain: params.domain, context: params.context, // get in arch the filter to display in the sidebar and the field to read filters: params.filters, }; this.setDate(params.initialDate); // Use mode attribute in xml file to specify zoom timeline (day,week,month) // by default month. this.setScale(params.mode); _.each(this.data.filters, function (filter) { if (filter.avatar_field && !filter.avatar_model) { filter.avatar_model = self.modelName; } }); return this.preloadPromise.then(this._loadCalendar.bind(this)); }, /** * Move the current date range to the next period */ next: function () { this.setDate(this.data.target_date.clone().add(1, this.data.scale)); }, /** * Move the current date range to the previous period */ prev: function () { this.setDate(this.data.target_date.clone().add(-1, this.data.scale)); }, /** * @override * @param {Object} [params.context] * @param {Array} [params.domain] * @returns {Promise} */ __reload: function (handle, params) { if (params.domain) { this.data.domain = params.domain; } if (params.context) { this.data.context = params.context; } return this._loadCalendar(); }, /** * @param {Moment} start. in local TZ */ setDate: function (start) { // keep highlight/target_date in localtime this.data.highlight_date = this.data.target_date = start.clone(); this.data.start_date = this.data.end_date = start; switch (this.data.scale) { case 'year': { const yearStart = this.data.start_date.clone().startOf('year'); let yearStartDay = this.week_start; if (yearStart.day() < yearStartDay) { // the 1st of January is before our week start (e.g. week start is Monday, and // 01/01 is Sunday), so we go one week back yearStartDay -= 7; } this.data.start_date = yearStart.day(yearStartDay).startOf('day'); this.data.end_date = this.data.end_date.clone() .endOf('year').day(this.week_stop).endOf('day'); break; } case 'month': var monthStart = this.data.start_date.clone().startOf('month'); var monthStartDay; if (monthStart.day() >= this.week_start) { // the month's first day is after our week start // Then we are in the right week monthStartDay = this.week_start; } else { // The month's first day is before our week start // Then we should go back to the the previous week monthStartDay = this.week_start - 7; } this.data.start_date = monthStart.day(monthStartDay).startOf('day'); this.data.end_date = this.data.start_date.clone().add(5, 'week').day(this.week_stop).endOf('day'); break; case 'week': var weekStart = this.data.start_date.clone().startOf('week'); var weekStartDay = this.week_start; if (this.data.start_date.day() < this.week_start) { // The week's first day is after our current day // Then we should go back to the previous week weekStartDay -= 7; } this.data.start_date = this.data.start_date.clone().day(weekStartDay).startOf('day'); this.data.end_date = this.data.end_date.clone().day(weekStartDay + 6).endOf('day'); break; default: this.data.start_date = this.data.start_date.clone().startOf('day'); this.data.end_date = this.data.end_date.clone().endOf('day'); } // We have set start/stop datetime as definite begin/end boundaries of a period (month, week, day) // in local TZ (what is the begining of the week *I am* in ?) // The following code: // - converts those to UTC using our homemade method (testable) // - sets the moment UTC flag to true, to ensure compatibility with third party libs var manualUtcDateStart = this.data.start_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes'); var formattedUtcDateStart = manualUtcDateStart.format('YYYY-MM-DDTHH:mm:ss') + 'Z'; this.data.start_date = moment.utc(formattedUtcDateStart); var manualUtcDateEnd = this.data.end_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes'); var formattedUtcDateEnd = manualUtcDateEnd.format('YYYY-MM-DDTHH:mm:ss') + 'Z'; this.data.end_date = moment.utc(formattedUtcDateEnd); }, /** * @param {string} scale the scale to set */ setScale: function (scale) { if (!_.contains(this.scales, scale)) { scale = "week"; } this.data.scale = scale; this.setDate(this.data.target_date); }, /** * Move the current date range to the period containing today */ today: function () { this.setDate(moment(new Date())); }, /** * @param {Object} record * @param {integer} record.id * @returns {Promise} */ updateRecord: function (record) { // Cannot modify actual name yet var data = _.omit(this.calendarEventToRecord(record), 'name'); for (var k in data) { if (data[k] && data[k]._isAMomentObject) { data[k] = dateToServer(data[k]); } } var context = new Context(this.data.context, {from_ui: true}); return this._rpc({ model: this.modelName, method: 'write', args: [[parseInt(record.id, 10)], data], context: context }); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Converts this.data.filters into a domain * * @private * @returns {Array} */ _getFilterDomain: function () { // List authorized values for every field // fields with an active 'all' filter are skipped var authorizedValues = {}; var avoidValues = {}; _.each(this.data.filters, function (filter) { // Skip 'all' filters because they do not affect the domain if (filter.all) return; // Loop over subfilters to complete authorizedValues _.each(filter.filters, function (f) { if (filter.write_model) { if (!authorizedValues[filter.fieldName]) authorizedValues[filter.fieldName] = []; if (f.active) { authorizedValues[filter.fieldName].push(f.value); } } else { if (!f.active) { if (!avoidValues[filter.fieldName]) avoidValues[filter.fieldName] = []; avoidValues[filter.fieldName].push(f.value); } } }); }); // Compute the domain var domain = []; for (var field in authorizedValues) { domain.push([field, 'in', authorizedValues[field]]); } for (var field in avoidValues) { if (avoidValues[field].length > 0) { domain.push([field, 'not in', avoidValues[field]]); } } return domain; }, /** * @private * @returns {Object} */ _getFullCalendarOptions: function () { var format12Hour = { hour: 'numeric', minute: '2-digit', omitZeroMinute: true, meridiem: 'short' }; var format24Hour = { hour: 'numeric', minute: '2-digit', hour12: false, }; return { defaultView: this.scalesInfo[this.mode || 'week'], header: false, selectable: this.creatable && this.create_right, selectMirror: true, editable: this.editable, droppable: true, navLinks: false, eventLimit: this.eventLimit, // allow "more" link when too many events snapMinutes: 15, longPressDelay: 500, eventResizableFromStart: true, nowIndicator: true, weekNumbers: true, weekNumbersWithinDays: true, weekNumberCalculation: function (date) { // Since FullCalendar v4 ISO 8601 week date is preferred so we force the old system return moment(date).week(); }, weekLabel: _t("Week"), allDayText: _t("All day"), monthNames: moment.months(), monthNamesShort: moment.monthsShort(), dayNames: moment.weekdays(), dayNamesShort: moment.weekdaysShort(), firstDay: this.week_start, slotLabelFormat: _t.database.parameters.time_format.search("%H") !== -1 ? format24Hour : format12Hour, allDaySlot: this.mapping.all_day || this.fields[this.mapping.date_start].type === 'date', }; }, /** * Return a domain from the date range * * @private * @returns {Array} A domain containing datetimes start and stop in UTC * those datetimes are formatted according to server's standards */ _getRangeDomain: function () { // Build OpenERP Domain to filter object by this.mapping.date_start field // between given start, end dates. var domain = [[this.mapping.date_start, '<=', dateToServer(this.data.end_date)]]; if (this.mapping.date_stop) { domain.push([this.mapping.date_stop, '>=', dateToServer(this.data.start_date)]); } else if (!this.mapping.date_delay) { domain.push([this.mapping.date_start, '>=', dateToServer(this.data.start_date)]); } return domain; }, /** * @private * @returns {Promise} */ _loadCalendar: function () { var self = this; this.data.fc_options = this._getFullCalendarOptions(); var defs = _.map(this.data.filters, this._loadFilter.bind(this)); return Promise.all(defs).then(function () { return self._rpc({ model: self.modelName, method: 'search_read', context: self.data.context, fields: self.fieldNames, domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain()) }) .then(function (events) { self._parseServerData(events); self.data.data = _.map(events, self._recordToCalendarEvent.bind(self)); return Promise.all([ self._loadColors(self.data, self.data.data), self._loadRecordsToFilters(self.data, self.data.data) ]); }); }); }, /** * @private * @param {any} element * @param {any} events * @returns {Promise} */ _loadColors: function (element, events) { if (this.fieldColor) { var fieldName = this.fieldColor; _.each(events, function (event) { var value = event.record[fieldName]; event.color_index = _.isArray(value) ? value[0] % 30 : value % 30; }); this.model_color = this.fields[fieldName].relation || element.model; } return Promise.resolve(); }, /** * @private * @param {any} filter * @returns {Promise} */ _loadFilter: function (filter) { if (!filter.write_model) { return Promise.resolve(); } var field = this.fields[filter.fieldName]; return this._rpc({ model: filter.write_model, method: 'search_read', domain: [["user_id", "=", session.uid]], fields: [filter.write_field], }) .then(function (res) { var records = _.map(res, function (record) { var _value = record[filter.write_field]; var value = _.isArray(_value) ? _value[0] : _value; var f = _.find(filter.filters, function (f) {return f.value === value;}); var formater = fieldUtils.format[_.contains(['many2many', 'one2many'], field.type) ? 'many2one' : field.type]; return { 'id': record.id, 'value': value, 'label': formater(_value, field), 'active': !f || f.active, }; }); records.sort(function (f1,f2) { return _.string.naturalCmp(f2.label, f1.label); }); // add my profile if (field.relation === 'res.partner' || field.relation === 'res.users') { var value = field.relation === 'res.partner' ? session.partner_id : session.uid; var me = _.find(records, function (record) { return record.value === value; }); if (me) { records.splice(records.indexOf(me), 1); } else { var f = _.find(filter.filters, function (f) {return f.value === value;}); me = { 'value': value, 'label': session.name + _t(" [Me]"), 'active': !f || f.active, }; } records.unshift(me); } // add all selection records.push({ 'value': 'all', 'label': field.relation === 'res.partner' || field.relation === 'res.users' ? _t("Everybody's calendars") : _t("Everything"), 'active': filter.all, }); filter.filters = records; }); }, /** * @private * @param {any} element * @param {any} events * @returns {Promise} */ _loadRecordsToFilters: function (element, events) { var self = this; var new_filters = {}; var to_read = {}; var defs = []; var color_filter = {}; _.each(this.data.filters, function (filter, fieldName) { var field = self.fields[fieldName]; new_filters[fieldName] = filter; if (filter.write_model) { if (field.relation === self.model_color) { _.each(filter.filters, function (f) { f.color_index = f.value; }); } return; } _.each(filter.filters, function (filter) { filter.display = !filter.active; }); var fs = []; var undefined_fs = []; _.each(events, function (event) { var data = event.record[fieldName]; if (!_.contains(['many2many', 'one2many'], field.type)) { data = [data]; } else { to_read[field.relation] = (to_read[field.relation] || []).concat(data); } _.each(data, function (_value) { var value = _.isArray(_value) ? _value[0] : _value; var f = { 'color_index': self.model_color === (field.relation || element.model) ? value % 30 : false, 'value': value, 'label': fieldUtils.format[field.type](_value, field) || _t("Undefined"), 'avatar_model': field.relation || element.model, }; // if field used as color does not have value then push filter in undefined_fs, // such filters should come last in filter list with Undefined string, later merge it with fs value ? fs.push(f) : undefined_fs.push(f); }); }); _.each(_.union(fs, undefined_fs), function (f) { var f1 = _.findWhere(filter.filters, _.omit(f, 'color_index')); if (f1) { f1.display = true; } else { f.display = f.active = true; filter.filters.push(f); } }); if (filter.color_model && filter.field_color) { var ids = filter.filters.reduce((acc, f) => { if (!f.color_index && f.value) { acc.push(f.value); } return acc; }, []); if (!color_filter[filter.color_model]) { color_filter[filter.color_model] = {}; } if (ids.length) { defs.push(self._rpc({ model: filter.color_model, method: 'read', args: [_.uniq(ids), [filter.field_color]], }) .then(function (res) { _.each(res, function (c) { color_filter[filter.color_model][c.id] = c[filter.field_color]; }); })); } } }); _.each(to_read, function (ids, model) { defs.push(self._rpc({ model: model, method: 'name_get', args: [_.uniq(ids)], }) .then(function (res) { to_read[model] = _.object(res); })); }); return Promise.all(defs).then(function () { _.each(self.data.filters, function (filter) { if (filter.write_model) { return; } if (filter.filters.length && (filter.filters[0].avatar_model in to_read)) { _.each(filter.filters, function (f) { f.label = to_read[f.avatar_model][f.value]; }); } if (filter.color_model && filter.field_color) { _.each(filter.filters, function (f) { if (!f.color_index) { f.color_index = color_filter[filter.color_model] && color_filter[filter.color_model][f.value]; } }); } }); }); }, /** * parse the server values to javascript framwork * * @private * @param {Object} data the server data to parse */ _parseServerData: function (data) { var self = this; _.each(data, function(event) { _.each(self.fieldNames, function (fieldName) { event[fieldName] = self._parseServerValue(self.fields[fieldName], event[fieldName]); }); }); }, /** * Transform OpenERP event object to fullcalendar event object * * @private * @param {Object} evt */ _recordToCalendarEvent: function (evt) { var date_start; var date_stop; var date_delay = evt[this.mapping.date_delay] || 1.0, all_day = this.fields[this.mapping.date_start].type === 'date' || this.mapping.all_day && evt[this.mapping.all_day] || false, the_title = '', attendees = []; if (!all_day) { date_start = evt[this.mapping.date_start].clone(); date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone() : null; } else { date_start = evt[this.mapping.date_start].clone().startOf('day'); date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone().startOf('day') : null; } if (!date_stop && date_delay) { date_stop = date_start.clone().add(date_delay,'hours'); } if (!all_day) { date_start.add(this.getSession().getTZOffset(date_start), 'minutes'); date_stop.add(this.getSession().getTZOffset(date_stop), 'minutes'); } if (this.mapping.all_day && evt[this.mapping.all_day]) { date_stop.add(1, 'days'); } var r = { 'record': evt, 'start': date_start.local(true).toDate(), 'end': date_stop.local(true).toDate(), 'r_start': date_start.clone().local(true).toDate(), 'r_end': date_stop.clone().local(true).toDate(), 'title': the_title, 'allDay': all_day, 'id': evt.id, 'attendees':attendees, }; if (!(this.mapping.all_day && evt[this.mapping.all_day]) && this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') { r.showTime = true; } return r; }, }); });