mirror of
https://github.com/bringout/oca-ocb-technical.git
synced 2026-04-20 12:52:02 +02:00
Initial commit: Technical packages
This commit is contained in:
commit
3473fa71a0
873 changed files with 297766 additions and 0 deletions
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
|
|
@ -0,0 +1,23 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70">
|
||||
<defs>
|
||||
<path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/>
|
||||
<linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#CDC484"/>
|
||||
<stop offset="100%" stop-color="#B5AA59"/>
|
||||
</linearGradient>
|
||||
<path id="icon-d" d="M28.3839286,37.7083333 L24.9017857,37.7083333 C24.3272321,37.7083333 23.8571429,37.2513021 23.8571429,36.6927083 L23.8571429,33.3072917 C23.8571429,32.7486979 24.3272321,32.2916667 24.9017857,32.2916667 L28.3839286,32.2916667 C28.9584821,32.2916667 29.4285714,32.7486979 29.4285714,33.3072917 L29.4285714,36.6927083 C29.4285714,37.2513021 28.9584821,37.7083333 28.3839286,37.7083333 Z M37.7857143,36.6927083 L37.7857143,33.3072917 C37.7857143,32.7486979 37.315625,32.2916667 36.7410714,32.2916667 L33.2589286,32.2916667 C32.684375,32.2916667 32.2142857,32.7486979 32.2142857,33.3072917 L32.2142857,36.6927083 C32.2142857,37.2513021 32.684375,37.7083333 33.2589286,37.7083333 L36.7410714,37.7083333 C37.315625,37.7083333 37.7857143,37.2513021 37.7857143,36.6927083 Z M46.1428571,36.6927083 L46.1428571,33.3072917 C46.1428571,32.7486979 45.6727679,32.2916667 45.0982143,32.2916667 L41.6160714,32.2916667 C41.0415179,32.2916667 40.5714286,32.7486979 40.5714286,33.3072917 L40.5714286,36.6927083 C40.5714286,37.2513021 41.0415179,37.7083333 41.6160714,37.7083333 L45.0982143,37.7083333 C45.6727679,37.7083333 46.1428571,37.2513021 46.1428571,36.6927083 Z M37.7857143,44.8177083 L37.7857143,41.4322917 C37.7857143,40.8736979 37.315625,40.4166667 36.7410714,40.4166667 L33.2589286,40.4166667 C32.684375,40.4166667 32.2142857,40.8736979 32.2142857,41.4322917 L32.2142857,44.8177083 C32.2142857,45.3763021 32.684375,45.8333333 33.2589286,45.8333333 L36.7410714,45.8333333 C37.315625,45.8333333 37.7857143,45.3763021 37.7857143,44.8177083 Z M29.4285714,44.8177083 L29.4285714,41.4322917 C29.4285714,40.8736979 28.9584821,40.4166667 28.3839286,40.4166667 L24.9017857,40.4166667 C24.3272321,40.4166667 23.8571429,40.8736979 23.8571429,41.4322917 L23.8571429,44.8177083 C23.8571429,45.3763021 24.3272321,45.8333333 24.9017857,45.8333333 L28.3839286,45.8333333 C28.9584821,45.8333333 29.4285714,45.3763021 29.4285714,44.8177083 Z M46.1428571,44.8177083 L46.1428571,41.4322917 C46.1428571,40.8736979 45.6727679,40.4166667 45.0982143,40.4166667 L41.6160714,40.4166667 C41.0415179,40.4166667 40.5714286,40.8736979 40.5714286,41.4322917 L40.5714286,44.8177083 C40.5714286,45.3763021 41.0415179,45.8333333 41.6160714,45.8333333 L45.0982143,45.8333333 C45.6727679,45.8333333 46.1428571,45.3763021 46.1428571,44.8177083 Z M54.5,22.8125 L54.5,52.6041667 C54.5,54.8470052 52.6283482,56.6666667 50.3214286,56.6666667 L19.6785714,56.6666667 C17.3716518,56.6666667 15.5,54.8470052 15.5,52.6041667 L15.5,22.8125 C15.5,20.5696615 17.3716518,18.75 19.6785714,18.75 L23.8571429,18.75 L23.8571429,14.3489583 C23.8571429,13.7903646 24.3272321,13.3333333 24.9017857,13.3333333 L28.3839286,13.3333333 C28.9584821,13.3333333 29.4285714,13.7903646 29.4285714,14.3489583 L29.4285714,18.75 L40.5714286,18.75 L40.5714286,14.3489583 C40.5714286,13.7903646 41.0415179,13.3333333 41.6160714,13.3333333 L45.0982143,13.3333333 C45.6727679,13.3333333 46.1428571,13.7903646 46.1428571,14.3489583 L46.1428571,18.75 L50.3214286,18.75 C52.6283482,18.75 54.5,20.5696615 54.5,22.8125 Z M50.3214286,52.0963542 L50.3214286,26.875 L19.6785714,26.875 L19.6785714,52.0963542 C19.6785714,52.375651 19.9136161,52.6041667 20.2008929,52.6041667 L49.7991071,52.6041667 C50.0863839,52.6041667 50.3214286,52.375651 50.3214286,52.0963542 Z"/>
|
||||
</defs>
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<mask id="icon-b" fill="#fff">
|
||||
<use xlink:href="#icon-a"/>
|
||||
</mask>
|
||||
<g mask="url(#icon-b)">
|
||||
<rect width="70" height="70" fill="url(#icon-c)"/>
|
||||
<path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/>
|
||||
<path fill="#393939" d="M43.4796282,58 L3.97692274,58 C1.98846137,58 -7.06443391e-15,57.8520408 0,53.8571429 L2.26109583e-16,28.7421381 L18.1939643,6.70980496 L20,6 L24,0 L29.235775,0.958543565 L30.1559259,9.58543565 L42.1178875,8.51357709e-15 L45.7984911,0 L45.7984911,8 L54,8 L53.7495748,40.7451155 L43.4796282,58 Z" opacity=".324" transform="translate(0 12)"/>
|
||||
<path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/>
|
||||
<use fill="#000" fill-rule="nonzero" opacity=".35" xlink:href="#icon-d"/>
|
||||
<path fill="#FFF" fill-rule="nonzero" d="M28.3839286,35.7083333 L24.9017857,35.7083333 C24.3272321,35.7083333 23.8571429,35.2513021 23.8571429,34.6927083 L23.8571429,31.3072917 C23.8571429,30.7486979 24.3272321,30.2916667 24.9017857,30.2916667 L28.3839286,30.2916667 C28.9584821,30.2916667 29.4285714,30.7486979 29.4285714,31.3072917 L29.4285714,34.6927083 C29.4285714,35.2513021 28.9584821,35.7083333 28.3839286,35.7083333 Z M37.7857143,34.6927083 L37.7857143,31.3072917 C37.7857143,30.7486979 37.315625,30.2916667 36.7410714,30.2916667 L33.2589286,30.2916667 C32.684375,30.2916667 32.2142857,30.7486979 32.2142857,31.3072917 L32.2142857,34.6927083 C32.2142857,35.2513021 32.684375,35.7083333 33.2589286,35.7083333 L36.7410714,35.7083333 C37.315625,35.7083333 37.7857143,35.2513021 37.7857143,34.6927083 Z M46.1428571,34.6927083 L46.1428571,31.3072917 C46.1428571,30.7486979 45.6727679,30.2916667 45.0982143,30.2916667 L41.6160714,30.2916667 C41.0415179,30.2916667 40.5714286,30.7486979 40.5714286,31.3072917 L40.5714286,34.6927083 C40.5714286,35.2513021 41.0415179,35.7083333 41.6160714,35.7083333 L45.0982143,35.7083333 C45.6727679,35.7083333 46.1428571,35.2513021 46.1428571,34.6927083 Z M37.7857143,42.8177083 L37.7857143,39.4322917 C37.7857143,38.8736979 37.315625,38.4166667 36.7410714,38.4166667 L33.2589286,38.4166667 C32.684375,38.4166667 32.2142857,38.8736979 32.2142857,39.4322917 L32.2142857,42.8177083 C32.2142857,43.3763021 32.684375,43.8333333 33.2589286,43.8333333 L36.7410714,43.8333333 C37.315625,43.8333333 37.7857143,43.3763021 37.7857143,42.8177083 Z M29.4285714,42.8177083 L29.4285714,39.4322917 C29.4285714,38.8736979 28.9584821,38.4166667 28.3839286,38.4166667 L24.9017857,38.4166667 C24.3272321,38.4166667 23.8571429,38.8736979 23.8571429,39.4322917 L23.8571429,42.8177083 C23.8571429,43.3763021 24.3272321,43.8333333 24.9017857,43.8333333 L28.3839286,43.8333333 C28.9584821,43.8333333 29.4285714,43.3763021 29.4285714,42.8177083 Z M46.1428571,42.8177083 L46.1428571,39.4322917 C46.1428571,38.8736979 45.6727679,38.4166667 45.0982143,38.4166667 L41.6160714,38.4166667 C41.0415179,38.4166667 40.5714286,38.8736979 40.5714286,39.4322917 L40.5714286,42.8177083 C40.5714286,43.3763021 41.0415179,43.8333333 41.6160714,43.8333333 L45.0982143,43.8333333 C45.6727679,43.8333333 46.1428571,43.3763021 46.1428571,42.8177083 Z M54.5,20.8125 L54.5,50.6041667 C54.5,52.8470052 52.6283482,54.6666667 50.3214286,54.6666667 L19.6785714,54.6666667 C17.3716518,54.6666667 15.5,52.8470052 15.5,50.6041667 L15.5,20.8125 C15.5,18.5696615 17.3716518,16.75 19.6785714,16.75 L23.8571429,16.75 L23.8571429,12.3489583 C23.8571429,11.7903646 24.3272321,11.3333333 24.9017857,11.3333333 L28.3839286,11.3333333 C28.9584821,11.3333333 29.4285714,11.7903646 29.4285714,12.3489583 L29.4285714,16.75 L40.5714286,16.75 L40.5714286,12.3489583 C40.5714286,11.7903646 41.0415179,11.3333333 41.6160714,11.3333333 L45.0982143,11.3333333 C45.6727679,11.3333333 46.1428571,11.7903646 46.1428571,12.3489583 L46.1428571,16.75 L50.3214286,16.75 C52.6283482,16.75 54.5,18.5696615 54.5,20.8125 Z M50.3214286,50.0963542 L50.3214286,24.875 L19.6785714,24.875 L19.6785714,50.0963542 C19.6785714,50.375651 19.9136161,50.6041667 20.2008929,50.6041667 L49.7991071,50.6041667 C50.0863839,50.6041667 50.3214286,50.375651 50.3214286,50.0963542 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-inherit="mail.Activity" t-inherit-mode="extension">
|
||||
<xpath expr="//button[hasclass('o_Activity_editButton')]" position="attributes">
|
||||
<attribute name="t-if">!activityView.activity.calendar_event_id</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//button[hasclass('o_Activity_editButton')]" position="after">
|
||||
<t t-if="activityView.activity.calendar_event_id">
|
||||
<button class="o_Activity_toolButton o_Activity_editButton btn btn-link pt-0" t-on-click="activityView.onClickEdit">
|
||||
<i class="fa fa-calendar"/> Reschedule
|
||||
</button>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ActivityMenuView" t-inherit-mode="extension">
|
||||
<xpath expr="//*[hasclass('o_ActivityMenuView_activityGroupTitle')]" position="after">
|
||||
<div t-if="activityGroupView.activityGroup.type == 'meeting'">
|
||||
<t t-set="is_next_meeting" t-value="true"/>
|
||||
<t t-foreach="activityGroupView.activityGroup.meetings" t-as="meeting" t-key="meeting.localId">
|
||||
<div class="d-flex">
|
||||
<span class="flex-grow-1" t-att-class="!meeting.allday and is_next_meeting ? 'o_meeting_filter o_meeting_bold' : 'o_meeting_filter'" t-att-data-res_model="activityGroupView.activityGroup.irModel.model" t-att-data-res_id="meeting.id" t-att-data-model_name="activityGroupView.activityGroup.irModel.name" t-att-title="meeting.name">
|
||||
<span><t t-esc="meeting.name"/></span>
|
||||
</span>
|
||||
<span t-if="meeting.formattedStart">
|
||||
<t t-if="meeting.allday">All Day</t>
|
||||
<t t-else=''>
|
||||
<t t-set="is_next_meeting" t-value="false"/>
|
||||
<t t-esc="meeting.formattedStart"/>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 605 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
|
|
@ -0,0 +1,85 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import BasicModel from 'web.BasicModel';
|
||||
import fieldRegistry from 'web.field_registry';
|
||||
import relationalFields from 'web.relational_fields';
|
||||
|
||||
const FieldMany2ManyTagsAvatar = relationalFields.FieldMany2ManyTagsAvatar;
|
||||
|
||||
BasicModel.include({
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} record
|
||||
* @param {string} fieldName
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_fetchSpecialAttendeeStatus: function (record, fieldName) {
|
||||
var context = record.getContext({fieldName: fieldName});
|
||||
var attendeeIDs = record.data[fieldName] ? this.localData[record.data[fieldName]].res_ids : [];
|
||||
var meetingID = _.isNumber(record.res_id) ? record.res_id : false;
|
||||
return this._rpc({
|
||||
model: 'res.partner',
|
||||
method: 'get_attendee_detail',
|
||||
args: [attendeeIDs, [meetingID]],
|
||||
context: context,
|
||||
}).then(function (result) {
|
||||
return result;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const Many2ManyAttendee = FieldMany2ManyTagsAvatar.extend({
|
||||
// as this widget is model dependant (rpc on res.partner), use it in another
|
||||
// context probably won't work
|
||||
// supportedFieldTypes: ['many2many'],
|
||||
specialData: "_fetchSpecialAttendeeStatus",
|
||||
className: 'o_field_many2manytags avatar',
|
||||
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.className += this.nodeOptions.block ? ' d-block' : '';
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_renderTags: function () {
|
||||
this._super.apply(this, arguments);
|
||||
const avatars = this.el.querySelectorAll('.o_m2m_avatar');
|
||||
for (const avatar of avatars) {
|
||||
const partner_id = parseInt(avatar.dataset["id"]);
|
||||
const partner_data = this.record.specialData.partner_ids.find(partner => partner.id === partner_id);
|
||||
if (partner_data) {
|
||||
avatar.classList.add('o_attendee_border', "o_attendee_border_" + partner_data.status);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_getRenderTagsContext: function () {
|
||||
let result = this._super.apply(this, arguments);
|
||||
result.attendeesData = this.record.specialData.partner_ids;
|
||||
// Sort attendees to have the organizer on top.
|
||||
// partner_ids are sorted by default according to their id/display_name in the "elements" FieldMany2ManyTag
|
||||
// This method sort them to put the organizer on top
|
||||
const organizer = result.attendeesData.find(item => item.is_organizer);
|
||||
if (organizer) {
|
||||
const org_id = organizer.id
|
||||
// sort elements according to the partner id
|
||||
result.elements.sort((a, b) => {
|
||||
const a_org = a.id === org_id;
|
||||
return a_org ? -1 : 1;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
fieldRegistry.add('many2manyattendee', Many2ManyAttendee);
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { ConnectionLostError } from "@web/core/network/rpc_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export const calendarNotificationService = {
|
||||
dependencies: ["action", "bus_service", "notification", "rpc"],
|
||||
|
||||
start(env, { action, bus_service, notification, rpc }) {
|
||||
let calendarNotifTimeouts = {};
|
||||
let nextCalendarNotifTimeout = null;
|
||||
const displayedNotifications = new Set();
|
||||
|
||||
bus_service.addEventListener('notification', ({ detail: notifications }) => {
|
||||
for (const { payload, type } of notifications) {
|
||||
if (type === "calendar.alarm") {
|
||||
displayCalendarNotification(payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
bus_service.start();
|
||||
|
||||
/**
|
||||
* Displays the Calendar notification on user's screen
|
||||
*/
|
||||
function displayCalendarNotification(notifications) {
|
||||
let lastNotifTimer = 0;
|
||||
|
||||
// Clear previously set timeouts and destroy currently displayed calendar notifications
|
||||
browser.clearTimeout(nextCalendarNotifTimeout);
|
||||
Object.values(calendarNotifTimeouts).forEach((notif) => browser.clearTimeout(notif));
|
||||
calendarNotifTimeouts = {};
|
||||
|
||||
// For each notification, set a timeout to display it
|
||||
notifications.forEach(function (notif) {
|
||||
const key = notif.event_id + "," + notif.alarm_id;
|
||||
if (displayedNotifications.has(key)) {
|
||||
return;
|
||||
}
|
||||
calendarNotifTimeouts[key] = browser.setTimeout(function () {
|
||||
const notificationRemove = notification.add(notif.message, {
|
||||
title: notif.title,
|
||||
type: "warning",
|
||||
sticky: true,
|
||||
onClose: () => {
|
||||
displayedNotifications.delete(key);
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
name: env._t("OK"),
|
||||
primary: true,
|
||||
onClick: async () => {
|
||||
await rpc("/calendar/notify_ack");
|
||||
notificationRemove();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: env._t("Details"),
|
||||
onClick: async () => {
|
||||
await action.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'calendar.event',
|
||||
res_id: notif.event_id,
|
||||
views: [[false, 'form']],
|
||||
}
|
||||
);
|
||||
notificationRemove();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: env._t("Snooze"),
|
||||
onClick: () => {
|
||||
notificationRemove();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
displayedNotifications.add(key);
|
||||
}, notif.timer * 1000);
|
||||
lastNotifTimer = Math.max(lastNotifTimer, notif.timer);
|
||||
});
|
||||
|
||||
// Set a timeout to get the next notifications when the last one has been displayed
|
||||
if (lastNotifTimer > 0) {
|
||||
nextCalendarNotifTimeout = browser.setTimeout(
|
||||
getNextCalendarNotif,
|
||||
lastNotifTimer * 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getNextCalendarNotif() {
|
||||
try {
|
||||
const result = await rpc("/calendar/notify", {}, { silent: true });
|
||||
displayCalendarNotification(result);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ConnectionLostError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("calendarNotification", calendarNotificationService);
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
|
||||
registerPatch({
|
||||
name: 'Activity',
|
||||
modelMethods: {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
convertData(data) {
|
||||
const res = this._super(data);
|
||||
if ('calendar_event_id' in data) {
|
||||
res.calendar_event_id = data.calendar_event_id[0];
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async deleteServerRecord() {
|
||||
if (!this.calendar_event_id){
|
||||
await this._super();
|
||||
} else {
|
||||
await this.messaging.rpc({
|
||||
model: 'mail.activity',
|
||||
method: 'unlink_w_meeting',
|
||||
args: [[this.id]],
|
||||
});
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.delete();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* In case the activity is linked to a meeting, we want to open the
|
||||
* calendar view instead.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async edit() {
|
||||
if (!this.calendar_event_id){
|
||||
await this._super();
|
||||
} else {
|
||||
const action = await this.messaging.rpc({
|
||||
model: 'mail.activity',
|
||||
method: 'action_create_calendar_event',
|
||||
args: [[this.id]],
|
||||
});
|
||||
this.env.services.action.doAction(action);
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
calendar_event_id: attr({
|
||||
default: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { many } from '@mail/model/model_field';
|
||||
|
||||
registerPatch({
|
||||
name: 'ActivityGroup',
|
||||
modelMethods: {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
convertData(data) {
|
||||
const data2 = this._super(data);
|
||||
data2.meetings = data.meetings;
|
||||
return data2;
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
_onChangeMeetings() {
|
||||
if (this.type === 'meeting' && this.meetings.length === 0) {
|
||||
this.delete();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
meetings: many('calendar.event'),
|
||||
},
|
||||
onChanges: [
|
||||
{
|
||||
dependencies: ['meetings', 'type'],
|
||||
methodName: '_onChangeMeetings',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
|
||||
registerPatch({
|
||||
name: 'ActivityGroupView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
onClickFilterButton(ev) {
|
||||
const $el = $(ev.currentTarget);
|
||||
const data = _.extend({}, $el.data());
|
||||
if (data.res_model === "calendar.event" && data.filter === "my") {
|
||||
this.activityMenuViewOwner.update({ isOpen: false });
|
||||
this.env.services['action'].doAction('calendar.action_calendar_event', {
|
||||
additionalContext: {
|
||||
default_mode: 'day',
|
||||
search_default_mymeetings: 1,
|
||||
},
|
||||
clearBreadcrumbs: true,
|
||||
});
|
||||
} else {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
|
||||
import Dialog from 'web.Dialog';
|
||||
|
||||
registerPatch({
|
||||
name: 'ActivityView',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async onClickCancel(ev) {
|
||||
if (this.activity.calendar_event_id) {
|
||||
await new Promise(resolve => {
|
||||
Dialog.confirm(
|
||||
this,
|
||||
this.env._t("The activity is linked to a meeting. Deleting it will remove the meeting as well. Do you want to proceed?"),
|
||||
{ confirm_callback: resolve },
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
await this._super();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import fieldUtils from 'web.field_utils';
|
||||
import { getLangTimeFormat } from 'web.time';
|
||||
|
||||
registerModel({
|
||||
name: 'calendar.event',
|
||||
fields: {
|
||||
allday: attr(),
|
||||
attendee_status: attr(),
|
||||
formattedStart: attr({
|
||||
compute() {
|
||||
if (!this.start) {
|
||||
return clear();
|
||||
}
|
||||
return moment(fieldUtils.parse.datetime(this.start, false, { isUTC: true })).local().format(getLangTimeFormat());
|
||||
},
|
||||
}),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
name: attr(),
|
||||
start: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
.o_calendar_invitation {
|
||||
@extend .o_status;
|
||||
|
||||
&.accepted {
|
||||
background-color: map-get($theme-colors, 'success');
|
||||
}
|
||||
|
||||
&.tentative {
|
||||
background-color: $o-main-color-muted;
|
||||
}
|
||||
|
||||
&.declined {
|
||||
background-color: map-get($theme-colors, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
.o_add_favorite_calendar {
|
||||
margin-top: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.o_calendar_invitation_page {
|
||||
flex: 0 0 auto;
|
||||
width: 50%;
|
||||
margin: 30px auto 0;
|
||||
@include o-webclient-padding($top: 10px, $bottom: 10px);
|
||||
background-color: $o-view-background-color;
|
||||
|
||||
.o_logo {
|
||||
width: 15%;
|
||||
}
|
||||
.o_event_title {
|
||||
margin-left: 20%;
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.o_event_table {
|
||||
clear: both;
|
||||
margin: 15px 0 0;
|
||||
|
||||
th {
|
||||
padding-right: 15px;
|
||||
}
|
||||
ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_accepted {
|
||||
@extend .text-success;
|
||||
}
|
||||
.o_declined {
|
||||
@extend .text-danger;
|
||||
}
|
||||
}
|
||||
.o_meeting_filter {
|
||||
@include o-text-overflow();
|
||||
min-width: 0;
|
||||
color: grey;
|
||||
vertical-align: top;
|
||||
&.o_meeting_bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.o_cw_body .o_field_copy {
|
||||
max-width: calc(100% - 6rem);
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
.o_cw_body .o_clipboard_button {
|
||||
padding-top: 0px !important;
|
||||
}
|
||||
|
||||
.o_calendar_attendees {
|
||||
max-width:80% !important;
|
||||
}
|
||||
|
||||
.o_attendee_border {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.o_attendee_border_accepted {
|
||||
border-color: map-get($theme-colors, 'success');
|
||||
}
|
||||
.o_attendee_border_declined {
|
||||
border-color: map-get($theme-colors, 'danger');
|
||||
}
|
||||
.o_attendee_border_tentative {
|
||||
border-color: map-get($theme-colors, 'light');
|
||||
}
|
||||
|
||||
@for $i from 1 through length($o-colors-complete) {
|
||||
$color: nth($o-colors-complete, $i);
|
||||
|
||||
.o_cw_popover_link.o_calendar_color_#{$i - 1} {
|
||||
&.o_attendee_status_tentative {
|
||||
color: color-contrast($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($o-view-background-color, 0.9) !important;
|
||||
}
|
||||
|
||||
&.o_attendee_status_declined {
|
||||
text-decoration: line-through;
|
||||
background-color: rgba($o-view-background-color, 0.9) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o_calendar_renderer {
|
||||
.fc-event.o_calendar_color_#{$i - 1} {
|
||||
&.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-contrast($color) !important;
|
||||
.fc-bg {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
$color,
|
||||
$color 10px,
|
||||
rgba(white, 0.4) 10px,
|
||||
rgba(white, 0.4) 20px
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_attendee_status_alone,
|
||||
&.o_attendee_status_needsAction {
|
||||
background-color: rgba($o-view-background-color, 0.9) !important;
|
||||
color: $o-black !important;
|
||||
.fc-bg {
|
||||
background-color: rgba($o-view-background-color, 0.9) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_attendee_status_declined {
|
||||
text-decoration: line-through;
|
||||
background-color: rgba($o-view-background-color, 0.9) !important;
|
||||
color: $o-black !important;
|
||||
.fc-bg {
|
||||
background-color: rgba($o-view-background-color, 0.9) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class AskRecurrenceUpdatePolicyDialog extends Component {
|
||||
setup() {
|
||||
this.possibleValues = {
|
||||
self_only: {
|
||||
checked: true,
|
||||
label: this.env._t("This event"),
|
||||
},
|
||||
future_events: {
|
||||
checked: false,
|
||||
label: this.env._t("This and following events"),
|
||||
},
|
||||
all_events: {
|
||||
checked: false,
|
||||
label: this.env._t("All events"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get selected() {
|
||||
return Object.entries(this.possibleValues).find(state => state[1].checked)[0];
|
||||
}
|
||||
|
||||
set selected(val) {
|
||||
this.possibleValues[this.selected].checked = false;
|
||||
this.possibleValues[val].checked = true;
|
||||
}
|
||||
|
||||
confirm() {
|
||||
this.props.confirm(this.selected);
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
AskRecurrenceUpdatePolicyDialog.template = "calendar.AskRecurrenceUpdatePolicyDialog";
|
||||
AskRecurrenceUpdatePolicyDialog.components = {
|
||||
Dialog,
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="calendar.AskRecurrenceUpdatePolicyDialog" owl="1">
|
||||
<Dialog size="'sm'" title="env._t('Edit Recurrent event')">
|
||||
<t t-foreach="Object.entries(possibleValues)" t-as="value" t-key="value[0]">
|
||||
<t t-set="name" t-value="value[0]"/>
|
||||
<t t-set="state" t-value="value[1]"/>
|
||||
<div class="form-check o_radio_item">
|
||||
<input name="recurrence-update" type="radio" class="form-check-input o_radio_input" t-att-checked="state.checked" t-att-value="name" t-att-id="name" t-on-click="(ev) => this.selected = ev.target.value"/>
|
||||
<label class="form-check-label o_form_label" t-att-for="name" t-esc="state.label"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set-slot="footer" owl="1">
|
||||
<button class="btn btn-primary" t-on-click="confirm">
|
||||
Confirm
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { AskRecurrenceUpdatePolicyDialog } from "@calendar/views/ask_recurrence_update_policy_dialog";
|
||||
|
||||
export function askRecurrenceUpdatePolicy(dialogService) {
|
||||
return new Promise((resolve) => {
|
||||
dialogService.add(AskRecurrenceUpdatePolicyDialog, {
|
||||
confirm: resolve,
|
||||
}, {
|
||||
onClose: resolve.bind(null, false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function useAskRecurrenceUpdatePolicy() {
|
||||
const dialogService = useService("dialog");
|
||||
return askRecurrenceUpdatePolicy.bind(null, dialogService);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CalendarController } from "@web/views/calendar/calendar_controller";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { onWillStart } from "@odoo/owl";
|
||||
|
||||
export class AttendeeCalendarController extends CalendarController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
this.user = useService("user");
|
||||
this.orm = useService("orm");
|
||||
onWillStart(async () => {
|
||||
this.isSystemUser = await this.user.hasGroup('base.group_system');
|
||||
});
|
||||
}
|
||||
|
||||
onClickAddButton() {
|
||||
this.actionService.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'calendar.event',
|
||||
views: [[false, 'form']],
|
||||
}, {
|
||||
additionalContext: this.props.context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* If the event is deleted by the organizer, the event is deleted, otherwise it is declined.
|
||||
*/
|
||||
deleteRecord(record) {
|
||||
if (this.user.partnerId === record.attendeeId && this.user.partnerId === record.rawRecord.partner_id[0]) {
|
||||
super.deleteRecord(...arguments);
|
||||
} else {
|
||||
// Decline event
|
||||
this.orm.call(
|
||||
"calendar.attendee",
|
||||
"do_decline",
|
||||
[record.calendarAttendeeId],
|
||||
).then(this.model.load.bind(this.model));
|
||||
}
|
||||
}
|
||||
|
||||
configureCalendarProviderSync(providerName) {
|
||||
this.actionService.doAction({
|
||||
name: this.env._t('Connect your Calendar'),
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'calendar.provider.config',
|
||||
views: [[false, "form"]],
|
||||
view_mode: "form",
|
||||
target: 'new',
|
||||
context: {
|
||||
'default_external_calendar_provider': providerName,
|
||||
'dialog_size': 'medium',
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
AttendeeCalendarController.template = "calendar.AttendeeCalendarController";
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="calendar.AttendeeCalendarController" t-inherit="web.CalendarController" t-inherit-mode="primary" owl="1">
|
||||
<DatePicker position="after">
|
||||
<div id="calendar_sync_wrapper" t-if="isSystemUser">
|
||||
<div id="calendar_sync_title" class="o_calendar_sync text-center">
|
||||
<span class="text-primary fs-6">Synchronize with:</span>
|
||||
</div>
|
||||
<div id="calendar_sync" class="container inline btn-group justify-content-evenly align-items-center">
|
||||
<div id="google_calendar_sync" class="o_calendar_sync" t-if="isSystemUser">
|
||||
<button type="button" id="google_sync_activate" class="btn btn-muted" t-on-click="() => this.configureCalendarProviderSync('google')">
|
||||
<b><i class='fa fa-plug'/> Google</b>
|
||||
</button>
|
||||
</div>
|
||||
<div id="microsoft_calendar_sync" class="o_calendar_sync" t-if="isSystemUser">
|
||||
<button type="button" id="microsoft_sync_activate" class="btn btn-muted" t-on-click="() => this.configureCalendarProviderSync('microsoft')">
|
||||
<b><i class='fa fa-plug'/> Outlook</b>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DatePicker>
|
||||
</t>
|
||||
|
||||
<t t-name="calendar.AttendeeCalendarController.controlButtons" t-inherit="web.CalendarController.controlButtons" owl="1">
|
||||
<xpath expr="//span[hasclass('o_calendar_navigation_buttons')]" position="before">
|
||||
<span class="o_calendar_create_buttons">
|
||||
<button class="btn btn-primary o-calendar-button-new me-1" t-on-click="onClickAddButton">New</button>
|
||||
</span>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CalendarModel } from "@web/views/calendar/calendar_model";
|
||||
import { askRecurrenceUpdatePolicy } from "@calendar/views/ask_recurrence_update_policy_hook";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
|
||||
export class AttendeeCalendarModel extends CalendarModel {
|
||||
setup(params, { dialog }) {
|
||||
super.setup(...arguments);
|
||||
this.dialog = dialog;
|
||||
}
|
||||
|
||||
get attendees() {
|
||||
return this.data.attendees;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Upon updating a record with recurrence, we need to ask how it will affect recurrent events.
|
||||
*/
|
||||
async updateRecord(record) {
|
||||
const rec = this.records[record.id];
|
||||
if (rec.rawRecord.recurrency) {
|
||||
const recurrenceUpdate = await askRecurrenceUpdatePolicy(this.dialog);
|
||||
if (!recurrenceUpdate) {
|
||||
return this.notify();
|
||||
}
|
||||
record.recurrenceUpdate = recurrenceUpdate;
|
||||
}
|
||||
return await super.updateRecord(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
buildRawRecord(partialRecord, options = {}) {
|
||||
const result = super.buildRawRecord(...arguments);
|
||||
if (partialRecord.recurrenceUpdate) {
|
||||
result.recurrence_update = partialRecord.recurrenceUpdate;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async updateData(data) {
|
||||
await super.updateData(...arguments);
|
||||
await this.updateAttendeeData(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 updateAttendeeData(data) {
|
||||
const attendeeFilters = data.filterSections.partner_ids;
|
||||
let isEveryoneFilterActive = false;
|
||||
let attendeeIds = [];
|
||||
const eventIds = Object.keys(data.records).map(id => Number.parseInt(id));
|
||||
if (attendeeFilters) {
|
||||
const allFilter = attendeeFilters.filters.find(filter => filter.type === "all")
|
||||
isEveryoneFilterActive = allFilter && allFilter.active || false;
|
||||
attendeeIds = attendeeFilters.filters.filter(filter => filter.type !== "all" && filter.value).map(filter => filter.value);
|
||||
}
|
||||
data.attendees = await this.orm.call(
|
||||
"res.partner",
|
||||
"get_attendee_detail",
|
||||
[attendeeIds, eventIds],
|
||||
);
|
||||
const currentPartnerId = this.user.partnerId;
|
||||
if (!isEveryoneFilterActive) {
|
||||
const activeAttendeeIds = new Set(attendeeFilters.filters
|
||||
.filter(filter => filter.type !== "all" && filter.value && filter.active)
|
||||
.map(filter => filter.value)
|
||||
);
|
||||
// Duplicate records per attendee
|
||||
const newRecords = {};
|
||||
let duplicatedRecordIdx = -1;
|
||||
for (const event of Object.values(data.records)) {
|
||||
const eventData = event.rawRecord;
|
||||
const attendees = eventData.partner_ids && eventData.partner_ids.length ? eventData.partner_ids : [eventData.partner_id[0]];
|
||||
let duplicatedRecords = 0;
|
||||
for (const attendee of attendees) {
|
||||
if (!activeAttendeeIds.has(attendee)) {
|
||||
continue;
|
||||
}
|
||||
// Records will share the same rawRecord.
|
||||
const record = { ...event };
|
||||
const attendeeInfo = data.attendees.find(a => (
|
||||
a.id === attendee &&
|
||||
a.event_id === event.id
|
||||
));
|
||||
record.attendeeId = attendee;
|
||||
// Colors are linked to the partner_id but in this case we want it linked
|
||||
// to attendeeId
|
||||
record.colorIndex = attendee;
|
||||
if (attendeeInfo) {
|
||||
record.attendeeStatus = attendeeInfo.status;
|
||||
record.isAlone = attendeeInfo.is_alone;
|
||||
record.isCurrentPartner = attendeeInfo.id === currentPartnerId;
|
||||
record.calendarAttendeeId = attendeeInfo.attendee_id;
|
||||
}
|
||||
const recordId = duplicatedRecords ? duplicatedRecordIdx-- : record.id;
|
||||
// Index in the records
|
||||
record._recordId = recordId;
|
||||
newRecords[recordId] = record;
|
||||
duplicatedRecords++;
|
||||
}
|
||||
}
|
||||
data.records = newRecords;
|
||||
} else {
|
||||
for (const event of Object.values(data.records)) {
|
||||
const eventData = event.rawRecord;
|
||||
event.attendeeId = eventData.partner_id && eventData.partner_id[0]
|
||||
const attendeeInfo = data.attendees.find(a => (
|
||||
a.id === currentPartnerId &&
|
||||
a.event_id === event.id
|
||||
));
|
||||
if (attendeeInfo) {
|
||||
event.isAlone = attendeeInfo.is_alone;
|
||||
event.calendarAttendeeId = attendeeInfo.attendee_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives a record, ask for the recurrence update policy in case of recurrent event.
|
||||
*/
|
||||
async archiveRecord(record) {
|
||||
let recurrenceUpdate = false;
|
||||
if (record.rawRecord.recurrency) {
|
||||
recurrenceUpdate = await askRecurrenceUpdatePolicy(this.dialog);
|
||||
if (!recurrenceUpdate) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const confirm = await new Promise((resolve) => {
|
||||
this.dialog.add(ConfirmationDialog, {
|
||||
body: this.env._t("Are you sure you want to delete this record ?"),
|
||||
confirm: resolve.bind(null, true),
|
||||
}, {
|
||||
onClose: resolve.bind(null, false),
|
||||
});
|
||||
})
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this._archiveRecord(record.id, recurrenceUpdate);
|
||||
}
|
||||
|
||||
async _archiveRecord(id, recurrenceUpdate) {
|
||||
if (!recurrenceUpdate && recurrenceUpdate !== "self_only") {
|
||||
await this.orm.call(
|
||||
this.resModel,
|
||||
"action_archive",
|
||||
[[id]],
|
||||
);
|
||||
} else {
|
||||
await this.orm.call(
|
||||
this.resModel,
|
||||
"action_mass_archive",
|
||||
[[id], recurrenceUpdate],
|
||||
);
|
||||
}
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
AttendeeCalendarModel.services = [...CalendarModel.services, "dialog", "orm"];
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CalendarRenderer } from "@web/views/calendar/calendar_renderer";
|
||||
import { AttendeeCalendarCommonRenderer } from "@calendar/views/attendee_calendar/common/attendee_calendar_common_renderer";
|
||||
import { AttendeeCalendarYearRenderer } from "@calendar/views/attendee_calendar/year/attendee_calendar_year_renderer";
|
||||
|
||||
export class AttendeeCalendarRenderer extends CalendarRenderer {}
|
||||
AttendeeCalendarRenderer.components = {
|
||||
...CalendarRenderer.components,
|
||||
day: AttendeeCalendarCommonRenderer,
|
||||
week: AttendeeCalendarCommonRenderer,
|
||||
month: AttendeeCalendarCommonRenderer,
|
||||
year: AttendeeCalendarYearRenderer,
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { calendarView } from "@web/views/calendar/calendar_view";
|
||||
import { AttendeeCalendarController } from "@calendar/views/attendee_calendar/attendee_calendar_controller";
|
||||
import { AttendeeCalendarModel } from "@calendar/views/attendee_calendar/attendee_calendar_model";
|
||||
import { AttendeeCalendarRenderer } from "@calendar/views/attendee_calendar/attendee_calendar_renderer";
|
||||
|
||||
export const attendeeCalendarView = {
|
||||
...calendarView,
|
||||
Controller: AttendeeCalendarController,
|
||||
Model: AttendeeCalendarModel,
|
||||
Renderer: AttendeeCalendarRenderer,
|
||||
buttonTemplate: "calendar.AttendeeCalendarController.controlButtons",
|
||||
};
|
||||
|
||||
registry.category("views").add("attendee_calendar", attendeeCalendarView);
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CalendarCommonPopover } from "@web/views/calendar/calendar_common/calendar_common_popover";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useAskRecurrenceUpdatePolicy } from "@calendar/views/ask_recurrence_update_policy_hook";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
export class AttendeeCalendarCommonPopover extends CalendarCommonPopover {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.user = useService("user");
|
||||
this.orm = useService("orm");
|
||||
this.askRecurrenceUpdatePolicy = useAskRecurrenceUpdatePolicy();
|
||||
// Show status dropdown if user is in attendees list
|
||||
if (this.isCurrentUserAttendee) {
|
||||
this.statusColors = {
|
||||
accepted: "text-success",
|
||||
declined: "text-danger",
|
||||
tentative: "text-muted",
|
||||
needsAction: "text-dark",
|
||||
};
|
||||
this.statusInfo = {};
|
||||
for (const selection of this.props.model.fields.attendee_status.selection) {
|
||||
this.statusInfo[selection[0]] = {
|
||||
text: selection[1],
|
||||
color: this.statusColors[selection[0]],
|
||||
};
|
||||
}
|
||||
this.selectedStatusInfo = this.statusInfo[this.props.record.attendeeStatus];
|
||||
}
|
||||
}
|
||||
|
||||
get isCurrentUserAttendee() {
|
||||
return this.props.record.rawRecord.partner_ids.includes(this.user.partnerId);
|
||||
}
|
||||
|
||||
get isCurrentUserOrganizer() {
|
||||
return this.props.record.rawRecord.partner_id[0] === this.user.partnerId;
|
||||
}
|
||||
|
||||
get isEventPrivate() {
|
||||
return this.props.record.rawRecord.privacy === "private";
|
||||
}
|
||||
|
||||
get displayAttendeeAnswerChoice() {
|
||||
return (
|
||||
this.props.record.rawRecord.partner_ids.some((partner) => partner !== this.user.partnerId) &&
|
||||
this.props.record.isCurrentPartner
|
||||
);
|
||||
}
|
||||
|
||||
get isEventDetailsVisible() {
|
||||
return this.isEventPrivate ? this.isCurrentUserAttendee : true;
|
||||
}
|
||||
|
||||
get isEventArchivable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get isEventDeletable() {
|
||||
return super.isEventDeletable && (this.isCurrentUserAttendee || this.isCurrentUserOrganizer) && !this.isEventArchivable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get isEventEditable() {
|
||||
return this.isEventPrivate ? this.isCurrentUserAttendee || this.isCurrentUserOrganizer : super.isEventEditable;
|
||||
}
|
||||
|
||||
async changeAttendeeStatus(selectedStatus) {
|
||||
const record = this.props.record;
|
||||
if (record.attendeeStatus === selectedStatus) {
|
||||
return this.props.close();
|
||||
}
|
||||
let recurrenceUpdate = false;
|
||||
if (record.rawRecord.recurrency) {
|
||||
recurrenceUpdate = await this.askRecurrenceUpdatePolicy();
|
||||
if (!recurrenceUpdate) {
|
||||
return this.props.close();
|
||||
}
|
||||
}
|
||||
await this.env.services.orm.call(
|
||||
this.props.model.resModel,
|
||||
"change_attendee_status",
|
||||
[[record.id], selectedStatus, recurrenceUpdate],
|
||||
);
|
||||
await this.props.model.load();
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
async onClickArchive() {
|
||||
this.props.close();
|
||||
await this.props.model.archiveRecord(this.props.record);
|
||||
}
|
||||
}
|
||||
AttendeeCalendarCommonPopover.components = {
|
||||
...CalendarCommonPopover.components,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
};
|
||||
AttendeeCalendarCommonPopover.subTemplates = {
|
||||
...CalendarCommonPopover.subTemplates,
|
||||
body: "calendar.AttendeeCalendarCommonPopover.body",
|
||||
footer: "calendar.AttendeeCalendarCommonPopover.footer",
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="calendar.AttendeeCalendarCommonPopover.body" t-inherit="web.CalendarCommonPopover.body" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//ul[hasclass('o_cw_popover_fields_secondary')]" position="attributes">
|
||||
<attribute name="t-if">isEventDetailsVisible</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="calendar.AttendeeCalendarCommonPopover.footer" t-inherit="web.CalendarCommonPopover.footer" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//t[@t-if='isEventDeletable']" position="after">
|
||||
<a t-if="isEventArchivable and isEventDetailsVisible" href="#" class="btn btn-secondary o_cw_popover_archive_g" t-on-click="onClickArchive">Delete</a>
|
||||
<div t-if="displayAttendeeAnswerChoice" class="d-inline-block">
|
||||
<Dropdown togglerClass="'btn btn-secondary'">
|
||||
<t t-set-slot="toggler">
|
||||
<i t-attf-class="fa fa-circle o-calendar-attendee-status-icon #{selectedStatusInfo.color}"/> <span class="o-calendar-attendee-status-text" t-esc="selectedStatusInfo.text"></span>
|
||||
</t>
|
||||
<DropdownItem onSelected="() => this.changeAttendeeStatus('accepted')">
|
||||
<i class="fa fa-circle text-success"/> Accept
|
||||
</DropdownItem>
|
||||
<DropdownItem onSelected="() => this.changeAttendeeStatus('declined')">
|
||||
<i class="fa fa-circle text-danger"/> Decline
|
||||
</DropdownItem>
|
||||
<DropdownItem onSelected="() => this.changeAttendeeStatus('tentative')">
|
||||
<i class="fa fa-circle text-muted"/> Uncertain
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CalendarCommonRenderer } from "@web/views/calendar/calendar_common/calendar_common_renderer";
|
||||
import { AttendeeCalendarCommonPopover } from "@calendar/views/attendee_calendar/common/attendee_calendar_common_popover";
|
||||
|
||||
export class AttendeeCalendarCommonRenderer extends CalendarCommonRenderer {
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Give a new key to our fc records to be able to iterate through in templates
|
||||
*/
|
||||
convertRecordToEvent(record) {
|
||||
return {
|
||||
...super.convertRecordToEvent(record),
|
||||
id: record._recordId || record.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
onEventRender(info) {
|
||||
super.onEventRender(...arguments);
|
||||
const { el, event } = info;
|
||||
const record = this.props.model.records[event.id];
|
||||
|
||||
if (record) {
|
||||
if (record.rawRecord.is_highlighted) {
|
||||
el.classList.add("o_event_highlight");
|
||||
}
|
||||
if (record.isAlone) {
|
||||
el.classList.add("o_attendee_status_alone");
|
||||
} else {
|
||||
el.classList.add(`o_attendee_status_${record.attendeeStatus}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* Allow slots to be selected over multiple days
|
||||
*/
|
||||
isSelectionAllowed(event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
AttendeeCalendarCommonRenderer.eventTemplate = "calendar.AttendeeCalendarCommonRenderer.event";
|
||||
AttendeeCalendarCommonRenderer.components = {
|
||||
...CalendarCommonRenderer.components,
|
||||
Popover: AttendeeCalendarCommonPopover,
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="calendar.AttendeeCalendarCommonRenderer.event" owl="1">
|
||||
<div class="fc-content">
|
||||
<div t-attf-class="#{attendeeStatus != 'accepted' ? 'fw-normal' : ''} o_event_title me-2">
|
||||
<span t-if="isAlone" class="fa fa-exclamation-circle"/>
|
||||
<t t-esc="title"/>
|
||||
</div>
|
||||
<span t-if="!isTimeHidden" class="fc-time" t-esc="startTime" />
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CalendarYearPopover } from "@web/views/calendar/calendar_year/calendar_year_popover";
|
||||
|
||||
export class AttendeeCalendarYearPopover extends CalendarYearPopover {
|
||||
getRecordClass(record) {
|
||||
const classes = [super.getRecordClass(record)];
|
||||
if (record.isAlone) {
|
||||
classes.push("o_attendee_status_alone");
|
||||
} else {
|
||||
classes.push(`o_attendee_status_${record.attendeeStatus}`);
|
||||
}
|
||||
return classes.join(" ");
|
||||
}
|
||||
}
|
||||
AttendeeCalendarYearPopover.subTemplates = {
|
||||
...CalendarYearPopover.subTemplates,
|
||||
body: "calendar.AttendeeCalendarYearPopover.body",
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="calendar.AttendeeCalendarYearPopover.body" t-inherit="web.CalendarYearPopover.body" t-inherit-mode="primary" owl="1">
|
||||
<!-- Since we duplicate the event this is needed to avoid duplicated key errors -->
|
||||
<xpath expr="//t[@t-foreach='recordGroup.records']" position="attributes">
|
||||
<attribute name="t-key">record._recordId</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CalendarYearRenderer } from "@web/views/calendar/calendar_year/calendar_year_renderer";
|
||||
import { AttendeeCalendarYearPopover } from "@calendar/views/attendee_calendar/year/attendee_calendar_year_popover";
|
||||
|
||||
export class AttendeeCalendarYearRenderer extends CalendarYearRenderer {}
|
||||
AttendeeCalendarYearRenderer.components = {
|
||||
...CalendarYearRenderer,
|
||||
Popover: AttendeeCalendarYearPopover,
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { FormController } from "@web/views/form/form_controller";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const { onWillStart } = owl;
|
||||
|
||||
export class CalendarFormController extends FormController {
|
||||
setup() {
|
||||
super.setup();
|
||||
const ormService = useService("orm");
|
||||
|
||||
onWillStart(async () => {
|
||||
this.discussVideocallLocation = await ormService.call(
|
||||
"calendar.event",
|
||||
"get_discuss_videocall_location",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async beforeExecuteActionButton(clickParams) {
|
||||
const action = clickParams.name;
|
||||
if (action == 'clear_videocall_location' || action === 'set_discuss_videocall_location') {
|
||||
let newVal = false;
|
||||
let videoCallSource = 'custom'
|
||||
let changes = {};
|
||||
if (action === 'set_discuss_videocall_location') {
|
||||
newVal = this.discussVideocallLocation;
|
||||
videoCallSource = 'discuss';
|
||||
changes.access_token = this.discussVideocallLocation.split('/').pop();
|
||||
}
|
||||
changes = Object.assign(changes, {
|
||||
videocall_location: newVal,
|
||||
videocall_source: videoCallSource,
|
||||
});
|
||||
this.model.root.update(changes);
|
||||
return false; // no continue
|
||||
}
|
||||
return super.beforeExecuteActionButton(...arguments);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
import { CalendarFormController } from "@calendar/views/calendar_form/calendar_form_controller";
|
||||
|
||||
export const CalendarFormView = {
|
||||
...formView,
|
||||
Controller: CalendarFormController,
|
||||
};
|
||||
|
||||
registry.category("views").add("calendar_form", CalendarFormView);
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { FormRenderer } from "@web/views/form/form_renderer";
|
||||
import { session } from "@web/session";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
const providerData = {
|
||||
'google': {
|
||||
'restart_sync_method': 'restart_google_synchronization',
|
||||
'sync_route': '/google_calendar/sync_data'
|
||||
},
|
||||
'microsoft': {
|
||||
'restart_sync_method': 'restart_microsoft_synchronization',
|
||||
'sync_route': '/microsoft_calendar/sync_data'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class CalendarProviderConfigFormRenderer extends FormRenderer {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService('orm');
|
||||
this.rpc = useService('rpc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the external sync for the first time, after installing the
|
||||
* relevant submodule if necessary.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async onConnect(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
if (!await this.props.record.save({stayInEdition: true})) {
|
||||
return; // handled by view
|
||||
}
|
||||
await this.orm.call(
|
||||
this.props.record.resModel,
|
||||
'action_calendar_prepare_external_provider_sync',
|
||||
[this.props.record.resId]
|
||||
)
|
||||
// See google/microsoft_calendar for the origin of this shortened version
|
||||
const { restart_sync_method, sync_route } = providerData[this.props.record.data.external_calendar_provider];
|
||||
await this.orm.call(
|
||||
'res.users',
|
||||
restart_sync_method,
|
||||
[[session.uid]]
|
||||
);
|
||||
const response = await this.rpc(
|
||||
sync_route, {
|
||||
model: 'calendar.event',
|
||||
fromurl: window.location.href,
|
||||
}
|
||||
);
|
||||
await this._beforeLeaveContext();
|
||||
if (response.status === "need_auth") {
|
||||
window.location.assign(response.url);
|
||||
} else if (response.status === "need_refresh") {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Hook to perform additional work before redirecting to external url or reloading.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _beforeLeaveContext() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
import { CalendarProviderConfigFormRenderer } from "./calendar_provider_config_form_renderer";
|
||||
|
||||
|
||||
export const CalendarProviderConfigFormView = {
|
||||
...formView,
|
||||
Renderer: CalendarProviderConfigFormRenderer,
|
||||
};
|
||||
|
||||
registry.category("views").add("calendar_provider_config_form", CalendarProviderConfigFormView);
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Many2ManyTagsAvatarField } from "@web/views/fields/many2many_tags_avatar/many2many_tags_avatar_field";
|
||||
|
||||
export class Many2ManyAttendee extends Many2ManyTagsAvatarField {
|
||||
get tags() {
|
||||
const { partner_ids: partnerIds } = this.props.record.preloadedData;
|
||||
const tags = super.tags.map((tag) => {
|
||||
const partner = partnerIds.find((partner) => tag.resId === partner.id);
|
||||
if (partner) {
|
||||
tag.className = `o_attendee_border o_attendee_border_${partner.status}`;
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
|
||||
const organizer = partnerIds.find((partner) => partner.is_organizer);
|
||||
if (organizer) {
|
||||
const orgId = organizer.id;
|
||||
// sort elements according to the partner id
|
||||
tags.sort((a, b) => {
|
||||
const a_org = a.resId === orgId;
|
||||
return a_org ? -1 : 1;
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
Many2ManyAttendee.additionalClasses = ["o_field_many2many_tags_avatar"];
|
||||
Many2ManyAttendee.legacySpecialData = "_fetchSpecialAttendeeStatus";
|
||||
|
||||
registry.category("fields").add("many2manyattendee", Many2ManyAttendee);
|
||||
|
||||
export function preloadMany2ManyAttendee(orm, record, fieldName) {
|
||||
const context = record.getFieldContext(fieldName);
|
||||
return orm.call(
|
||||
"res.partner",
|
||||
"get_attendee_detail",
|
||||
[record.data[fieldName].records.map(rec => rec.resId), [record.resId || false]],
|
||||
{
|
||||
context,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
registry.category("preloadedData").add("many2manyattendee", {
|
||||
loadOnTypes: ["many2many"],
|
||||
preload: preloadMany2ManyAttendee,
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { start, startServer } from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
QUnit.module('calendar', () => {
|
||||
QUnit.module('components', () => {
|
||||
QUnit.module('activity_tests.js');
|
||||
|
||||
QUnit.test('activity click on Reschedule', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const resPartnerId = pyEnv['res.partner'].create({});
|
||||
const meetingActivityTypeId = pyEnv['mail.activity.type'].create({ icon: 'fa-calendar', name: "Meeting" });
|
||||
const calendarAttendeeId = pyEnv['calendar.attendee'].create({ partner_id: resPartnerId });
|
||||
const calendaMeetingId = pyEnv['calendar.event'].create({
|
||||
res_model: "calendar.event",
|
||||
name: "meeting1",
|
||||
start: "2022-07-06 06:30:00",
|
||||
attendee_ids: [calendarAttendeeId],
|
||||
});
|
||||
pyEnv['mail.activity'].create({
|
||||
name: "Small Meeting",
|
||||
activity_type_id: meetingActivityTypeId,
|
||||
can_write: true,
|
||||
res_id: resPartnerId,
|
||||
res_model: 'res.partner',
|
||||
calendar_event_id: calendaMeetingId,
|
||||
});
|
||||
|
||||
const { click, openFormView } = await start();
|
||||
|
||||
await openFormView(
|
||||
{
|
||||
res_model: 'res.partner',
|
||||
res_id: resPartnerId,
|
||||
},
|
||||
);
|
||||
|
||||
await click('.o_Activity_editButton');
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_calendar_view',
|
||||
"should have opened calendar view"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
|
||||
|
||||
import { calendarNotificationService } from "@calendar/js/services/calendar_notification_service";
|
||||
import { click, contains } from "@web/../tests/utils";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { start } from "@mail/../tests/helpers/test_utils";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
QUnit.module("Calendar Notification", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
serviceRegistry.add("calendarNotification", calendarNotificationService);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"can listen on bus and display notifications in DOM and click OK",
|
||||
async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
const mockRPC = (route, args) => {
|
||||
if (route === "/calendar/notify") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (route === "/calendar/notify_ack") {
|
||||
assert.step("notifyAck");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
await start({ mockRPC });
|
||||
pyEnv["bus.bus"]._sendone(pyEnv.currentPartner, "calendar.alarm", [
|
||||
{
|
||||
alarm_id: 1,
|
||||
event_id: 2,
|
||||
title: "Meeting",
|
||||
message: "Very old meeting message",
|
||||
timer: 0,
|
||||
notify_at: "1978-04-14 12:45:00",
|
||||
},
|
||||
]);
|
||||
await contains(".o_notification", { text: "Very old meeting message" });
|
||||
await click(".o_notification_buttons button", { text: "OK" });
|
||||
await contains(".o_notification", { count: 0 });
|
||||
assert.verifySteps(["notifyAck"]);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"can listen on bus and display notifications in DOM and click Detail",
|
||||
async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
const mockRPC = (route, args) => {
|
||||
if (route === "/calendar/notify") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
};
|
||||
const fakeActionService = {
|
||||
name: "action",
|
||||
start() {
|
||||
return {
|
||||
doAction(actionId) {
|
||||
assert.step(actionId.type);
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
loadState(state, options) {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
serviceRegistry.add("action", fakeActionService, { force: true });
|
||||
await start({ mockRPC });
|
||||
pyEnv["bus.bus"]._sendone(pyEnv.currentPartner, "calendar.alarm", [
|
||||
{
|
||||
alarm_id: 1,
|
||||
event_id: 2,
|
||||
title: "Meeting",
|
||||
message: "Very old meeting message",
|
||||
timer: 0,
|
||||
notify_at: "1978-04-14 12:45:00",
|
||||
},
|
||||
]);
|
||||
await contains(".o_notification", { text: "Very old meeting message" });
|
||||
await click(".o_notification_buttons button", { text: "Details" });
|
||||
await contains(".o_notification", { count: 0 });
|
||||
assert.verifySteps(["ir.actions.act_window"]);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"can listen on bus and display notifications in DOM and click Snooze",
|
||||
async (assert) => {
|
||||
const pyEnv = await startServer();
|
||||
const mockRPC = (route, args) => {
|
||||
if (route === "/calendar/notify") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (route === "/calendar/notify_ack") {
|
||||
assert.step("notifyAck");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
await start({ mockRPC });
|
||||
pyEnv["bus.bus"]._sendone(pyEnv.currentPartner, "calendar.alarm", [
|
||||
{
|
||||
alarm_id: 1,
|
||||
event_id: 2,
|
||||
title: "Meeting",
|
||||
message: "Very old meeting message",
|
||||
timer: 0,
|
||||
notify_at: "1978-04-14 12:45:00",
|
||||
},
|
||||
]);
|
||||
await contains(".o_notification", { text: "Very old meeting message" });
|
||||
await click(".o_notification button", { text: "Snooze" });
|
||||
await contains(".o_notification", { count: 0 });
|
||||
assert.verifySteps([], "should only close the notification withtout calling a rpc");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { getFixture } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module(
|
||||
"calendar",
|
||||
{
|
||||
beforeEach: function () {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
event: {
|
||||
fields: {
|
||||
partner_ids: {
|
||||
string: "Partners",
|
||||
type: "many2many",
|
||||
relation: "partner",
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 14,
|
||||
partner_ids: [1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
partner: {
|
||||
fields: {
|
||||
name: { string: "Name", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Jesus",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Mahomet",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
setupViewRegistries();
|
||||
},
|
||||
},
|
||||
function () {
|
||||
QUnit.test("Many2ManyAttendee: basic rendering", async function (assert) {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "event",
|
||||
serverData,
|
||||
resId: 14,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="partner_ids" widget="many2manyattendee"/>
|
||||
</form>`,
|
||||
mockRPC(route, args) {
|
||||
if (args.method === "get_attendee_detail") {
|
||||
assert.step(args.method);
|
||||
assert.strictEqual(
|
||||
args.model,
|
||||
"res.partner",
|
||||
"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.deepEqual(
|
||||
args.args[1],
|
||||
[14],
|
||||
"the event id should be passed as argument"
|
||||
);
|
||||
return Promise.resolve([
|
||||
{ id: 1, name: "Jesus", status: "accepted", color: 0 },
|
||||
{ id: 2, name: "Mahomet", status: "tentative", color: 0 },
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(
|
||||
target.querySelector('.o_field_widget[name="partner_ids"] div'),
|
||||
"o_field_tags"
|
||||
);
|
||||
assert.containsN(
|
||||
target,
|
||||
'.o_field_widget[name="partner_ids"] .badge',
|
||||
2,
|
||||
"there should be 2 tags"
|
||||
);
|
||||
const badges = target.querySelectorAll('.o_field_widget[name="partner_ids"] .badge');
|
||||
assert.strictEqual(
|
||||
badges[0].textContent.trim(),
|
||||
"Jesus",
|
||||
"the tag should be correctly named"
|
||||
);
|
||||
assert.hasClass(
|
||||
badges[0].querySelector("img"),
|
||||
"o_attendee_border_accepted",
|
||||
"Jesus should attend the meeting"
|
||||
);
|
||||
assert.strictEqual(
|
||||
badges[1].textContent.trim(),
|
||||
"Mahomet",
|
||||
"the tag should be correctly named"
|
||||
);
|
||||
assert.hasClass(
|
||||
badges[1].querySelector("img"),
|
||||
"o_attendee_border_tentative",
|
||||
"Mohamet should still confirm his attendance to the meeting"
|
||||
);
|
||||
assert.containsOnce(badges[0], "img", "should have img tag");
|
||||
assert.hasAttrValue(
|
||||
badges[0].querySelector("img"),
|
||||
"data-src",
|
||||
"/web/image/partner/1/avatar_128",
|
||||
"should have correct avatar image"
|
||||
);
|
||||
assert.verifySteps(["get_attendee_detail"]);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
// ensure mail override is applied first.
|
||||
import '@mail/../tests/helpers/mock_server';
|
||||
|
||||
import { patch } from '@web/core/utils/patch';
|
||||
import { MockServer } from '@web/../tests/helpers/mock_server';
|
||||
|
||||
import { datetime_to_str } from 'web.time';
|
||||
|
||||
patch(MockServer.prototype, 'calendar', {
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _performRPC(route, args) {
|
||||
// mail.activity methods
|
||||
if (args.model === 'mail.activity' && args.method === 'action_create_calendar_event') {
|
||||
return {
|
||||
type: 'ir.actions.act_window',
|
||||
name: "Meetings",
|
||||
res_model: 'calendar.event',
|
||||
view_mode: 'calendar',
|
||||
views: [[false, 'calendar']],
|
||||
target: 'current',
|
||||
};
|
||||
}
|
||||
// calendar.event methods
|
||||
if (args.model === 'calendar.event' && args.method === 'check_access_rights') {
|
||||
return true;
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private Mocked Methods
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simulates `_systray_get_calendar_event_domain` on `res.users`.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_mockResUsers_SystrayGetCalendarEventDomain() {
|
||||
const startDate = new Date();
|
||||
startDate.setUTCHours(0, 0, 0, 0);
|
||||
const endDate = new Date();
|
||||
endDate.setUTCHours(23, 59, 59, 999);
|
||||
const currentPartnerAttendeeIds = this.pyEnv['calendar.attendee'].search([['partner_id', '=', this.currentPartnerId]]);
|
||||
return [
|
||||
'&',
|
||||
'|',
|
||||
'&',
|
||||
'|',
|
||||
['start', '>=', datetime_to_str(startDate)],
|
||||
['stop', '>=', datetime_to_str(startDate)],
|
||||
['start', '<=', datetime_to_str(endDate)],
|
||||
'&',
|
||||
['allday', '=', true],
|
||||
['start_date', '=', datetime_to_str(startDate)],
|
||||
['attendee_ids', 'in', currentPartnerAttendeeIds],
|
||||
];
|
||||
},
|
||||
|
||||
/**
|
||||
* Simulates `systray_get_activities` on `res.users`.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
_mockResUsersSystrayGetActivities() {
|
||||
const activities = this._super(...arguments);
|
||||
const meetingsLines = this.pyEnv['calendar.event'].searchRead(
|
||||
this._mockResUsers_SystrayGetCalendarEventDomain(),
|
||||
{
|
||||
fields: ['id', 'start', 'name', 'allday', 'attendee_status'],
|
||||
order: 'start',
|
||||
}
|
||||
).filter(meetingLine => meetingLine['attendee_status'] !== 'declined');
|
||||
if (meetingsLines.length) {
|
||||
activities.unshift({
|
||||
id: 'calendar.event', // for simplicity
|
||||
meetings: meetingsLines,
|
||||
model: 'calendar.event',
|
||||
name: 'Today\'s Meetings',
|
||||
type: 'meeting',
|
||||
});
|
||||
}
|
||||
return activities;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers';
|
||||
|
||||
addModelNamesToFetch(['calendar.event', 'calendar.attendee']);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
const viewArchsRegistry = registry.category('bus.view.archs');
|
||||
const calendarArchsRegistry = viewArchsRegistry.category('calendar');
|
||||
|
||||
calendarArchsRegistry.add('default', '<calendar date_start="start"/>');
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import FormView from "web.FormView";
|
||||
import testUtils from "web.test_utils";
|
||||
|
||||
var createView = testUtils.createView;
|
||||
|
||||
QUnit.module(
|
||||
"Legacy calendar",
|
||||
{
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
event: {
|
||||
fields: {
|
||||
partner_ids: { string: "Partners", type: "many2many", relation: "partner" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 14,
|
||||
partner_ids: [1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
partner: {
|
||||
fields: {
|
||||
name: { string: "Name", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Jesus",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Mahomet",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
function () {
|
||||
QUnit.test("many2manyattendee widget: basic rendering", async function (assert) {
|
||||
assert.expect(12);
|
||||
|
||||
var form = await createView({
|
||||
View: FormView,
|
||||
model: "event",
|
||||
data: this.data,
|
||||
res_id: 14,
|
||||
debug: 1,
|
||||
arch:
|
||||
"<form>" + '<field name="partner_ids" widget="many2manyattendee"/>' + "</form>",
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === "get_attendee_detail") {
|
||||
assert.strictEqual(
|
||||
args.model,
|
||||
"res.partner",
|
||||
"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.deepEqual(
|
||||
args.args[1],
|
||||
[14],
|
||||
"the event id should be passed as argument"
|
||||
);
|
||||
return Promise.resolve([
|
||||
{ id: 1, name: "Jesus", status: "accepted", color: 0 },
|
||||
{ id: 2, name: "Mahomet", status: "tentative", color: 0 },
|
||||
]);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
assert.hasClass(form.$('.o_field_widget[name="partner_ids"]'), "o_field_many2manytags");
|
||||
assert.containsN(
|
||||
form,
|
||||
'.o_field_widget[name="partner_ids"] .badge',
|
||||
2,
|
||||
"there should be 2 tags"
|
||||
);
|
||||
assert.strictEqual(
|
||||
form.$('.o_field_widget[name="partner_ids"] .badge:first').text().trim(),
|
||||
"Jesus",
|
||||
"the tag should be correctly named"
|
||||
);
|
||||
assert.hasClass(
|
||||
form.$('.o_field_widget[name="partner_ids"] .badge:first img'),
|
||||
"o_attendee_border_accepted",
|
||||
"Jesus should attend the meeting"
|
||||
);
|
||||
assert.strictEqual(
|
||||
form.$('.o_field_widget[name="partner_ids"] .badge[data-id="2"]').text().trim(),
|
||||
"Mahomet",
|
||||
"the tag should be correctly named"
|
||||
);
|
||||
assert.hasClass(
|
||||
form.el.querySelector(
|
||||
'.o_field_widget[name="partner_ids"] .badge[data-id="2"] img'
|
||||
),
|
||||
"o_attendee_border_tentative",
|
||||
"Mohamet should still confirm his attendance to the meeting"
|
||||
);
|
||||
assert.hasClass(
|
||||
form.el.querySelector(".o_field_many2manytags"),
|
||||
"avatar",
|
||||
"should have avatar class"
|
||||
);
|
||||
assert.containsOnce(
|
||||
form,
|
||||
".o_field_many2manytags.avatar.o_field_widget .badge:first img",
|
||||
"should have img tag"
|
||||
);
|
||||
assert.hasAttrValue(
|
||||
form.$(".o_field_many2manytags.avatar.o_field_widget .badge:first img"),
|
||||
"data-src",
|
||||
"/web/image/partner/1/avatar_128",
|
||||
"should have correct avatar image"
|
||||
);
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { start, startServer } from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
import { patchDate, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
|
||||
QUnit.module('calendar', {}, function () {
|
||||
QUnit.module('ActivityMenu');
|
||||
|
||||
QUnit.test('activity menu widget:today meetings', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
patchDate(2018, 3, 20, 6, 0, 0);
|
||||
const pyEnv = await startServer();
|
||||
const calendarAttendeeId1 = pyEnv['calendar.attendee'].create({ partner_id: pyEnv.currentPartnerId });
|
||||
pyEnv['calendar.event'].create([
|
||||
{
|
||||
res_model: "calendar.event",
|
||||
name: "meeting1",
|
||||
start: "2018-04-20 06:30:00",
|
||||
attendee_ids: [calendarAttendeeId1],
|
||||
},
|
||||
{
|
||||
res_model: "calendar.event",
|
||||
name: "meeting2",
|
||||
start: "2018-04-20 09:30:00",
|
||||
attendee_ids: [calendarAttendeeId1],
|
||||
},
|
||||
]);
|
||||
const { click, env } = await start();
|
||||
assert.containsOnce(document.body, '.o_ActivityMenuView', 'should contain an instance of widget');
|
||||
|
||||
await click('.dropdown-toggle[title="Activities"]');
|
||||
|
||||
patchWithCleanup(env.services.action, {
|
||||
doAction(action) {
|
||||
assert.strictEqual(action, "calendar.action_calendar_event", 'should open meeting calendar view in day mode');
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(document.querySelector('.o_meeting_filter'), "should be a meeting");
|
||||
assert.containsN(document.body, '.o_meeting_filter', 2, 'there should be 2 meetings');
|
||||
assert.hasClass(document.querySelector('.o_meeting_filter'), 'o_meeting_bold', 'this meeting is yet to start');
|
||||
assert.doesNotHaveClass(document.querySelectorAll('.o_meeting_filter')[1], 'o_meeting_bold', 'this meeting has been started');
|
||||
|
||||
await click('.o_ActivityMenuView_activityGroup');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
/** @odoo-module **/
|
||||
import tour from 'web_tour.tour';
|
||||
|
||||
const todayDate = function() {
|
||||
let now = new Date();
|
||||
let year = now.getFullYear();
|
||||
let month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
let day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
return `${month}/${day}/${year} 10:00:00`;
|
||||
};
|
||||
|
||||
tour.register('calendar_appointments_hour_tour', {
|
||||
url: '/web',
|
||||
test: true,
|
||||
}, [
|
||||
tour.stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
trigger: '.o_app[data-menu-xmlid="calendar.mail_menu_calendar"]',
|
||||
content: 'Open Calendar',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
trigger: '.o-calendar-button-new',
|
||||
content: 'Create a new event',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
trigger: '#name',
|
||||
content: 'Give a name to the new event',
|
||||
run: 'text TEST EVENT',
|
||||
},
|
||||
{
|
||||
trigger: '#start',
|
||||
content: 'Give a date to the new event',
|
||||
run: `text ${todayDate()}`,
|
||||
},
|
||||
{
|
||||
trigger: '.fa-cloud-upload',
|
||||
content: 'Save the new event',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
trigger: '.dropdown-item:contains("Calendar")',
|
||||
content: 'Go back to Calendar view',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
trigger: '.dropdown-toggle:contains("Week")',
|
||||
content: 'Click to change calendar view',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
trigger: '.dropdown-item:contains("Month")',
|
||||
content: 'Change the calendar view to Month',
|
||||
run: 'click',
|
||||
},
|
||||
{
|
||||
trigger: '.fc-day-header:contains("Monday")',
|
||||
content: 'Change the calendar view to week',
|
||||
},
|
||||
{
|
||||
trigger: '.fc-time:contains("10:00")',
|
||||
content: 'Check the time is properly displayed',
|
||||
},
|
||||
{
|
||||
trigger: '.o_event_title:contains("TEST EVENT")',
|
||||
content: 'Check the event title',
|
||||
},
|
||||
]);
|
||||
|
||||
tour.register('test_calendar_delete_tour', {
|
||||
test: true,
|
||||
},
|
||||
[
|
||||
{
|
||||
content: 'Select filter (everybody)',
|
||||
trigger: 'div[data-value="all"] input',
|
||||
},
|
||||
{
|
||||
content: 'Click on the event (focus + waiting)',
|
||||
trigger: 'a .fc-content:contains("Test Event")',
|
||||
async run() {
|
||||
$('a .fc-content:contains("Test Event")').click();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
$('a .fc-content:contains("Test Event")').click();
|
||||
}
|
||||
},
|
||||
{
|
||||
content: 'Delete the event',
|
||||
trigger: '.o_cw_popover_delete',
|
||||
},
|
||||
{
|
||||
content: 'Validate the deletion',
|
||||
trigger:'button:contains("Ok")',
|
||||
async run() {
|
||||
$('button:contains("Ok")').click();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
tour.register('test_calendar_decline_tour', {
|
||||
test: true,
|
||||
},
|
||||
[
|
||||
{
|
||||
content: 'Click on the event (focus + waiting)',
|
||||
trigger: 'a .fc-content:contains("Test Event")',
|
||||
async run() {
|
||||
$('a .fc-content:contains("Test Event")').click();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
$('a .fc-content:contains("Test Event")').click();
|
||||
}
|
||||
},
|
||||
{
|
||||
content: 'Delete the event',
|
||||
trigger: '.o_cw_popover_delete',
|
||||
},
|
||||
{
|
||||
content: 'Wait declined status',
|
||||
trigger: '.o_attendee_status_declined',
|
||||
},
|
||||
]);
|
||||
|
||||
tour.register('test_calendar_decline_with_everybody_filter_tour', {
|
||||
test: true,
|
||||
},
|
||||
[
|
||||
{
|
||||
content: 'Select filter (everybody)',
|
||||
trigger: 'div[data-value="all"] input',
|
||||
},
|
||||
{
|
||||
content: 'Click on the event (focus + waiting)',
|
||||
trigger: 'a .fc-content:contains("Test Event")',
|
||||
async run() {
|
||||
$('a .fc-content:contains("Test Event")').click();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
$('a .fc-content:contains("Test Event")').click();
|
||||
}
|
||||
},
|
||||
{
|
||||
content: 'Delete the event',
|
||||
trigger: '.o_cw_popover_delete',
|
||||
},
|
||||
{
|
||||
content: 'Select filter (everybody)',
|
||||
trigger: 'div[data-value="all"] input',
|
||||
},
|
||||
{
|
||||
content: 'Wait declined status',
|
||||
trigger: '.o_attendee_status_declined',
|
||||
},
|
||||
]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue