Skip to content

Commit 3d5273c

Browse files
committed
[IMP] * : Working hours appears in Calendar application
* = calendar, hr, hr_contract Before this commit, working hours don't appear in Calendar application. If in Calendar app, you load the calendar of an toher employee or your own calendar, their working hours will appear. If you selected more than one calendar, the working hours displayed is the intersection of all working hours. task : 3169246 Part-of: odoo#114964 Signed-off-by: Sofie Gvaladze (sgv) <[email protected]>
1 parent b210068 commit 3d5273c

28 files changed

+1266
-15
lines changed

addons/calendar/models/calendar_event.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ def _compute_user_can_edit(self):
295295
editor_candidates += event._origin.partner_ids.user_ids
296296
event.user_can_edit = self.env.user in editor_candidates
297297

298-
@api.depends('attendee_ids')
298+
@api.depends('partner_ids')
299299
def _compute_invalid_email_partner_ids(self):
300300
for event in self:
301301
event.invalid_email_partner_ids = event.partner_ids.filtered(

addons/calendar/static/src/views/fields/attendee_tags_list.xml

+6
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
</div>
1616
</xpath>
1717

18+
<xpath expr="//div[hasclass('o_tag_badge_text')]" position="after">
19+
<div t-if="tag.noEmail" title="no email" class="ms-1">
20+
<i class="fa fa-exclamation-triangle position-relative" role="img"/>
21+
</div>
22+
</xpath>
23+
1824
<xpath expr="//span[hasclass('o_m2m_avatar_empty')]" position="attributes">
1925
<attribute name="class" remove="text-center" separator=" "/>
2026
</xpath>

addons/calendar/static/src/views/fields/many2many_attendee.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
/** @odoo-module **/
2-
31
import { registry } from "@web/core/registry";
42
import {
53
Many2ManyTagsAvatarField,
@@ -36,12 +34,19 @@ export class Many2ManyAttendee extends Many2ManyTagsAvatarField {
3634

3735
get tags() {
3836
const partnerIds = this.specialData.data;
37+
const noEmailPartnerIds = this.props.record.data.invalid_email_partner_ids
38+
? this.props.record.data.invalid_email_partner_ids.records
39+
: [];
3940
const tags = super.tags.map((tag) => {
4041
const partner = partnerIds.find((partner) => tag.resId === partner.id);
42+
const noEmail = noEmailPartnerIds.find((partner) => (tag.resId == partner.resId));
4143
if (partner) {
4244
tag.status = partner.status;
4345
tag.statusIcon = ICON_BY_STATUS[partner.status];
4446
}
47+
if (noEmail) {
48+
tag.noEmail = true;
49+
}
4550
return tag;
4651
});
4752

addons/calendar/views/calendar_views.xml

+4-4
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@
116116
<field name="res_id" invisible="1" />
117117
<field name="active" invisible="1"/>
118118
<field name="user_can_edit" invisible="1"/>
119+
<field name="invalid_email_partner_ids" invisible="1"/> <!-- this field will be used in
120+
many2many_attendees widget -->
119121
<div class="oe_title mb-3">
120122
<div>
121123
<label for="name"/>
@@ -239,10 +241,6 @@
239241
readonly="not user_can_edit"
240242
/>
241243
</div>
242-
<div class="alert alert-warning o_form_header mt-2" colspan="2" invisible="not invalid_email_partner_ids" role="status">
243-
<p><strong>The following attendees have invalid email addresses and won't receive any email notifications:</strong></p>
244-
<field name="invalid_email_partner_ids" widget="many2manyattendee" class="oe_inline"/>
245-
</div>
246244
</group>
247245
</group>
248246
<notebook>
@@ -298,6 +296,8 @@
298296
<field name="recurrence_update" invisible="1"/>
299297
<field name="videocall_source" invisible="1"/>
300298
<field name="access_token" invisible="1"/>
299+
<field name="invalid_email_partner_ids" invisible="1"/> <!-- this field will be used in
300+
many2many_attendees widget -->
301301
<div class="o_row">
302302
<h1 class="w-100"><field name="name" nolabel="1" placeholder="Add title" colspan="2"/></h1>
303303
</div>

addons/hr/models/hr_employee_base.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,12 @@ def _get_employee_working_now(self):
306306
return working_now
307307

308308
def _get_calendar_periods(self, start, stop):
309-
# This method can be overridden in other modules where it's possible
310-
# to have different resource calendars for an employee depending on the
311-
# date.
309+
"""
310+
:param datetime start: the start of the period
311+
:param datetime stop: the stop of the period
312+
This method can be overridden in other modules where it's possible to have different resource calendars for an
313+
employee depending on the date.
314+
"""
312315
calendar_periods_by_employee = {}
313316
for employee in self:
314317
calendar = employee.resource_calendar_id or employee.company_id.resource_calendar_id

addons/hr/models/res_partner.py

-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
# -*- coding: utf-8 -*-
21
# Part of Odoo. See LICENSE file for full copyright and licensing details.
3-
42
from odoo import fields, models, _
53

64

addons/hr_calendar/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from . import models

addons/hr_calendar/__manifest__.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
{
4+
'name': "Display Working Hours in Calendar",
5+
'version': '1.0',
6+
'category': 'Human Resources/Employees',
7+
'depends': ['hr', 'calendar'],
8+
'auto_install': True,
9+
'data': [
10+
'views/calendar_views_calendarApp.xml'
11+
],
12+
'assets': {
13+
'web.assets_backend': [
14+
'hr_calendar/static/src/**/*',
15+
],
16+
'web.qunit_suite_tests': [
17+
'hr_calendar/static/tests/**/*',
18+
],
19+
},
20+
'license': 'LGPL-3',
21+
}

addons/hr_calendar/models/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from . import calendar_event
4+
from . import res_partner
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
from pytz import UTC
3+
4+
from odoo import api, fields, models
5+
6+
from odoo.addons.resource.models.utils import Intervals, sum_intervals, timezone_datetime
7+
8+
9+
class CalendarEvent(models.Model):
10+
_inherit = "calendar.event"
11+
12+
unavailable_partner_ids = fields.Many2many('res.partner', compute='_compute_unavailable_partner_ids')
13+
14+
@api.depends('partner_ids', 'start', 'stop', 'allday')
15+
def _compute_unavailable_partner_ids(self):
16+
event_intervals = self._get_events_interval()
17+
18+
# Event without start and stop are skipped, except all day event: their interval is computed
19+
# based on company calendar's interval.
20+
for event, event_interval in event_intervals.items():
21+
start = event_interval._items[0][0]
22+
stop = event_interval._items[0][1]
23+
if not event.partner_ids:
24+
event.unavailable_partner_ids = []
25+
continue
26+
schedule_by_partner = event.partner_ids._get_schedule(start, stop, merge=False)
27+
event.unavailable_partner_ids = event._check_employees_availability_for_event(
28+
schedule_by_partner, event_interval)
29+
30+
@api.model
31+
def get_unusual_days(self, date_from, date_to=None):
32+
return self.env.user.employee_id._get_unusual_days(date_from, date_to)
33+
34+
def _get_events_interval(self):
35+
"""
36+
Calculate the interval of an event based on its start, stop, and allday values. If an event is scheduled for the
37+
entire day, its interval will correspond to the work interval defined by the company's calendar.
38+
"""
39+
start = min(self.mapped('start')).replace(hour=0, minute=0, second=0, tzinfo=UTC)
40+
stop = max(self.mapped('stop')).replace(hour=23, minute=59, second=59, tzinfo=UTC)
41+
if not start or not stop:
42+
return {}
43+
company_calendar = self.env.company.resource_calendar_id
44+
global_interval = company_calendar._work_intervals_batch(start, stop)[False]
45+
interval_by_event = {}
46+
for event in self:
47+
event_interval = Intervals([(
48+
timezone_datetime(event.start),
49+
timezone_datetime(event.stop),
50+
self.env['resource.calendar']
51+
)])
52+
if event.allday:
53+
interval_by_event[event] = event_interval & global_interval
54+
elif event.start and event.stop:
55+
interval_by_event[event] = event_interval
56+
return interval_by_event
57+
58+
def _check_employees_availability_for_event(self, schedule_by_partner, event_interval):
59+
unavailable_partners = []
60+
for partner, schedule in schedule_by_partner.items():
61+
common_interval = schedule & event_interval
62+
if sum_intervals(common_interval) != sum_intervals(event_interval):
63+
unavailable_partners.append(partner.id)
64+
return unavailable_partners
+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from pytz import UTC, timezone
4+
from datetime import datetime
5+
from collections import defaultdict
6+
from functools import reduce
7+
8+
from odoo import api, models
9+
10+
from odoo.osv import expression
11+
from odoo.addons.resource.models.utils import Intervals
12+
13+
14+
class Partner(models.Model):
15+
_inherit = ['res.partner']
16+
17+
def _get_employees_from_attendees(self, everybody=False):
18+
domain = [
19+
('company_id', 'in', self.env.companies.ids),
20+
('work_contact_id', '!=', False),
21+
]
22+
if not everybody:
23+
domain = expression.AND([
24+
domain,
25+
[('work_contact_id', 'in', self.ids)]
26+
])
27+
return dict(self.env['hr.employee'].sudo()._read_group(domain, groupby=['work_contact_id'], aggregates=['id:recordset']))
28+
29+
def _get_schedule(self, start_period, stop_period, everybody=False, merge=True):
30+
"""
31+
This method implements the general case where employees might have different resource calendars at different
32+
times, even though this is not the case with only this module installed.
33+
This way it will work with these other modules by just overriding
34+
`_get_calendar_periods`.
35+
36+
:param datetime start_period: the start of the period
37+
:param datetime stop_period: the stop of the period
38+
:param boolean everybody: represents the "everybody" filter on calendar
39+
:param boolean merge: specifies if calendar's work_intervals needs to be merged
40+
:return: schedule (merged or not) by partner
41+
:rtype: defaultdict
42+
"""
43+
employees_by_partner = self._get_employees_from_attendees(everybody)
44+
if not employees_by_partner:
45+
return {}
46+
interval_by_calendar = defaultdict()
47+
calendar_periods_by_employee = defaultdict(list)
48+
employees_by_calendar = defaultdict(list)
49+
50+
# Compute employee's calendars's period and order employee by his involved calendars
51+
employees = sum(employees_by_partner.values(), start=self.env['hr.employee'])
52+
calendar_periods_by_employee = employees._get_calendar_periods(start_period, stop_period)
53+
for employee, calendar_periods in calendar_periods_by_employee.items():
54+
for (start, stop, calendar) in calendar_periods:
55+
employees_by_calendar[calendar].append(employee)
56+
57+
# Compute all work intervals per calendar
58+
for calendar, employees in employees_by_calendar.items():
59+
work_intervals = calendar._work_intervals_batch(start_period, stop_period, resources=employees, tz=timezone(calendar.tz))
60+
del work_intervals[False]
61+
# Merge all employees intervals to avoid to compute it multiples times
62+
if merge:
63+
interval_by_calendar[calendar] = reduce(Intervals.__and__, work_intervals.values())
64+
else:
65+
interval_by_calendar[calendar] = work_intervals
66+
67+
# Compute employee's schedule based own his calendar's periods
68+
schedule_by_employee = defaultdict(list)
69+
for employee, calendar_periods in calendar_periods_by_employee.items():
70+
employee_interval = Intervals([])
71+
for (start, stop, calendar) in calendar_periods:
72+
interval = Intervals([(start, stop, self.env['resource.calendar'])])
73+
if merge:
74+
calendar_interval = interval_by_calendar[calendar]
75+
else:
76+
calendar_interval = interval_by_calendar[calendar][employee.id]
77+
employee_interval = employee_interval | (calendar_interval & interval)
78+
schedule_by_employee[employee] = employee_interval
79+
80+
# Compute partner's schedule equals to the union between his employees's schedule
81+
schedules = defaultdict()
82+
for partner, employees in employees_by_partner.items():
83+
partner_schedule = Intervals([])
84+
for employee in employees:
85+
if schedule_by_employee[employee]:
86+
partner_schedule = partner_schedule | schedule_by_employee[employee]
87+
schedules[partner] = partner_schedule
88+
return schedules
89+
90+
@api.model
91+
def get_working_hours_for_all_attendees(self, attendee_ids, date_from, date_to, everybody=False):
92+
93+
start_period = datetime.fromisoformat(date_from).replace(hour=0, minute=0, second=0, tzinfo=UTC)
94+
stop_period = datetime.fromisoformat(date_to).replace(hour=23, minute=59, second=59, tzinfo=UTC)
95+
96+
schedule_by_partner = self.env['res.partner'].browse(attendee_ids)._get_schedule(start_period, stop_period, everybody)
97+
if not schedule_by_partner:
98+
return []
99+
return self._interval_to_business_hours(reduce(Intervals.__and__, schedule_by_partner.values()))
100+
101+
def _interval_to_business_hours(self, working_intervals):
102+
# This is the format expected by the fullcalendar library to do the overlay
103+
return [{
104+
"daysOfWeek": [interval[0].weekday() + 1],
105+
"startTime": interval[0].astimezone(timezone(self.env.user.tz)).strftime("%H:%M"),
106+
"endTime": interval[1].astimezone(timezone(self.env.user.tz)).strftime("%H:%M"),
107+
} for interval in working_intervals] if working_intervals else [{
108+
# 7 is used a dummy value to gray the full week
109+
# Returning an empty list would leave the week uncolored
110+
"daysOfWeek": [7],
111+
"startTime": datetime.today().strftime("00:00"),
112+
"endTime": datetime.today().strftime("00:00"),
113+
}]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { AttendeeCalendarModel } from "@calendar/views/attendee_calendar/attendee_calendar_model";
2+
import { serializeDate } from "@web/core/l10n/dates";
3+
import { patch } from "@web/core/utils/patch";
4+
5+
patch(AttendeeCalendarModel.prototype, {
6+
setup() {
7+
super.setup(...arguments)
8+
this.data.workingHours = {};
9+
},
10+
11+
get workingHours() {
12+
return this.data.workingHours;
13+
},
14+
15+
async updateData(data) {
16+
await super.updateData(...arguments)
17+
data.workingHours = await this.fetchWorkingHours(data);
18+
},
19+
20+
async fetchWorkingHours(data){
21+
if (this.meta.scale !== "day" && this.meta.scale !== "week"){
22+
return [];
23+
}
24+
const attendeeFilters = data.filterSections.partner_ids;
25+
const activeAttendeeIds = attendeeFilters.filters
26+
.filter((filter) => filter.type !== "all" && filter.value && filter.active)
27+
.map((filter) => filter.value);
28+
const allFilter = attendeeFilters.filters.find((filter) => filter.type === "all");
29+
return this.orm.call("res.partner", "get_working_hours_for_all_attendees", [
30+
activeAttendeeIds,
31+
serializeDate(data.range.start),
32+
serializeDate(data.range.end),
33+
allFilter?.active
34+
]);
35+
},
36+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { AttendeeCalendarCommonRenderer } from "@calendar/views/attendee_calendar/common/attendee_calendar_common_renderer";
2+
import { patch } from "@web/core/utils/patch";
3+
import { onWillUpdateProps } from "@odoo/owl";
4+
5+
patch(AttendeeCalendarCommonRenderer.prototype, {
6+
setup() {
7+
super.setup(...arguments);
8+
onWillUpdateProps(() => {
9+
this.fc.api.setOption("businessHours", this.props.model.workingHours)
10+
});
11+
},
12+
get options() {
13+
return Object.assign(super.options, {
14+
businessHours: this.props.model.workingHours,
15+
});
16+
},
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<templates>
3+
<t t-name="hr_calendar.AttendeeTagsList" t-inherit="calendar.AttendeeTagsList" t-inherit-mode="extension">
4+
<xpath expr="//div[hasclass('o_tag_badge_text')]" position="after">
5+
<div t-if="tag.unavailableIcon" title="unavailable" class="ms-1">
6+
<i class="fa fa-moon-o position-relative" role="img"/>
7+
</div>
8+
</xpath>
9+
</t>
10+
</templates>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Many2ManyAttendee } from "@calendar/views/fields/many2many_attendee";
2+
import { patch } from "@web/core/utils/patch";
3+
patch(Many2ManyAttendee.prototype, {
4+
get tags() {
5+
const tags = super.tags;
6+
if (this.props.record.data.unavailable_partner_ids) {
7+
const unavailablePartnerIds = this.props.record.data.unavailable_partner_ids.records;
8+
for (const tag of tags) {
9+
if (unavailablePartnerIds.find((partner) => (tag.resId == partner.resId))) {
10+
tag.unavailableIcon = true;
11+
}
12+
}
13+
}
14+
return tags;
15+
}
16+
})

0 commit comments

Comments
 (0)