19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:34 +01:00
parent 5faf7397c5
commit 2696f14ed7
721 changed files with 220375 additions and 91221 deletions

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_http
@ -10,7 +9,10 @@ from . import calendar_attendee
from . import calendar_filter
from . import calendar_event_type
from . import calendar_recurrence
from . import discuss_channel
from . import mail_activity
from . import mail_activity_mixin
from . import mail_activity_type
from . import res_users
from . import res_users_settings
from . import utils

View file

@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import api, fields, models, _
from odoo.fields import Domain
class Alarm(models.Model):
class CalendarAlarm(models.Model):
_name = 'calendar.alarm'
_description = 'Event Alarm'
@ -26,6 +26,7 @@ class Alarm(models.Model):
compute='_compute_mail_template_id', readonly=False, store=True,
help="Template used to render mail reminder content.")
body = fields.Text("Additional Message", help="Additional message that would be sent with the notification for the reminder")
notify_responsible = fields.Boolean("Notify Responsible", default=False)
@api.depends('interval', 'duration')
def _compute_duration_minutes(self):
@ -48,6 +49,14 @@ class Alarm(models.Model):
alarm.mail_template_id = False
def _search_duration_minutes(self, operator, value):
if operator == 'in':
# recursive call with operator '='
return Domain.OR(self._search_duration_minutes('=', v) for v in value)
elif operator == '=':
if not value:
return Domain('duration', '=', False)
elif operator not in ('>=', '<=', '<', '>'):
return NotImplemented
return [
'|', '|',
'&', ('interval', '=', 'minutes'), ('duration', operator, value),
@ -55,10 +64,14 @@ class Alarm(models.Model):
'&', ('interval', '=', 'days'), ('duration', operator, value / 60 / 24),
]
@api.onchange('duration', 'interval', 'alarm_type')
@api.onchange('duration', 'interval', 'alarm_type', 'notify_responsible')
def _onchange_duration_interval(self):
if self.notify_responsible and self.alarm_type in ('email', 'notification'):
self.notify_responsible = False
display_interval = self._interval_selection.get(self.interval, '')
display_alarm_type = {
key: value for key, value in self._fields['alarm_type']._description_selection(self.env)
}[self.alarm_type]
}.get(self.alarm_type, '')
self.name = "%s - %s %s" % (display_alarm_type, self.duration, display_interval)
if self.notify_responsible:
self.name += " - " + _("Notify Responsible")

View file

