calendar_event.py 65 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import logging
  4. import math
  5. from collections import defaultdict
  6. from datetime import timedelta
  7. from itertools import repeat
  8. from werkzeug.urls import url_parse
  9. import pytz
  10. import uuid
  11. from odoo import api, fields, models, Command
  12. from odoo.osv.expression import AND
  13. from odoo.addons.base.models.res_partner import _tz_get
  14. from odoo.addons.calendar.models.calendar_attendee import Attendee
  15. from odoo.addons.calendar.models.calendar_recurrence import (
  16. weekday_to_field,
  17. RRULE_TYPE_SELECTION,
  18. END_TYPE_SELECTION,
  19. MONTH_BY_SELECTION,
  20. WEEKDAY_SELECTION,
  21. BYDAY_SELECTION
  22. )
  23. from odoo.tools.translate import _
  24. from odoo.tools.misc import get_lang
  25. from odoo.tools import pycompat, html2plaintext, is_html_empty, single_email_re
  26. from odoo.exceptions import UserError, ValidationError
  27. _logger = logging.getLogger(__name__)
  28. try:
  29. import vobject
  30. except ImportError:
  31. _logger.warning("`vobject` Python module not found, iCal file generation disabled. Consider installing this module if you want to generate iCal files")
  32. vobject = None
  33. SORT_ALIASES = {
  34. 'start': 'sort_start',
  35. 'start_date': 'sort_start',
  36. }
  37. def get_weekday_occurence(date):
  38. """
  39. :returns: ocurrence
  40. >>> get_weekday_occurence(date(2019, 12, 17))
  41. 3 # third Tuesday of the month
  42. >>> get_weekday_occurence(date(2019, 12, 25))
  43. -1 # last Friday of the month
  44. """
  45. occurence_in_month = math.ceil(date.day/7)
  46. if occurence_in_month in {4, 5}: # fourth or fifth week on the month -> last
  47. return -1
  48. return occurence_in_month
  49. class Meeting(models.Model):
  50. _name = 'calendar.event'
  51. _description = "Calendar Event"
  52. _order = "start desc"
  53. _inherit = ["mail.thread"]
  54. DISCUSS_ROUTE = 'calendar/join_videocall'
  55. @api.model
  56. def default_get(self, fields):
  57. # super default_model='crm.lead' for easier use in addons
  58. if self.env.context.get('default_res_model') and not self.env.context.get('default_res_model_id'):
  59. self = self.with_context(
  60. default_res_model_id=self.env['ir.model']._get_id(self.env.context['default_res_model'])
  61. )
  62. defaults = super(Meeting, self).default_get(fields)
  63. # support active_model / active_id as replacement of default_* if not already given
  64. if 'res_model_id' not in defaults and 'res_model_id' in fields and \
  65. self.env.context.get('active_model') and self.env.context['active_model'] != 'calendar.event':
  66. defaults['res_model_id'] = self.env['ir.model']._get_id(self.env.context['active_model'])
  67. defaults['res_model'] = self.env.context.get('active_model')
  68. if 'res_id' not in defaults and 'res_id' in fields and \
  69. defaults.get('res_model_id') and self.env.context.get('active_id'):
  70. defaults['res_id'] = self.env.context['active_id']
  71. return defaults
  72. @api.model
  73. def _default_partners(self):
  74. """ When active_model is res.partner, the current partners should be attendees """
  75. partners = self.env.user.partner_id
  76. active_id = self._context.get('active_id')
  77. if self._context.get('active_model') == 'res.partner' and active_id and active_id not in partners.ids:
  78. partners |= self.env['res.partner'].browse(active_id)
  79. return partners
  80. # description
  81. name = fields.Char('Meeting Subject', required=True)
  82. description = fields.Html('Description')
  83. user_id = fields.Many2one('res.users', 'Organizer', default=lambda self: self.env.user)
  84. partner_id = fields.Many2one(
  85. 'res.partner', string='Scheduled by', related='user_id.partner_id', readonly=True)
  86. location = fields.Char('Location', tracking=True)
  87. videocall_location = fields.Char('Meeting URL', compute='_compute_videocall_location', store=True, copy=True)
  88. access_token = fields.Char('Invitation Token', store=True, copy=False, index=True)
  89. videocall_source = fields.Selection([('discuss', 'Discuss'), ('custom', 'Custom')], compute='_compute_videocall_source')
  90. videocall_channel_id = fields.Many2one('mail.channel', 'Discuss Channel')
  91. # visibility
  92. privacy = fields.Selection(
  93. [('public', 'Public'),
  94. ('private', 'Private'),
  95. ('confidential', 'Only internal users')],
  96. 'Privacy', default='public', required=True,
  97. help="People to whom this event will be visible.")
  98. show_as = fields.Selection(
  99. [('free', 'Available'),
  100. ('busy', 'Busy')], 'Show as', default='busy', required=True,
  101. help="If the time is shown as 'busy', this event will be visible to other people with either the full \
  102. information or simply 'busy' written depending on its privacy. Use this option to let other people know \
  103. that you are unavailable during that period of time. \n If the event is shown as 'free', other users know \
  104. that you are available during that period of time.")
  105. is_highlighted = fields.Boolean(
  106. compute='_compute_is_highlighted', string='Is the Event Highlighted')
  107. is_organizer_alone = fields.Boolean(compute='_compute_is_organizer_alone', string="Is the Organizer Alone",
  108. help="""Check if the organizer is alone in the event, i.e. if the organizer is the only one that hasn't declined
  109. the event (only if the organizer is not the only attendee)""")
  110. # filtering
  111. active = fields.Boolean(
  112. 'Active', default=True,
  113. tracking=True,
  114. help="If the active field is set to false, it will allow you to hide the event alarm information without removing it.")
  115. categ_ids = fields.Many2many(
  116. 'calendar.event.type', 'meeting_category_rel', 'event_id', 'type_id', 'Tags')
  117. # timing
  118. start = fields.Datetime(
  119. 'Start', required=True, tracking=True, default=fields.Date.today,
  120. help="Start date of an event, without time for full days events")
  121. stop = fields.Datetime(
  122. 'Stop', required=True, tracking=True, default=lambda self: fields.Datetime.today() + timedelta(hours=1),
  123. compute='_compute_stop', readonly=False, store=True,
  124. help="Stop date of an event, without time for full days events")
  125. display_time = fields.Char('Event Time', compute='_compute_display_time')
  126. allday = fields.Boolean('All Day', default=False)
  127. start_date = fields.Date(
  128. 'Start Date', store=True, tracking=True,
  129. compute='_compute_dates', inverse='_inverse_dates')
  130. stop_date = fields.Date(
  131. 'End Date', store=True, tracking=True,
  132. compute='_compute_dates', inverse='_inverse_dates')
  133. duration = fields.Float('Duration', compute='_compute_duration', store=True, readonly=False)
  134. # linked document
  135. res_id = fields.Many2oneReference('Document ID', model_field='res_model')
  136. res_model_id = fields.Many2one('ir.model', 'Document Model', ondelete='cascade')
  137. res_model = fields.Char(
  138. 'Document Model Name', related='res_model_id.model', readonly=True, store=True)
  139. # messaging
  140. activity_ids = fields.One2many('mail.activity', 'calendar_event_id', string='Activities')
  141. # attendees
  142. attendee_ids = fields.One2many(
  143. 'calendar.attendee', 'event_id', 'Participant')
  144. attendee_status = fields.Selection(
  145. Attendee.STATE_SELECTION, string='Attendee Status', compute='_compute_attendee')
  146. partner_ids = fields.Many2many(
  147. 'res.partner', 'calendar_event_res_partner_rel',
  148. string='Attendees', default=_default_partners)
  149. invalid_email_partner_ids = fields.Many2many('res.partner', compute='_compute_invalid_email_partner_ids')
  150. # alarms
  151. alarm_ids = fields.Many2many(
  152. 'calendar.alarm', 'calendar_alarm_calendar_event_rel',
  153. string='Reminders', ondelete="restrict",
  154. help="Notifications sent to all attendees to remind of the meeting.")
  155. # RECURRENCE FIELD
  156. recurrency = fields.Boolean('Recurrent')
  157. recurrence_id = fields.Many2one(
  158. 'calendar.recurrence', string="Recurrence Rule")
  159. follow_recurrence = fields.Boolean(default=False) # Indicates if an event follows the recurrence, i.e. is not an exception
  160. recurrence_update = fields.Selection([
  161. ('self_only', "This event"),
  162. ('future_events', "This and following events"),
  163. ('all_events', "All events"),
  164. ], store=False, copy=False, default='self_only',
  165. help="Choose what to do with other events in the recurrence. Updating All Events is not allowed when dates or time is modified")
  166. # Those field are pseudo-related fields of recurrence_id.
  167. # They can't be "real" related fields because it should work at record creation
  168. # when recurrence_id is not created yet.
  169. # If some of these fields are set and recurrence_id does not exists,
  170. # a `calendar.recurrence.rule` will be dynamically created.
  171. rrule = fields.Char('Recurrent Rule', compute='_compute_recurrence', readonly=False)
  172. rrule_type = fields.Selection(RRULE_TYPE_SELECTION, string='Recurrence',
  173. help="Let the event automatically repeat at that interval",
  174. compute='_compute_recurrence', readonly=False)
  175. event_tz = fields.Selection(
  176. _tz_get, string='Timezone', compute='_compute_recurrence', readonly=False)
  177. end_type = fields.Selection(
  178. END_TYPE_SELECTION, string='Recurrence Termination',
  179. compute='_compute_recurrence', readonly=False)
  180. interval = fields.Integer(
  181. string='Repeat Every', compute='_compute_recurrence', readonly=False,
  182. help="Repeat every (Days/Week/Month/Year)")
  183. count = fields.Integer(
  184. string='Repeat', help="Repeat x times", compute='_compute_recurrence', readonly=False)
  185. mon = fields.Boolean(compute='_compute_recurrence', readonly=False)
  186. tue = fields.Boolean(compute='_compute_recurrence', readonly=False)
  187. wed = fields.Boolean(compute='_compute_recurrence', readonly=False)
  188. thu = fields.Boolean(compute='_compute_recurrence', readonly=False)
  189. fri = fields.Boolean(compute='_compute_recurrence', readonly=False)
  190. sat = fields.Boolean(compute='_compute_recurrence', readonly=False)
  191. sun = fields.Boolean(compute='_compute_recurrence', readonly=False)
  192. month_by = fields.Selection(
  193. MONTH_BY_SELECTION, string='Option', compute='_compute_recurrence', readonly=False)
  194. day = fields.Integer('Date of month', compute='_compute_recurrence', readonly=False)
  195. weekday = fields.Selection(WEEKDAY_SELECTION, compute='_compute_recurrence', readonly=False)
  196. byday = fields.Selection(BYDAY_SELECTION, string="By day", compute='_compute_recurrence', readonly=False)
  197. until = fields.Date(compute='_compute_recurrence', readonly=False)
  198. # UI Fields.
  199. display_description = fields.Boolean(compute='_compute_display_description')
  200. @api.depends('attendee_ids')
  201. def _compute_invalid_email_partner_ids(self):
  202. for event in self:
  203. event.invalid_email_partner_ids = event.partner_ids.filtered(
  204. lambda a: not (a.email and single_email_re.match(a.email))
  205. )
  206. def _compute_is_highlighted(self):
  207. if self.env.context.get('active_model') == 'res.partner':
  208. partner_id = self.env.context.get('active_id')
  209. for event in self:
  210. if event.partner_ids.filtered(lambda s: s.id == partner_id):
  211. event.is_highlighted = True
  212. else:
  213. event.is_highlighted = False
  214. else:
  215. for event in self:
  216. event.is_highlighted = False
  217. @api.depends('partner_id', 'attendee_ids')
  218. def _compute_is_organizer_alone(self):
  219. """
  220. Check if the organizer of the event is the only one who has accepted the event.
  221. It does not apply if the organizer is the only attendee of the event because it
  222. would represent a personnal event.
  223. The goal of this field is to highlight to the user that the others attendees are
  224. not available for this event.
  225. """
  226. for event in self:
  227. organizer = event.attendee_ids.filtered(lambda a: a.partner_id == event.partner_id)
  228. all_declined = not any((event.attendee_ids - organizer).filtered(lambda a: a.state != 'declined'))
  229. event.is_organizer_alone = len(event.attendee_ids) > 1 and all_declined
  230. def _compute_display_time(self):
  231. for meeting in self:
  232. meeting.display_time = self._get_display_time(meeting.start, meeting.stop, meeting.duration, meeting.allday)
  233. @api.depends('allday', 'start', 'stop')
  234. def _compute_dates(self):
  235. """ Adapt the value of start_date(time)/stop_date(time)
  236. according to start/stop fields and allday. Also, compute
  237. the duration for not allday meeting ; otherwise the
  238. duration is set to zero, since the meeting last all the day.
  239. """
  240. for meeting in self:
  241. if meeting.allday and meeting.start and meeting.stop:
  242. meeting.start_date = meeting.start.date()
  243. meeting.stop_date = meeting.stop.date()
  244. else:
  245. meeting.start_date = False
  246. meeting.stop_date = False
  247. @api.depends('stop', 'start')
  248. def _compute_duration(self):
  249. for event in self:
  250. event.duration = self._get_duration(event.start, event.stop)
  251. @api.depends('start', 'duration')
  252. def _compute_stop(self):
  253. # stop and duration fields both depends on the start field.
  254. # But they also depends on each other.
  255. # When start is updated, we want to update the stop datetime based on
  256. # the *current* duration. In other words, we want: change start => keep the duration fixed and
  257. # recompute stop accordingly.
  258. # However, while computing stop, duration is marked to be recomputed. Calling `event.duration` would trigger
  259. # its recomputation. To avoid this we manually mark the field as computed.
  260. duration_field = self._fields['duration']
  261. self.env.remove_to_compute(duration_field, self)
  262. for event in self:
  263. # Round the duration (in hours) to the minute to avoid weird situations where the event
  264. # stops at 4:19:59, later displayed as 4:19.
  265. event.stop = event.start and event.start + timedelta(minutes=round((event.duration or 1.0) * 60))
  266. if event.allday:
  267. event.stop -= timedelta(seconds=1)
  268. def _inverse_dates(self):
  269. """ This method is used to set the start and stop values of all day events.
  270. The calendar view needs date_start and date_stop values to display correctly the allday events across
  271. several days. As the user edit the {start,stop}_date fields when allday is true,
  272. this inverse method is needed to update the start/stop value and have a relevant calendar view.
  273. """
  274. for meeting in self:
  275. if meeting.allday:
  276. # Convention break:
  277. # stop and start are NOT in UTC in allday event
  278. # in this case, they actually represent a date
  279. # because fullcalendar just drops times for full day events.
  280. # i.e. Christmas is on 25/12 for everyone
  281. # even if people don't celebrate it simultaneously
  282. enddate = fields.Datetime.from_string(meeting.stop_date)
  283. enddate = enddate.replace(hour=18)
  284. startdate = fields.Datetime.from_string(meeting.start_date)
  285. startdate = startdate.replace(hour=8) # Set 8 AM
  286. meeting.write({
  287. 'start': startdate.replace(tzinfo=None),
  288. 'stop': enddate.replace(tzinfo=None)
  289. })
  290. def _compute_attendee(self):
  291. mapped_attendees = self._find_attendee_batch()
  292. for meeting in self:
  293. attendee = mapped_attendees[meeting.id]
  294. meeting.attendee_status = attendee.state if attendee else 'needsAction'
  295. @api.constrains('start', 'stop', 'start_date', 'stop_date')
  296. def _check_closing_date(self):
  297. for meeting in self:
  298. if not meeting.allday and meeting.start and meeting.stop and meeting.stop < meeting.start:
  299. raise ValidationError(
  300. _('The ending date and time cannot be earlier than the starting date and time.') + '\n' +
  301. _("Meeting '%(name)s' starts '%(start_datetime)s' and ends '%(end_datetime)s'",
  302. name=meeting.name,
  303. start_datetime=meeting.start,
  304. end_datetime=meeting.stop
  305. )
  306. )
  307. if meeting.allday and meeting.start_date and meeting.stop_date and meeting.stop_date < meeting.start_date:
  308. raise ValidationError(
  309. _('The ending date cannot be earlier than the starting date.') + '\n' +
  310. _("Meeting '%(name)s' starts '%(start_datetime)s' and ends '%(end_datetime)s'",
  311. name=meeting.name,
  312. start_datetime=meeting.start,
  313. end_datetime=meeting.stop
  314. )
  315. )
  316. @api.depends('recurrence_id', 'recurrency')
  317. def _compute_recurrence(self):
  318. recurrence_fields = self._get_recurrent_fields()
  319. false_values = {field: False for field in recurrence_fields} # computes need to set a value
  320. defaults = self.env['calendar.recurrence'].default_get(recurrence_fields)
  321. default_rrule_values = self.recurrence_id.default_get(recurrence_fields)
  322. for event in self:
  323. if event.recurrency:
  324. event.update(defaults) # default recurrence values are needed to correctly compute the recurrence params
  325. event_values = event._get_recurrence_params()
  326. rrule_values = {
  327. field: event.recurrence_id[field]
  328. for field in recurrence_fields
  329. if event.recurrence_id[field]
  330. }
  331. rrule_values = rrule_values or default_rrule_values
  332. event.update({**false_values, **defaults, **event_values, **rrule_values})
  333. else:
  334. event.update(false_values)
  335. @api.depends('description')
  336. def _compute_display_description(self):
  337. for event in self:
  338. event.display_description = not is_html_empty(event.description)
  339. @api.depends('videocall_source', 'access_token')
  340. def _compute_videocall_location(self):
  341. for event in self:
  342. if event.videocall_source == 'discuss':
  343. event._set_discuss_videocall_location()
  344. @api.model
  345. def _set_videocall_location(self, vals_list):
  346. for vals in vals_list:
  347. if not vals.get('videocall_location'):
  348. continue
  349. url = url_parse(vals['videocall_location'])
  350. if url.scheme in ('http', 'https'):
  351. continue
  352. # relative url to convert to absolute
  353. base = url_parse(self.get_base_url())
  354. vals['videocall_location'] = url.replace(scheme=base.scheme, netloc=base.netloc).to_url()
  355. @api.depends('videocall_location')
  356. def _compute_videocall_source(self):
  357. for event in self:
  358. if event.videocall_location and self.DISCUSS_ROUTE in event.videocall_location:
  359. event.videocall_source = 'discuss'
  360. else:
  361. event.videocall_source = 'custom'
  362. def _set_discuss_videocall_location(self):
  363. """
  364. This method sets the videocall_location to a discuss route.
  365. If no access_token exists for this event, we create one.
  366. Note that recurring events will have different access_tokens.
  367. This is done by design to prevent users not being able to join a discuss meeting because the base event of the recurrency was deleted.
  368. """
  369. if not self.access_token:
  370. self.access_token = uuid.uuid4().hex
  371. self.videocall_location = f"{self.get_base_url()}/{self.DISCUSS_ROUTE}/{self.access_token}"
  372. @api.model
  373. def get_discuss_videocall_location(self):
  374. access_token = uuid.uuid4().hex
  375. return f"{self.get_base_url()}/{self.DISCUSS_ROUTE}/{access_token}"
  376. # ------------------------------------------------------------
  377. # CRUD
  378. # ------------------------------------------------------------
  379. @api.model_create_multi
  380. def create(self, vals_list):
  381. # Prevent sending update notification when _inverse_dates is called
  382. self = self.with_context(is_calendar_event_new=True)
  383. vals_list = [ # Else bug with quick_create when we are filter on an other user
  384. dict(vals, user_id=self.env.user.id) if not 'user_id' in vals else vals
  385. for vals in vals_list
  386. ]
  387. defaults = self.default_get(['activity_ids', 'res_model_id', 'res_id', 'user_id', 'res_model', 'partner_ids'])
  388. meeting_activity_type = self.env['mail.activity.type'].search([('category', '=', 'meeting')], limit=1)
  389. # get list of models ids and filter out None values directly
  390. model_ids = list(filter(None, {values.get('res_model_id', defaults.get('res_model_id')) for values in vals_list}))
  391. model_name = defaults.get('res_model')
  392. valid_activity_model_ids = model_name and self.env[model_name].sudo().browse(model_ids).filtered(lambda m: 'activity_ids' in m).ids or []
  393. if meeting_activity_type and not defaults.get('activity_ids'):
  394. for values in vals_list:
  395. # created from calendar: try to create an activity on the related record
  396. if values.get('activity_ids'):
  397. continue
  398. res_model_id = values.get('res_model_id', defaults.get('res_model_id'))
  399. res_id = values.get('res_id', defaults.get('res_id'))
  400. user_id = values.get('user_id', defaults.get('user_id'))
  401. if not res_model_id or not res_id:
  402. continue
  403. if res_model_id not in valid_activity_model_ids:
  404. continue
  405. activity_vals = {
  406. 'res_model_id': res_model_id,
  407. 'res_id': res_id,
  408. 'activity_type_id': meeting_activity_type.id,
  409. }
  410. if user_id:
  411. activity_vals['user_id'] = user_id
  412. values['activity_ids'] = [(0, 0, activity_vals)]
  413. self._set_videocall_location(vals_list)
  414. # Add commands to create attendees from partners (if present) if no attendee command
  415. # is already given (coming from Google event for example).
  416. # Automatically add the current partner when creating an event if there is none (happens when we quickcreate an event)
  417. default_partners_ids = defaults.get('partner_ids') or ([(4, self.env.user.partner_id.id)])
  418. vals_list = [
  419. dict(vals, attendee_ids=self._attendees_values(vals.get('partner_ids', default_partners_ids)))
  420. if not vals.get('attendee_ids')
  421. else vals
  422. for vals in vals_list
  423. ]
  424. recurrence_fields = self._get_recurrent_fields()
  425. recurring_vals = [vals for vals in vals_list if vals.get('recurrency')]
  426. other_vals = [vals for vals in vals_list if not vals.get('recurrency')]
  427. events = super().create(other_vals)
  428. for vals in recurring_vals:
  429. vals['follow_recurrence'] = True
  430. recurring_events = super().create(recurring_vals)
  431. events += recurring_events
  432. for event, vals in zip(recurring_events, recurring_vals):
  433. recurrence_values = {field: vals.pop(field) for field in recurrence_fields if field in vals}
  434. if vals.get('recurrency'):
  435. detached_events = event._apply_recurrence_values(recurrence_values)
  436. detached_events.active = False
  437. events.filtered(lambda event: event.start > fields.Datetime.now()).attendee_ids._send_mail_to_attendees(
  438. self.env.ref('calendar.calendar_template_meeting_invitation', raise_if_not_found=False)
  439. )
  440. events._sync_activities(fields={f for vals in vals_list for f in vals.keys()})
  441. if not self.env.context.get('dont_notify'):
  442. events._setup_alarms()
  443. return events.with_context(is_calendar_event_new=False)
  444. def _compute_field_value(self, field):
  445. if field.compute_sudo:
  446. return super(Meeting, self.with_context(prefetch_fields=False))._compute_field_value(field)
  447. return super()._compute_field_value(field)
  448. def _read(self, fields):
  449. if self.env.is_system():
  450. super()._read(fields)
  451. return
  452. fields = set(fields)
  453. private_fields = fields - self._get_public_fields()
  454. if not private_fields:
  455. super()._read(fields)
  456. return
  457. private_fields.add('partner_ids')
  458. super()._read(fields | {'privacy', 'user_id', 'partner_ids'})
  459. current_partner_id = self.env.user.partner_id
  460. others_private_events = self.filtered(
  461. lambda e: e.privacy == 'private' \
  462. and e.user_id != self.env.user \
  463. and current_partner_id not in e.partner_ids
  464. )
  465. if not others_private_events:
  466. return
  467. for field_name in private_fields:
  468. field = self._fields[field_name]
  469. replacement = field.convert_to_cache(
  470. _('Busy') if field_name == 'name' else False,
  471. others_private_events)
  472. self.env.cache.update(others_private_events, field, repeat(replacement))
  473. def write(self, values):
  474. detached_events = self.env['calendar.event']
  475. recurrence_update_setting = values.pop('recurrence_update', None)
  476. update_recurrence = recurrence_update_setting in ('all_events', 'future_events') and len(self) == 1
  477. break_recurrence = values.get('recurrency') is False
  478. update_alarms = False
  479. update_time = False
  480. self._set_videocall_location([values])
  481. if 'partner_ids' in values:
  482. values['attendee_ids'] = self._attendees_values(values['partner_ids'])
  483. update_alarms = True
  484. if self.videocall_channel_id:
  485. new_partner_ids = []
  486. for command in values['partner_ids']:
  487. if command[0] == Command.LINK:
  488. new_partner_ids.append(command[1])
  489. elif command[0] == Command.SET:
  490. new_partner_ids.extend(command[2])
  491. self.videocall_channel_id.add_members(new_partner_ids)
  492. time_fields = self.env['calendar.event']._get_time_fields()
  493. if any([values.get(key) for key in time_fields]):
  494. update_alarms = True
  495. update_time = True
  496. if 'alarm_ids' in values:
  497. update_alarms = True
  498. if (not recurrence_update_setting or recurrence_update_setting == 'self_only' and len(self) == 1) and 'follow_recurrence' not in values:
  499. if any({field: values.get(field) for field in time_fields if field in values}):
  500. values['follow_recurrence'] = False
  501. previous_attendees = self.attendee_ids
  502. recurrence_values = {field: values.pop(field) for field in self._get_recurrent_fields() if field in values}
  503. if update_recurrence:
  504. if break_recurrence:
  505. # Update this event
  506. detached_events |= self._break_recurrence(future=recurrence_update_setting == 'future_events')
  507. else:
  508. future_edge_case = recurrence_update_setting == 'future_events' and self == self.recurrence_id.base_event_id
  509. time_values = {field: values.pop(field) for field in time_fields if field in values}
  510. if 'access_token' in values:
  511. values.pop('access_token') # prevents copying access_token to other events in recurrency
  512. if recurrence_update_setting == 'all_events' or future_edge_case:
  513. # Update all events: we create a new reccurrence and dismiss the existing events
  514. self._rewrite_recurrence(values, time_values, recurrence_values)
  515. else:
  516. # Update future events: trim recurrence, delete remaining events except base event and recreate it
  517. # All the recurrent events processing is done within the following method
  518. self._update_future_events(values, time_values, recurrence_values)
  519. else:
  520. super().write(values)
  521. self._sync_activities(fields=values.keys())
  522. # We reapply recurrence for future events and when we add a rrule and 'recurrency' == True on the event
  523. if recurrence_update_setting not in ['self_only', 'all_events'] and not break_recurrence:
  524. detached_events |= self._apply_recurrence_values(recurrence_values, future=recurrence_update_setting == 'future_events')
  525. (detached_events & self).active = False
  526. (detached_events - self).with_context(archive_on_error=True).unlink()
  527. # Notify attendees if there is an alarm on the modified event, or if there was an alarm
  528. # that has just been removed, as it might have changed their next event notification
  529. if not self.env.context.get('dont_notify') and update_alarms:
  530. self._setup_alarms()
  531. attendee_update_events = self.filtered(lambda ev: ev.user_id != self.env.user)
  532. if update_time and attendee_update_events:
  533. # Another user update the event time fields. It should not be auto accepted for the organizer.
  534. # This prevent weird behavior when a user modified future events time fields and
  535. # the base event of a recurrence is accepted by the organizer but not the following events
  536. attendee_update_events.attendee_ids.filtered(lambda att: self.user_id.partner_id == att.partner_id).write({'state': 'needsAction'})
  537. current_attendees = self.filtered('active').attendee_ids
  538. if 'partner_ids' in values:
  539. # we send to all partners and not only the new ones
  540. (current_attendees - previous_attendees)._send_mail_to_attendees(
  541. self.env.ref('calendar.calendar_template_meeting_invitation', raise_if_not_found=False)
  542. )
  543. if not self.env.context.get('is_calendar_event_new') and 'start' in values:
  544. start_date = fields.Datetime.to_datetime(values.get('start'))
  545. # Only notify on future events
  546. if start_date and start_date >= fields.Datetime.now():
  547. (current_attendees & previous_attendees).with_context(
  548. calendar_template_ignore_recurrence=not update_recurrence
  549. )._send_mail_to_attendees(
  550. self.env.ref('calendar.calendar_template_meeting_changedate', raise_if_not_found=False)
  551. )
  552. return True
  553. def name_get(self):
  554. """ Hide private events' name for events which don't belong to the current user
  555. """
  556. hidden = self.filtered(
  557. lambda evt:
  558. evt.privacy == 'private' and
  559. evt.user_id.id != self.env.uid and
  560. self.env.user.partner_id not in evt.partner_ids
  561. )
  562. shown = self - hidden
  563. shown_names = super(Meeting, shown).name_get()
  564. obfuscated_names = [(eid, _('Busy')) for eid in hidden.ids]
  565. return shown_names + obfuscated_names
  566. @api.model
  567. def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
  568. groupby = [groupby] if isinstance(groupby, str) else groupby
  569. grouped_fields = set(group_field.split(':')[0] for group_field in groupby)
  570. private_fields = grouped_fields - self._get_public_fields()
  571. if not self.env.su and private_fields:
  572. # display public and confidential events
  573. domain = AND([domain, ['|', ('privacy', '!=', 'private'), ('user_id', '=', self.env.user.id)]])
  574. return super(Meeting, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
  575. return super(Meeting, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
  576. def unlink(self):
  577. if not self:
  578. return super().unlink()
  579. # Get concerned attendees to notify them if there is an alarm on the unlinked events,
  580. # as it might have changed their next event notification
  581. events = self.filtered_domain([('alarm_ids', '!=', False)])
  582. partner_ids = events.mapped('partner_ids').ids
  583. # don't forget to update recurrences if there are some base events in the set to unlink,
  584. # but after having removed the events ;-)
  585. recurrences = self.env["calendar.recurrence"].search([
  586. ('base_event_id.id', 'in', [e.id for e in self])
  587. ])
  588. result = super().unlink()
  589. if recurrences:
  590. recurrences._select_new_base_event()
  591. # Notify the concerned attendees (must be done after removing the events)
  592. self.env['calendar.alarm_manager']._notify_next_alarm(partner_ids)
  593. return result
  594. def copy(self, default=None):
  595. """When an event is copied, the attendees should be recreated to avoid sharing the same attendee records
  596. between copies
  597. """
  598. self.ensure_one()
  599. if not default:
  600. default = {}
  601. # We need to make sure that the attendee_ids are recreated with new ids to avoid sharing attendees between events
  602. # The copy should not have the same attendee status than the original event
  603. default.update(partner_ids=[Command.set([])], attendee_ids=[Command.set([])])
  604. copied_event = super().copy(default)
  605. copied_event.write({'partner_ids': [(Command.set(self.partner_ids.ids))]})
  606. return copied_event
  607. def _attendees_values(self, partner_commands):
  608. """
  609. :param partner_commands: ORM commands for partner_id field (0 and 1 commands not supported)
  610. :return: associated attendee_ids ORM commands
  611. """
  612. attendee_commands = []
  613. removed_partner_ids = []
  614. added_partner_ids = []
  615. for command in partner_commands:
  616. op = command[0]
  617. if op in (2, 3): # Remove partner
  618. removed_partner_ids += [command[1]]
  619. elif op == 6: # Replace all
  620. removed_partner_ids += set(self.partner_ids.ids) - set(command[2]) # Don't recreate attendee if partner already attend the event
  621. added_partner_ids += set(command[2]) - set(self.partner_ids.ids)
  622. elif op == 4:
  623. added_partner_ids += [command[1]] if command[1] not in self.partner_ids.ids else []
  624. # commands 0 and 1 not supported
  625. if not self:
  626. attendees_to_unlink = self.env['calendar.attendee']
  627. else:
  628. attendees_to_unlink = self.env['calendar.attendee'].search([
  629. ('event_id', 'in', self.ids),
  630. ('partner_id', 'in', removed_partner_ids),
  631. ])
  632. attendee_commands += [[2, attendee.id] for attendee in attendees_to_unlink] # Removes and delete
  633. attendee_commands += [
  634. [0, 0, dict(partner_id=partner_id)]
  635. for partner_id in added_partner_ids
  636. ]
  637. return attendee_commands
  638. def _create_videocall_channel(self):
  639. if self.recurrency:
  640. # check if any of the events have videocall_channel_id, if not create one
  641. event_with_channel = self.env['calendar.event'].search([
  642. ('recurrence_id', '=', self.recurrence_id.id),
  643. ('videocall_channel_id', '!=', False)
  644. ], limit=1)
  645. if event_with_channel:
  646. self.videocall_channel_id = event_with_channel.videocall_channel_id
  647. return
  648. self.videocall_channel_id = self._create_videocall_channel_id(self.name, self.partner_ids.ids)
  649. self.videocall_channel_id.channel_change_description(self.recurrence_id.name if self.recurrency else self.display_time)
  650. def _create_videocall_channel_id(self, name, partner_ids):
  651. videocall_channel_id = self.env['mail.channel'].create_group(partner_ids, default_display_mode='video_full_screen', name=name)
  652. # if recurrent event, set channel to all other records of the same recurrency
  653. if self.recurrency:
  654. recurrent_events_without_channel = self.env['calendar.event'].search([
  655. ('recurrence_id', '=', self.recurrence_id.id), ('videocall_channel_id', '=', False)
  656. ])
  657. recurrent_events_without_channel.videocall_channel_id = videocall_channel_id['id']
  658. return videocall_channel_id['id']
  659. # ------------------------------------------------------------
  660. # ACTIONS
  661. # ------------------------------------------------------------
  662. # dummy method. this method is intercepted in the frontend and the value is set locally
  663. def set_discuss_videocall_location(self):
  664. return True
  665. # dummy method. this method is intercepted in the frontend and the value is set locally
  666. def clear_videocall_location(self):
  667. return True
  668. def action_open_calendar_event(self):
  669. if self.res_model and self.res_id:
  670. return self.env[self.res_model].browse(self.res_id).get_formview_action()
  671. return False
  672. def action_sendmail(self):
  673. email = self.env.user.email
  674. if email:
  675. for meeting in self:
  676. meeting.attendee_ids._send_mail_to_attendees(
  677. self.env.ref('calendar.calendar_template_meeting_invitation', raise_if_not_found=False)
  678. )
  679. return True
  680. def action_open_composer(self):
  681. if not self.partner_ids:
  682. raise UserError(_("There are no attendees on these events"))
  683. template_id = self.env['ir.model.data']._xmlid_to_res_id('calendar.calendar_template_meeting_update', raise_if_not_found=False)
  684. # The mail is sent with datetime corresponding to the sending user TZ
  685. composition_mode = self.env.context.get('composition_mode', 'comment')
  686. compose_ctx = dict(
  687. default_composition_mode=composition_mode,
  688. default_model='calendar.event',
  689. default_res_ids=self.ids,
  690. default_use_template=bool(template_id),
  691. default_template_id=template_id,
  692. default_partner_ids=self.partner_ids.ids,
  693. mail_tz=self.env.user.tz,
  694. )
  695. return {
  696. 'type': 'ir.actions.act_window',
  697. 'name': _('Contact Attendees'),
  698. 'view_mode': 'form',
  699. 'res_model': 'mail.compose.message',
  700. 'views': [(False, 'form')],
  701. 'view_id': False,
  702. 'target': 'new',
  703. 'context': compose_ctx,
  704. }
  705. def action_join_video_call(self):
  706. return {
  707. 'type': 'ir.actions.act_url',
  708. 'url': self.videocall_location,
  709. 'target': 'self' if self.videocall_source == 'discuss' else 'new'
  710. }
  711. def action_join_meeting(self, partner_id):
  712. """ Method used when an existing user wants to join
  713. """
  714. self.ensure_one()
  715. partner = self.env['res.partner'].browse(partner_id)
  716. if partner not in self.partner_ids:
  717. self.write({'partner_ids': [(4, partner.id)]})
  718. def action_mass_deletion(self, recurrence_update_setting):
  719. self.ensure_one()
  720. if recurrence_update_setting == 'all_events':
  721. events = self.recurrence_id.calendar_event_ids
  722. self.recurrence_id.unlink()
  723. events.unlink()
  724. elif recurrence_update_setting == 'future_events':
  725. future_events = self.recurrence_id.calendar_event_ids.filtered(lambda ev: ev.start >= self.start)
  726. future_events.unlink()
  727. def action_mass_archive(self, recurrence_update_setting):
  728. """
  729. The aim of this action purpose is to be called from sync calendar module when mass deletion is not possible.
  730. """
  731. self.ensure_one()
  732. if recurrence_update_setting == 'all_events':
  733. self.recurrence_id.calendar_event_ids.write(self._get_archive_values())
  734. elif recurrence_update_setting == 'future_events':
  735. detached_events = self.recurrence_id._stop_at(self)
  736. detached_events.write(self._get_archive_values())
  737. elif recurrence_update_setting == 'self_only':
  738. self.write({
  739. 'active': False,
  740. 'recurrence_update': 'self_only'
  741. })
  742. if len(self.recurrence_id.calendar_event_ids) == 0:
  743. self.recurrence_id.unlink()
  744. elif self == self.recurrence_id.base_event_id:
  745. self.recurrence_id._select_new_base_event()
  746. # ------------------------------------------------------------
  747. # MAILING
  748. # ------------------------------------------------------------
  749. def _get_attendee_emails(self):
  750. """ Get comma-separated attendee email addresses. """
  751. self.ensure_one()
  752. return ",".join([e for e in self.attendee_ids.mapped("email") if e])
  753. def _get_mail_tz(self):
  754. self.ensure_one()
  755. return self.event_tz or self.env.user.tz
  756. def _sync_activities(self, fields):
  757. # update activities
  758. for event in self:
  759. if event.activity_ids:
  760. activity_values = {}
  761. if 'name' in fields:
  762. activity_values['summary'] = event.name
  763. if 'description' in fields:
  764. activity_values['note'] = event.description
  765. if 'start' in fields:
  766. # self.start is a datetime UTC *only when the event is not allday*
  767. # activty.date_deadline is a date (No TZ, but should represent the day in which the user's TZ is)
  768. # See 72254129dbaeae58d0a2055cba4e4a82cde495b7 for the same issue, but elsewhere
  769. deadline = event.start
  770. user_tz = self.env.context.get('tz')
  771. if user_tz and not event.allday:
  772. deadline = pytz.utc.localize(deadline)
  773. deadline = deadline.astimezone(pytz.timezone(user_tz))
  774. activity_values['date_deadline'] = deadline.date()
  775. if 'user_id' in fields:
  776. activity_values['user_id'] = event.user_id.id
  777. if activity_values.keys():
  778. event.activity_ids.write(activity_values)
  779. # ------------------------------------------------------------
  780. # ALARMS
  781. # ------------------------------------------------------------
  782. def _get_trigger_alarm_types(self):
  783. return ['email']
  784. def _setup_alarms(self):
  785. """ Schedule cron triggers for future events """
  786. cron = self.env.ref('calendar.ir_cron_scheduler_alarm').sudo()
  787. alarm_types = self._get_trigger_alarm_types()
  788. events_to_notify = self.env['calendar.event']
  789. for event in self:
  790. for alarm in (alarm for alarm in event.alarm_ids if alarm.alarm_type in alarm_types):
  791. at = event.start - timedelta(minutes=alarm.duration_minutes)
  792. if not cron.lastcall or at > cron.lastcall:
  793. # Don't trigger for past alarms, they would be skipped by design
  794. cron._trigger(at=at)
  795. if any(alarm.alarm_type == 'notification' for alarm in event.alarm_ids):
  796. # filter events before notifying attendees through calendar_alarm_manager
  797. events_to_notify |= event.filtered(lambda ev: ev.alarm_ids and ev.stop >= fields.Datetime.now())
  798. if events_to_notify:
  799. self.env['calendar.alarm_manager']._notify_next_alarm(events_to_notify.partner_ids.ids)
  800. # ------------------------------------------------------------
  801. # RECURRENCY
  802. # ------------------------------------------------------------
  803. def _apply_recurrence_values(self, values, future=True):
  804. """Apply the new recurrence rules in `values`. Create a recurrence if it does not exist
  805. and create all missing events according to the rrule.
  806. If the changes are applied to future
  807. events only, a new recurrence is created with the updated rrule.
  808. :param values: new recurrence values to apply
  809. :param future: rrule values are applied to future events only if True.
  810. Rrule changes are applied to all events in the recurrence otherwise.
  811. (ignored if no recurrence exists yet).
  812. :return: events detached from the recurrence
  813. """
  814. if not values:
  815. return self.browse()
  816. recurrence_vals = []
  817. to_update = self.env['calendar.recurrence']
  818. for event in self:
  819. if not event.recurrence_id:
  820. recurrence_vals += [dict(values, base_event_id=event.id, calendar_event_ids=[(4, event.id)])]
  821. elif future:
  822. to_update |= event.recurrence_id._split_from(event, values)
  823. self.write({'recurrency': True, 'follow_recurrence': True})
  824. to_update |= self.env['calendar.recurrence'].create(recurrence_vals)
  825. return to_update._apply_recurrence()
  826. def _get_recurrence_params(self):
  827. if not self:
  828. return {}
  829. event_date = self._get_start_date()
  830. weekday_field_name = weekday_to_field(event_date.weekday())
  831. return {
  832. weekday_field_name: True,
  833. 'weekday': weekday_field_name.upper(),
  834. 'byday': str(get_weekday_occurence(event_date)),
  835. 'day': event_date.day,
  836. }
  837. @api.model
  838. def _get_recurrence_params_by_date(self, event_date):
  839. """ Return the recurrence parameters from a date object. """
  840. weekday_field_name = weekday_to_field(event_date.weekday())
  841. return {
  842. weekday_field_name: True,
  843. 'weekday': weekday_field_name.upper(),
  844. 'byday': str(get_weekday_occurence(event_date)),
  845. 'day': event_date.day,
  846. }
  847. def _split_recurrence(self, time_values):
  848. """Apply time changes to events and update the recurrence accordingly.
  849. :return: detached events
  850. """
  851. self.ensure_one()
  852. if not time_values:
  853. return self.browse()
  854. if self.follow_recurrence and self.recurrency:
  855. previous_week_day_field = weekday_to_field(self._get_start_date().weekday())
  856. else:
  857. # When we try to change recurrence values of an event not following the recurrence, we get the parameters from
  858. # the base_event
  859. previous_week_day_field = weekday_to_field(self.recurrence_id.base_event_id._get_start_date().weekday())
  860. self.write(time_values)
  861. return self._apply_recurrence_values({
  862. previous_week_day_field: False,
  863. **self._get_recurrence_params(),
  864. }, future=True)
  865. def _break_recurrence(self, future=True):
  866. """Breaks the event's recurrence.
  867. Stop the recurrence at the current event if `future` is True, leaving past events in the recurrence.
  868. If `future` is False, all events in the recurrence are detached and the recurrence itself is unlinked.
  869. :return: detached events excluding the current events
  870. """
  871. recurrences_to_unlink = self.env['calendar.recurrence']
  872. detached_events = self.env['calendar.event']
  873. for event in self:
  874. recurrence = event.recurrence_id
  875. if future:
  876. detached_events |= recurrence._stop_at(event)
  877. else:
  878. detached_events |= recurrence.calendar_event_ids
  879. recurrence.calendar_event_ids.recurrence_id = False
  880. recurrences_to_unlink |= recurrence
  881. recurrences_to_unlink.with_context(archive_on_error=True).unlink()
  882. return detached_events - self
  883. def _get_time_update_dict(self, base_event, time_values):
  884. """ Return the update dictionary for shifting the base_event's time to the new date. """
  885. if not base_event:
  886. raise UserError(_("You can't update a recurrence without base event."))
  887. [base_time_values] = base_event.read(['start', 'stop', 'allday'])
  888. update_dict = {}
  889. start_update = fields.Datetime.to_datetime(time_values.get('start'))
  890. stop_update = fields.Datetime.to_datetime(time_values.get('stop'))
  891. # Convert the base_event_id hours according to new values: time shift
  892. if start_update or stop_update:
  893. if start_update:
  894. start = base_time_values['start'] + (start_update - self.start)
  895. stop = base_time_values['stop'] + (start_update - self.start)
  896. start_date = base_time_values['start'].date() + (start_update.date() - self.start.date())
  897. stop_date = base_time_values['stop'].date() + (start_update.date() - self.start.date())
  898. update_dict.update({'start': start, 'start_date': start_date, 'stop': stop, 'stop_date': stop_date})
  899. if stop_update:
  900. if not start_update:
  901. # Apply the same shift for start
  902. start = base_time_values['start'] + (stop_update - self.stop)
  903. start_date = base_time_values['start'].date() + (stop_update.date() - self.stop.date())
  904. update_dict.update({'start': start, 'start_date': start_date})
  905. stop = base_time_values['stop'] + (stop_update - self.stop)
  906. stop_date = base_time_values['stop'].date() + (stop_update.date() - self.stop.date())
  907. update_dict.update({'stop': stop, 'stop_date': stop_date})
  908. return update_dict
  909. @api.model
  910. def _get_archive_values(self):
  911. """ Return parameters for archiving events in calendar module. """
  912. return {'active': False}
  913. @api.model
  914. def _check_values_to_sync(self, values):
  915. """ Method to be overriden: return candidate values to be synced within rewrite_recurrence function scope. """
  916. return False
  917. @api.model
  918. def _get_update_future_events_values(self):
  919. """ Return parameters for updating future events within _update_future_events function scope. """
  920. return {}
  921. @api.model
  922. def _get_remove_sync_id_values(self):
  923. """ Return parameters for removing event synchronization id within _update_future_events function scope. """
  924. return {}
  925. def _get_updated_recurrence_values(self, new_start_date):
  926. """ Copy values from current recurrence and update the start date weekday. """
  927. [previous_recurrence_values] = self.recurrence_id.copy_data()
  928. if self.start.weekday() != new_start_date.weekday():
  929. previous_recurrence_values.pop(weekday_to_field(self.start.weekday()), None)
  930. return previous_recurrence_values
  931. def _update_future_events(self, values, time_values, recurrence_values):
  932. """
  933. Trim the current recurrence detaching the occurrences after current event,
  934. deactivate the detached events except for the updated event and apply recurrence values.
  935. """
  936. self.ensure_one()
  937. update_dict = self._get_time_update_dict(self, time_values)
  938. time_values.update(update_dict)
  939. # Get base values from the previous recurrence and update the start date weekday field.
  940. start_date = time_values['start'].date() if 'start' in time_values else self.start.date()
  941. previous_recurrence_values = self._get_updated_recurrence_values(start_date)
  942. # Trim previous recurrence at current event, deleting following events except for the updated event.
  943. detached_events_split = self.recurrence_id._stop_at(self)
  944. (detached_events_split - self).write({'active': False, **self._get_remove_sync_id_values()})
  945. # Update the current event with the new recurrence information.
  946. if values or time_values:
  947. self.write({
  948. **time_values, **values,
  949. **self._get_remove_sync_id_values(),
  950. **self._get_update_future_events_values()
  951. })
  952. # Combine parameters from previous recurrence with the new recurrence parameters.
  953. new_values = {
  954. **previous_recurrence_values,
  955. **self._get_recurrence_params_by_date(start_date),
  956. **recurrence_values,
  957. 'count': recurrence_values.get('count', 0) or len(detached_events_split)
  958. }
  959. new_values.pop('rrule', None)
  960. # Generate the new recurrence by patching the updated event and return an empty list.
  961. self._apply_recurrence_values(new_values)
  962. def _rewrite_recurrence(self, values, time_values, recurrence_values):
  963. """ Delete the current recurrence, reactivate base event and apply updated recurrence values. """
  964. self.ensure_one()
  965. base_event = self.recurrence_id.base_event_id
  966. update_dict = self._get_time_update_dict(base_event, time_values)
  967. time_values.update(update_dict)
  968. if self._check_values_to_sync(values) or time_values or recurrence_values:
  969. # Get base values from the previous recurrence and update the start date weekday field.
  970. start_date = time_values['start'].date() if 'start' in time_values else self.start.date()
  971. old_recurrence_values = self._get_updated_recurrence_values(start_date)
  972. # Archive all events and delete recurrence, reactivate base event and apply updated values.
  973. base_event.action_mass_archive("all_events")
  974. base_event.recurrence_id.unlink()
  975. base_event.write({
  976. 'active': True,
  977. 'recurrence_id': False,
  978. **values, **time_values
  979. })
  980. # Combine parameters from previous recurrence with the new recurrence parameters.
  981. new_values = {
  982. **old_recurrence_values,
  983. **base_event._get_recurrence_params(),
  984. **recurrence_values,
  985. }
  986. new_values.pop('rrule', None)
  987. # Patch base event with updated recurrence parameters: this will recreate the recurrence.
  988. detached_events = base_event._apply_recurrence_values(new_values)
  989. detached_events.write({'active': False})
  990. else:
  991. # Write on all events. Carefull, it could trigger a lot of noise to Google/Microsoft...
  992. self.recurrence_id._write_events(values)
  993. # ------------------------------------------------------------
  994. # MANAGEMENT
  995. # ------------------------------------------------------------
  996. def change_attendee_status(self, status, recurrence_update_setting):
  997. self.ensure_one()
  998. if recurrence_update_setting == 'all_events':
  999. events = self.recurrence_id.calendar_event_ids
  1000. elif recurrence_update_setting == 'future_events':
  1001. events = self.recurrence_id.calendar_event_ids.filtered(lambda ev: ev.start >= self.start)
  1002. else:
  1003. events = self
  1004. attendee = events.attendee_ids.filtered(lambda x: x.partner_id == self.env.user.partner_id)
  1005. if status == 'accepted':
  1006. all_events = recurrence_update_setting == 'all_events'
  1007. return attendee.with_context(all_events=all_events).do_accept()
  1008. if status == 'declined':
  1009. return attendee.do_decline()
  1010. return attendee.do_tentative()
  1011. def find_partner_customer(self):
  1012. self.ensure_one()
  1013. return next(
  1014. (attendee.partner_id for attendee in self.attendee_ids.sorted('create_date')
  1015. if attendee.partner_id != self.user_id.partner_id),
  1016. self.env['calendar.attendee']
  1017. )
  1018. # ------------------------------------------------------------
  1019. # TOOLS
  1020. # ------------------------------------------------------------
  1021. def _find_attendee_batch(self):
  1022. """ Return the first attendee where the user connected has been invited
  1023. or the attendee selected in the filter that is the owner
  1024. from all the meeting_ids in parameters.
  1025. """
  1026. result = defaultdict(lambda: self.env['calendar.attendee'])
  1027. self_attendees = self.attendee_ids.filtered(lambda a: a.partner_id == self.env.user.partner_id)
  1028. for attendee in self_attendees:
  1029. result[attendee.event_id.id] = attendee
  1030. remaining_events = self - self_attendees.event_id
  1031. events_checked_partners = self.env['calendar.filters'].search([
  1032. ('user_id', '=', self.env.user.id),
  1033. ('partner_id', 'in', remaining_events.attendee_ids.partner_id.ids),
  1034. ('partner_checked', '=', True)
  1035. ]).partner_id
  1036. filter_events = self.env['calendar.event']
  1037. for event in remaining_events:
  1038. event_partners = event.attendee_ids.partner_id
  1039. event_checked_partners = events_checked_partners & event_partners
  1040. if event.partner_id in event_checked_partners and event.partner_id in event_partners:
  1041. filter_events |= event
  1042. result[event.id] = event.attendee_ids.filtered(lambda attendee: attendee.partner_id == event.partner_id)[:1]
  1043. remaining_events -= filter_events
  1044. for event in remaining_events:
  1045. event_checked_partners = events_checked_partners & event_partners
  1046. attendee = event.attendee_ids.filtered(
  1047. lambda a: a.partner_id in event_checked_partners and a.state != "needsAction")
  1048. result[event.id] = attendee[:1]
  1049. return result
  1050. # YTI TODO MASTER: Remove deprecated method
  1051. def _find_attendee(self):
  1052. """ Return the first attendee where the user connected has been invited
  1053. or the attendee selected in the filter that is the owner
  1054. from all the meeting_ids in parameters.
  1055. """
  1056. self.ensure_one()
  1057. return self._find_attendee_batch()[self.id]
  1058. def _get_start_date(self):
  1059. """Return the event starting date in the event's timezone.
  1060. If no starting time is assigned (yet), return today as default
  1061. :return: date
  1062. """
  1063. if not self.start:
  1064. return fields.Date.today()
  1065. if self.recurrency and self.event_tz:
  1066. tz = pytz.timezone(self.event_tz)
  1067. # Ensure that all day events date are not calculated around midnight. TZ shift would potentially return bad date
  1068. start = self.start if not self.allday else self.start.replace(hour=12)
  1069. return pytz.utc.localize(start).astimezone(tz).date()
  1070. return self.start.date()
  1071. def _range(self):
  1072. self.ensure_one()
  1073. return (self.start, self.stop)
  1074. def get_display_time_tz(self, tz=False):
  1075. """ get the display_time of the meeting, forcing the timezone. This method is called from email template, to not use sudo(). """
  1076. self.ensure_one()
  1077. if tz:
  1078. self = self.with_context(tz=tz)
  1079. return self._get_display_time(self.start, self.stop, self.duration, self.allday)
  1080. def _get_ics_file(self):
  1081. """ Returns iCalendar file for the event invitation.
  1082. :returns a dict of .ics file content for each meeting
  1083. """
  1084. result = {}
  1085. def ics_datetime(idate, allday=False):
  1086. if idate:
  1087. if allday:
  1088. return idate
  1089. return idate.replace(tzinfo=pytz.timezone('UTC'))
  1090. return False
  1091. if not vobject:
  1092. return result
  1093. for meeting in self:
  1094. cal = vobject.iCalendar()
  1095. event = cal.add('vevent')
  1096. if not meeting.start or not meeting.stop:
  1097. raise UserError(_("First you have to specify the date of the invitation."))
  1098. event.add('created').value = ics_datetime(fields.Datetime.now())
  1099. event.add('dtstart').value = ics_datetime(meeting.start, meeting.allday)
  1100. event.add('dtend').value = ics_datetime(meeting.stop, meeting.allday)
  1101. event.add('summary').value = meeting.name
  1102. if not is_html_empty(meeting.description):
  1103. if 'appointment_type_id' in meeting._fields and self.appointment_type_id:
  1104. # convert_online_event_desc_to_text method for correct data formatting in external calendars
  1105. event.add('description').value = self.convert_online_event_desc_to_text(meeting.description)
  1106. else:
  1107. event.add('description').value = html2plaintext(meeting.description)
  1108. if meeting.location:
  1109. event.add('location').value = meeting.location
  1110. if meeting.rrule:
  1111. event.add('rrule').value = meeting.rrule
  1112. if meeting.alarm_ids:
  1113. for alarm in meeting.alarm_ids:
  1114. valarm = event.add('valarm')
  1115. interval = alarm.interval
  1116. duration = alarm.duration
  1117. trigger = valarm.add('TRIGGER')
  1118. trigger.params['related'] = ["START"]
  1119. if interval == 'days':
  1120. delta = timedelta(days=duration)
  1121. elif interval == 'hours':
  1122. delta = timedelta(hours=duration)
  1123. elif interval == 'minutes':
  1124. delta = timedelta(minutes=duration)
  1125. trigger.value = delta
  1126. valarm.add('DESCRIPTION').value = alarm.name or u'Odoo'
  1127. for attendee in meeting.attendee_ids:
  1128. attendee_add = event.add('attendee')
  1129. attendee_add.value = u'MAILTO:' + (attendee.email or u'')
  1130. # Add "organizer" field if email available
  1131. if meeting.partner_id.email:
  1132. organizer = event.add('organizer')
  1133. organizer.value = u'MAILTO:' + meeting.partner_id.email
  1134. if meeting.partner_id.name:
  1135. organizer.params['CN'] = [meeting.partner_id.display_name.replace('\"', '\'')]
  1136. result[meeting.id] = cal.serialize().encode('utf-8')
  1137. return result
  1138. def convert_online_event_desc_to_text(self, description):
  1139. """
  1140. We can sync the calendar events with google calendar, iCal and Outlook, and we
  1141. also pass the event description along with other data. This description needs
  1142. to be in plaintext to be displayed properly in above platforms. Because online
  1143. events have fixed format for the description, this method removes some specific
  1144. html tags, and converts it into readable plaintext (to be used in external
  1145. calendars). Note that for regular (offline) events, we simply use the standard
  1146. `html2plaintext` method instead.
  1147. """
  1148. desc_str = str(description)
  1149. tags_to_replace = ["<ul>", "</ul>", "<li>"]
  1150. for tag in tags_to_replace:
  1151. desc_str = desc_str.replace(tag, "")
  1152. desc_str = desc_str.replace("</li>", "<br/>")
  1153. return html2plaintext(desc_str)
  1154. @api.model
  1155. def _get_display_time(self, start, stop, zduration, zallday):
  1156. """ Return date and time (from to from) based on duration with timezone in string. Eg :
  1157. 1) if user add duration for 2 hours, return : August-23-2013 at (04-30 To 06-30) (Europe/Brussels)
  1158. 2) if event all day ,return : AllDay, July-31-2013
  1159. """
  1160. timezone = self._context.get('tz') or self.env.user.partner_id.tz or 'UTC'
  1161. # get date/time format according to context
  1162. format_date, format_time = self._get_date_formats()
  1163. # convert date and time into user timezone
  1164. self_tz = self.with_context(tz=timezone)
  1165. date = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(start))
  1166. date_deadline = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(stop))
  1167. # convert into string the date and time, using user formats
  1168. to_text = pycompat.to_text
  1169. date_str = to_text(date.strftime(format_date))
  1170. time_str = to_text(date.strftime(format_time))
  1171. if zallday:
  1172. display_time = _("All Day, %(day)s", day=date_str)
  1173. elif zduration < 24:
  1174. duration = date + timedelta(minutes=round(zduration*60))
  1175. duration_time = to_text(duration.strftime(format_time))
  1176. display_time = _(
  1177. u"%(day)s at (%(start)s To %(end)s) (%(timezone)s)",
  1178. day=date_str,
  1179. start=time_str,
  1180. end=duration_time,
  1181. timezone=timezone,
  1182. )
  1183. else:
  1184. dd_date = to_text(date_deadline.strftime(format_date))
  1185. dd_time = to_text(date_deadline.strftime(format_time))
  1186. display_time = _(
  1187. u"%(date_start)s at %(time_start)s To\n %(date_end)s at %(time_end)s (%(timezone)s)",
  1188. date_start=date_str,
  1189. time_start=time_str,
  1190. date_end=dd_date,
  1191. time_end=dd_time,
  1192. timezone=timezone,
  1193. )
  1194. return display_time
  1195. def _get_duration(self, start, stop):
  1196. """ Get the duration value between the 2 given dates. """
  1197. if not start or not stop:
  1198. return 0
  1199. duration = (stop - start).total_seconds() / 3600
  1200. return round(duration, 2)
  1201. @api.model
  1202. def _get_date_formats(self):
  1203. """ get current date and time format, according to the context lang
  1204. :return: a tuple with (format date, format time)
  1205. """
  1206. lang = get_lang(self.env)
  1207. return (lang.date_format, lang.time_format)
  1208. @api.model
  1209. def _get_recurrent_fields(self):
  1210. return {'byday', 'until', 'rrule_type', 'month_by', 'event_tz', 'rrule',
  1211. 'interval', 'count', 'end_type', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat',
  1212. 'sun', 'day', 'weekday'}
  1213. @api.model
  1214. def _get_time_fields(self):
  1215. return {'start', 'stop', 'start_date', 'stop_date'}
  1216. @api.model
  1217. def _get_custom_fields(self):
  1218. all_fields = self.fields_get(attributes=['manual'])
  1219. return {fname for fname in all_fields if all_fields[fname]['manual']}
  1220. @api.model
  1221. def _get_public_fields(self):
  1222. return self._get_recurrent_fields() | self._get_time_fields() | self._get_custom_fields() | {
  1223. 'id', 'active', 'allday',
  1224. 'duration', 'user_id', 'interval', 'partner_id',
  1225. 'count', 'rrule', 'recurrence_id', 'show_as', 'privacy'}