1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 ##############################################################################
22 from datetime import datetime, timedelta
23 from dateutil import parser
24 from dateutil.rrule import *
25 from osv import osv, fields
26 from tools.translate import _
34 from caldav_node import res_node_calendar
35 from orm_utils import get_last_modified
36 from tools.safe_eval import safe_eval as eval
41 raise osv.except_osv(_('vobject Import Error!'), _('Please install python-vobject from http://vobject.skyhouseconsulting.com/'))
43 # O-1 Optional and can come only once
44 # O-n Optional and can come more than once
45 # R-1 Required and can come only once
46 # R-n Required and can come more than once
48 def uid2openobjectid(cr, uidval, oomodel, rdate):
49 """ UID To Open Object Id
50 @param cr: the current row, from the database cursor,
51 @param uidval: Get USerId vale
52 @oomodel: Open Object ModelName
53 @param rdate: Get Recurrent Date
55 __rege = re.compile(r'OpenObject-([\w|\.]+)_([0-9]+)@(\w+)$')
58 wematch = __rege.match(uidval.encode('utf8'))
62 model, id, dbname = wematch.groups()
63 model_obj = pooler.get_pool(cr.dbname).get(model)
64 if (not model == oomodel) or (not dbname == cr.dbname):
66 qry = 'SELECT DISTINCT(id) FROM %s' % model_obj._table
68 qry += " WHERE recurrent_id=%s"
69 cr.execute(qry, (rdate,))
76 ids = map(lambda x: str(x[0]), cr.fetchall())
81 def openobjectid2uid(cr, uidval, oomodel):
82 """ Gives the value of UID for VEVENT
83 @param cr: the current row, from the database cursor,
84 @param uidval: Id value of the Event
85 @oomodel: Open Object ModelName """
87 value = 'OpenObject-%s_%s@%s' % (oomodel, uidval, cr.dbname)
91 """Take a dict of mail and convert to string.
94 if isinstance(arg, dict):
100 rstr = ard.get('name','')
101 if ard.get('company',False):
102 rstr += ' (%s)' % ard.get('company')
104 rstr += ' <%s>' % ard.get('email')
106 return ', '.join(ret)
108 def str2mailto(emailstr, multi=False):
109 """Split one email string to a dict of name, company, mail parts
111 @param multi Return an array, recognize comma-sep
113 # TODO: move to tools or sth.
114 mege = re.compile(r'([^\(\<]+) *(\((.*?)\))? *(\< ?(.*?) ?\>)? ?(\((.*?)\))? *$')
119 mailz = emailstr.split(',')
122 m = mege.match(mas.strip())
124 # one of the rare non-matching strings is "sad" :(
125 # retz.append({ 'name': mas.strip() })
127 raise ValueError("Invalid email address %r" % mas)
128 rd = { 'name': m.group(1).strip(),
129 'email': m.group(5), }
131 rd['company'] = m.group(3).strip()
133 rd['company'] = m.group(7).strip()
135 if rd['name'].startswith('"') and rd['name'].endswith('"'):
136 rd['name'] = rd['name'][1:-1]
144 def get_attribute_mapping(cr, uid, calname, context=None):
145 """ Attribute Mapping with Basic calendar fields and lines
146 @param cr: the current row, from the database cursor,
147 @param uid: the current user’s ID for security checks,
148 @param calname: Get Calendar name
149 @param context: A standard dictionary for contextual values """
153 pool = pooler.get_pool(cr.dbname)
154 field_obj = pool.get('basic.calendar.fields')
155 type_obj = pool.get('basic.calendar.lines')
156 domain = [('object_id.model', '=', context.get('model'))]
157 if context.get('calendar_id'):
158 domain.append(('calendar_id', '=', context.get('calendar_id')))
159 type_id = type_obj.search(cr, uid, domain)
160 fids = field_obj.search(cr, uid, [('type_id', '=', type_id[0])])
162 for field in field_obj.browse(cr, uid, fids, context=context):
163 attr = field.name.name
165 res[attr]['field'] = field.field_id.name
166 res[attr]['type'] = field.field_id.ttype
167 if field.fn == 'datetime_utc':
168 res[attr]['type'] = 'utc'
169 if field.fn == 'hours':
170 res[attr]['type'] = "timedelta"
171 if res[attr]['type'] in ('one2many', 'many2many', 'many2one'):
172 res[attr]['object'] = field.field_id.relation
173 elif res[attr]['type'] in ('selection') and field.mapping:
174 res[attr]['mapping'] = eval(field.mapping)
175 if not res.get('uid', None):
177 res['uid']['field'] = 'id'
178 res['uid']['type'] = "integer"
181 def map_data(cr, uid, obj, context=None):
183 @param self: The object pointer
184 @param cr: the current row, from the database cursor,"""
187 for map_dict in obj.__attribute__:
188 map_val = obj.ical_get(map_dict, 'value')
189 field = obj.ical_get(map_dict, 'field')
190 field_type = obj.ical_get(map_dict, 'type')
192 if field_type == 'selection':
195 if type(map_val) == list and len(map_val): #TOFIX: why need to check this
197 mapping = obj.__attribute__[map_dict].get('mapping', False)
199 map_val = mapping.get(map_val.lower(), False)
201 map_val = map_val.lower()
202 if field_type == 'many2many':
207 model = obj.__attribute__[map_dict].get('object', False)
208 modobj = obj.pool.get(model)
209 for map_vall in map_val:
210 id = modobj.create(cr, uid, map_vall, context=context)
212 vals[field] = [(6, 0, ids)]
214 if field_type == 'many2one':
216 if not map_val or not isinstance(map_val, dict):
219 model = obj.__attribute__[map_dict].get('object', False)
220 modobj = obj.pool.get(model)
221 # check if the record exists or not
222 key1 = map_val.keys()
223 value1 = map_val.values()
224 domain = [(key1[i], '=', value1[i]) for i in range(len(key1)) if value1[i]]
225 exist_id = modobj.search(cr, uid, domain, context=context)
229 id = modobj.create(cr, uid, map_val, context=context)
232 if field_type == 'timedelta':
234 vals[field] = (map_val.seconds/float(86400) + map_val.days)
235 vals[field] = map_val
238 class CalDAV(object):
240 _logger = logging.getLogger('document.caldav')
242 def ical_set(self, name, value, type):
243 """ set calendar Attribute
244 @param self: The object pointer,
245 @param name: Get Attribute Name
246 @param value: Get Attribute Value
247 @param type: Get Attribute Type
249 if name in self.__attribute__ and self.__attribute__[name]:
250 self.__attribute__[name][type] = value
253 def ical_get(self, name, type):
254 """ Get calendar Attribute
255 @param self: The object pointer,
256 @param name: Get Attribute Name
257 @param type: Get Attribute Type
259 if self.__attribute__.get(name):
260 val = self.__attribute__.get(name).get(type, None)
261 valtype = self.__attribute__.get(name).get('type', None)
263 if valtype and valtype == 'datetime' and val:
264 if isinstance(val, list):
265 val = ','.join(map(lambda x: x.strftime('%Y-%m-%d %H:%M:%S'), val))
267 val = val.strftime('%Y-%m-%d %H:%M:%S')
270 return self.__attribute__.get(name, None)
272 def ical_reset(self, type):
273 """ Reset Calendar Attribute
274 @param self: The object pointer,
275 @param type: Get Attribute Type
277 for name in self.__attribute__:
278 if self.__attribute__[name]:
279 self.__attribute__[name][type] = None
282 def format_date_tz(self, src_date, tz=None):
283 """ This function converts date into specifice timezone value
284 @param src_date: Date to be converted (datetime.datetime)
285 @return: Converted datetime.datetime object for the date
287 format = tools.DEFAULT_SERVER_DATETIME_FORMAT
288 date_str = src_date.strftime('%Y-%m-%d %H:%M:%S')
289 res_date = tools.server_to_local_timestamp(date_str, format, format, tz)
290 return datetime.strptime(res_date, "%Y-%m-%d %H:%M:%S")
292 def parse_ics(self, cr, uid, child, cal_children=None, context=None):
293 """ parse calendaring and scheduling information
294 @param self: The object pointer
295 @param cr: the current row, from the database cursor,
296 @param uid: the current user’s ID for security checks,
297 @param context: A standard dictionary for contextual values """
301 _server_tzinfo = pytz.timezone(tools.get_server_timezone())
303 for cal_data in child.getChildren():
304 if cal_data.name.lower() == 'organizer':
305 dmail = { 'name': cal_data.params.get('CN', ['',])[0],
306 'email': cal_data.value.lower().replace('mailto:',''),
309 self.ical_set(cal_data.name.lower(), mailto2str(dmail), 'value')
311 if cal_data.name.lower() == 'attendee':
314 ctx.update({'model': cal_children[cal_data.name.lower()]})
315 attendee = self.pool.get('basic.calendar.attendee')
316 att_data.append(attendee.import_cal(cr, uid, cal_data, context=ctx))
317 self.ical_set(cal_data.name.lower(), att_data, 'value')
319 if cal_data.name.lower() == 'valarm':
320 alarm = self.pool.get('basic.calendar.alarm')
323 ctx.update({'model': cal_children[cal_data.name.lower()]})
324 vals = alarm.import_cal(cr, uid, cal_data, context=ctx)
325 self.ical_set(cal_data.name.lower(), vals, 'value')
327 if cal_data.name.lower() == 'exdate':
328 exdates += cal_data.value
330 for exdate in exdates:
331 exvals.append(datetime.fromtimestamp(time.mktime(exdate.utctimetuple())).strftime('%Y%m%dT%H%M%S'))
332 self.ical_set(cal_data.name.lower(), ','.join(exvals), 'value')
334 if cal_data.name.lower() in self.__attribute__:
335 if cal_data.params.get('X-VOBJ-ORIGINAL-TZID'):
336 self.ical_set('vtimezone', cal_data.params.get('X-VOBJ-ORIGINAL-TZID'), 'value')
337 date_local = cal_data.value.astimezone(_server_tzinfo)
338 self.ical_set(cal_data.name.lower(), date_local, 'value')
340 self.ical_set(cal_data.name.lower(), cal_data.value, 'value')
341 vals = map_data(cr, uid, self, context=context)
344 def create_ics(self, cr, uid, datas, name, ical, context=None):
345 """ create calendaring and scheduling information
346 @param self: The object pointer
347 @param cr: the current row, from the database cursor,
348 @param uid: the current user’s ID for security checks,
349 @param context: A standard dictionary for contextual values """
358 vevent = ical.add(name)
359 for field in self.__attribute__.keys():
360 map_field = self.ical_get(field, 'field')
361 map_type = self.ical_get(field, 'type')
362 if map_field in data.keys():
364 model = context.get('model', None)
367 uidval = openobjectid2uid(cr, data[map_field], model)
368 #Computation for getting events with the same UID (RFC4791 Section4.1)
370 model_obj = self.pool.get(model)
372 if model_obj._columns.get('recurrent_uid', None):
373 cr.execute('SELECT id FROM %s WHERE recurrent_uid=%%s' % model_obj._table,
375 r_ids = map(lambda x: x[0], cr.fetchall())
377 r_datas = model_obj.read(cr, uid, r_ids, context=context)
378 rcal = CalDAV.export_cal(self, cr, uid, r_datas, 'vevent', context=context)
379 for revents in rcal.contents.get('vevent', []):
380 ical.contents['vevent'].append(revents)
382 if data.get('recurrent_uid', None):
383 # Change the UID value in case of modified event from any recurrent event
384 uidval = openobjectid2uid(cr, data['recurrent_uid'], model)
385 vevent.add('uid').value = uidval
386 elif field == 'attendee' and data[map_field]:
387 model = self.__attribute__[field].get('object', False)
388 attendee_obj = self.pool.get('basic.calendar.attendee')
389 vevent = attendee_obj.export_cal(cr, uid, model, \
390 data[map_field], vevent, context=context)
391 elif field == 'valarm' and data[map_field]:
392 model = self.__attribute__[field].get('object', False)
394 ctx.update({'model': model})
395 alarm_obj = self.pool.get('basic.calendar.alarm')
396 vevent = alarm_obj.export_cal(cr, uid, model, \
397 data[map_field][0], vevent, context=ctx)
398 elif field == 'vtimezone' and data[map_field]:
399 tzval = data[map_field]
400 if tzval not in timezones:
401 tz_obj = self.pool.get('basic.calendar.timezone')
402 ical = tz_obj.export_cal(cr, uid, None, \
403 data[map_field], ical, context=context)
404 timezones.append(data[map_field])
405 if vevent.contents.get('recurrence-id'):
406 # Convert recurrence-id field value accroding to timezone value
407 recurid_val = vevent.contents.get('recurrence-id')[0].value
408 vevent.contents.get('recurrence-id')[0].params['TZID'] = [tzval.title()]
409 vevent.contents.get('recurrence-id')[0].value = self.format_date_tz(recurid_val, tzval.title())
411 # Set exdates according to timezone value
412 # This is the case when timezone mapping comes after the exdate mapping
413 # and we have exdate value available
414 exfield.params['TZID'] = [tzval.title()]
416 for exdate in exdates:
417 exdates_updated.append(self.format_date_tz(parser.parse(exdate), tzval.title()))
418 exfield.value = exdates_updated
419 elif field == 'organizer' and data[map_field]:
420 organizer = str2mailto(data[map_field])
421 event_org = vevent.add('organizer')
422 event_org.params['CN'] = [organizer['name']]
423 event_org.value = 'MAILTO:' + (organizer.get('email') or '')
425 elif data[map_field]:
426 if map_type in ("char", "text"):
427 if field in ('exdate'):
428 exfield = vevent.add(field)
429 exdates = (data[map_field]).split(',')
431 # Set exdates according to timezone value
432 # This is the case when timezone mapping comes before the exdate mapping
433 # and we have timezone value available
434 exfield.params['TZID'] = [tzval.title()]
436 for exdate in exdates:
437 exdates_updated.append(self.format_date_tz(parser.parse(exdate), tzval.title()))
438 exfield.value = exdates_updated
440 vevent.add(field).value = tools.ustr(data[map_field])
441 elif map_type in ('datetime', 'date') and data[map_field]:
442 dtfield = vevent.add(field)
444 # Export the date according to the event timezone value
445 dtfield.params['TZID'] = [tzval.title()]
446 dtfield.value = self.format_date_tz(parser.parse(data[map_field]), tzval.title())
448 dtfield.value = parser.parse(data[map_field])
450 elif map_type == 'utc'and data[map_field]:
452 local = pytz.timezone (tzval.title())
453 naive = datetime.strptime (data[map_field], "%Y-%m-%d %H:%M:%S")
454 local_dt = naive.replace (tzinfo = local)
455 utc_dt = local_dt.astimezone (pytz.utc)
456 vevent.add(field).value = utc_dt
458 utc_timezone = pytz.timezone ('UTC')
459 naive = datetime.strptime (data[map_field], "%Y-%m-%d %H:%M:%S")
460 local_dt = naive.replace (tzinfo = utc_timezone)
461 utc_dt = local_dt.astimezone (pytz.utc)
462 vevent.add(field).value = utc_dt
464 elif map_type == "timedelta":
465 vevent.add(field).value = timedelta(hours=data[map_field])
466 elif map_type == "many2one":
467 vevent.add(field).value = tools.ustr(data.get(map_field)[1])
468 elif map_type in ("float", "integer"):
469 vevent.add(field).value = str(data.get(map_field))
470 elif map_type == "selection":
471 if not self.ical_get(field, 'mapping'):
472 vevent.add(field).value = (tools.ustr(data[map_field])).upper()
474 for key1, val1 in self.ical_get(field, 'mapping').items():
475 if val1 == data[map_field]:
476 vevent.add(field).value = key1.upper()
479 def check_import(self, cr, uid, vals, context=None):
481 @param self: The object pointer
482 @param cr: the current row, from the database cursor,
483 @param uid: the current user’s ID for security checks,
484 @param vals: Get Values
485 @param context: A standard dictionary for contextual values
490 model_obj = self.pool.get(context.get('model'))
494 # Compute value of duration
495 if 'date_deadline' in val and 'duration' not in val:
496 start = datetime.strptime(val['date'], '%Y-%m-%d %H:%M:%S')
497 end = datetime.strptime(val['date_deadline'], '%Y-%m-%d %H:%M:%S')
499 val['duration'] = (diff.seconds/float(86400) + diff.days) * 24
500 exists, r_id = calendar.uid2openobjectid(cr, val['id'], context.get('model'), \
501 val.get('recurrent_id'))
502 if val.has_key('create_date'):
503 val.pop('create_date')
504 u_id = val.get('id', None)
507 val.update({'recurrent_uid': exists})
508 model_obj.write(cr, uid, [r_id], val)
511 model_obj.write(cr, uid, [exists], val)
514 if u_id in recur_pool and val.get('recurrent_id'):
515 val.update({'recurrent_uid': recur_pool[u_id]})
516 revent_id = model_obj.create(cr, uid, val)
517 ids.append(revent_id)
519 __rege = re.compile(r'OpenObject-([\w|\.]+)_([0-9]+)@(\w+)$')
520 wematch = __rege.match(u_id.encode('utf8'))
522 model, recur_id, dbname = wematch.groups()
523 val.update({'recurrent_uid': recur_id})
524 event_id = model_obj.create(cr, uid, val)
525 recur_pool[u_id] = event_id
531 def export_cal(self, cr, uid, datas, vobj=None, context=None):
533 @param self: The object pointer
534 @param cr: the current row, from the database cursor,
535 @param uid: the current user’s ID for security checks,
536 @param datas: Get Data's for caldav
537 @param context: A standard dictionary for contextual values
540 self.__attribute__ = get_attribute_mapping(cr, uid, self._calname, context)
541 ical = vobject.iCalendar()
542 self.create_ics(cr, uid, datas, vobj, ical, context=context)
545 raise # osv.except_osv(('Error !'), (str(e)))
547 def import_cal(self, cr, uid, content, data_id=None, context=None):
549 @param self: The object pointer
550 @param cr: the current row, from the database cursor,
551 @param uid: the current user’s ID for security checks,
552 @param data_id: Get Data’s ID or False
553 @param context: A standard dictionary for contextual values
557 self.__attribute__ = get_attribute_mapping(cr, uid, self._calname, context)
558 parsedCal = vobject.readOne(ical_data)
561 for child in parsedCal.getChildren():
562 if child.name.lower() in ('vevent', 'vtodo'):
563 vals = self.parse_ics(cr, uid, child, context=context)
567 if vals: res.append(vals)
568 self.ical_reset('value')
571 class Calendar(CalDAV, osv.osv):
572 _name = 'basic.calendar'
573 _calname = 'calendar'
576 'prodid': None, # Use: R-1, Type: TEXT, Specifies the identifier for the product that created the iCalendar object.
577 'version': None, # Use: R-1, Type: TEXT, Specifies the identifier corresponding to the highest version number
578 # or the minimum and maximum range of the iCalendar specification
579 # that is required in order to interpret the iCalendar object.
580 'calscale': None, # Use: O-1, Type: TEXT, Defines the calendar scale used for the calendar information specified in the iCalendar object.
581 'method': None, # Use: O-1, Type: TEXT, Defines the iCalendar object method associated with the calendar object.
582 'vevent': None, # Use: O-n, Type: Collection of Event class
583 'vtodo': None, # Use: O-n, Type: Collection of ToDo class
584 'vjournal': None, # Use: O-n, Type: Collection of Journal class
585 'vfreebusy': None, # Use: O-n, Type: Collection of FreeBusy class
586 'vtimezone': None, # Use: O-n, Type: Collection of Timezone class
589 'name': fields.char("Name", size=64),
590 'user_id': fields.many2one('res.users', 'Owner'),
591 'collection_id': fields.many2one('document.directory', 'Collection', \
593 'type': fields.selection([('vevent', 'Event'), ('vtodo', 'TODO')], \
594 string="Type", size=64),
595 'line_ids': fields.one2many('basic.calendar.lines', 'calendar_id', 'Calendar Lines'),
596 'create_date': fields.datetime('Created Date', readonly=True),
597 'write_date': fields.datetime('Write Date', readonly=True),
598 'description': fields.text("Description"),
599 'calendar_color': fields.char('Color', size=20, help="For supporting clients, the color of the calendar entries"),
600 'calendar_order': fields.integer('Order', help="For supporting clients, the order of this folder among the calendars"),
601 'has_webcal': fields.boolean('WebCal', required=True, help="Also export a <name>.ics entry next to the calendar folder, with WebCal content."),
608 def get_calendar_objects(self, cr, uid, ids, parent=None, domain=None, context=None):
614 ctx_res_id = context.get('res_id', None)
615 ctx_model = context.get('model', None)
616 for cal in self.browse(cr, uid, ids):
617 for line in cal.line_ids:
618 if ctx_model and ctx_model != line.object_id.model:
620 if line.name in ('valarm', 'attendee'):
622 line_domain = eval(line.domain or '[]', context)
623 line_domain += domain
625 line_domain += [('id','=',ctx_res_id)]
626 mod_obj = self.pool.get(line.object_id.model)
627 data_ids = mod_obj.search(cr, uid, line_domain, order="id", context=context)
628 for data in mod_obj.browse(cr, uid, data_ids, context):
629 ctx = parent and parent.context or None
630 if hasattr(data, 'recurrent_uid') and data.recurrent_uid:
631 # Skip for event which is child of other event
633 node = res_node_calendar('%s.ics' %data.id, parent, ctx, data, line.object_id.model, data.id)
638 def get_cal_max_modified(self, cr, uid, ids, parent=None, domain=None, context=None):
644 ctx_res_id = context.get('res_id', None)
645 ctx_model = context.get('model', None)
646 for cal in self.browse(cr, uid, ids):
647 for line in cal.line_ids:
648 if ctx_model and ctx_model != line.object_id.model:
650 if line.name in ('valarm', 'attendee'):
652 line_domain = eval(line.domain or '[]', context)
653 line_domain += domain
655 line_domain += [('id','=',ctx_res_id)]
656 mod_obj = self.pool.get(line.object_id.model)
657 max_data = get_last_modified(mod_obj, cr, uid, line_domain, context=context)
658 if res and res > max_data:
663 def export_cal(self, cr, uid, ids, vobj='vevent', context=None):
665 @param ids: List of calendar’s IDs
666 @param vobj: the type of object to export
667 @return the ical data.
671 ctx_model = context.get('model', None)
672 ctx_res_id = context.get('res_id', None)
673 ical = vobject.iCalendar()
674 for cal in self.browse(cr, uid, ids, context=context):
675 for line in cal.line_ids:
676 if ctx_model and ctx_model != line.object_id.model:
678 if line.name in ('valarm', 'attendee'):
680 domain = eval(line.domain or '[]', context)
682 domain += [('id','=',ctx_res_id)]
683 mod_obj = self.pool.get(line.object_id.model)
684 data_ids = mod_obj.search(cr, uid, domain, context=context)
685 datas = mod_obj.read(cr, uid, data_ids, context=context)
686 context.update({'model': line.object_id.model,
687 'calendar_id': cal.id
689 self.__attribute__ = get_attribute_mapping(cr, uid, line.name, context)
690 self.create_ics(cr, uid, datas, line.name, ical, context=context)
691 return ical.serialize()
693 def import_cal(self, cr, uid, content, data_id=None, context=None):
695 @param self: The object pointer
696 @param cr: the current row, from the database cursor,
697 @param uid: the current user’s ID for security checks,
698 @param data_id: Get Data’s ID or False
699 @param context: A standard dictionary for contextual values
705 parsedCal = vobject.readOne(ical_data)
707 data_id = self.search(cr, uid, [])[0]
708 cal = self.browse(cr, uid, data_id, context=context)
711 for line in cal.line_ids:
712 cal_children[line.name] = line.object_id.model
715 for child in parsedCal.getChildren():
716 if child.name.lower() in cal_children:
717 context.update({'model': cal_children[child.name.lower()],
718 'calendar_id': cal['id']
720 self.__attribute__ = get_attribute_mapping(cr, uid, child.name.lower(), context=context)
721 val = self.parse_ics(cr, uid, child, cal_children=cal_children, context=context)
723 objs.append(cal_children[child.name.lower()])
724 elif child.name.upper() == 'CALSCALE':
725 if child.value.upper() != 'GREGORIAN':
726 self._logger.warning('How do I handle %s calendars?',child.value)
727 elif child.name.upper() in ('PRODID', 'VERSION'):
729 elif child.name.upper().startswith('X-'):
730 self._logger.debug("skipping custom node %s", child.name)
732 self._logger.debug("skipping node %s", child.name)
735 for obj_name in list(set(objs)):
736 obj = self.pool.get(obj_name)
737 if hasattr(obj, 'check_import'):
738 r = obj.check_import(cr, uid, vals, context=context)
743 r = self.check_import(cr, uid, vals, context=context)
750 class basic_calendar_line(osv.osv):
751 """ Calendar Lines """
753 _name = 'basic.calendar.lines'
754 _description = 'Calendar Lines'
757 'name': fields.selection([('vevent', 'Event'), ('vtodo', 'TODO'), \
758 ('valarm', 'Alarm'), \
759 ('attendee', 'Attendee')], \
760 string="Type", size=64),
761 'object_id': fields.many2one('ir.model', 'Object'),
762 'calendar_id': fields.many2one('basic.calendar', 'Calendar', \
763 required=True, ondelete='cascade'),
764 'domain': fields.char('Domain', size=124),
765 'mapping_ids': fields.one2many('basic.calendar.fields', 'type_id', 'Fields Mapping')
769 'domain': lambda *a: '[]',
772 def create(self, cr, uid, vals, context=None):
773 """ create calendar's line
774 @param self: The object pointer
775 @param cr: the current row, from the database cursor,
776 @param uid: the current user’s ID for security checks,
777 @param vals: Get the Values
778 @param context: A standard dictionary for contextual values
781 cr.execute("SELECT COUNT(id) FROM basic_calendar_lines \
782 WHERE name=%s AND calendar_id=%s",
783 (vals.get('name'), vals.get('calendar_id')))
787 raise osv.except_osv(_('Warning !'), _('Can not create line "%s" more than once') % (vals.get('name')))
788 return super(basic_calendar_line, self).create(cr, uid, vals, context=context)
790 basic_calendar_line()
792 class basic_calendar_alias(osv.osv):
793 """ Mapping of client filenames to ORM ids of calendar records
795 Since some clients insist on putting arbitrary filenames on the .ics data
796 they send us, and they won't respect the redirection "Location:" header,
797 we have to store those filenames and allow clients to call our calendar
799 Note that adding a column to all tables that would possibly hold calendar-
800 mapped data won't work. The user is always allowed to specify more
801 calendars, on any arbitrary ORM object, without need to alter those tables'
804 _name = 'basic.calendar.alias'
806 'name': fields.char('Filename', size=512, required=True, select=1),
807 'cal_line_id': fields.many2one('basic.calendar.lines', 'Calendar', required=True,
808 select=1, help='The calendar/line this mapping applies to'),
809 'res_id': fields.integer('Res. ID', required=True, select=1),
812 _sql_constraints = [ ('name_cal_uniq', 'UNIQUE(cal_line_id, name)',
813 _('The same filename cannot apply to two records!')), ]
815 basic_calendar_alias()
817 class basic_calendar_attribute(osv.osv):
818 _name = 'basic.calendar.attributes'
819 _description = 'Calendar attributes'
821 'name': fields.char("Name", size=64, required=True),
822 'type': fields.selection([('vevent', 'Event'), ('vtodo', 'TODO'), \
823 ('alarm', 'Alarm'), \
824 ('attendee', 'Attendee')], \
825 string="Type", size=64, required=True),
828 basic_calendar_attribute()
831 class basic_calendar_fields(osv.osv):
832 """ Calendar fields """
834 _name = 'basic.calendar.fields'
835 _description = 'Calendar fields'
839 'field_id': fields.many2one('ir.model.fields', 'OpenObject Field'),
840 'name': fields.many2one('basic.calendar.attributes', 'Name', required=True),
841 'type_id': fields.many2one('basic.calendar.lines', 'Type', \
842 required=True, ondelete='cascade'),
843 'expr': fields.char("Expression", size=64),
844 'fn': fields.selection([('field', 'Use the field'),
845 ('const', 'Expression as constant'),
846 ('hours', 'Interval in hours'),
847 ('datetime_utc', 'Datetime In UTC'),
849 'mapping': fields.text('Mapping'),
857 ( 'name_type_uniq', 'UNIQUE(name, type_id)', 'Can not map a field more than once'),
860 def check_line(self, cr, uid, vals, name, context=None):
861 """ check calendar's line
862 @param self: The object pointer
863 @param cr: the current row, from the database cursor,
864 @param uid: the current user’s ID for security checks,
865 @param vals: Get Values
866 @param context: A standard dictionary for contextual values
868 f_obj = self.pool.get('ir.model.fields')
869 field = f_obj.browse(cr, uid, vals['field_id'], context=context)
870 relation = field.relation
871 line_obj = self.pool.get('basic.calendar.lines')
872 l_id = line_obj.search(cr, uid, [('name', '=', name)])
874 line = line_obj.browse(cr, uid, l_id, context=context)[0]
875 line_rel = line.object_id.model
876 if (relation != 'NULL') and (not relation == line_rel):
877 raise osv.except_osv(_('Warning !'), _('Please provide proper configuration of "%s" in Calendar Lines') % (name))
880 def create(self, cr, uid, vals, context=None):
881 """ Create Calendar's fields
882 @param self: The object pointer
883 @param cr: the current row, from the database cursor,
884 @param uid: the current user’s ID for security checks,
885 @param vals: Get Values
886 @param context: A standard dictionary for contextual values
889 cr.execute('SELECT name FROM basic_calendar_attributes \
890 WHERE id=%s', (vals.get('name'),))
893 if name in ('valarm', 'attendee'):
894 self.check_line(cr, uid, vals, name, context=context)
895 return super(basic_calendar_fields, self).create(cr, uid, vals, context=context)
897 def write(self, cr, uid, ids, vals, context=None):
898 """ write Calendar's fields
899 @param self: The object pointer
900 @param cr: the current row, from the database cursor,
901 @param uid: the current user’s ID for security checks,
902 @param vals: Get Values
903 @param context: A standard dictionary for contextual values
909 field = self.browse(cr, uid, id, context=context)
910 name = field.name.name
911 if name in ('valarm', 'attendee'):
912 self.check_line(cr, uid, vals, name, context=context)
913 return super(basic_calendar_fields, self).write(cr, uid, ids, vals, context)
915 basic_calendar_fields()
918 class Event(CalDAV, osv.osv_memory):
919 _name = 'basic.calendar.event'
922 'class': None, # Use: O-1, Type: TEXT, Defines the access classification for a calendar component like "PUBLIC" / "PRIVATE" / "CONFIDENTIAL"
923 'created': None, # Use: O-1, Type: DATE-TIME, Specifies the date and time that the calendar information was created by the calendar user agent in the calendar store.
924 'description': None, # Use: O-1, Type: TEXT, Provides a more complete description of the calendar component, than that provided by the "SUMMARY" property.
925 'dtstart': None, # Use: O-1, Type: DATE-TIME, Specifies when the calendar component begins.
926 'geo': None, # Use: O-1, Type: FLOAT, Specifies information related to the global position for the activity specified by a calendar component.
927 'last-mod': None, # Use: O-1, Type: DATE-TIME Specifies the date and time that the information associated with the calendar component was last revised in the calendar store.
928 'location': None, # Use: O-1, Type: TEXT Defines the intended venue for the activity defined by a calendar component.
929 'organizer': None, # Use: O-1, Type: CAL-ADDRESS, Defines the organizer for a calendar component.
930 'priority': None, # Use: O-1, Type: INTEGER, Defines the relative priority for a calendar component.
931 'dtstamp': None, # Use: O-1, Type: DATE-TIME, Indicates the date/time that the instance of the iCalendar object was created.
932 'seq': None, # Use: O-1, Type: INTEGER, Defines the revision sequence number of the calendar component within a sequence of revision.
933 'status': None, # Use: O-1, Type: TEXT, Defines the overall status or confirmation for the calendar component.
934 'summary': None, # Use: O-1, Type: TEXT, Defines a short summary or subject for the calendar component.
935 'transp': None, # Use: O-1, Type: TEXT, Defines whether an event is transparent or not to busy time searches.
936 'uid': None, # Use: O-1, Type: TEXT, Defines the persistent, globally unique identifier for the calendar component.
937 'url': None, # Use: O-1, Type: URL, Defines a Uniform Resource Locator (URL) associated with the iCalendar object.
939 'attach': None, # Use: O-n, Type: BINARY, Provides the capability to associate a document object with a calendar component.
940 'attendee': None, # Use: O-n, Type: CAL-ADDRESS, Defines an "Attendee" within a calendar component.
941 'categories': None, # Use: O-n, Type: TEXT, Defines the categories for a calendar component.
942 'comment': None, # Use: O-n, Type: TEXT, Specifies non-processing information intended to provide a comment to the calendar user.
943 'contact': None, # Use: O-n, Type: TEXT, Used to represent contact information or alternately a reference to contact information associated with the calendar component.
944 'exdate': None, # Use: O-n, Type: DATE-TIME, Defines the list of date/time exceptions for a recurring calendar component.
945 'exrule': None, # Use: O-n, Type: RECUR, Defines a rule or repeating pattern for an exception to a recurrence set.
947 'related': None, # Use: O-n, Specify the relationship of the alarm trigger with respect to the start or end of the calendar component.
948 # like A trigger set 5 minutes after the end of the event or to-do.---> TRIGGER;related=END:PT5M
949 'resources': None, # Use: O-n, Type: TEXT, Defines the equipment or resources anticipated for an activity specified by a calendar entity like RESOURCES:EASEL,PROJECTOR,VCR, LANGUAGE=fr:1 raton-laveur
950 'rdate': None, # Use: O-n, Type: DATE-TIME, Defines the list of date/times for a recurrence set.
951 'rrule': None, # Use: O-n, Type: RECUR, Defines a rule or repeating pattern for recurring events, to-dos, or time zone definitions.
953 'duration': None, # Use: O-1, Type: DURATION, Specifies a positive duration of time.
954 'dtend': None, # Use: O-1, Type: DATE-TIME, Specifies the date and time that a calendar component ends.
957 def export_cal(self, cr, uid, datas, vobj='vevent', context=None):
959 @param self: The object pointer
960 @param cr: the current row, from the database cursor,
961 @param uid: the current user’s ID for security checks,
962 @param datas: Get datas
963 @param context: A standard dictionary for contextual values
966 return super(Event, self).export_cal(cr, uid, datas, 'vevent', context=context)
971 class ToDo(CalDAV, osv.osv_memory):
972 _name = 'basic.calendar.todo'
1010 def export_cal(self, cr, uid, datas, vobj='vevent', context=None):
1012 @param self: The object pointer
1013 @param cr: the current row, from the database cursor,
1014 @param uid: the current user’s ID for security checks,
1015 @param datas: Get datas
1016 @param context: A standard dictionary for contextual values
1019 return super(ToDo, self).export_cal(cr, uid, datas, 'vtodo', context=context)
1024 class Journal(CalDAV):
1029 class FreeBusy(CalDAV):
1031 'contact': None, # Use: O-1, Type: Text, Represent contact information or alternately a reference to contact information associated with the calendar component.
1032 'dtstart': None, # Use: O-1, Type: DATE-TIME, Specifies when the calendar component begins.
1033 'dtend': None, # Use: O-1, Type: DATE-TIME, Specifies the date and time that a calendar component ends.
1034 'duration': None, # Use: O-1, Type: DURATION, Specifies a positive duration of time.
1035 'dtstamp': None, # Use: O-1, Type: DATE-TIME, Indicates the date/time that the instance of the iCalendar object was created.
1036 'organizer': None, # Use: O-1, Type: CAL-ADDRESS, Defines the organizer for a calendar component.
1037 'uid': None, # Use: O-1, Type: Text, Defines the persistent, globally unique identifier for the calendar component.
1038 'url': None, # Use: O-1, Type: URL, Defines a Uniform Resource Locator (URL) associated with the iCalendar object.
1039 'attendee': None, # Use: O-n, Type: CAL-ADDRESS, Defines an "Attendee" within a calendar component.
1040 'comment': None, # Use: O-n, Type: TEXT, Specifies non-processing information intended to provide a comment to the calendar user.
1041 'freebusy': None, # Use: O-n, Type: PERIOD, Defines one or more free or busy time intervals.
1047 class Timezone(CalDAV, osv.osv_memory):
1048 _name = 'basic.calendar.timezone'
1049 _calname = 'vtimezone'
1052 'tzid': {'field': 'tzid'}, # Use: R-1, Type: Text, Specifies the text value that uniquely identifies the "VTIMEZONE" calendar component.
1053 'last-mod': None, # Use: O-1, Type: DATE-TIME, Specifies the date and time that the information associated with the calendar component was last revised in the calendar store.
1054 'tzurl': None, # Use: O-1, Type: URI, Provides a means for a VTIMEZONE component to point to a network location that can be used to retrieve an up-to-date version of itself.
1055 'standardc': {'tzprop': None}, # Use: R-1,
1056 'daylightc': {'tzprop': None}, # Use: R-1,
1057 'x-prop': None, # Use: O-n, Type: Text,
1060 def get_name_offset(self, cr, uid, tzid, context=None):
1061 """ Get Name Offset value
1062 @param self: The object pointer
1063 @param cr: the current row, from the database cursor,
1064 @param uid: the current user’s ID for security checks,
1065 @param context: A standard dictionary for contextual values
1068 mytz = pytz.timezone(tzid.title())
1069 mydt = datetime.now(tz=mytz)
1070 offset = mydt.utcoffset()
1071 val = offset.days * 24 + float(offset.seconds) / 3600
1072 realoffset = '%02d%02d' % (math.floor(abs(val)), \
1073 round(abs(val) % 1 + 0.01, 2) * 60)
1074 realoffset = (val < 0 and ('-' + realoffset) or ('+' + realoffset))
1075 return (mydt.tzname(), realoffset)
1077 def export_cal(self, cr, uid, model, tzid, ical, context=None):
1079 @param self: The object pointer
1080 @param cr: the current row, from the database cursor,
1081 @param uid: the current user’s ID for security checks,
1082 @param model: Get Model's name
1083 @param context: A standard dictionary for contextual values
1087 ctx = context.copy()
1088 ctx.update({'model': model})
1089 cal_tz = ical.add('vtimezone')
1090 cal_tz.add('TZID').value = tzid.title()
1091 tz_std = cal_tz.add('STANDARD')
1092 tzname, offset = self.get_name_offset(cr, uid, tzid)
1093 tz_std.add("TZOFFSETFROM").value = offset
1094 tz_std.add("TZOFFSETTO").value = offset
1095 #TODO: Get start date for timezone
1096 tz_std.add("DTSTART").value = datetime.strptime('1970-01-01 00:00:00', '%Y-%m-%d %H:%M:%S')
1097 tz_std.add("TZNAME").value = tzname
1100 def import_cal(self, cr, uid, ical_data, context=None):
1102 @param self: The object pointer
1103 @param cr: the current row, from the database cursor,
1104 @param uid: the current user’s ID for security checks,
1105 @param ical_data: Get calendar's data
1106 @param context: A standard dictionary for contextual values
1109 for child in ical_data.getChildren():
1110 if child.name.lower() == 'tzid':
1111 tzname = child.value
1112 self.ical_set(child.name.lower(), tzname, 'value')
1113 vals = map_data(cr, uid, self, context=context)
1119 class Alarm(CalDAV, osv.osv_memory):
1120 _name = 'basic.calendar.alarm'
1124 'action': None, # Use: R-1, Type: Text, defines the action to be invoked when an alarm is triggered LIKE "AUDIO" / "DISPLAY" / "EMAIL" / "PROCEDURE"
1125 'description': None, # Type: Text, Provides a more complete description of the calendar component, than that provided by the "SUMMARY" property. Use:- R-1 for DISPLAY,Use:- R-1 for EMAIL,Use:- R-1 for PROCEDURE
1126 'summary': None, # Use: R-1, Type: Text Which contains the text to be used as the message subject. Use for EMAIL
1127 'attendee': None, # Use: R-n, Type: CAL-ADDRESS, Contain the email address of attendees to receive the message. It can also include one or more. Use for EMAIL
1128 'trigger': None, # Use: R-1, Type: DURATION, The "TRIGGER" property specifies a duration prior to the start of an event or a to-do. The "TRIGGER" edge may be explicitly set to be relative to the "START" or "END" of the event or to-do with the "related" parameter of the "TRIGGER" property. The "TRIGGER" property value type can alternatively be set to an absolute calendar date and time of day value. Use for all action like AUDIO, DISPLAY, EMAIL and PROCEDURE
1129 'duration': None, # Type: DURATION, Duration' and 'repeat' are both optional, and MUST NOT occur more than once each, but if one occurs, so MUST the other. Use:- 0-1 for AUDIO, EMAIL and PROCEDURE, Use:- 0-n for DISPLAY
1130 'repeat': None, # Type: INTEGER, Duration' and 'repeat' are both optional, and MUST NOT occur more than once each, but if one occurs, so MUST the other. Use:- 0-1 for AUDIO, EMAIL and PROCEDURE, Use:- 0-n for DISPLAY
1131 'attach': None, # Use:- O-n: which MUST point to a sound resource, which is rendered when the alarm is triggered for AUDIO, Use:- O-n: which are intended to be sent as message attachments for EMAIL, Use:- R-1:which MUST point to a procedure resource, which is invoked when the alarm is triggered for PROCEDURE.
1135 def export_cal(self, cr, uid, model, alarm_id, vevent, context=None):
1137 @param self: The object pointer
1138 @param cr: the current row, from the database cursor,
1139 @param uid: the current user’s ID for security checks,
1140 @param model: Get Model's name
1141 @param alarm_id: Get Alarm's Id
1142 @param context: A standard dictionary for contextual values
1144 valarm = vevent.add('valarm')
1145 alarm_object = self.pool.get(model)
1146 alarm_data = alarm_object.read(cr, uid, alarm_id, [])
1148 # Compute trigger data
1149 interval = alarm_data['trigger_interval']
1150 occurs = alarm_data['trigger_occurs']
1151 duration = (occurs == 'after' and alarm_data['trigger_duration']) \
1152 or -(alarm_data['trigger_duration'])
1153 related = alarm_data['trigger_related']
1154 trigger = valarm.add('TRIGGER')
1155 trigger.params['related'] = [related.upper()]
1156 if interval == 'days':
1157 delta = timedelta(days=duration)
1158 if interval == 'hours':
1159 delta = timedelta(hours=duration)
1160 if interval == 'minutes':
1161 delta = timedelta(minutes=duration)
1162 trigger.value = delta
1164 # Compute other details
1165 valarm.add('DESCRIPTION').value = alarm_data['name'] or 'OpenERP'
1166 valarm.add('ACTION').value = alarm_data['action']
1169 def import_cal(self, cr, uid, ical_data, context=None):
1171 @param self: The object pointer
1172 @param cr: the current row, from the database cursor,
1173 @param uid: the current user’s ID for security checks,
1174 @param ical_data: Get calendar's Data
1175 @param context: A standard dictionary for contextual values
1179 ctx = context.copy()
1180 ctx.update({'model': context.get('model', None)})
1181 self.__attribute__ = get_attribute_mapping(cr, uid, self._calname, ctx)
1182 for child in ical_data.getChildren():
1183 if child.name.lower() == 'trigger':
1184 if isinstance(child.value, timedelta):
1185 seconds = child.value.seconds
1186 days = child.value.days
1187 diff = (days * 86400) + seconds
1190 elif isinstance(child.value, datetime):
1192 # remember, spec says this datetime is in UTC
1193 raise NotImplementedError("we cannot parse absolute triggers")
1195 duration = abs(days)
1196 related = days > 0 and 'after' or 'before'
1197 elif (abs(diff) / 3600) == 0:
1198 duration = abs(diff / 60)
1199 interval = 'minutes'
1200 related = days >= 0 and 'after' or 'before'
1202 duration = abs(diff / 3600)
1204 related = days >= 0 and 'after' or 'before'
1205 self.ical_set('trigger_interval', interval, 'value')
1206 self.ical_set('trigger_duration', duration, 'value')
1207 self.ical_set('trigger_occurs', related.lower(), 'value')
1209 if child.params.get('related'):
1210 self.ical_set('trigger_related', child.params.get('related')[0].lower(), 'value')
1212 self.ical_set(child.name.lower(), child.value.lower(), 'value')
1213 vals = map_data(cr, uid, self, context=context)
1219 class Attendee(CalDAV, osv.osv_memory):
1220 _name = 'basic.calendar.attendee'
1221 _calname = 'attendee'
1224 'cutype': None, # Use: 0-1 Specify the type of calendar user specified by the property like "INDIVIDUAL"/"GROUP"/"RESOURCE"/"ROOM"/"UNKNOWN".
1225 'member': None, # Use: 0-1 Specify the group or list membership of the calendar user specified by the property.
1226 'role': None, # Use: 0-1 Specify the participation role for the calendar user specified by the property like "CHAIR"/"REQ-PARTICIPANT"/"OPT-PARTICIPANT"/"NON-PARTICIPANT"
1227 'partstat': None, # Use: 0-1 Specify the participation status for the calendar user specified by the property. like use for VEVENT:- "NEEDS-ACTION"/"ACCEPTED"/"DECLINED"/"TENTATIVE"/"DELEGATED", use for VTODO:-"NEEDS-ACTION"/"ACCEPTED"/"DECLINED"/"TENTATIVE"/"DELEGATED"/"COMPLETED"/"IN-PROCESS" and use for VJOURNAL:- "NEEDS-ACTION"/"ACCEPTED"/"DECLINED".
1228 'rsvp': None, # Use: 0-1 Specify whether there is an expectation of a favor of a reply from the calendar user specified by the property value like TRUE / FALSE.
1229 'delegated-to': None, # Use: 0-1 Specify the calendar users to whom the calendar user specified by the property has delegated participation.
1230 'delegated-from': None, # Use: 0-1 Specify the calendar users that have delegated their participation to the calendar user specified by the property.
1231 'sent-by': None, # Use: 0-1 Specify the calendar user that is acting on behalf of the calendar user specified by the property.
1232 'cn': None, # Use: 0-1 Specify the common name to be associated with the calendar user specified by the property.
1233 'dir': None, # Use: 0-1 Specify reference to a directory entry associated with the calendar user specified by the property.
1234 'language': None, # Use: 0-1 Specify the language for text values in a property or property parameter.
1237 def import_cal(self, cr, uid, ical_data, context=None):
1239 @param self: The object pointer
1240 @param cr: the current row, from the database cursor,
1241 @param uid: the current user’s ID for security checks,
1242 @param ical_data: Get calendar's Data
1243 @param context: A standard dictionary for contextual values
1247 ctx = context.copy()
1248 ctx.update({'model': context.get('model', None)})
1249 self.__attribute__ = get_attribute_mapping(cr, uid, self._calname, ctx)
1250 for para in ical_data.params:
1251 if para.lower() == 'cn':
1252 self.ical_set(para.lower(), ical_data.params[para][0]+':'+ \
1253 ical_data.value, 'value')
1255 self.ical_set(para.lower(), ical_data.params[para][0].lower(), 'value')
1256 if not ical_data.params.get('CN'):
1257 self.ical_set('cn', ical_data.value, 'value')
1258 vals = map_data(cr, uid, self, context=context)
1261 def export_cal(self, cr, uid, model, attendee_ids, vevent, context=None):
1263 @param self: The object pointer
1264 @param cr: the current row, from the database cursor,
1265 @param uid: the current user’s ID for security checks,
1266 @param model: Get model's name
1267 @param attendee_ids: Get Attendee's Id
1268 @param context: A standard dictionary for contextual values
1272 attendee_object = self.pool.get(model)
1273 ctx = context.copy()
1274 ctx.update({'model': model})
1275 self.__attribute__ = get_attribute_mapping(cr, uid, self._calname, ctx)
1276 for attendee in attendee_object.read(cr, uid, attendee_ids, []):
1277 attendee_add = vevent.add('attendee')
1279 for a_key, a_val in self.__attribute__.items():
1280 if attendee[a_val['field']] and a_val['field'] != 'cn':
1281 if a_val['type'] in ('text', 'char', 'selection'):
1282 attendee_add.params[a_key] = [str(attendee[a_val['field']])]
1283 elif a_val['type'] == 'boolean':
1284 attendee_add.params[a_key] = [str(attendee[a_val['field']])]
1285 if a_val['field'] == 'cn' and attendee[a_val['field']]:
1286 cn_val = [str(attendee[a_val['field']])]
1288 attendee_add.params['CN'] = cn_val
1289 if not attendee['email']:
1290 attendee_add.value = 'MAILTO:'
1291 #raise osv.except_osv(_('Error !'), _('Attendee must have an Email Id'))
1292 elif attendee['email']:
1293 attendee_add.value = 'MAILTO:' + attendee['email']
1298 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: