document: fix regressions at storage and node_descriptor
[odoo/odoo.git] / addons / caldav / calendar.py
index ab247cd..ccc4bf3 100644 (file)
@@ -30,7 +30,10 @@ import pytz
 import re
 import tools
 import time
+import logging
 from caldav_node import res_node_calendar
+from orm_utils import get_last_modified
+from tools.safe_eval import safe_eval as eval
 
 try:
     import vobject
@@ -61,13 +64,15 @@ def uid2openobjectid(cr, uidval, oomodel, rdate):
         model_obj = pooler.get_pool(cr.dbname).get(model)
         if (not model == oomodel) or (not dbname == cr.dbname):
             return (False, None)
-        qry = 'select distinct(id) from %s' % model_obj._table
+        qry = 'SELECT DISTINCT(id) FROM %s' % model_obj._table
         if rdate:
-            qry += " where recurrent_id='%s'" % (rdate)
-            cr.execute(qry)
+            qry += " WHERE recurrent_id=%s"
+            cr.execute(qry, (rdate,))
             r_id = cr.fetchone()
             if r_id:
                 return (id, r_id[0])
+            else:
+                return (False, None)
         cr.execute(qry)
         ids = map(lambda x: str(x[0]), cr.fetchall())
         if id in ids:
@@ -75,14 +80,68 @@ def uid2openobjectid(cr, uidval, oomodel, rdate):
         return (False, None)
 
 def openobjectid2uid(cr, uidval, oomodel):
-    """ Open Object Id To UId
+    """ Gives the value of UID for VEVENT
         @param cr: the current row, from the database cursor,
-        @param uidval: Get USerId vale
+        @param uidval: Id value of the Event
         @oomodel: Open Object ModelName """
 
     value = 'OpenObject-%s_%s@%s' % (oomodel, uidval, cr.dbname)
     return value
 
+def mailto2str(arg):
+    """Take a dict of mail and convert to string.
+    """
+    ret = []
+    if isinstance(arg, dict):
+        args = [arg,]
+    else:
+        args = arg
+
+    for ard in args:
+        rstr = ard.get('name','')
+        if ard.get('company',False):
+            rstr += ' (%s)' % ard.get('company')
+        if ard.get('email'):
+            rstr += ' <%s>' % ard.get('email')
+        ret.append(rstr)
+    return ', '.join(ret)
+
+def str2mailto(emailstr, multi=False):
+    """Split one email string to a dict of name, company, mail parts
+
+       @param multi Return an array, recognize comma-sep
+    """
+    # TODO: move to tools or sth.
+    mege = re.compile(r'([^\(\<]+) *(\((.*?)\))? *(\< ?(.*?) ?\>)? ?(\((.*?)\))? *$')
+
+    mailz= [emailstr,]
+    retz = []
+    if multi:
+        mailz = emailstr.split(',')
+
+    for mas in mailz:
+        m = mege.match(mas.strip())
+        if not m:
+            # one of the rare non-matching strings is "sad" :(
+            # retz.append({ 'name': mas.strip() })
+            # continue
+            raise ValueError("Invalid email address %r" % mas)
+        rd = {  'name': m.group(1).strip(),
+                'email': m.group(5), }
+        if m.group(2):
+            rd['company'] = m.group(3).strip()
+        elif m.group(6):
+            rd['company'] = m.group(7).strip()
+
+        if rd['name'].startswith('"') and rd['name'].endswith('"'):
+            rd['name'] = rd['name'][1:-1]
+        retz.append(rd)
+
+    if multi:
+        return retz
+    else:
+        return retz[0]
+
 def get_attribute_mapping(cr, uid, calname, context=None):
     """ Attribute Mapping with Basic calendar fields and lines
         @param cr: the current row, from the database cursor,
@@ -132,6 +191,8 @@ def map_data(cr, uid, obj, context=None):
             if field_type == 'selection':
                 if not map_val:
                     continue
+                if type(map_val) == list and len(map_val): #TOFIX: why need to check this
+                    map_val = map_val[0]
                 mapping = obj.__attribute__[map_dict].get('mapping', False)
                 if mapping:
                     map_val = mapping.get(map_val.lower(), False)
@@ -175,8 +236,7 @@ def map_data(cr, uid, obj, context=None):
 
 class CalDAV(object):
     __attribute__ = {}
