Initial commit: Technical packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit 3473fa71a0
873 changed files with 297766 additions and 0 deletions

View file

@ -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>

View file

@ -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

View file

@ -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);

View file

@ -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);

View file

@ -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,
}),
},
});

View file

@ -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',
},
],
});

View file

@ -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);
}
},
},
});

View file

@ -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();
},
},
});

View file

@ -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(),
},
});

View file

@ -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;
}
}
}
}
}

View file

@ -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,
};

View file

@ -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>

View file

@ -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);
}

View file

@ -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";

View file

@ -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>

View file

@ -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"];

View file

@ -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,
};

View file

@ -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);

View file

@ -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",
};

View file

@ -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>

View file

@ -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,
};

View file

@ -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>

View file

@ -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",
};

View file

@ -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>

View file

@ -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,
};

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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,
});