mirror of
https://github.com/bringout/oca-ocb-technical.git
synced 2026-04-20 10:31:59 +02:00
Initial commit: Technical packages
This commit is contained in:
commit
3473fa71a0
873 changed files with 297766 additions and 0 deletions
16
odoo-bringout-oca-ocb-calendar/calendar/models/__init__.py
Normal file
16
odoo-bringout-oca-ocb-calendar/calendar/models/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import ir_http
|
||||
from . import res_partner
|
||||
from . import calendar_event
|
||||
from . import calendar_alarm
|
||||
from . import calendar_alarm_manager
|
||||
from . import calendar_attendee
|
||||
from . import calendar_filter
|
||||
from . import calendar_event_type
|
||||
from . import calendar_recurrence
|
||||
from . import mail_activity
|
||||
from . import mail_activity_mixin
|
||||
from . import mail_activity_type
|
||||
from . import res_users
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,64 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class Alarm(models.Model):
|
||||
_name = 'calendar.alarm'
|
||||
_description = 'Event Alarm'
|
||||
|
||||
_interval_selection = {'minutes': 'Minutes', 'hours': 'Hours', 'days': 'Days'}
|
||||
|
||||
name = fields.Char('Name', translate=True, required=True)
|
||||
alarm_type = fields.Selection(
|
||||
[('notification', 'Notification'), ('email', 'Email')],
|
||||
string='Type', required=True, default='email')
|
||||
duration = fields.Integer('Remind Before', required=True, default=1)
|
||||
interval = fields.Selection(
|
||||
list(_interval_selection.items()), 'Unit', required=True, default='hours')
|
||||
duration_minutes = fields.Integer(
|
||||
'Duration in minutes', store=True,
|
||||
search='_search_duration_minutes', compute='_compute_duration_minutes')
|
||||
mail_template_id = fields.Many2one(
|
||||
'mail.template', string="Email Template",
|
||||
domain=[('model', 'in', ['calendar.attendee'])],
|
||||
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")
|
||||
|
||||
@api.depends('interval', 'duration')
|
||||
def _compute_duration_minutes(self):
|
||||
for alarm in self:
|
||||
if alarm.interval == "minutes":
|
||||
alarm.duration_minutes = alarm.duration
|
||||
elif alarm.interval == "hours":
|
||||
alarm.duration_minutes = alarm.duration * 60
|
||||
elif alarm.interval == "days":
|
||||
alarm.duration_minutes = alarm.duration * 60 * 24
|
||||
else:
|
||||
alarm.duration_minutes = 0
|
||||
|
||||
@api.depends('alarm_type', 'mail_template_id')
|
||||
def _compute_mail_template_id(self):
|
||||
for alarm in self:
|
||||
if alarm.alarm_type == 'email' and not alarm.mail_template_id:
|
||||
alarm.mail_template_id = self.env['ir.model.data']._xmlid_to_res_id('calendar.calendar_template_meeting_reminder')
|
||||
elif alarm.alarm_type != 'email' or not alarm.mail_template_id:
|
||||
alarm.mail_template_id = False
|
||||
|
||||
def _search_duration_minutes(self, operator, value):
|
||||
return [
|
||||
'|', '|',
|
||||
'&', ('interval', '=', 'minutes'), ('duration', operator, value),
|
||||
'&', ('interval', '=', 'hours'), ('duration', operator, value / 60),
|
||||
'&', ('interval', '=', 'days'), ('duration', operator, value / 60 / 24),
|
||||
]
|
||||
|
||||
@api.onchange('duration', 'interval', 'alarm_type')
|
||||
def _onchange_duration_interval(self):
|
||||
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]
|
||||
self.name = "%s - %s %s" % (display_alarm_type, self.duration, display_interval)
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
# -*- 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__)
|
||||
|
||||
|
||||
class AlarmManager(models.AbstractModel):
|
||||
_name = 'calendar.alarm_manager'
|
||||
_description = 'Event Alarm Manager'
|
||||
|
||||
def _get_next_potential_limit_alarm(self, alarm_type, seconds=None, partners=None):
|
||||
# flush models before making queries
|
||||
for model_name in ('calendar.alarm', 'calendar.event', 'calendar.recurrence'):
|
||||
self.env[model_name].flush_model()
|
||||
|
||||
result = {}
|
||||
delta_request = """
|
||||
SELECT
|
||||
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
|
||||
WHERE alarm.alarm_type = %s
|
||||
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
|
||||
"""
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
# Add filter on alarm type
|
||||
tuple_params = (alarm_type,)
|
||||
|
||||
# Add filter on partner_id
|
||||
if partners:
|
||||
base_request += filter_user
|
||||
tuple_params += (tuple(partners.ids), )
|
||||
|
||||
# Upper bound on first_alarm of requested events
|
||||
first_alarm_max_value = ""
|
||||
if seconds is None:
|
||||
# first alarm in the future + 3 minutes if there is one, now otherwise
|
||||
first_alarm_max_value = """
|
||||
COALESCE((SELECT MIN(cal.start - interval '1' minute * calcul_delta.max_delta)
|
||||
FROM calendar_event cal
|
||||
RIGHT JOIN calcul_delta ON calcul_delta.calendar_event_id = cal.id
|
||||
WHERE cal.start - interval '1' minute * calcul_delta.max_delta > now() at time zone 'utc'
|
||||
) + interval '3' minute, now() at time zone 'utc')"""
|
||||
else:
|
||||
# now + given seconds
|
||||
first_alarm_max_value = "(now() at time zone 'utc' + interval '%s' second )"
|
||||
tuple_params += (seconds,)
|
||||
|
||||
self.env.flush_all()
|
||||
self._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')
|
||||
""" % (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():
|
||||
result[event_id] = {
|
||||
'event_id': event_id,
|
||||
'first_alarm': first_alarm,
|
||||
'last_alarm': last_alarm,
|
||||
'first_meeting': first_meeting,
|
||||
'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)
|
||||
}
|
||||
return result
|
||||
|
||||
def do_check_alarm_for_one_date(self, one_date, event, event_maxdelta, in_the_next_X_seconds, alarm_type, after=False, missing=False):
|
||||
""" Search for some alarms in the interval of time determined by some parameters (after, in_the_next_X_seconds, ...)
|
||||
:param one_date: date of the event to check (not the same that in the event browse if recurrent)
|
||||
:param event: Event browse record
|
||||
:param event_maxdelta: biggest duration from alarms for this event
|
||||
:param in_the_next_X_seconds: looking in the future (in seconds)
|
||||
:param after: if not False: will return alert if after this date (date as string - todo: change in master)
|
||||
:param missing: if not False: will return alert even if we are too late
|
||||
:param notif: Looking for type notification
|
||||
:param mail: looking for type email
|
||||
"""
|
||||
result = []
|
||||
# TODO: remove event_maxdelta and if using it
|
||||
past = one_date - timedelta(minutes=(missing * event_maxdelta))
|
||||
future = fields.Datetime.now() + timedelta(seconds=in_the_next_X_seconds)
|
||||
if future <= past:
|
||||
return result
|
||||
for alarm in event.alarm_ids:
|
||||
if alarm.alarm_type != alarm_type:
|
||||
continue
|
||||
past = one_date - timedelta(minutes=(missing * alarm.duration_minutes))
|
||||
if future <= past:
|
||||
continue
|
||||
if after and past <= fields.Datetime.from_string(after):
|
||||
continue
|
||||
result.append({
|
||||
'alarm_id': alarm.id,
|
||||
'event_id': event.id,
|
||||
'notify_at': one_date - timedelta(minutes=alarm.duration_minutes),
|
||||
})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _get_notify_alert_extra_conditions(self):
|
||||
"""
|
||||
To be overriden on inherited modules
|
||||
adding extra conditions to extract only the unsynced events
|
||||
"""
|
||||
return ""
|
||||
|
||||
def _get_events_by_alarm_to_notify(self, alarm_type):
|
||||
"""
|
||||
Get the events with an alarm of the given type between the cron
|
||||
last call and now.
|
||||
|
||||
Please note that all new reminders created since the cron last
|
||||
call with an alarm prior to the cron last call are skipped by
|
||||
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])
|
||||
|
||||
events_by_alarm = {}
|
||||
for alarm_id, event_id in self.env.cr.fetchall():
|
||||
events_by_alarm.setdefault(alarm_id, list()).append(event_id)
|
||||
return events_by_alarm
|
||||
|
||||
@api.model
|
||||
def _send_reminder(self):
|
||||
# Executed via cron
|
||||
events_by_alarm = self._get_events_by_alarm_to_notify('email')
|
||||
if not events_by_alarm:
|
||||
return
|
||||
|
||||
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')
|
||||
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.mail_template_id,
|
||||
force_send=True
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_next_notif(self):
|
||||
partner = self.env.user.partner_id
|
||||
all_notif = []
|
||||
|
||||
if not partner:
|
||||
return []
|
||||
|
||||
all_meetings = self._get_next_potential_limit_alarm('notification', partners=partner)
|
||||
time_limit = 3600 * 24 # return alarms of the next 24 hours
|
||||
for event_id in all_meetings:
|
||||
max_delta = all_meetings[event_id]['max_duration']
|
||||
meeting = self.env['calendar.event'].browse(event_id)
|
||||
in_date_format = fields.Datetime.from_string(meeting.start)
|
||||
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, time_limit, 'notification', after=partner.calendar_last_notif_ack)
|
||||
if last_found:
|
||||
for alert in last_found:
|
||||
all_notif.append(self.do_notif_reminder(alert))
|
||||
return all_notif
|
||||
|
||||
def do_notif_reminder(self, alert):
|
||||
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
|
||||
meeting = self.env['calendar.event'].browse(alert['event_id'])
|
||||
|
||||
if alarm.alarm_type == 'notification':
|
||||
message = meeting.display_time
|
||||
if alarm.body:
|
||||
message += '<p>%s</p>' % plaintext2html(alarm.body)
|
||||
|
||||
delta = alert['notify_at'] - fields.Datetime.now()
|
||||
delta = delta.seconds + delta.days * 3600 * 24
|
||||
|
||||
return {
|
||||
'alarm_id': alarm.id,
|
||||
'event_id': meeting.id,
|
||||
'title': meeting.name,
|
||||
'message': message,
|
||||
'timer': delta,
|
||||
'notify_at': fields.Datetime.to_string(alert['notify_at']),
|
||||
}
|
||||
|
||||
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),
|
||||
])
|
||||
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)
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
# -*- 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.addons.base.models.res_partner import _tz_get
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Attendee(models.Model):
|
||||
""" Calendar Attendee Information """
|
||||
_name = 'calendar.attendee'
|
||||
_rec_name = 'common_name'
|
||||
_description = 'Calendar Attendee Information'
|
||||
_order = 'create_date ASC'
|
||||
|
||||
def _default_access_token(self):
|
||||
return uuid.uuid4().hex
|
||||
|
||||
STATE_SELECTION = [
|
||||
('needsAction', 'Needs Action'),
|
||||
('tentative', 'Uncertain'),
|
||||
('declined', 'Declined'),
|
||||
('accepted', 'Accepted'),
|
||||
]
|
||||
|
||||
# event
|
||||
event_id = fields.Many2one('calendar.event', 'Meeting linked', required=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)
|
||||
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')
|
||||
availability = fields.Selection(
|
||||
[('free', 'Available'), ('busy', 'Busy')], 'Available/Busy', readonly=True)
|
||||
|
||||
@api.depends('partner_id', 'partner_id.name', 'email')
|
||||
def _compute_common_name(self):
|
||||
for attendee in self:
|
||||
attendee.common_name = attendee.partner_id.name or attendee.email
|
||||
|
||||
def _compute_mail_tz(self):
|
||||
for attendee in self:
|
||||
attendee.mail_tz = attendee.partner_id.tz
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for values in vals_list:
|
||||
# by default, if no state is given for the attendee corresponding to the current user
|
||||
# that means he's the event organizer so we can set his state to "accepted"
|
||||
if 'state' not in values and values.get('partner_id') == self.env.user.partner_id.id:
|
||||
values['state'] = 'accepted'
|
||||
if not values.get("email") and values.get("common_name"):
|
||||
common_nameval = values.get("common_name").split(':')
|
||||
email = [x for x in common_nameval if '@' in x]
|
||||
values['email'] = email[0] if email else ''
|
||||
values['common_name'] = values.get("common_name")
|
||||
attendees = super().create(vals_list)
|
||||
attendees._subscribe_partner()
|
||||
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)
|
||||
"""
|
||||
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"):
|
||||
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()
|
||||
|
||||
for attendee in self:
|
||||
if attendee.email and attendee._should_notify_attendee():
|
||||
event_id = attendee.event_id.id
|
||||
ics_file = ics_files.get(event_id)
|
||||
|
||||
attachment_values = [Command.set(mail_template.attachment_ids.ids)]
|
||||
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)})
|
||||
]
|
||||
body = mail_template._render_field(
|
||||
'body_html',
|
||||
attendee.ids,
|
||||
compute_lang=True,
|
||||
post_process=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,
|
||||
author_id=attendee.event_id.user_id.partner_id.id or self.env.user.partner_id.id,
|
||||
body=body,
|
||||
subject=subject,
|
||||
partner_ids=attendee.partner_id.ids,
|
||||
email_layout_xmlid='mail.mail_notification_light',
|
||||
attachment_ids=attachment_values,
|
||||
force_send=force_send,
|
||||
)
|
||||
|
||||
def _should_notify_attendee(self):
|
||||
""" 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.
|
||||
(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
|
||||
|
||||
def do_tentative(self):
|
||||
""" Makes event invitation as Tentative. """
|
||||
return self.write({'state': 'tentative'})
|
||||
|
||||
def do_accept(self):
|
||||
""" Marks event invitation as Accepted. """
|
||||
for attendee in self:
|
||||
attendee.event_id.message_post(
|
||||
author_id=attendee.partner_id.id,
|
||||
body=_("%s has accepted the invitation") % (attendee.common_name),
|
||||
subtype_xmlid="calendar.subtype_invitation",
|
||||
)
|
||||
return self.write({'state': 'accepted'})
|
||||
|
||||
def do_decline(self):
|
||||
""" Marks event invitation as Declined. """
|
||||
for attendee in self:
|
||||
attendee.event_id.message_post(
|
||||
author_id=attendee.partner_id.id,
|
||||
body=_("%s has declined the invitation") % (attendee.common_name),
|
||||
subtype_xmlid="calendar.subtype_invitation",
|
||||
)
|
||||
return self.write({'state': 'declined'})
|
||||
1449
odoo-bringout-oca-ocb-calendar/calendar/models/calendar_event.py
Normal file
1449
odoo-bringout-oca-ocb-calendar/calendar/models/calendar_event.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,22 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from random import randint
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class MeetingType(models.Model):
|
||||
|
||||
_name = 'calendar.event.type'
|
||||
_description = 'Event Meeting Type'
|
||||
|
||||
def _default_color(self):
|
||||
return randint(1, 11)
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
color = fields.Integer('Color', default=_default_color)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq', 'unique (name)', "Tag name already exists !"),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# -*- 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):
|
||||
_name = 'calendar.filters'
|
||||
_description = 'Calendar Filters'
|
||||
|
||||
user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user, index=True)
|
||||
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.')
|
||||
]
|
||||
|
||||
@api.model
|
||||
def unlink_from_partner_id(self, partner_id):
|
||||
return self.search([('partner_id', '=', partner_id)]).unlink()
|
||||
|
|
@ -0,0 +1,576 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, time
|
||||
import pytz
|
||||
import re
|
||||
|
||||
from dateutil import rrule
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from odoo.addons.base.models.res_partner import _tz_get
|
||||
|
||||
|
||||
MAX_RECURRENT_EVENT = 720
|
||||
|
||||
SELECT_FREQ_TO_RRULE = {
|
||||
'daily': rrule.DAILY,
|
||||
'weekly': rrule.WEEKLY,
|
||||
'monthly': rrule.MONTHLY,
|
||||
'yearly': rrule.YEARLY,
|
||||
}
|
||||
|
||||
RRULE_FREQ_TO_SELECT = {
|
||||
rrule.DAILY: 'daily',
|
||||
rrule.WEEKLY: 'weekly',
|
||||
rrule.MONTHLY: 'monthly',
|
||||
rrule.YEARLY: 'yearly',
|
||||
}
|
||||
|
||||
RRULE_WEEKDAY_TO_FIELD = {
|
||||
rrule.MO.weekday: 'mon',
|
||||
rrule.TU.weekday: 'tue',
|
||||
rrule.WE.weekday: 'wed',
|
||||
rrule.TH.weekday: 'thu',
|
||||
rrule.FR.weekday: 'fri',
|
||||
rrule.SA.weekday: 'sat',
|
||||
rrule.SU.weekday: 'sun',
|
||||
}
|
||||
|
||||
RRULE_WEEKDAYS = {'SUN': 'SU', 'MON': 'MO', 'TUE': 'TU', 'WED': 'WE', 'THU': 'TH', 'FRI': 'FR', 'SAT': 'SA'}
|
||||
|
||||
RRULE_TYPE_SELECTION = [
|
||||
('daily', 'Days'),
|
||||
('weekly', 'Weeks'),
|
||||
('monthly', 'Months'),
|
||||
('yearly', 'Years'),
|
||||
]
|
||||
|
||||
END_TYPE_SELECTION = [
|
||||
('count', 'Number of repetitions'),
|
||||
('end_date', 'End date'),
|
||||
('forever', 'Forever'),
|
||||
]
|
||||
|
||||
MONTH_BY_SELECTION = [
|
||||
('date', 'Date of month'),
|
||||
('day', 'Day of month'),
|
||||
]
|
||||
|
||||
WEEKDAY_SELECTION = [
|
||||
('MON', 'Monday'),
|
||||
('TUE', 'Tuesday'),
|
||||
('WED', 'Wednesday'),
|
||||
('THU', 'Thursday'),
|
||||
('FRI', 'Friday'),
|
||||
('SAT', 'Saturday'),
|
||||
('SUN', 'Sunday'),
|
||||
]
|
||||
|
||||
BYDAY_SELECTION = [
|
||||
('1', 'First'),
|
||||
('2', 'Second'),
|
||||
('3', 'Third'),
|
||||
('4', 'Fourth'),
|
||||
('-1', 'Last'),
|
||||
]
|
||||
|
||||
def freq_to_select(rrule_freq):
|
||||
return RRULE_FREQ_TO_SELECT[rrule_freq]
|
||||
|
||||
|
||||
def freq_to_rrule(freq):
|
||||
return SELECT_FREQ_TO_RRULE[freq]
|
||||
|
||||
|
||||
def weekday_to_field(weekday_index):
|
||||
return RRULE_WEEKDAY_TO_FIELD.get(weekday_index)
|
||||
|
||||
|
||||
class RecurrenceRule(models.Model):
|
||||
_name = 'calendar.recurrence'
|
||||
_description = 'Event Recurrence Rule'
|
||||
|
||||
name = fields.Char(compute='_compute_name', store=True)
|
||||
base_event_id = fields.Many2one(
|
||||
'calendar.event', ondelete='set null', copy=False) # store=False ?
|
||||
calendar_event_ids = fields.One2many('calendar.event', 'recurrence_id')
|
||||
event_tz = fields.Selection(
|
||||
_tz_get, string='Timezone',
|
||||
default=lambda self: self.env.context.get('tz') or self.env.user.tz)
|
||||
rrule = fields.Char(compute='_compute_rrule', inverse='_inverse_rrule', store=True)
|
||||
dtstart = fields.Datetime(compute='_compute_dtstart')
|
||||
rrule_type = fields.Selection(RRULE_TYPE_SELECTION, default='weekly')
|
||||
end_type = fields.Selection(END_TYPE_SELECTION, default='count')
|
||||
interval = fields.Integer(default=1)
|
||||
count = fields.Integer(default=1)
|
||||
mon = fields.Boolean()
|
||||
tue = fields.Boolean()
|
||||
wed = fields.Boolean()
|
||||
thu = fields.Boolean()
|
||||
fri = fields.Boolean()
|
||||
sat = fields.Boolean()
|
||||
sun = fields.Boolean()
|
||||
month_by = fields.Selection(MONTH_BY_SELECTION, default='date')
|
||||
day = fields.Integer(default=1)
|
||||
weekday = fields.Selection(WEEKDAY_SELECTION, string='Weekday')
|
||||
byday = fields.Selection(BYDAY_SELECTION, string='By day')
|
||||
until = fields.Date('Repeat Until')
|
||||
|
||||
_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"),
|
||||
]
|
||||
|
||||
def _get_daily_recurrence_name(self):
|
||||
if self.end_type == 'count':
|
||||
return _("Every %(interval)s Days for %(count)s events", interval=self.interval, count=self.count)
|
||||
if self.end_type == 'end_date':
|
||||
return _("Every %(interval)s Days until %(until)s", interval=self.interval, until=self.until)
|
||||
return _("Every %(interval)s Days", interval=self.interval)
|
||||
|
||||
def _get_weekly_recurrence_name(self):
|
||||
weekday_selection = dict(self._fields['weekday']._description_selection(self.env))
|
||||
weekdays = self._get_week_days()
|
||||
# Convert Weekday object
|
||||
weekdays = [str(w) for w in weekdays]
|
||||
# We need to get the day full name from its three first letters.
|
||||
week_map = {v: k for k, v in RRULE_WEEKDAYS.items()}
|
||||
weekday_short = [week_map[w] for w in weekdays]
|
||||
day_strings = [weekday_selection[day] for day in weekday_short]
|
||||
days = ", ".join(day_strings)
|
||||
|
||||
if self.end_type == 'count':
|
||||
return _("Every %(interval)s Weeks on %(days)s for %(count)s events", interval=self.interval, days=days, count=self.count)
|
||||
if self.end_type == 'end_date':
|
||||
return _("Every %(interval)s Weeks on %(days)s until %(until)s", interval=self.interval, days=days, until=self.until)
|
||||
return _("Every %(interval)s Weeks on %(days)s", interval=self.interval, days=days)
|
||||
|
||||
def _get_monthly_recurrence_name(self):
|
||||
if self.month_by == 'day':
|
||||
weekday_selection = dict(self._fields['weekday']._description_selection(self.env))
|
||||
byday_selection = dict(self._fields['byday']._description_selection(self.env))
|
||||
position_label = byday_selection[self.byday]
|
||||
weekday_label = weekday_selection[self.weekday]
|
||||
|
||||
if self.end_type == 'count':
|
||||
return _("Every %(interval)s Months on the %(position)s %(weekday)s for %(count)s events", interval=self.interval, position=position_label, weekday=weekday_label, count=self.count)
|
||||
if self.end_type == 'end_date':
|
||||
return _("Every %(interval)s Months on the %(position)s %(weekday)s until %(until)s", interval=self.interval, position=position_label, weekday=weekday_label, until=self.until)
|
||||
return _("Every %(interval)s Months on the %(position)s %(weekday)s", interval=self.interval, position=position_label, weekday=weekday_label)
|
||||
else:
|
||||
if self.end_type == 'count':
|
||||
return _("Every %(interval)s Months day %(day)s for %(count)s events", interval=self.interval, day=self.day, count=self.count)
|
||||
if self.end_type == 'end_date':
|
||||
return _("Every %(interval)s Months day %(day)s until %(until)s", interval=self.interval, day=self.day, until=self.until)
|
||||
return _("Every %(interval)s Months day %(day)s", interval=self.interval, day=self.day)
|
||||
|
||||
def _get_yearly_recurrence_name(self):
|
||||
if self.end_type == 'count':
|
||||
return _("Every %(interval)s Years for %(count)s events", interval=self.interval, count=self.count)
|
||||
if self.end_type == 'end_date':
|
||||
return _("Every %(interval)s Years until %(until)s", interval=self.interval, until=self.until)
|
||||
return _("Every %(interval)s Years", interval=self.interval)
|
||||
|
||||
def get_recurrence_name(self):
|
||||
if self.rrule_type == 'daily':
|
||||
return self._get_daily_recurrence_name()
|
||||
if self.rrule_type == 'weekly':
|
||||
return self._get_weekly_recurrence_name()
|
||||
if self.rrule_type == 'monthly':
|
||||
return self._get_monthly_recurrence_name()
|
||||
if self.rrule_type == 'yearly':
|
||||
return self._get_yearly_recurrence_name()
|
||||
|
||||
@api.depends('rrule')
|
||||
def _compute_name(self):
|
||||
for recurrence in self:
|
||||
recurrence.name = recurrence.get_recurrence_name()
|
||||
|
||||
@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
|
||||
}
|
||||
for recurrence in self:
|
||||
recurrence.dtstart = start_mapping.get(recurrence.id)
|
||||
|
||||
@api.depends(
|
||||
'byday', 'until', 'rrule_type', 'month_by', 'interval', 'count', 'end_type',
|
||||
'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun', 'day', 'weekday')
|
||||
def _compute_rrule(self):
|
||||
for recurrence in self:
|
||||
current_rule = recurrence._rrule_serialize()
|
||||
if recurrence.rrule != current_rule:
|
||||
recurrence.write({'rrule': current_rule})
|
||||
|
||||
def _inverse_rrule(self):
|
||||
for recurrence in self:
|
||||
if recurrence.rrule:
|
||||
values = self._rrule_parse(recurrence.rrule, recurrence.dtstart)
|
||||
recurrence.with_context(dont_notify=True).write(values)
|
||||
|
||||
def _reconcile_events(self, ranges):
|
||||
"""
|
||||
:param ranges: iterable of tuples (datetime_start, datetime_stop)
|
||||
:return: tuple (events of the recurrence already in sync with ranges,
|
||||
and ranges not covered by any events)
|
||||
"""
|
||||
ranges = set(ranges)
|
||||
|
||||
synced_events = self.calendar_event_ids.filtered(lambda e: e._range() in ranges)
|
||||
|
||||
existing_ranges = set(event._range() for event in synced_events)
|
||||
ranges_to_create = (event_range for event_range in ranges if event_range not in existing_ranges)
|
||||
return synced_events, ranges_to_create
|
||||
|
||||
def _select_new_base_event(self):
|
||||
"""
|
||||
when the base event is no more available (archived, deleted, etc.), a new one should be selected
|
||||
"""
|
||||
for recurrence in self:
|
||||
recurrence.base_event_id = recurrence._get_first_event()
|
||||
|
||||
def _apply_recurrence(self, specific_values_creation=None, no_send_edit=False, generic_values_creation=None):
|
||||
"""Create missing events in the recurrence and detach events which no longer
|
||||
follow the recurrence rules.
|
||||
:return: detached events
|
||||
"""
|
||||
event_vals = []
|
||||
keep = self.env['calendar.event']
|
||||
if specific_values_creation is None:
|
||||
specific_values_creation = {}
|
||||
|
||||
for recurrence in self.filtered('base_event_id'):
|
||||
recurrence.calendar_event_ids |= recurrence.base_event_id
|
||||
event = recurrence.base_event_id or recurrence._get_first_event(include_outliers=False)
|
||||
duration = event.stop - event.start
|
||||
if specific_values_creation:
|
||||
ranges = set([(x[1], x[2]) for x in specific_values_creation if x[0] == recurrence.id])
|
||||
else:
|
||||
ranges = recurrence._range_calculation(event, duration)
|
||||
|
||||
events_to_keep, ranges = recurrence._reconcile_events(ranges)
|
||||
keep |= events_to_keep
|
||||
[base_values] = event.copy_data()
|
||||
values = []
|
||||
for start, stop in ranges:
|
||||
value = dict(base_values, start=start, stop=stop, recurrence_id=recurrence.id, follow_recurrence=True)
|
||||
if (recurrence.id, start, stop) in specific_values_creation:
|
||||
value.update(specific_values_creation[(recurrence.id, start, stop)])
|
||||
if generic_values_creation and recurrence.id in generic_values_creation:
|
||||
value.update(generic_values_creation[recurrence.id])
|
||||
values += [value]
|
||||
event_vals += values
|
||||
|
||||
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)
|
||||
return detached_events
|
||||
|
||||
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.
|
||||
:param event: starting point of the new recurrence
|
||||
:param recurrence_values: values applied to the new recurrence
|
||||
:return: new recurrence
|
||||
"""
|
||||
if recurrence_values is None:
|
||||
recurrence_values = {}
|
||||
event.ensure_one()
|
||||
if not self:
|
||||
return
|
||||
[values] = self.copy_data()
|
||||
detached_events = self._stop_at(event)
|
||||
|
||||
count = recurrence_values.get('count', 0) or len(detached_events)
|
||||
return self.create({
|
||||
**values,
|
||||
**recurrence_values,
|
||||
'base_event_id': event.id,
|
||||
'calendar_event_ids': [(6, 0, detached_events.ids)],
|
||||
'count': max(count, 1),
|
||||
})
|
||||
|
||||
def _stop_at(self, event):
|
||||
"""Stops the recurrence at the given event. Detach the event and all following
|
||||
events from the recurrence.
|
||||
|
||||
:return: detached events from the recurrence
|
||||
"""
|
||||
self.ensure_one()
|
||||
events = self._get_events_from(event.start)
|
||||
detached_events = self._detach_events(events)
|
||||
if not self.calendar_event_ids:
|
||||
self.with_context(archive_on_error=True).unlink()
|
||||
return detached_events
|
||||
|
||||
if event.allday:
|
||||
until = self._get_start_of_period(event.start_date)
|
||||
else:
|
||||
until_datetime = self._get_start_of_period(event.start)
|
||||
until_timezoned = pytz.utc.localize(until_datetime).astimezone(self._get_timezone())
|
||||
until = until_timezoned.date()
|
||||
self.write({
|
||||
'end_type': 'end_date',
|
||||
'until': until - relativedelta(days=1),
|
||||
})
|
||||
return detached_events
|
||||
|
||||
@api.model
|
||||
def _detach_events(self, events):
|
||||
events.with_context(dont_notify=True).write({
|
||||
'recurrence_id': False,
|
||||
'recurrency': False,
|
||||
})
|
||||
return events
|
||||
|
||||
def _write_events(self, values, dtstart=None):
|
||||
"""
|
||||
Write values on events in the recurrence.
|
||||
:param values: event values
|
||||
:param dstart: if provided, only write events starting from this point in time
|
||||
"""
|
||||
events = self._get_events_from(dtstart) if dtstart else self.calendar_event_ids
|
||||
return events.with_context(no_mail_to_attendees=True, dont_notify=True).write(dict(values, recurrence_update='self_only'))
|
||||
|
||||
def _rrule_serialize(self):
|
||||
"""
|
||||
Compute rule string according to value type RECUR of iCalendar
|
||||
:return: string containing recurring rule (empty if no rule)
|
||||
"""
|
||||
if self.interval <= 0:
|
||||
raise UserError(_('The interval cannot be negative.'))
|
||||
if self.end_type == 'count' and self.count <= 0:
|
||||
raise UserError(_('The number of repetitions cannot be negative.'))
|
||||
|
||||
return str(self._get_rrule()) if self.rrule_type else ''
|
||||
|
||||
@api.model
|
||||
def _rrule_parse(self, rule_str, date_start):
|
||||
# LUL TODO clean this mess
|
||||
data = {}
|
||||
day_list = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
|
||||
# Skip X-named RRULE extensions
|
||||
# TODO Remove patch when dateutils contains the fix
|
||||
# HACK https://github.com/dateutil/dateutil/pull/1374
|
||||
# Optional parameters starts with X- and they can be placed anywhere in the RRULE string.
|
||||
# RRULE:FREQ=MONTHLY;INTERVAL=3;X-RELATIVE=1
|
||||
# RRULE;X-EVOLUTION-ENDDATE=20200120:FREQ=WEEKLY;COUNT=3;BYDAY=MO
|
||||
# X-EVOLUTION-ENDDATE=20200120:FREQ=WEEKLY;COUNT=3;BYDAY=MO
|
||||
rule_str = re.sub(r';?X-[-\w]+=[^;:]*', '', rule_str).replace(":;", ":").lstrip(":;")
|
||||
|
||||
if 'Z' in rule_str and date_start and not date_start.tzinfo:
|
||||
date_start = pytz.utc.localize(date_start)
|
||||
rule = rrule.rrulestr(rule_str, dtstart=date_start)
|
||||
|
||||
data['rrule_type'] = freq_to_select(rule._freq)
|
||||
data['count'] = rule._count
|
||||
data['interval'] = rule._interval
|
||||
data['until'] = rule._until
|
||||
# Repeat weekly
|
||||
if rule._byweekday:
|
||||
for weekday in day_list:
|
||||
data[weekday] = False # reset
|
||||
for weekday_index in rule._byweekday:
|
||||
weekday = rrule.weekday(weekday_index)
|
||||
data[weekday_to_field(weekday.weekday)] = True
|
||||
data['rrule_type'] = 'weekly'
|
||||
|
||||
# Repeat monthly by nweekday ((weekday, weeknumber), )
|
||||
if rule._bynweekday:
|
||||
data['weekday'] = day_list[list(rule._bynweekday)[0][0]].upper()
|
||||
data['byday'] = str(list(rule._bynweekday)[0][1])
|
||||
data['month_by'] = 'day'
|
||||
data['rrule_type'] = 'monthly'
|
||||
|
||||
if rule._bymonthday:
|
||||
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'
|
||||
elif data.get('count'):
|
||||
data['end_type'] = 'count'
|
||||
else:
|
||||
data['end_type'] = 'forever'
|
||||
return data
|
||||
|
||||
def _get_lang_week_start(self):
|
||||
lang = self.env['res.lang']._lang_get(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
|
||||
|
||||
def _get_start_of_period(self, dt):
|
||||
if self.rrule_type == 'weekly':
|
||||
week_start = self._get_lang_week_start()
|
||||
start = dt + relativedelta(weekday=week_start(-1))
|
||||
elif self.rrule_type == 'monthly':
|
||||
start = dt + relativedelta(day=1)
|
||||
else:
|
||||
start = dt
|
||||
# Comparaison of DST (to manage the case of going too far back in time).
|
||||
# If we detect a change in the DST between the creation date of an event
|
||||
# and the date used for the occurrence period, we use the creation date of the event.
|
||||
# This is a hack to avoid duplication of events (for example on google calendar).
|
||||
if isinstance(dt, datetime):
|
||||
timezone = self._get_timezone()
|
||||
dst_dt = timezone.localize(dt).dst()
|
||||
dst_start = timezone.localize(start).dst()
|
||||
if dst_dt != dst_start:
|
||||
start = dt
|
||||
return start
|
||||
|
||||
def _get_first_event(self, include_outliers=False):
|
||||
if not self.calendar_event_ids:
|
||||
return self.env['calendar.event']
|
||||
events = self.calendar_event_ids.sorted('start')
|
||||
if not include_outliers:
|
||||
events -= self._get_outliers()
|
||||
return events[:1]
|
||||
|
||||
def _get_outliers(self):
|
||||
synced_events = self.env['calendar.event']
|
||||
for recurrence in self:
|
||||
if recurrence.calendar_event_ids:
|
||||
start = min(recurrence.calendar_event_ids.mapped('start'))
|
||||
starts = set(recurrence._get_occurrences(start))
|
||||
synced_events |= recurrence.calendar_event_ids.filtered(lambda e: e.start in starts)
|
||||
return self.calendar_event_ids - synced_events
|
||||
|
||||
def _range_calculation(self, event, duration):
|
||||
""" Calculate the range of recurrence when applying the recurrence
|
||||
The following issues are taken into account:
|
||||
start of period is sometimes in the past (weekly or monthly rule).
|
||||
We can easily filter these range values but then the count value may be wrong...
|
||||
In that case, we just increase the count value, recompute the ranges and dismiss the useless values
|
||||
"""
|
||||
self.ensure_one()
|
||||
original_count = self.end_type == 'count' and self.count
|
||||
ranges = set(self._get_ranges(event.start, duration))
|
||||
future_events = set((x, y) for x, y in ranges if x.date() >= event.start.date() and y.date() >= event.start.date())
|
||||
if original_count and len(future_events) < original_count:
|
||||
# Rise count number because some past values will be dismissed.
|
||||
self.count = (2*original_count) - len(future_events)
|
||||
ranges = set(self._get_ranges(event.start, duration))
|
||||
# We set back the occurrence number to its original value
|
||||
self.count = original_count
|
||||
# Remove ranges of events occurring in the past
|
||||
ranges = set((x, y) for x, y in ranges if x.date() >= event.start.date() and y.date() >= event.start.date())
|
||||
return ranges
|
||||
|
||||
|
||||
def _get_ranges(self, start, event_duration):
|
||||
starts = self._get_occurrences(start)
|
||||
return ((start, start + event_duration) for start in starts)
|
||||
|
||||
def _get_timezone(self):
|
||||
return pytz.timezone(self.event_tz or self.env.context.get('tz') or 'UTC')
|
||||
|
||||
def _get_occurrences(self, dtstart):
|
||||
"""
|
||||
Get ocurrences of the rrule
|
||||
:param dtstart: start of the recurrence
|
||||
:return: iterable of datetimes
|
||||
"""
|
||||
self.ensure_one()
|
||||
dtstart = self._get_start_of_period(dtstart)
|
||||
if self._is_allday():
|
||||
return self._get_rrule(dtstart=dtstart)
|
||||
|
||||
timezone = self._get_timezone()
|
||||
# Localize the starting datetime to avoid missing the first occurrence
|
||||
dtstart = pytz.utc.localize(dtstart).astimezone(timezone)
|
||||
# dtstart is given as a naive datetime, but it actually represents a timezoned datetime
|
||||
# (rrule package expects a naive datetime)
|
||||
occurences = self._get_rrule(dtstart=dtstart.replace(tzinfo=None))
|
||||
|
||||
# Special timezoning is needed to handle DST (Daylight Saving Time) changes.
|
||||
# Given the following recurrence:
|
||||
# - monthly
|
||||
# - 1st of each month
|
||||
# - timezone America/New_York (UTC−05:00)
|
||||
# - at 6am America/New_York = 11am UTC
|
||||
# - from 2019/02/01 to 2019/05/01.
|
||||
# The naive way would be to store:
|
||||
# 2019/02/01 11:00 - 2019/03/01 11:00 - 2019/04/01 11:00 - 2019/05/01 11:00 (UTC)
|
||||
#
|
||||
# But a DST change occurs on 2019/03/10 in America/New_York timezone. America/New_York is now UTC−04:00.
|
||||
# From this point in time, 11am (UTC) is actually converted to 7am (America/New_York) instead of the expected 6am!
|
||||
# What should be stored is:
|
||||
# 2019/02/01 11:00 - 2019/03/01 11:00 - 2019/04/01 10:00 - 2019/05/01 10:00 (UTC)
|
||||
# ***** *****
|
||||
return (timezone.localize(occurrence, is_dst=False).astimezone(pytz.utc).replace(tzinfo=None) for occurrence in occurences)
|
||||
|
||||
def _get_events_from(self, dtstart):
|
||||
return self.env['calendar.event'].search([
|
||||
('id', 'in', self.calendar_event_ids.ids),
|
||||
('start', '>=', dtstart)
|
||||
])
|
||||
|
||||
def _get_week_days(self):
|
||||
"""
|
||||
:return: tuple of rrule weekdays for this recurrence.
|
||||
"""
|
||||
return tuple(
|
||||
rrule.weekday(weekday_index)
|
||||
for weekday_index, weekday in {
|
||||
rrule.MO.weekday: self.mon,
|
||||
rrule.TU.weekday: self.tue,
|
||||
rrule.WE.weekday: self.wed,
|
||||
rrule.TH.weekday: self.thu,
|
||||
rrule.FR.weekday: self.fri,
|
||||
rrule.SA.weekday: self.sat,
|
||||
rrule.SU.weekday: self.sun,
|
||||
}.items() if weekday
|
||||
)
|
||||
|
||||
def _is_allday(self):
|
||||
"""Returns whether a majority of events are allday or not (there might be some outlier events)
|
||||
"""
|
||||
score = sum(1 if e.allday else -1 for e in self.calendar_event_ids)
|
||||
return score >= 0
|
||||
|
||||
def _get_rrule(self, dtstart=None):
|
||||
self.ensure_one()
|
||||
freq = self.rrule_type
|
||||
rrule_params = dict(
|
||||
dtstart=dtstart,
|
||||
interval=self.interval,
|
||||
)
|
||||
if freq == 'monthly' and self.month_by == 'date': # e.g. every 15th of the month
|
||||
rrule_params['bymonthday'] = self.day
|
||||
elif freq == 'monthly' and self.month_by == 'day': # e.g. every 2nd Monday in the month
|
||||
rrule_params['byweekday'] = getattr(rrule, RRULE_WEEKDAYS[self.weekday])(int(self.byday)) # e.g. MO(+2) for the second Monday of the month
|
||||
elif freq == 'weekly':
|
||||
weekdays = self._get_week_days()
|
||||
if not weekdays:
|
||||
raise UserError(_("You have to choose at least one day in the week"))
|
||||
rrule_params['byweekday'] = weekdays
|
||||
rrule_params['wkst'] = self._get_lang_week_start()
|
||||
|
||||
if self.end_type == 'count': # e.g. stop after X occurence
|
||||
rrule_params['count'] = min(self.count, MAX_RECURRENT_EVENT)
|
||||
elif self.end_type == 'forever':
|
||||
rrule_params['count'] = MAX_RECURRENT_EVENT
|
||||
elif self.end_type == 'end_date': # e.g. stop after 12/10/2020
|
||||
rrule_params['until'] = datetime.combine(self.until, time.max)
|
||||
return rrule.rrule(
|
||||
freq_to_rrule(freq), **rrule_params
|
||||
)
|
||||
30
odoo-bringout-oca-ocb-calendar/calendar/models/ir_http.py
Normal file
30
odoo-bringout-oca-ocb-calendar/calendar/models/ir_http.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@classmethod
|
||||
def _auth_method_calendar(cls):
|
||||
token = request.get_http_params().get('token', '')
|
||||
|
||||
error_message = False
|
||||
|
||||
attendee = request.env['calendar.attendee'].sudo().search([('access_token', '=', token)], limit=1)
|
||||
if not attendee:
|
||||
error_message = """Invalid Invitation Token."""
|
||||
elif request.session.uid and request.session.login != 'anonymous':
|
||||
# if valid session but user is not match
|
||||
user = request.env['res.users'].sudo().browse(request.session.uid)
|
||||
if attendee.partner_id != user.partner_id:
|
||||
error_message = """Invitation cannot be forwarded via email. This event/meeting belongs to %s and you are logged in as %s. Please ask organizer to add you.""" % (attendee.email, user.email)
|
||||
if error_message:
|
||||
raise BadRequest(error_message)
|
||||
|
||||
cls._auth_method_public()
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields, tools, _
|
||||
from odoo.tools import is_html_empty
|
||||
|
||||
|
||||
class MailActivity(models.Model):
|
||||
_inherit = "mail.activity"
|
||||
|
||||
calendar_event_id = fields.Many2one('calendar.event', string="Calendar Meeting", ondelete='cascade')
|
||||
|
||||
def action_create_calendar_event(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("calendar.action_calendar_event")
|
||||
action['context'] = {
|
||||
'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_description': self.note if not is_html_empty(self.note) else '',
|
||||
'default_activity_ids': [(6, 0, self.ids)],
|
||||
'orig_activity_ids': self,
|
||||
}
|
||||
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)
|
||||
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
|
||||
|
||||
def unlink_w_meeting(self):
|
||||
events = self.mapped('calendar_event_id')
|
||||
res = self.unlink()
|
||||
events.unlink()
|
||||
return res
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# -*- 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'
|
||||
|
||||
activity_calendar_event_id = fields.Many2one(
|
||||
'calendar.event', string="Next Activity Calendar Event",
|
||||
compute='_compute_activity_calendar_event_id', groups="base.group_user")
|
||||
|
||||
@api.depends('activity_ids.calendar_event_id')
|
||||
def _compute_activity_calendar_event_id(self):
|
||||
"""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
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class MailActivityType(models.Model):
|
||||
_inherit = "mail.activity.type"
|
||||
|
||||
category = fields.Selection(selection_add=[('meeting', 'Meeting')])
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
meeting_count = fields.Integer("# Meetings", compute='_compute_meeting_count')
|
||||
meeting_ids = fields.Many2many('calendar.event', 'calendar_event_res_partner_rel', 'res_partner_id',
|
||||
'calendar_event_id', string='Meetings', copy=False)
|
||||
|
||||
calendar_last_notif_ack = fields.Datetime(
|
||||
'Last notification marked as read from base Calendar', default=fields.Datetime.now)
|
||||
|
||||
def _compute_meeting_count(self):
|
||||
result = self._compute_meeting()
|
||||
for p in self:
|
||||
p.meeting_count = len(result.get(p.id, []))
|
||||
|
||||
def _compute_meeting(self):
|
||||
if self.ids:
|
||||
all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
|
||||
|
||||
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("""
|
||||
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 ({})
|
||||
GROUP BY res_partner_id, calendar_event_id
|
||||
""".format(subquery), [tuple(p["id"] for p in all_partners)])
|
||||
|
||||
meeting_data = self.env.cr.fetchall()
|
||||
|
||||
# Create a dict {partner_id: event_ids} and fill with events linked to the partner
|
||||
meetings = {}
|
||||
for p_id, m_id, _ in meeting_data:
|
||||
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:
|
||||
partner = partner.parent_id
|
||||
if partner in self:
|
||||
meetings[partner.id] = meetings.get(partner.id, set()) | meetings[meeting_pid]
|
||||
return {p_id: list(meetings.get(p_id, set())) for p_id in self.ids}
|
||||
return {}
|
||||
|
||||
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)
|
||||
"""
|
||||
attendees_details = []
|
||||
meetings = self.env['calendar.event'].browse(meeting_ids)
|
||||
for attendee in meetings.attendee_ids:
|
||||
if attendee.partner_id not in self:
|
||||
continue
|
||||
attendee_is_organizer = self.env.user == attendee.event_id.user_id and attendee.partner_id == self.env.user.partner_id
|
||||
attendees_details.append({
|
||||
'id': attendee.partner_id.id,
|
||||
'name': attendee.partner_id.display_name,
|
||||
'status': attendee.state,
|
||||
'event_id': attendee.event_id.id,
|
||||
'attendee_id': attendee.id,
|
||||
'is_alone': attendee.event_id.is_organizer_alone and attendee_is_organizer,
|
||||
# attendees data is sorted according to this key in JS.
|
||||
'is_organizer': 1 if attendee.partner_id == attendee.event_id.user_id.partner_id else 0,
|
||||
})
|
||||
return attendees_details
|
||||
|
||||
@api.model
|
||||
def _set_calendar_last_notif_ack(self):
|
||||
partner = self.env['res.users'].browse(self.env.context.get('uid', self.env.uid)).partner_id
|
||||
partner.write({'calendar_last_notif_ack': datetime.now()})
|
||||
|
||||
def schedule_meeting(self):
|
||||
self.ensure_one()
|
||||
partner_ids = self.ids
|
||||
partner_ids.append(self.env.user.partner_id.id)
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("calendar.action_calendar_event")
|
||||
action['context'] = {
|
||||
'default_partner_ids': partner_ids,
|
||||
}
|
||||
action['domain'] = ['|', ('id', 'in', self._compute_meeting()[self.id]), ('partner_ids', 'in', self.ids)]
|
||||
return action
|
||||
83
odoo-bringout-oca-ocb-calendar/calendar/models/res_users.py
Normal file
83
odoo-bringout-oca-ocb-calendar/calendar/models/res_users.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import datetime
|
||||
|
||||
from odoo import api, fields, models, modules, _
|
||||
from pytz import timezone, UTC
|
||||
|
||||
|
||||
class Users(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
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
|
||||
# user TZ and converted into UTC to compare with the records values and "the end of the day" needs
|
||||
# also conversion. Otherwise TZ diverting a lot from UTC would send notification for events occurring
|
||||
# tomorrow.
|
||||
# The user is notified if the start is occurring between now and the end of the day
|
||||
# if the event is not finished.
|
||||
# | |
|
||||
# |===========|===> DAY A (`start_dt`): now in the user TZ
|
||||
# | |
|
||||
# | | <--- `start_dt_utc`: now is on the right if the user lives
|
||||
# | | in West Longitude (America for example)
|
||||
# | |
|
||||
# | ------- | <--- `start`: the start of the event (in UTC)
|
||||
# | | event | |
|
||||
# | ------- | <--- `stop`: the stop of the event (in UTC)
|
||||
# | |
|
||||
# | |
|
||||
# | | <--- `stop_dt_utc` = `stop_dt` if user lives in an area of East longitude (positive shift compared to UTC, Belgium for example)
|
||||
# | |
|
||||
# | |
|
||||
# |-----------| <--- `stop_dt` = end of the day for DAY A from user point of view (23:59 in this TZ)
|
||||
# |===========|===> DAY B
|
||||
# | |
|
||||
# | | <--- `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))
|
||||
|
||||
tz = self.env.user.tz
|
||||
if tz:
|
||||
user_tz = timezone(tz)
|
||||
start_dt = start_dt_utc.astimezone(user_tz)
|
||||
stop_dt = user_tz.localize(datetime.datetime.combine(start_dt.date(), datetime.time.max))
|
||||
stop_dt_utc = stop_dt.astimezone(UTC)
|
||||
|
||||
start_date = start_dt.date()
|
||||
|
||||
return ['&', '|',
|
||||
'&',
|
||||
'|',
|
||||
['start', '>=', fields.Datetime.to_string(start_dt_utc)],
|
||||
['stop', '>=', fields.Datetime.to_string(start_dt_utc)],
|
||||
['start', '<=', fields.Datetime.to_string(stop_dt_utc)],
|
||||
'&',
|
||||
['allday', '=', True],
|
||||
['start_date', '=', fields.Date.to_string(start_date)],
|
||||
('attendee_ids.partner_id', '=', self.env.user.partner_id.id)]
|
||||
|
||||
@api.model
|
||||
def systray_get_activities(self):
|
||||
res = super(Users, self).systray_get_activities()
|
||||
|
||||
meetings_lines = self.env['calendar.event'].search_read(
|
||||
self._systray_get_calendar_event_domain(),
|
||||
['id', 'start', 'name', 'allday', 'attendee_status'],
|
||||
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 = {
|
||||
'id': self.env['ir.model']._get('calendar.event').id,
|
||||
'type': 'meeting',
|
||||
'name': meeting_label,
|
||||
'model': 'calendar.event',
|
||||
'icon': modules.module.get_module_icon(self.env['calendar.event']._original_module),
|
||||
'meetings': meetings_lines,
|
||||
}
|
||||
res.insert(0, meetings_systray)
|
||||
|
||||
return res
|
||||
Loading…
Add table
Add a link
Reference in a new issue