-
-
+    _logger = logging.getLogger('document.caldav')
 
     def ical_set(self, name, value, type):
         """ set calendar Attribute
@@ -220,9 +280,15 @@ class CalDAV(object):
                 self.__attribute__[name][type] = None
         return True
 
-    def format_date_tz(self, date, tz=None):
+    def format_date_tz(self, src_date, tz=None):
+        """ This function converts date into specifice timezone value
+        @param src_date: Date to be converted (datetime.datetime)
+        @return: Converted datetime.datetime object for the date
+        """
         format = tools.DEFAULT_SERVER_DATETIME_FORMAT
-        return tools.server_to_local_timestamp(date, format, format, tz)
+        date_str = src_date.strftime('%Y-%m-%d %H:%M:%S')
+        res_date = tools.server_to_local_timestamp(date_str, format, format, tz)
+        return datetime.strptime(res_date, "%Y-%m-%d %H:%M:%S")
 
     def parse_ics(self, cr, uid, child, cal_children=None, context=None):
         """ parse calendaring and scheduling information
@@ -233,9 +299,15 @@ class CalDAV(object):
 
         att_data = []
         exdates = []
+        _server_tzinfo = pytz.timezone(tools.get_server_timezone())
+
         for cal_data in child.getChildren():
             if cal_data.name.lower() == 'organizer':
-                self.ical_set(cal_data.name.lower(), cal_data.params.get('CN') and cal_data.params.get('CN')[0], 'value')
+                dmail = { 'name': cal_data.params.get('CN', ['',])[0],
+                            'email': cal_data.value.lower().replace('mailto:',''),
+                            # TODO: company? 
+                }
+                self.ical_set(cal_data.name.lower(), mailto2str(dmail), 'value')
                 continue
             if cal_data.name.lower() == 'attendee':
                 ctx = context.copy()
@@ -263,8 +335,8 @@ class CalDAV(object):
             if cal_data.name.lower() in self.__attribute__:
                 if cal_data.params.get('X-VOBJ-ORIGINAL-TZID'):
                     self.ical_set('vtimezone', cal_data.params.get('X-VOBJ-ORIGINAL-TZID'), 'value')
-                    date_utc = cal_data.value.astimezone(pytz.utc)
-                    self.ical_set(cal_data.name.lower(), date_utc, 'value')
+                    date_local = cal_data.value.astimezone(_server_tzinfo)
+                    self.ical_set(cal_data.name.lower(), date_local, 'value')
                     continue
                 self.ical_set(cal_data.name.lower(), cal_data.value, 'value')
         vals = map_data(cr, uid, self, context=context)
@@ -294,15 +366,22 @@ class CalDAV(object):
                         if not model:
                             continue
                         uidval = openobjectid2uid(cr, data[map_field], model)
+                        #Computation for getting events with the same UID (RFC4791 Section4.1)
+                        #START
                         model_obj = self.pool.get(model)
                         r_ids = []
                         if model_obj._columns.get('recurrent_uid', None):
-                            cr.execute('select id from %s  where recurrent_uid=%s'
-                                           % (model_obj._table, data[map_field]))
+                            cr.execute('SELECT id FROM %s WHERE recurrent_uid=%%s' % model_obj._table,
+                                        (data[map_field],))
                             r_ids = map(lambda x: x[0], cr.fetchall())
                         if r_ids:
-                            rcal = self.export_cal(cr, uid, r_ids, 'vevent', context=context)
+                            r_datas = model_obj.read(cr, uid, r_ids, context=context)
+                            rcal = CalDAV.export_cal(self, cr, uid, r_datas, 'vevent', context=context)
+                            for revents in rcal.contents.get('vevent', []):
+                                ical.contents['vevent'].append(revents)
+                        #END
                         if data.get('recurrent_uid', None):
+                            # Change the UID value in case of modified event from any recurrent event 
                             uidval = openobjectid2uid(cr, data['recurrent_uid'], model)
                         vevent.add('uid').value = uidval
                     elif field == 'attendee' and data[map_field]:
@@ -324,43 +403,48 @@ class CalDAV(object):
                             ical = tz_obj.export_cal(cr, uid, None, \
                                          data[map_field], ical, context=context)
                             timezones.append(data[map_field])
+                        if vevent.contents.get('recurrence-id'):
+                            # Convert recurrence-id field value accroding to timezone value
+                            recurid_val = vevent.contents.get('recurrence-id')[0].value
+                            vevent.contents.get('recurrence-id')[0].params['TZID'] = [tzval.title()]
+                            vevent.contents.get('recurrence-id')[0].value =  self.format_date_tz(recurid_val, tzval.title())
                         if exfield:
+                            # Set exdates according to timezone value
+                            # This is the case when timezone mapping comes after the exdate mapping
+                            # and we have exdate value available 
                             exfield.params['TZID'] = [tzval.title()]
                             exdates_updated = []
                             for exdate in exdates:
-                                date1 = (datetime.strptime(exdate, "%Y%m%dT%H%M%S")).strftime('%Y-%m-%d %H:%M:%S')
-                                dest_date = self.format_date_tz(date1, tzval.title())
-                                ex_date = (datetime.strptime(dest_date, "%Y-%m-%d %H:%M:%S")).strftime('%Y%m%dT%H%M%S')
-                                exdates_updated.append(ex_date)
-                            exfield.value = map(parser.parse, exdates_updated)
+                                exdates_updated.append(self.format_date_tz(parser.parse(exdate), tzval.title()))
+                            exfield.value = exdates_updated
                     elif field == 'organizer' and data[map_field]:
-                        organizer = data[map_field]
+                        organizer = str2mailto(data[map_field])
                         event_org = vevent.add('organizer')
-                        event_org.params['CN'] = [organizer]
-                        event_org.value = 'MAILTO:' + (organizer)
+                        event_org.params['CN'] = [organizer['name']]
+                        event_org.value = 'MAILTO:' + (organizer.get('email') or '')
+                        # TODO: company?
                     elif data[map_field]:
                         if map_type in ("char", "text"):
                             if field in ('exdate'):
                                 exfield = vevent.add(field)
                                 exdates = (data[map_field]).split(',')
                                 if tzval:
+                                    # Set exdates according to timezone value
+                                    # This is the case when timezone mapping comes before the exdate mapping
+                                    # and we have timezone value available 
                                     exfield.params['TZID'] = [tzval.title()]
                                     exdates_updated = []
                                     for exdate in exdates:
-                                        date1 = (datetime.strptime(exdate, "%Y%m%dT%H%M%S")).strftime('%Y-%m-%d %H:%M:%S')
-                                        dest_date = self.format_date_tz(date1, tzval.title())
-                                        ex_date = (datetime.strptime(dest_date, "%Y-%m-%d %H:%M:%S")).strftime('%Y%m%dT%H%M%S')
-                                        exdates_updated.append(ex_date)
-                                    exdates = exdates_updated
-                                exfield.value = map(parser.parse, exdates)
+                                        exdates_updated.append(self.format_date_tz(parser.parse(exdate), tzval.title()))
+                                    exfield.value = exdates_updated
                             else:
                                 vevent.add(field).value = tools.ustr(data[map_field])
                         elif map_type in ('datetime', 'date') and data[map_field]:
                             dtfield = vevent.add(field)
                             if tzval:
-                                dest_date = self.format_date_tz(data[map_field], tzval.title())
+                                # Export the date according to the event timezone value
                                 dtfield.params['TZID'] = [tzval.title()]
-                                dtfield.value = parser.parse(dest_date)
+                                dtfield.value = self.format_date_tz(parser.parse(data[map_field]), tzval.title())
                             else:
                                 dtfield.value = parser.parse(data[map_field])
                         elif map_type == "timedelta":
@@ -375,7 +459,7 @@ class CalDAV(object):
                             else:
                                 for key1, val1 in self.ical_get(field, 'mapping').items():
                                     if val1 == data[map_field]:
-                                        vevent.add(field).value = key1
+                                        vevent.add(field).value = key1.upper()
         return vevent
 
     def check_import(self, cr, uid, vals, context=None):
@@ -393,9 +477,16 @@ class CalDAV(object):
         recur_pool = {}
         try:
             for val in vals:
-                exists, r_id = uid2openobjectid(cr, val['id'], context.get('model'), \
+                # Compute value of duration
+                if 'date_deadline' in val and 'duration' not in val:
+                    start = datetime.strptime(val['date'], '%Y-%m-%d %H:%M:%S')
+                    end = datetime.strptime(val['date_deadline'], '%Y-%m-%d %H:%M:%S')
+                    diff = end - start
+                    val['duration'] = (diff.seconds/float(86400) + diff.days) * 24
+                exists, r_id = calendar.uid2openobjectid(cr, val['id'], context.get('model'), \
                                                                  val.get('recurrent_id'))
-                if val.has_key('create_date'): val.pop('create_date')
+                if val.has_key('create_date'):
+                    val.pop('create_date')
                 u_id = val.get('id', None)
                 val.pop('id')
                 if exists and r_id:
@@ -411,11 +502,16 @@ class CalDAV(object):
                         revent_id = model_obj.create(cr, uid, val)
                         ids.append(revent_id)
                     else:
+                        __rege = re.compile(r'OpenObject-([\w|\.]+)_([0-9]+)@(\w+)$')
+                        wematch = __rege.match(u_id.encode('utf8'))
+                        if wematch:
+                            model, recur_id, dbname = wematch.groups()
+                            val.update({'recurrent_uid': recur_id})
                         event_id = model_obj.create(cr, uid, val)
                         recur_pool[u_id] = event_id
                         ids.append(event_id)
-        except Exception, e:
-            raise osv.except_osv(('Error !'), (str(e)))
+        except Exception:
+            raise
         return ids
 
     def export_cal(self, cr, uid, datas, vobj=None, context=None):
@@ -426,13 +522,12 @@ class CalDAV(object):
             @param datas: Get Data's for caldav
             @param context: A standard dictionary for contextual values
         """
-
         try:
             self.__attribute__ = get_attribute_mapping(cr, uid, self._calname, context)
             ical = vobject.iCalendar()
             self.create_ics(cr, uid, datas, vobj, ical, context=context)
             return ical
-        except Exception, e:
+        except:
             raise  # osv.except_osv(('Error !'), (str(e)))
 
     def import_cal(self, cr, uid, content, data_id=None, context=None):
@@ -486,6 +581,14 @@ class Calendar(CalDAV, osv.osv):
             'line_ids': fields.one2many('basic.calendar.lines', 'calendar_id', 'Calendar Lines'),
             'create_date': fields.datetime('Created Date', readonly=True),
             'write_date': fields.datetime('Modifided Date', readonly=True),
+            'description': fields.text("description"),
+            'calendar_color': fields.char('Color', size=20, help="For supporting clients, the color of the calendar entries"),
+            'calendar_order': fields.integer('Order', help="For supporting clients, the order of this folder among the calendars"),
+            'has_webcal': fields.boolean('WebCal', required=True, help="Also export a <name>.ics entry next to the calendar folder, with WebCal content."),
+    }
+    
+    _defaults = {
+        'has_webcal': False,
     }
 
     def get_calendar_objects(self, cr, uid, ids, parent=None, domain=None, context=None):
@@ -502,23 +605,51 @@ class Calendar(CalDAV, osv.osv):
                     continue
                 if line.name in ('valarm', 'attendee'):
                     continue
-                line_domain = eval(line.domain or '[]')
+                line_domain = eval(line.domain or '[]', context)
                 line_domain += domain
                 if ctx_res_id:
                     line_domain += [('id','=',ctx_res_id)]
                 mod_obj = self.pool.get(line.object_id.model)
-                data_ids = mod_obj.search(cr, uid, line_domain, context=context)
+                data_ids = mod_obj.search(cr, uid, line_domain, order="id", context=context)
                 for data in mod_obj.browse(cr, uid, data_ids, context):
                     ctx = parent and parent.context or None
+                    if hasattr(data, 'recurrent_uid') and data.recurrent_uid:
+                        # Skip for event which is child of other event
+                        continue
                     node = res_node_calendar('%s.ics' %data.id, parent, ctx, data, line.object_id.model, data.id)
                     res.append(node)
         return res
+        
+
+    def get_cal_max_modified(self, cr, uid, ids, parent=None, domain=None, context=None):
+        if not context:
+            context = {}
+        if not domain:
+            domain = []
+        res = None
+        ctx_res_id = context.get('res_id', None)
+        ctx_model = context.get('model', None)
+        for cal in self.browse(cr, uid, ids):
+            for line in cal.line_ids:
+                if ctx_model and ctx_model != line.object_id.model:
+                    continue
+                if line.name in ('valarm', 'attendee'):
+                    continue
+                line_domain = eval(line.domain or '[]', context)
+                line_domain += domain
+                if ctx_res_id:
+                    line_domain += [('id','=',ctx_res_id)]
+                mod_obj = self.pool.get(line.object_id.model)
+                max_data = get_last_modified(mod_obj, cr, uid, line_domain, context=context)
+                if res and res > max_data:
+                    continue
+                res = max_data
+        return res
 
     def export_cal(self, cr, uid, ids, vobj='vevent', context=None):
         """ Export Calendar
             @param ids: List of calendar’s IDs
             @param vobj: the type of object to export
-            
             @return the ical data.
         """
         if not context:
@@ -532,7 +663,7 @@ class Calendar(CalDAV, osv.osv):
                     continue
                 if line.name in ('valarm', 'attendee'):
                     continue
-                domain = eval(line.domain or '[]')
+                domain = eval(line.domain or '[]', context)
                 if ctx_res_id:
                     domain += [('id','=',ctx_res_id)]
                 mod_obj = self.pool.get(line.object_id.model)
@@ -553,7 +684,6 @@ class Calendar(CalDAV, osv.osv):
             @param data_id: Get Data’s ID or False
             @param context: A standard dictionary for contextual values
         """
-
         if not context:
             context = {}
         vals = []
@@ -563,7 +693,7 @@ class Calendar(CalDAV, osv.osv):
             data_id = self.search(cr, uid, [])[0]
         cal = self.browse(cr, uid, data_id, context=context)
         cal_children = {}
-        count = 0
+
         for line in cal.line_ids:
             cal_children[line.name] = line.object_id.model
         objs = []
@@ -577,6 +707,15 @@ class Calendar(CalDAV, osv.osv):
                 val = self.parse_ics(cr, uid, child, cal_children=cal_children, context=context)
                 vals.append(val)
                 objs.append(cal_children[child.name.lower()])
+            elif child.name.upper() == 'CALSCALE':
+                if child.value.upper() != 'GREGORIAN':
+                    self._logger.warning('How do I handle %s calendars?',child.value)
+            elif child.name.upper() in ('PRODID', 'VERSION'):
+                pass
+            elif child.name.upper().startswith('X-'):
+                self._logger.debug("skipping custom node %s", child.name)
+            else:
+                self._logger.debug("skipping node %s", child.name)
         
         res = []
         for obj_name in list(set(objs)):
@@ -590,6 +729,7 @@ class Calendar(CalDAV, osv.osv):
             r = self.check_import(cr, uid, vals, context=context)
             res.extend(r)
         return res
+
 Calendar()
 
 
@@ -624,8 +764,9 @@ class basic_calendar_line(osv.osv):
             @param context: A standard dictionary for contextual values
         """
 
-        cr.execute("Select count(id) from basic_calendar_lines \
-                                where name='%s' and calendar_id=%s" % (vals.get('name'), vals.get('calendar_id')))
+        cr.execute("SELECT COUNT(id) FROM basic_calendar_lines \
+                                WHERE name=%s AND calendar_id=%s", 
+                                (vals.get('name'), vals.get('calendar_id')))
         res = cr.fetchone()
         if res:
             if res[0] > 0:
@@ -635,6 +776,30 @@ line "%s" more than once' % (vals.get('name'))))
 
 basic_calendar_line()
 
+class basic_calendar_alias(osv.osv):
+    """ Mapping of client filenames to ORM ids of calendar records
+    
+        Since some clients insist on putting arbitrary filenames on the .ics data
+        they send us, and they won't respect the redirection "Location:" header, 
+        we have to store those filenames and allow clients to call our calendar
+        records with them.
+        Note that adding a column to all tables that would possibly hold calendar-
+        mapped data won't work. The user is always allowed to specify more 
+        calendars, on any arbitrary ORM object, without need to alter those tables'
+        data or structure
+    """
+    _name = 'basic.calendar.alias'
+    _columns = {
+        'name': fields.char('Filename', size=512, required=True, select=1),
+        'cal_line_id': fields.many2one('basic.calendar.lines', 'Calendar', required=True,
+                        select=1, help='The calendar/line this mapping applies to'),
+        'res_id': fields.integer('Res. ID', required=True, select=1),
+        }
+        
+    _sql_constraints = [ ('name_cal_uniq', 'UNIQUE(cal_line_id, name)',
+                _('The same filename cannot apply to two records!')), ]
+
+basic_calendar_alias()
 
 class basic_calendar_attribute(osv.osv):
     _name = 'basic.calendar.attributes'
@@ -706,8 +871,8 @@ class basic_calendar_fields(osv.osv):
             @param context: A standard dictionary for contextual values
         """
 
-        cr.execute('select name from basic_calendar_attributes \
-                            where id=%s' % (vals.get('name')))
+        cr.execute('SELECT name FROM basic_calendar_attributes \
+                            WHERE id=%s', (vals.get('name'),))
         name = cr.fetchone()
         name = name[0]
         if name in ('valarm', 'attendee'):