@ -1,17 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
from odoo.tools import plaintext2html
_logger = logging.getLogger(__name__)
from odoo.tools.sql import SQL
class AlarmManager(models.AbstractModel):
class CalendarAlarm_Manager(models.AbstractModel):
_name = 'calendar.alarm_manager'
_description = 'Event Alarm Manager'
@ -23,7 +19,9 @@ class AlarmManager(models.AbstractModel):
result = {}
delta_request = """
SELECT
rel.calendar_event_id, max(alarm.duration_minutes) AS max_delta,min(alarm.duration_minutes) AS min_delta
rel.calendar_event_id,
max(alarm.duration_minutes) AS max_delta,
min(alarm.duration_minutes) AS min_delta
FROM
calendar_alarm_calendar_event_rel AS rel
LEFT JOIN calendar_alarm AS alarm ON alarm.id = rel.calendar_alarm_id
@ -31,30 +29,24 @@ class AlarmManager(models.AbstractModel):
GROUP BY rel.calendar_event_id
"""
base_request = """
SELECT
cal.id,
cal.start - interval '1' minute * calcul_delta.max_delta AS first_alarm,
CASE
WHEN cal.recurrency THEN rrule.until - interval '1' minute * calcul_delta.min_delta
ELSE cal.stop - interval '1' minute * calcul_delta.min_delta
END as last_alarm,
cal.start as first_event_date,
CASE
WHEN cal.recurrency THEN rrule.until
ELSE cal.stop
END as last_event_date,
calcul_delta.min_delta,
calcul_delta.max_delta,
rrule.rrule AS rule
FROM
calendar_event AS cal
RIGHT JOIN calcul_delta ON calcul_delta.calendar_event_id = cal.id
LEFT JOIN calendar_recurrence as rrule ON rrule.id = cal.recurrence_id
"""
SELECT
cal.id,
cal.start - interval '1' minute * calcul_delta.max_delta AS first_alarm,
cal.stop - interval '1' minute * calcul_delta.min_delta AS last_alarm,
cal.start AS first_meeting,
cal.stop AS last_meeting,
calcul_delta.min_delta,
calcul_delta.max_delta
FROM
calendar_event AS cal
INNER JOIN calcul_delta ON calcul_delta.calendar_event_id = cal.id
WHERE cal.active = True
"""
filter_user = """
RIGHT JOIN calendar_event_res_partner_rel AS part_rel ON part_rel.calendar_event_id = cal.id
AND part_rel.res_partner_id IN %s
INNER JOIN calendar_event_res_partner_rel AS part_rel
ON part_rel.calendar_event_id = cal.id
AND part_rel.res_partner_id IN %s
WHERE cal.active = True
"""
# Add filter on alarm type
@ -62,7 +54,7 @@ class AlarmManager(models.AbstractModel):
# Add filter on partner_id
if partners:
base_request += filter_user
base_request = base_request.replace("WHERE cal.active = True", filter_user)
tuple_params += (tuple(partners.ids), )
# Upper bound on first_alarm of requested events
@ -81,15 +73,15 @@ class AlarmManager(models.AbstractModel):
tuple_params += (seconds,)
self.env.flush_all()
self._cr.execute("""
self.env.cr.execute("""
WITH calcul_delta AS (%s)
SELECT *
FROM ( %s WHERE cal.active = True ) AS ALL_EVENTS
WHERE ALL_EVENTS.first_alarm < %s
AND ALL_EVENTS.last_event_date > (now() at time zone 'utc')
FROM ( %s ) AS ALL_EVENTS
WHERE ALL_EVENTS.first_alarm < %s
AND ALL_EVENTS.last_alarm > (now() at time zone 'utc')
""" % (delta_request, base_request, first_alarm_max_value), tuple_params)
for event_id, first_alarm, last_alarm, first_meeting, last_meeting, min_duration, max_duration, rule in self._cr.fetchall():
for event_id, first_alarm, last_alarm, first_meeting, last_meeting, min_duration, max_duration in self.env.cr.fetchall():
result[event_id] = {
'event_id': event_id,
'first_alarm': first_alarm,
@ -98,14 +90,13 @@ class AlarmManager(models.AbstractModel):
'last_meeting': last_meeting,
'min_duration': min_duration,
'max_duration': max_duration,
'rrule': rule
}
# determine accessible events
events = self.env['calendar.event'].browse(result)
result = {
key: result[key]
for key in set(events._filter_access_rules('read').ids)
for key in events._filtered_access('read').ids
}
return result
@ -147,7 +138,7 @@ class AlarmManager(models.AbstractModel):
To be overriden on inherited modules
adding extra conditions to extract only the unsynced events
"""
return ""
return SQL("")
def _get_events_by_alarm_to_notify(self, alarm_type):
"""
@ -159,21 +150,28 @@ class AlarmManager(models.AbstractModel):
design. The attendees receive an invitation for any new event
already.
"""
lastcall = self.env.context.get('lastcall', False) or fields.date.today() - relativedelta(weeks=1)
self.env.cr.execute('''
SELECT "alarm"."id", "event"."id"
FROM "calendar_event" AS "event"
JOIN "calendar_alarm_calendar_event_rel" AS "event_alarm_rel"
ON "event"."id" = "event_alarm_rel"."calendar_event_id"
JOIN "calendar_alarm" AS "alarm"
ON "event_alarm_rel"."calendar_alarm_id" = "alarm"."id"
WHERE
"alarm"."alarm_type" = %s
AND "event"."active"
AND "event"."start" - CAST("alarm"."duration" || ' ' || "alarm"."interval" AS Interval) >= %s
AND "event"."start" - CAST("alarm"."duration" || ' ' || "alarm"."interval" AS Interval) < now() at time zone 'utc'
AND "event"."stop" > now() at time zone 'utc'
''' + self._get_notify_alert_extra_conditions(), [alarm_type, lastcall])
lastcall = self.env.context.get('lastcall', False) or fields.Date.today() - timedelta(weeks=1)
# TODO MASTER: remove context and add a proper parameter
extra_conditions = self.with_context(alarm_type=alarm_type)._get_notify_alert_extra_conditions()
now = fields.Datetime.now()
self.env.cr.execute(SQL("""
SELECT alarm.id, event.id
FROM calendar_event AS event
JOIN calendar_alarm_calendar_event_rel AS event_alarm_rel
ON event.id = event_alarm_rel.calendar_event_id
JOIN calendar_alarm AS alarm
ON event_alarm_rel.calendar_alarm_id = alarm.id
WHERE alarm.alarm_type = %s
AND event.active
AND event.start - CAST(alarm.duration || ' ' || alarm.interval AS Interval) >= %s
AND event.start - CAST(alarm.duration || ' ' || alarm.interval AS Interval) < %s
%s
""",
alarm_type,
lastcall,
now,
extra_conditions,
))
events_by_alarm = {}
for alarm_id, event_id in self.env.cr.fetchall():
@ -187,21 +185,24 @@ class AlarmManager(models.AbstractModel):
if not events_by_alarm:
return
# force_send limit should apply to the total nb of attendees, not per alarm
force_send_limit = int(self.env['ir.config_parameter'].sudo().get_param('mail.mail_force_send_limit', 100))
event_ids = list(set(event_id for event_ids in events_by_alarm.values() for event_id in event_ids))
events = self.env['calendar.event'].browse(event_ids)
attendees = events.attendee_ids.filtered(lambda a: a.state != 'declined')
now = fields.Datetime.now()
attendees = events.filtered(lambda e: e.stop > now).attendee_ids.filtered(lambda a: a.state != 'declined')
alarms = self.env['calendar.alarm'].browse(events_by_alarm.keys())
for alarm in alarms:
alarm_attendees = attendees.filtered(lambda attendee: attendee.event_id.id in events_by_alarm[alarm.id])
alarm_attendees.with_context(
mail_notify_force_send=True,
calendar_template_ignore_recurrence=True,
mail_notify_author=True
)._send_mail_to_attendees(
alarm_attendees.with_context(calendar_template_ignore_recurrence=True)._notify_attendees(
alarm.mail_template_id,
force_send=True
force_send=len(attendees) <= force_send_limit,
notify_author=True,
)
events._setup_event_recurrent_alarms(events_by_alarm)
@api.model
def get_next_notif(self):
partner = self.env.user.partner_id
@ -245,13 +246,10 @@ class AlarmManager(models.AbstractModel):
def _notify_next_alarm(self, partner_ids):
""" Sends through the bus the next alarm of given partners """
notifications = []
users = self.env['res.users'].search([
('partner_id', 'in', tuple(partner_ids)),
('groups_id', 'in', self.env.ref('base.group_user').ids),
('group_ids', 'in', self.env.ref('base.group_user').ids),
])
for user in users:
notif = self.with_user(user).with_context(allowed_company_ids=user.company_ids.ids).get_next_notif()
notifications.append([user.partner_id, 'calendar.alarm', notif])
if len(notifications) > 0:
self.env['bus.bus']._sendmany(notifications)
user._bus_send("calendar.alarm", notif)

View file

@ -1,18 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
import base64
import logging
from collections import defaultdict
from odoo import api, Command, fields, models, _
from odoo import api, fields, models, _
from odoo.addons.base.models.res_partner import _tz_get
from odoo.exceptions import UserError
from odoo.tools.misc import clean_context
from odoo.tools import split_every
_logger = logging.getLogger(__name__)
class Attendee(models.Model):
class CalendarAttendee(models.Model):
""" Calendar Attendee Information """
_name = 'calendar.attendee'
_rec_name = 'common_name'
@ -23,24 +24,24 @@ class Attendee(models.Model):
return uuid.uuid4().hex
STATE_SELECTION = [
('accepted', 'Yes'),
('declined', 'No'),
('tentative', 'Maybe'),
('needsAction', 'Needs Action'),
('tentative', 'Uncertain'),
('declined', 'Declined'),
('accepted', 'Accepted'),
]
# event
event_id = fields.Many2one('calendar.event', 'Meeting linked', required=True, ondelete='cascade')
event_id = fields.Many2one('calendar.event', 'Meeting linked', required=True, index=True, ondelete='cascade')
recurrence_id = fields.Many2one('calendar.recurrence', related='event_id.recurrence_id')
# attendee
partner_id = fields.Many2one('res.partner', 'Attendee', required=True, readonly=True)
partner_id = fields.Many2one('res.partner', 'Attendee', required=True, readonly=True, ondelete='cascade')
email = fields.Char('Email', related='partner_id.email')
phone = fields.Char('Phone', related='partner_id.phone')
common_name = fields.Char('Common name', compute='_compute_common_name', store=True)
access_token = fields.Char('Invitation Token', default=_default_access_token)
mail_tz = fields.Selection(_tz_get, compute='_compute_mail_tz', help='Timezone used for displaying time in the mail template')
# state
state = fields.Selection(STATE_SELECTION, string='Status', readonly=True, default='needsAction')
state = fields.Selection(STATE_SELECTION, string='Status', default='needsAction')
availability = fields.Selection(
[('free', 'Available'), ('busy', 'Busy')], 'Available/Busy', readonly=True)
@ -66,90 +67,159 @@ class Attendee(models.Model):
values['email'] = email[0] if email else ''
values['common_name'] = values.get("common_name")
attendees = super().create(vals_list)
attendees._subscribe_partner()
attendees.event_id.check_access('write')
return attendees
def write(self, vals):
attendees = super().write(vals)
self.event_id.check_access('write')
return attendees
def unlink(self):
self._unsubscribe_partner()
return super().unlink()
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
raise UserError(_('You cannot duplicate a calendar attendee.'))
def _subscribe_partner(self):
mapped_followers = defaultdict(lambda: self.env['calendar.event'])
for event in self.event_id:
partners = (event.attendee_ids & self).partner_id - event.message_partner_ids
# current user is automatically added as followers, don't add it twice.
partners -= self.env.user.partner_id
mapped_followers[partners] |= event
for partners, events in mapped_followers.items():
events.message_subscribe(partner_ids=partners.ids)
def _unsubscribe_partner(self):
for event in self.event_id:
partners = (event.attendee_ids & self).partner_id & event.message_partner_ids
event.message_unsubscribe(partner_ids=partners.ids)
def _send_mail_to_attendees(self, mail_template, force_send=False):
""" Send mail for event invitation to event attendees.
:param mail_template: a mail.template record
:param force_send: if set to True, the mail(s) will be sent immediately (instead of the next queue processing)
# ------------------------------------------------------------
# MAILING
# ------------------------------------------------------------
@api.model
def _mail_template_default_values(self):
return {
"email_from": "{{ (object.event_id.user_id.email_formatted or user.email_formatted or '') }}",
"email_to": False,
"partner_to": False,
"lang": "{{ object.partner_id.lang }}",
"use_default_to": True,
}
def _message_add_default_recipients(self):
# override: partner_id being the only stored field, we can currently
# simplify computation, we have no other choice than relying on it
return {
attendee.id: {
'partners': attendee.partner_id,
'email_to_lst': [],
'email_cc_lst': [],
} for attendee in self
}
def _send_invitation_emails(self):
""" Hook to be able to override the invitation email sending process.
Notably inside appointment to use a different mail template from the appointment type. """
self._notify_attendees(
self.env.ref('calendar.calendar_template_meeting_invitation', raise_if_not_found=False),
force_send=True,
)
def _notify_attendees(self, mail_template, notify_author=False, force_send=False):
""" Notify attendees about event main changes (invite, cancel, ...) based
on template.
:param mail_template: a mail.template record
:param force_send: if set to True, the mail(s) will be sent immediately (instead of the next queue processing)
"""
# TDE FIXME: check this
if force_send:
force_send_limit = int(self.env['ir.config_parameter'].sudo().get_param('mail.mail_force_send_limit', 100))
notified_attendees_ids = set(self.ids)
for event, attendees in self.grouped('event_id').items():
if event._skip_send_mail_status_update():
notified_attendees_ids -= set(attendees.ids)
notified_attendees = self.browse(notified_attendees_ids)
if isinstance(mail_template, str):
raise ValueError('Template should be a template record, not an XML ID anymore.')
if self.env['ir.config_parameter'].sudo().get_param('calendar.block_mail') or self._context.get("no_mail_to_attendees"):
if self.env['ir.config_parameter'].sudo().get_param('calendar.block_mail') or self.env.context.get("no_mail_to_attendees"):
return False
if not mail_template:
_logger.warning("No template passed to %s notification process. Skipped.", self)
return False
# get ics file for all meetings
ics_files = self.mapped('event_id')._get_ics_file()
ics_files = notified_attendees.event_id._get_ics_file()
for attendee in self:
if attendee.email and attendee._should_notify_attendee():
# If the mail template has attachments, prepare copies for each attendee (to be added to each attendee's mail)
if mail_template.attachment_ids:
# Setting res_model to ensure attachments are linked to the msg (otherwise only internal users are allowed link attachments)
attachments_values = [a.copy_data({'res_id': 0, 'res_model': 'mail.compose.message'})[0] for a in mail_template.attachment_ids]
attachments_values *= len(self)
attendee_attachment_ids = self.env['ir.attachment'].create(attachments_values).ids
# Map attendees to their respective attachments
template_attachment_count = len(mail_template.attachment_ids)
attendee_id_attachment_id_map = dict(zip(self.ids, split_every(template_attachment_count, attendee_attachment_ids, list)))
mail_messages = self.env['mail.message']
for attendee in notified_attendees:
if attendee.email and attendee._should_notify_attendee(notify_author=notify_author):
event_id = attendee.event_id.id
ics_file = ics_files.get(event_id)
attachment_values = [Command.set(mail_template.attachment_ids.ids)]
# Add template attachments copies to the attendee's email, if available
attachment_ids = attendee_id_attachment_id_map[attendee.id] if mail_template.attachment_ids else []
if ics_file:
attachment_values += [
(0, 0, {'name': 'invitation.ics',
'mimetype': 'text/calendar',
'res_id': event_id,
'res_model': 'calendar.event',
'datas': base64.b64encode(ics_file)})
]
context = {
**clean_context(self.env.context),
'no_document': True, # An ICS file must not create a document
}
attachment_ids += self.env['ir.attachment'].with_context(context).create({
'datas': base64.b64encode(ics_file),
'description': 'invitation.ics',
'mimetype': 'text/calendar',
'res_id': 0,
'res_model': 'mail.compose.message',
'name': 'invitation.ics',
}).ids
body = mail_template._render_field(
'body_html',
attendee.ids,
compute_lang=True,
post_process=True)[attendee.id]
compute_lang=True)[attendee.id]
subject = mail_template._render_field(
'subject',
attendee.ids,
compute_lang=True)[attendee.id]
attendee.event_id.with_context(no_document=True).sudo().message_notify(
email_from=attendee.event_id.user_id.email_formatted or self.env.user.email_formatted,
email_from = mail_template._render_field(
'email_from',
attendee.ids)[attendee.id]
mail_messages += attendee.event_id.with_context(no_document=True).sudo().message_notify(
email_from=email_from or None, # use None to trigger fallback sender
author_id=attendee.event_id.user_id.partner_id.id or self.env.user.partner_id.id,
body=body,
subject=subject,
notify_author=notify_author,
partner_ids=attendee.partner_id.ids,
email_layout_xmlid='mail.mail_notification_light',
attachment_ids=attachment_values,
force_send=force_send,
attachment_ids=attachment_ids,
force_send=False,
)
# batch sending at the end
if force_send and len(notified_attendees) < force_send_limit:
mail_messages.sudo().mail_ids.send_after_commit()
def _should_notify_attendee(self):
def _should_notify_attendee(self, notify_author=False):
""" Utility method that determines if the attendee should be notified.
By default, we do not want to notify (aka no message and no mail) the current user
if he is part of the attendees.
if he is part of the attendees. But for reminders, mail_notify_author could be forced
(Override in appointment to ignore that rule and notify all attendees if it's an appointment)
"""
self.ensure_one()
return self.partner_id != self.env.user.partner_id
partner_not_sender = self.partner_id != self.env.user.partner_id
return partner_not_sender or notify_author
# ------------------------------------------------------------
# STATE MANAGEMENT
# ------------------------------------------------------------
def do_tentative(self):
""" Makes event invitation as Tentative. """
@ -160,7 +230,7 @@ class Attendee(models.Model):
for attendee in self:
attendee.event_id.message_post(
author_id=attendee.partner_id.id,
body=_("%s has accepted the invitation") % (attendee.common_name),
body=_("%s has accepted the invitation", attendee.common_name),
subtype_xmlid="calendar.subtype_invitation",
)
return self.write({'state': 'accepted'})
@ -170,7 +240,7 @@ class Attendee(models.Model):
for attendee in self:
attendee.event_id.message_post(
author_id=attendee.partner_id.id,
body=_("%s has declined the invitation") % (attendee.common_name),
body=_("%s has declined the invitation", attendee.common_name),
subtype_xmlid="calendar.subtype_invitation",
)
return self.write({'state': 'declined'})

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from random import randint
@ -6,9 +5,9 @@ from random import randint
from odoo import fields, models
class MeetingType(models.Model):
class CalendarEventType(models.Model):
_name = 'calendar.event.type'
_description = 'Event Meeting Type'
def _default_color(self):
@ -17,6 +16,7 @@ class MeetingType(models.Model):
name = fields.Char('Name', required=True)
color = fields.Integer('Color', default=_default_color)
_sql_constraints = [
('name_uniq', 'unique (name)', "Tag name already exists !"),
]
_name_uniq = models.Constraint(
'unique (name)',
'Tag name already exists!',
)

View file

@ -1,21 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class Contacts(models.Model):
class CalendarFilters(models.Model):
_name = 'calendar.filters'
_description = 'Calendar Filters'
user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user, index=True)
user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user, index=True, ondelete='cascade')
partner_id = fields.Many2one('res.partner', 'Employee', required=True, index=True)
active = fields.Boolean('Active', default=True)
partner_checked = fields.Boolean('Checked', default=True) # used to know if the partner is checked in the filter of the calendar view for the user_id.
_sql_constraints = [
('user_id_partner_id_unique', 'UNIQUE(user_id, partner_id)', 'A user cannot have the same contact twice.')
]
_user_id_partner_id_unique = models.Constraint(
'UNIQUE(user_id, partner_id)',
'A user cannot have the same contact twice.',
)
@api.model
def unlink_from_partner_id(self, partner_id):

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, time
@ -10,6 +9,7 @@ from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools.misc import clean_context
from odoo.addons.base.models.res_partner import _tz_get
@ -90,7 +90,7 @@ def weekday_to_field(weekday_index):
return RRULE_WEEKDAY_TO_FIELD.get(weekday_index)
class RecurrenceRule(models.Model):
class CalendarRecurrence(models.Model):
_name = 'calendar.recurrence'
_description = 'Event Recurrence Rule'
@ -119,16 +119,18 @@ class RecurrenceRule(models.Model):
weekday = fields.Selection(WEEKDAY_SELECTION, string='Weekday')
byday = fields.Selection(BYDAY_SELECTION, string='By day')
until = fields.Date('Repeat Until')
trigger_id = fields.Many2one('ir.cron.trigger')
_sql_constraints = [
('month_day',
"CHECK (rrule_type != 'monthly' "
"OR month_by != 'day' "
"OR day >= 1 AND day <= 31 "
"OR weekday in %s AND byday in %s)"
% (tuple(wd[0] for wd in WEEKDAY_SELECTION), tuple(bd[0] for bd in BYDAY_SELECTION)),
"The day must be between 1 and 31"),
]
_month_day = models.Constraint("""CHECK (
rrule_type != 'monthly'
OR month_by != 'day'
OR day >= 1 AND day <= 31
OR weekday IN %s AND byday IN %s)""" % (
tuple(wd[0] for wd in WEEKDAY_SELECTION),
tuple(bd[0] for bd in BYDAY_SELECTION),
),
"The day must be between 1 and 31",
)
def _get_daily_recurrence_name(self):
if self.end_type == 'count':
@ -197,11 +199,8 @@ class RecurrenceRule(models.Model):
@api.depends('calendar_event_ids.start')
def _compute_dtstart(self):
groups = self.env['calendar.event'].read_group([('recurrence_id', 'in', self.ids)], ['start:min'], ['recurrence_id'])
start_mapping = {
group['recurrence_id'][0]: group['start']
for group in groups
}
groups = self.env['calendar.event']._read_group([('recurrence_id', 'in', self.ids)], ['recurrence_id'], ['start:min'])
start_mapping = {recurrence.id: start_min for recurrence, start_min in groups}
for recurrence in self:
recurrence.dtstart = start_mapping.get(recurrence.id)
@ -275,9 +274,44 @@ class RecurrenceRule(models.Model):
events = self.calendar_event_ids - keep
detached_events = self._detach_events(events)
self.env['calendar.event'].with_context(no_mail_to_attendees=True, mail_create_nolog=True).create(event_vals)
context = {
**clean_context(self.env.context),
**{'no_mail_to_attendees': True, 'mail_create_nolog': True},
}
self.env['calendar.event'].with_context(context).create(event_vals)
return detached_events
def _setup_alarms(self, recurrence_update=False):
""" Schedule cron triggers for future events
Create one ir.cron.trigger per recurrence.
:param recurrence_update: boolean: if true, update all recurrences in self, else only the recurrences
without trigger
"""
now = self.env.context.get('date') or fields.Datetime.now()
# get next events
self.env['calendar.event'].flush_model(fnames=['recurrence_id', 'start'])
if not self.calendar_event_ids.ids:
return
self.env.cr.execute("""
SELECT DISTINCT ON (recurrence_id) id event_id, recurrence_id
FROM calendar_event
WHERE start > %s
AND id IN %s
ORDER BY recurrence_id,start ASC;
""", (now, tuple(self.calendar_event_ids.ids)))
result = self.env.cr.dictfetchall()
if not result:
return
events = self.env['calendar.event'].browse(value['event_id'] for value in result)
triggers_by_events = events._setup_alarms()
for vals in result:
trigger_id = triggers_by_events.get(vals['event_id'])
if not trigger_id:
continue
recurrence = self.env['calendar.recurrence'].browse(vals['recurrence_id'])
recurrence.trigger_id = trigger_id
def _split_from(self, event, recurrence_values=None):
"""Stops the current recurrence at the given event and creates a new one starting
with the event.
@ -331,7 +365,7 @@ class RecurrenceRule(models.Model):
def _detach_events(self, events):
events.with_context(dont_notify=True).write({
'recurrence_id': False,
'recurrency': False,
'recurrency': True,
})
return events
@ -395,14 +429,9 @@ class RecurrenceRule(models.Model):
data['month_by'] = 'day'
data['rrule_type'] = 'monthly'
if rule._bymonthday:
if rule._bymonthday and data['rrule_type'] == 'monthly':
data['day'] = list(rule._bymonthday)[0]
data['month_by'] = 'date'
data['rrule_type'] = 'monthly'
# Repeat yearly but for odoo it's monthly, take same information as monthly but interval is 12 times
if rule._bymonth:
data['interval'] *= 12
if data.get('until'):
data['end_type'] = 'end_date'
@ -413,7 +442,7 @@ class RecurrenceRule(models.Model):
return data
def _get_lang_week_start(self):
lang = self.env['res.lang']._lang_get(self.env.user.lang)
lang = self.env['res.lang']._get_data(code=self.env.user.lang)
week_start = int(lang.week_start) # lang.week_start ranges from '1' to '7'
return rrule.weekday(week_start - 1) # rrule expects an int from 0 to 6
@ -574,3 +603,19 @@ class RecurrenceRule(models.Model):
return rrule.rrule(
freq_to_rrule(freq), **rrule_params
)
def _is_event_over(self):
"""Check if all events in this recurrence are in the past.
:return: True if all events are over, False otherwise
"""
self.ensure_one()
if not self.calendar_event_ids:
return False
now = fields.Datetime.now()
today = fields.Date.today()
return all(
(event.stop_date < today if event.allday else event.stop < now)
for event in self.calendar_event_ids
)

View file

@ -0,0 +1,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class DiscussChannel(models.Model):
_inherit = "discuss.channel"
calendar_event_ids = fields.One2many("calendar.event", "videocall_channel_id")
def _should_invite_members_to_join_call(self):
if self.calendar_event_ids:
return False
return super()._should_invite_members_to_join_call()

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.exceptions import BadRequest
@ -12,7 +11,7 @@ class IrHttp(models.AbstractModel):
@classmethod
def _auth_method_calendar(cls):
token = request.get_http_params().get('token', '')
token = request.httprequest.args.get('token', '')
error_message = False

View file

@ -1,14 +1,38 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import pytz
from odoo import models, fields, tools, _
from odoo.tools import is_html_empty
from odoo.addons.mail.tools.discuss import Store
class MailActivity(models.Model):
_inherit = "mail.activity"
calendar_event_id = fields.Many2one('calendar.event', string="Calendar Meeting", ondelete='cascade')
calendar_event_id = fields.Many2one('calendar.event', string="Calendar Meeting", index='btree_not_null', ondelete='cascade')
def write(self, vals):
# synchronize calendar events
res = super().write(vals)
# protect against loops in case of ill-managed timezones
if 'date_deadline' in vals and not self.env.context.get('calendar_event_meeting_update') and self.calendar_event_id:
date_deadline = self[0].date_deadline # updated, hence all same value
# also protect against loops in case of ill-managed timezones
events = self.calendar_event_id.with_context(mail_activity_meeting_update=True)
user_tz = self.env.context.get('tz') or 'UTC'
for event in events:
# allday: just apply diff between dates
if event.allday and event.start_date != date_deadline:
event.start = event.start + (date_deadline - event.start_date)
# otherwise: we have to check if day did change, based on TZ
elif not event.allday:
# old start in user timezone
old_deadline_dt = pytz.utc.localize(event.start).astimezone(pytz.timezone(user_tz))
date_diff = date_deadline - old_deadline_dt.date()
event.start = event.start + date_diff
return res
def action_create_calendar_event(self):
self.ensure_one()
@ -17,32 +41,33 @@ class MailActivity(models.Model):
'default_activity_type_id': self.activity_type_id.id,
'default_res_id': self.env.context.get('default_res_id'),
'default_res_model': self.env.context.get('default_res_model'),
'default_name': self.summary or self.res_name,
'default_name': self.res_name,
'default_description': self.note if not is_html_empty(self.note) else '',
'default_activity_ids': [(6, 0, self.ids)],
'orig_activity_ids': self,
'default_partner_ids': self.user_id.partner_id.ids,
'default_user_id': self.user_id.id,
'initial_date': self.date_deadline,
'default_calendar_event_id': self.calendar_event_id.id,
'orig_activity_ids': self.ids,
'return_to_parent_breadcrumb': True,
}
return action
def _action_done(self, feedback=False, attachment_ids=False):
events = self.calendar_event_id
# To avoid the feedback to be included in the activity note (due to the synchronization in event.write
# that updates the related activity note each time the event description is updated),
# when the activity is written as a note in the chatter in _action_done (leading to duplicate feedback),
# we call super before updating the description. As self is deleted in super, we load the related events before.
messages, activities = super(MailActivity, self)._action_done(feedback=feedback, attachment_ids=attachment_ids)
# Add feedback to the internal event 'notes', which is not synchronized with the activity's 'note'
if feedback:
for event in events:
description = event.description
description = '%s<br />%s' % (
description if not tools.is_html_empty(description) else '',
_('Feedback: %(feedback)s', feedback=tools.plaintext2html(feedback)) if feedback else '',
)
event.write({'description': description})
return messages, activities
for event in self.calendar_event_id:
notes = event.notes if not tools.is_html_empty(event.notes) else ''
notes_feedback = _('Feedback: %s', tools.plaintext2html(feedback))
notes = f'{notes}<br />{notes_feedback}'
event.write({'notes': notes})
return super()._action_done(feedback=feedback, attachment_ids=attachment_ids)
def unlink_w_meeting(self):
events = self.mapped('calendar_event_id')
res = self.unlink()
events.unlink()
return res
def _to_store_defaults(self, target):
return super()._to_store_defaults(target) + [Store.One("calendar_event_id", [])]

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class MailActivityMixin(models.AbstractModel):
_inherit = 'mail.activity.mixin'
@ -15,4 +15,6 @@ class MailActivityMixin(models.AbstractModel):
"""This computes the calendar event of the next activity.
It evaluates to false if there is no such event."""
for record in self:
record.activity_calendar_event_id = fields.first(record.activity_ids).calendar_event_id
activities = record.activity_ids
activity = next(iter(activities), activities)
record.activity_calendar_event_id = activity.calendar_event_id

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields

View file

@ -1,12 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from datetime import datetime
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.tools import SQL
class Partner(models.Model):
class ResPartner(models.Model):
_inherit = 'res.partner'
meeting_count = fields.Integer("# Meetings", compute='_compute_meeting_count')
@ -23,20 +24,21 @@ class Partner(models.Model):
def _compute_meeting(self):
if self.ids:
all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
# prefetch 'parent_id'
all_partners = self.with_context(active_test=False).search_fetch(
[('id', 'child_of', self.ids)], ['parent_id'],
)
event_id = self.env['calendar.event']._search([]) # ir.rules will be applied
subquery_string, subquery_params = event_id.select()
subquery = self.env.cr.mogrify(subquery_string, subquery_params).decode()
self.env.cr.execute("""
query = self.env['calendar.event']._search([]) # ir.rules will be applied
meeting_data = self.env.execute_query(SQL("""
SELECT res_partner_id, calendar_event_id, count(1)
FROM calendar_event_res_partner_rel
WHERE res_partner_id IN %s AND calendar_event_id IN ({})
WHERE res_partner_id IN %s AND calendar_event_id IN %s
GROUP BY res_partner_id, calendar_event_id
""".format(subquery), [tuple(p["id"] for p in all_partners)])
meeting_data = self.env.cr.fetchall()
""",
all_partners._ids,
query.subselect(),
))
# Create a dict {partner_id: event_ids} and fill with events linked to the partner
meetings = {}
@ -44,20 +46,28 @@ class Partner(models.Model):
meetings.setdefault(p_id, set()).add(m_id)
# Add the events linked to the children of the partner
for meeting_pid in set(meetings):
partner = self.browse(meeting_pid)
while partner:
for p in self.browse(meetings.keys()):
partner = p
while partner.parent_id:
partner = partner.parent_id
if partner in self:
meetings[partner.id] = meetings.get(partner.id, set()) | meetings[meeting_pid]
meetings[partner.id] = meetings.get(partner.id, set()) | meetings[p.id]
return {p_id: list(meetings.get(p_id, set())) for p_id in self.ids}
return {}
def _compute_application_statistics_hook(self):
data_list = super()._compute_application_statistics_hook()
for partner in self.filtered('meeting_count'):
stat_info = {'iconClass': 'fa-calendar', 'value': partner.meeting_count, 'label': _('Meetings'), 'tagClass': 'o_tag_color_3'}
data_list[partner.id].append(stat_info)
return data_list
def get_attendee_detail(self, meeting_ids):
""" Return a list of dict of the given meetings with the attendees details
Used by:
- base_calendar.js : Many2ManyAttendee
- calendar_model.js (calendar.CalendarModel)
- many2many_attendee.js: Many2ManyAttendee
- calendar_model.js (calendar.CalendarModel)
"""
attendees_details = []
meetings = self.env['calendar.event'].browse(meeting_ids)
@ -92,3 +102,21 @@ class Partner(models.Model):
}
action['domain'] = ['|', ('id', 'in', self._compute_meeting()[self.id]), ('partner_ids', 'in', self.ids)]
return action
def _get_busy_calendar_events(self, start_datetime, end_datetime):
"""Get a mapping from partner id to attended events intersecting with the time interval.
:rtype: dict[int, <calendar.event>]
"""
events = self.env['calendar.event'].search([
('stop', '>=', start_datetime.replace(tzinfo=None)),
('start', '<=', end_datetime.replace(tzinfo=None)),
('partner_ids', 'in', self.ids),
('show_as', '=', 'busy'),
])
event_by_partner_id = defaultdict(lambda: self.env['calendar.event'])
for event in events:
for partner in event.partner_ids:
event_by_partner_id[partner.id] |= event
return dict(event_by_partner_id)

View file

@ -3,12 +3,102 @@
import datetime
from odoo import api, fields, models, modules, _
from odoo.exceptions import AccessError
from pytz import timezone, UTC
class Users(models.Model):
class ResUsers(models.Model):
_inherit = 'res.users'
calendar_default_privacy = fields.Selection(
[('public', 'Public by default'),
('private', 'Private by default'),
('confidential', 'Internal users only')],
compute="_compute_calendar_default_privacy",
inverse="_inverse_calendar_res_users_settings",
)
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + ['calendar_default_privacy']
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + ['calendar_default_privacy']
def get_selected_calendars_partner_ids(self, include_user=True):
"""
Retrieves the partner IDs of the attendees selected in the calendar view.
:param bool include_user: Determines whether to include the current user's partner ID in the results.
:return: A list of integer IDs representing the partners selected in the calendar view.
If 'include_user' is True, the list will also include the current user's partner ID.
:rtype: list
"""
self.ensure_one()
partner_ids = self.env['calendar.filters'].search([
('user_id', '=', self.id),
('partner_checked', '=', True)
]).partner_id.ids
if include_user:
partner_ids += [self.env.user.partner_id.id]
return partner_ids
@api.model
def _default_user_calendar_default_privacy(self):
""" Get the calendar default privacy from the Default User Template, set public as default. """
return self.env['ir.config_parameter'].sudo().get_param('calendar.default_privacy', 'public')
@api.model_create_multi
def create(self, vals_list):
""" Set the calendar default privacy as the same as Default User Template when defined. """
default_privacy = self._default_user_calendar_default_privacy()
# Update the dictionaries in vals_list with the calendar default privacy.
for vals_dict in vals_list:
if not vals_dict.get('calendar_default_privacy'):
vals_dict.update(calendar_default_privacy=default_privacy)
res = super().create(vals_list)
return res
def write(self, vals):
""" Forbid the calendar default privacy update from different users for keeping private events secured. """
privacy_update = 'calendar_default_privacy' in vals
if privacy_update and self != self.env.user:
raise AccessError(_("You are not allowed to change the calendar default privacy of another user due to privacy constraints."))
return super().write(vals)
@api.depends("res_users_settings_id.calendar_default_privacy")
def _compute_calendar_default_privacy(self):
"""
Compute the calendar default privacy of the users, pointing to its ResUsersSettings.
When any user doesn't have its setting from ResUsersSettings defined, fallback to Default User Template's.
"""
fallback_default_privacy = 'public'
# sudo: any user has access to other users calendar_default_privacy setting
if any(not user.sudo().res_users_settings_id.calendar_default_privacy for user in self):
fallback_default_privacy = self._default_user_calendar_default_privacy()
for user in self:
user.calendar_default_privacy = user.sudo().res_users_settings_id.calendar_default_privacy or fallback_default_privacy
def _inverse_calendar_res_users_settings(self):
"""
Updates the values of the calendar fields in 'res_users_settings_ids' to have the same values as their related
fields in 'res.users'. If there is no 'res.users.settings' record for the user, then the record is created.
"""
for user in self.filtered(lambda user: user._is_internal()):
settings = self.env["res.users.settings"].sudo()._find_or_create_for_user(user)
configuration = {field: user[field] for field in self._get_user_calendar_configuration_fields()}
settings.sudo().update(configuration)
@api.model
def _get_user_calendar_configuration_fields(self) -> list[str]:
""" Return the list of configurable fields for the user related to the res.users.settings table. """
return ['calendar_default_privacy']
def _systray_get_calendar_event_domain(self):
# Determine the domain for which the users should be notified. This method sends notification to
# events occurring between now and the end of the day. Note that "now" needs to be computed in the
@ -37,7 +127,7 @@ class Users(models.Model):
# | | <--- `stop_dt_utc` = `stop_dt` if user lives in an area of West longitude (positive shift compared to UTC, America for example)
# | |
start_dt_utc = start_dt = datetime.datetime.now(UTC)
stop_dt_utc = UTC.localize(datetime.datetime.combine(start_dt.date(), datetime.time.max))
stop_dt_utc = UTC.localize(datetime.datetime.combine(start_dt_utc.date(), datetime.time.max))
tz = self.env.user.tz
if tz:
@ -48,6 +138,11 @@ class Users(models.Model):
start_date = start_dt.date()
current_user_non_declined_attendee_ids = self.env['calendar.attendee']._search([
('partner_id', '=', self.env.user.partner_id.id),
('state', '!=', 'declined'),
])
return ['&', '|',
'&',
'|',
@ -57,17 +152,16 @@ class Users(models.Model):
'&',
['allday', '=', True],
['start_date', '=', fields.Date.to_string(start_date)],
('attendee_ids.partner_id', '=', self.env.user.partner_id.id)]
('attendee_ids', 'in', current_user_non_declined_attendee_ids)]
@api.model
def systray_get_activities(self):
res = super(Users, self).systray_get_activities()
meetings_lines = self.env['calendar.event'].search_read(
def _get_activity_groups(self):
res = super()._get_activity_groups()
EventModel = self.env['calendar.event']
meetings_lines = EventModel.search_read(
self._systray_get_calendar_event_domain(),
['id', 'start', 'name', 'allday', 'attendee_status'],
['id', 'start', 'name', 'allday'],
order='start')
meetings_lines = [line for line in meetings_lines if line['attendee_status'] != 'declined']
if meetings_lines:
meeting_label = _("Today's Meetings")
meetings_systray = {
@ -75,9 +169,25 @@ class Users(models.Model):
'type': 'meeting',
'name': meeting_label,
'model': 'calendar.event',
'icon': modules.module.get_module_icon(self.env['calendar.event']._original_module),
'icon': modules.module.get_module_icon(EventModel._original_module),
'domain': [('active', 'in', [True, False])],
'meetings': meetings_lines,
"view_type": EventModel._systray_view,
}
res.insert(0, meetings_systray)
return res
@api.model
def check_calendar_credentials(self):
return {}
def check_synchronization_status(self):
return {}
def _has_any_active_synchronization(self):
"""
Overridable method for checking if user has any synchronization active in inherited modules.
:return: boolean indicating if any synchronization is active.
"""
return False

View file

@ -0,0 +1,22 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResUsersSettings(models.Model):
_inherit = "res.users.settings"
# Calendar module settings.
calendar_default_privacy = fields.Selection(
[('public', 'Public'),
('private', 'Private'),
('confidential', 'Only internal users')],
'Calendar Default Privacy', default='public', required=True,
store=True, readonly=False, help="Default privacy setting for whom the calendar events will be visible."
)
@api.model
def _get_fields_blacklist(self):
""" Get list of calendar fields that won't be formatted in session_info. """
calendar_fields_blacklist = ['calendar_default_privacy']
return super()._get_fields_blacklist() + calendar_fields_blacklist

View file

@ -0,0 +1,11 @@
from odoo.tools.intervals import Intervals
def interval_from_events(event_ids):
"""Group events with contiguous and/or overlapping time slots.
Can be used to avoid doing time-related querries on long stretches of time with no relevant event.
:param <calendar.event> event_ids: Any recordset of events
:return Intervals|Iterable[tuple[datetime, datetime, <calendar.event>]]:
"""
return Intervals([(event.start, event.stop, event) for event in event_ids if event.start and event.stop])