diff --git a/addons/base_calendar/base_calendar.py b/addons/base_calendar/base_calendar.py index 1ff7777cf36..d602fce3a0f 100644 --- a/addons/base_calendar/base_calendar.py +++ b/addons/base_calendar/base_calendar.py @@ -306,7 +306,7 @@ class calendar_attendee(osv.osv): res = obj.read(cr, uid, ids, ['object', 'name'], context=context) return [(r['object'], r['name']) for r in res] - def _lang_get(self, cr, uid, context={}): + def _lang_get(self, cr, uid, context=None): """ Get language for language selection field. @param cr: the current row, from the database cursor, @@ -912,10 +912,10 @@ class calendar_event(osv.osv): _description = "Calendar Event" __attribute__ = {} - def _tz_get(self, cr, uid, context={}): + def _tz_get(self, cr, uid, context=None): return [(x.lower(), x) for x in pytz.all_timezones] - def onchange_allday(self, cr, uid, ids, allday, context={}): + def onchange_allday(self, cr, uid, ids, allday, context=None): """Sets duration as 24 Hours if event is selcted for all day @param self: The object pointer @param cr: the current row, from the database cursor, @@ -1017,7 +1017,8 @@ class calendar_event(osv.osv): elif int(val.get('interval')) > 1: #If interval is other than 1 rule is custom rrule_type = 'custom' - qry = "UPDATE %(table)s set rrule_type=\'%(rule_type)s\' " + qry = "UPDATE \"%s\" set rrule_type=%%s " % self._table + qry_args = [ rrule_type, ] if rrule_type == 'custom': new_val = val.copy() @@ -1055,17 +1056,12 @@ class calendar_event(osv.osv): new_val.pop('bymonth') for k, v in new_val.items(): - temp = ", %s='%s'" % (k, v) - qry += temp + qry += ", %s=%%s" % k + qry_args.append(v) - whr = " where id=%(id)s" - qry = qry + whr - val.update({ - 'table': self._table, - 'rule_type': rrule_type, - 'id': id, - }) - cr.execute(qry % val) + qry = qry + " where id=%s" + qry_args.append(id) + cr.execute(qry, qry_args) return True def _get_rulestring(self, cr, uid, ids, name, arg, context=None): @@ -1479,6 +1475,8 @@ true, it will allow you to hide the event alarm information without removing it. if not real_event_id in new_ids: new_ids.append(real_event_id) + if vals.get('vtimezone', '').startswith('/freeassociation.sourceforge.net/tzfile/'): + vals['vtimezone'] = vals['vtimezone'][40:] if new_ids: res = super(calendar_event, self).write(cr, uid, new_ids, vals, context=context) if (vals.has_key('alarm_id') or vals.has_key('base_calendar_alarm_id'))\ @@ -1489,7 +1487,7 @@ true, it will allow you to hide the event alarm information without removing it. context=context) return res - def browse(self, cr, uid, ids, context=None, list_class=None, fields_process={}): + def browse(self, cr, uid, ids, context=None, list_class=None, fields_process=None): """ Overrides orm browse method. @param self: the object pointer @@ -1608,6 +1606,8 @@ true, it will allow you to hide the event alarm information without removing it. """ if not context: context = {} + if vals.get('vtimezone', '') and vals.get('vtimezone', '').startswith('/freeassociation.sourceforge.net/tzfile/'): + vals['vtimezone'] = vals['vtimezone'][40:] res = super(calendar_event, self).create(cr, uid, vals, context) alarm_obj = self.pool.get('res.alarm') alarm_obj.do_alarm_create(cr, uid, [res], self._name, 'date', context=context) @@ -1682,7 +1682,7 @@ class calendar_todo(osv.osv): @param args: list of tuples of form [(‘name_of_the_field’, ‘operator’, value), ...]. @param context: A standard dictionary for contextual values """ - + assert name == 'date' return self.write(cr, uid, id, { 'date_start': value }, context=context) diff --git a/addons/base_calendar/security/ir.model.access.csv b/addons/base_calendar/security/ir.model.access.csv index 0ae628fb720..aa269e9ff18 100644 --- a/addons/base_calendar/security/ir.model.access.csv +++ b/addons/base_calendar/security/ir.model.access.csv @@ -1,5 +1,5 @@ "id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" -"access_calendar_attendee","calendar.attendee","model_calendar_attendee","base.group_user",1,1,1,0 +"access_calendar_attendee","calendar.attendee","model_calendar_attendee","base.group_user",1,1,1,1 "access_calendar_alarm","calendar.alarm","model_calendar_alarm","base.group_user",1,1,1,1 "access_res_alarm","res.alarm","model_res_alarm","base.group_user",1,1,1,1 "access_calendar_todo","calendar.todo","model_calendar_todo","base.group_user",1,1,1,1 diff --git a/addons/caldav/__openerp__.py b/addons/caldav/__openerp__.py index cdb06a17a1f..40088cb0bac 100644 --- a/addons/caldav/__openerp__.py +++ b/addons/caldav/__openerp__.py @@ -21,12 +21,12 @@ { - "name" : "Share Calendar using Caldav", - "version" : "1.0", + "name" : "Share Calendar using CalDAV", + "version" : "1.1", "depends" : [ "base", "document_webdav", - ], + ], 'description': """ This module Contains basic functionality for caldav system like: - Webdav server that provides remote access to calendar @@ -34,7 +34,10 @@ - Customize calendar event and todo attribute with any of OpenERP model - Provides iCal Import/Export functionality - To access OpenERP Calendar using caldav to remote site use the URL like: + To access Calendars using CalDAV clients, point them to: + http://HOSTNAME:PORT/webdav/DATABASE_NAME/calendars/users/USERNAME/c + + To access OpenERP Calendar using WebCal to remote site use the URL like: http://HOSTNAME:PORT/webdav/DATABASE_NAME/Calendars/CALENDAR_NAME.ics Where, @@ -53,7 +56,8 @@ 'wizard/calendar_event_export_view.xml', 'wizard/calendar_event_import_view.xml', 'wizard/calendar_event_subscribe_view.xml', - 'caldav_view.xml' + 'caldav_view.xml', + 'caldav_setup.xml' ], "installable" : True, "active" : False, diff --git a/addons/caldav/caldav_node.py b/addons/caldav/caldav_node.py index 11f720bdcab..b735d853025 100644 --- a/addons/caldav/caldav_node.py +++ b/addons/caldav/caldav_node.py @@ -20,41 +20,29 @@ ############################################################################## import time -from document import nodes +from document_webdav import nodes +from document.nodes import _str2time +import logging import StringIO +from orm_utils import get_last_modified -class node_database(nodes.node_database): - def _child_get(self, cr, name=False, parent_id=False, domain=None): - dirobj = self.context._dirobj - uid = self.context.uid - ctx = self.context.context.copy() - ctx.update(self.dctx) - if not domain: - domain = [] - domain2 = domain + [('calendar_collection','=', False)] - res = super(node_database, self)._child_get(cr, name=name, parent_id=parent_id, domain=domain2) - where = [('parent_id','=',parent_id)] - domain2 = domain + [('calendar_collection','=', True)] - if name: - where.append(('name','=',name)) - if domain2: - where += domain2 +try: + from tools.dict_tools import dict_merge, dict_merge2 +except ImportError: + from document.dict_tools import dict_merge, dict_merge2 - where2 = where + [('type', '=', 'directory')] - ids = dirobj.search(cr, uid, where2, context=ctx) - for dirr in dirobj.browse(cr,uid,ids,context=ctx): - res.append(node_calendar_collection(dirr.name,self,self.context,dirr)) - return res +# TODO: implement DAV-aware errors, inherit from IOError + +# Assuming that we have set global properties right, we mark *all* +# directories as having calendar-access. +nodes.node_dir.http_options = dict_merge2(nodes.node_dir.http_options, + { 'DAV': ['calendar-access',] }) class node_calendar_collection(nodes.node_dir): - DAV_PROPS = { - "http://calendarserver.org/ns/" : ('getctag',), - } - DAV_M_NS = { - "http://calendarserver.org/ns/" : '_get_dav', - } - - http_options = { 'DAV': ['calendar-access'] } + DAV_PROPS = dict_merge2(nodes.node_dir.DAV_PROPS, + { "http://calendarserver.org/ns/" : ('getctag',), } ) + DAV_M_NS = dict_merge2(nodes.node_dir.DAV_M_NS, + { "http://calendarserver.org/ns/" : '_get_dav', } ) def _file_get(self,cr, nodename=False): return [] @@ -80,45 +68,117 @@ class node_calendar_collection(nodes.node_dir): for cal in fil_obj.browse(cr, uid, ids, context=ctx): if (not name) or not ext: res.append(node_calendar(cal.name, self, self.context, cal)) - if (not name) or ext: + if self.context.get('DAV-client', '') in ('iPhone', 'iCalendar'): + # these ones must not see the webcal entry. + continue + if cal.has_webcal and (not name) or ext: res.append(res_node_calendar(cal.name+'.ics', self, self.context, cal)) # May be both of them! return res - def _get_dav_owner(self, cr): - # Todo? - return False - def _get_ttag(self, cr): return 'calen-dir-%d' % self.dir_id def _get_dav_getctag(self, cr): - result = self.get_etag(cr) - return str(result) + dirobj = self.context._dirobj + uid = self.context.uid + ctx = self.context.context.copy() + ctx.update(self.dctx) + where = [('collection_id','=',self.dir_id)] + bc_obj = dirobj.pool.get('basic.calendar') + + res = get_last_modified(bc_obj, cr, uid, where, context=ctx) + return _str2time(res) + +class node_calendar_res_col(nodes.node_res_obj): + """ Calendar collection, as a dynamically created node + + This class shall be used instead of node_calendar_collection, when the + node is under dynamic ones. + """ + DAV_PROPS = dict_merge2(nodes.node_res_obj.DAV_PROPS, + { "http://calendarserver.org/ns/" : ('getctag',), } ) + DAV_M_NS = dict_merge2(nodes.node_res_obj.DAV_M_NS, + { "http://calendarserver.org/ns/" : '_get_dav', } ) + + def _file_get(self,cr, nodename=False): + return [] + + def _child_get(self, cr, name=False, parent_id=False, domain=None): + dirobj = self.context._dirobj + uid = self.context.uid + ctx = self.context.context.copy() + ctx.update(self.dctx) + where = [('collection_id','=',self.dir_id)] + ext = False + if name and name.endswith('.ics'): + name = name[:-4] + ext = True + if name: + where.append(('name','=',name)) + if not domain: + domain = [] + where = where + domain + fil_obj = dirobj.pool.get('basic.calendar') + ids = fil_obj.search(cr,uid,where,context=ctx) + res = [] + # TODO: shall we use any of our dynamic information?? + for cal in fil_obj.browse(cr, uid, ids, context=ctx): + if (not name) or not ext: + res.append(node_calendar(cal.name, self, self.context, cal)) + if self.context.get('DAV-client', '') in ('iPhone', 'iCalendar'): + # these ones must not see the webcal entry. + continue + if cal.has_webcal and (not name) or ext: + res.append(res_node_calendar(cal.name+'.ics', self, self.context, cal)) + # May be both of them! + return res + + def _get_ttag(self, cr): + return 'calen-dir-%d' % self.dir_id + + def _get_dav_getctag(self, cr): + dirobj = self.context._dirobj + uid = self.context.uid + ctx = self.context.context.copy() + ctx.update(self.dctx) + where = [('collection_id','=',self.dir_id)] + bc_obj = dirobj.pool.get('basic.calendar') + + res = get_last_modified(bc_obj, cr, uid, where, context=ctx) + return _str2time(res) class node_calendar(nodes.node_class): our_type = 'collection' DAV_PROPS = { - "DAV:": ('principal-collection-set'), - "http://cal.me.com/_namespace/" : ('user-state'), - "http://calendarserver.org/ns/" : ( - 'dropbox-home-URL', - 'notification-URL', - 'getctag',), + "DAV:": ('supported-report-set',), + # "http://cal.me.com/_namespace/" : ('user-state',), + "http://calendarserver.org/ns/" : ( 'getctag',), 'http://groupdav.org/': ('resourcetype',), "urn:ietf:params:xml:ns:caldav" : ( - 'calendar-description', + 'calendar-description', + 'supported-calendar-component-set', + ), + "http://apple.com/ns/ical/": ("calendar-color", "calendar-order"), + } + DAV_PROPS_HIDDEN = { + "urn:ietf:params:xml:ns:caldav" : ( 'calendar-data', - 'calendar-home-set', - 'calendar-user-address-set', - 'schedule-inbox-URL', - 'schedule-outbox-URL',)} + 'calendar-timezone', + 'supported-calendar-data', + 'max-resource-size', + 'min-date-time', + 'max-date-time', + )} + DAV_M_NS = { "DAV:" : '_get_dav', - "http://cal.me.com/_namespace/": '_get_dav', + # "http://cal.me.com/_namespace/": '_get_dav', 'http://groupdav.org/': '_get_gdav', "http://calendarserver.org/ns/" : '_get_dav', - "urn:ietf:params:xml:ns:caldav" : '_get_caldav'} + "urn:ietf:params:xml:ns:caldav" : '_get_caldav', + "http://apple.com/ns/ical/": '_get_apple_cal', + } http_options = { 'DAV': ['calendar-access'] } @@ -131,42 +191,33 @@ class node_calendar(nodes.node_class): self.content_length = 0 self.displayname = calendar.name self.cal_type = calendar.type + self.cal_color = calendar.calendar_color or None + self.cal_order = calendar.calendar_order or None + try: + self.uuser = (calendar.user_id and calendar.user_id.login) or 'nobody' + except Exception: + self.uuser = 'nobody' def _get_dav_getctag(self, cr): - result = self._get_ttag(cr) + ':' + str(time.time()) - return str(result) + dirobj = self.context._dirobj + uid = self.context.uid + ctx = self.context.context.copy() + ctx.update(self.dctx) - def _get_dav_dropbox_home_URL(self, cr): - import urllib - uid = self.context.uid - ctx = self.context.context.copy() - ctx.update(self.dctx) - calendar_obj = self.context._dirobj.pool.get('basic.calendar') - calendar = calendar_obj.browse(cr, uid, self.calendar_id, context=ctx) - res = '%s/%s' %(calendar.collection_id.name, calendar.name) - url = urllib.quote('/%s/%s' % (cr.dbname, res)) - return url - - def _get_dav_notification_URL(self, cr): - import urllib - uid = self.context.uid - ctx = self.context.context.copy() - ctx.update(self.dctx) - calendar_obj = self.context._dirobj.pool.get('basic.calendar') - calendar = calendar_obj.browse(cr, uid, self.calendar_id, context=ctx) - res = '%s/%s' %(calendar.collection_id.name, calendar.name) - url = urllib.quote('/%s/%s' % (cr.dbname, res)) - return url + bc_obj = dirobj.pool.get('basic.calendar') + res = bc_obj.get_cal_max_modified(cr, uid, [self.calendar_id], self, domain=[], context=ctx) + return _str2time(res) def _get_dav_user_state(self, cr): #TODO - return True - + return 'online' def get_dav_resourcetype(self, cr): res = [ ('collection', 'DAV:'), - (str(self.cal_type + '-collection'), 'http://groupdav.org/'), - ('calendar', 'urn:ietf:params:xml:ns:caldav') ] + ('calendar', 'urn:ietf:params:xml:ns:caldav'), + ] + if self.context.get('DAV-client', '') == 'GroupDAV': + res.append((str(self.cal_type + '-collection'), 'http://groupdav.org/')) return res def get_domain(self, cr, filters): @@ -174,6 +225,7 @@ class node_calendar(nodes.node_class): res = [] if not filters: return res + _log = logging.getLogger('caldav.query') if filters.localName == 'calendar-query': res = [] for filter_child in filters.childNodes: @@ -189,26 +241,33 @@ class node_calendar(nodes.node_class): if vevent_filter.nodeType == vevent_filter.TEXT_NODE: continue if vevent_filter.localName == 'comp-filter': - if vevent_filter.getAttribute('name') == 'VEVENT': - res = [('type','=','vevent')] - if vevent_filter.getAttribute('name') == 'VTODO': - res = [('type','=','vtodo')] + if vevent_filter.getAttribute('name'): + res = [('type','=',vevent_filter.getAttribute('name').lower() )] + + for cfe in vevent_filter.childNodes: + if cfe.localName == 'time-range': + if cfe.getAttribute('start'): + _log.warning("Ignore start.. ") + # No, it won't work in this API + #val = cfe.getAttribute('start') + #res += [('dtstart','=', cfe)] + elif cfe.getAttribute('end'): + _log.warning("Ignore end.. ") + else: + _log.debug("Unknown comp-filter: %s", cfe.localName) + else: + _log.debug("Unknown comp-filter: %s", vevent_filter.localName) + else: + _log.debug("Unknown filter element: %s", vcalendar_filter.localName) + else: + _log.debug("Unknown calendar-query element: %s", filter_child.localName) return res elif filters.localName == 'calendar-multiget': - names = [] - for filter_child in filters.childNodes: - if filter_child.nodeType == filter_child.TEXT_NODE: - continue - if filter_child.localName == 'href': - if not filter_child.firstChild: - continue - uri = filter_child.firstChild.data - caluri = uri.split('/') - if len(caluri): - caluri = caluri[-2] - if caluri not in names : names.append(caluri) - res = [('name','in',names)] - return res + # this is not the place to process, as it wouldn't support multi-level + # hrefs. So, the code is moved to document_webdav/dav_fs.py + pass + else: + _log.debug("Unknown element in REPORT: %s", filters.localName) return res def children(self, cr, domain=None): @@ -227,26 +286,33 @@ class node_calendar(nodes.node_class): ctx = self.context.context.copy() ctx.update(self.dctx) where = [] + bc_obj = dirobj.pool.get('basic.calendar') + if name: if name.endswith('.ics'): name = name[:-4] try: - where.append(('id','=',int(name))) + if name.isdigit(): + where.append(('id','=',int(name))) + else: + bca_obj = dirobj.pool.get('basic.calendar.alias') + bc_alias = bca_obj.search(cr, uid, + [('cal_line_id.calendar_id', '=', self.calendar_id), + ('name', '=', name)] ) + if not bc_alias: + return [] + bc_val = bca_obj.read(cr, uid, bc_alias, ['res_id',]) + where.append(('id', '=', bc_val[0]['res_id'])) except ValueError: # if somebody requests any other name than the ones we # generate (non-numeric), it just won't exist - # FIXME: however, this confuses Evolution (at least), which - # thinks the .ics node hadn't been saved. return [] if not domain: domain = [] - fil_obj = dirobj.pool.get('basic.calendar') - ids = fil_obj.search(cr, uid, domain) - res = [] - if self.calendar_id in ids: - res = fil_obj.get_calendar_objects(cr, uid, [self.calendar_id], self, domain=where, context=ctx) + # we /could/ be supplying an invalid calendar id to bc_obj, it has to check + res = bc_obj.get_calendar_objects(cr, uid, [self.calendar_id], self, domain=where, context=ctx) return res def create_child(self, cr, path, data): @@ -268,7 +334,24 @@ class node_calendar(nodes.node_class): assert isinstance(res[0], (int, long)) fnodes = fil_obj.get_calendar_objects(cr, uid, [self.calendar_id], self, domain=[('id','=',res[0])], context=ctx) + if self.context.get('DAV-client','') in ('iPhone', 'iCalendar',): + # For those buggy clients, register the alias + bca_obj = fil_obj.pool.get('basic.calendar.alias') + ourcal = fil_obj.browse(cr, uid, self.calendar_id) + line_id = None + for line in ourcal.line_ids: + if line.name == ourcal.type: + line_id = line.id + break + assert line_id, "Calendar #%d must have at least one %s line" % \ + (ourcal.id, ourcal.type) + if path.endswith('.ics'): + path = path[:-4] + bca_obj.create(cr, uid, { 'cal_line_id': line_id, + 'res_id': res[0], 'name': path}, context=ctx) return fnodes[0] + # If we reach this line, it means that we couldn't import any useful + # (and matching type vs. our node kind) data from the iCal content. return None @@ -287,8 +370,12 @@ class node_calendar(nodes.node_class): def rmcol(self, cr): return False - def _get_caldav_calendar_data(self, cr): + if self.context.get('DAV-client', '') in ('iPhone', 'iCalendar'): + # Never return collective data to iClients, they get confused + # because they do propfind on the calendar node with Depth=1 + # and only expect the childrens' data + return None res = [] for child in self.children(cr): res.append(child._get_caldav_calendar_data(cr)) @@ -299,86 +386,54 @@ class node_calendar(nodes.node_class): calendar_obj = self.context._dirobj.pool.get('basic.calendar') ctx = self.context.context.copy() ctx.update(self.dctx) - calendar = calendar_obj.browse(cr, uid, self.calendar_id, context=ctx) - return calendar.description + try: + calendar = calendar_obj.browse(cr, uid, self.calendar_id, context=ctx) + return calendar.description or calendar.name + except Exception, e: + return None - def _get_dav_principal_collection_set(self, cr): - import xml - import urllib - uid = self.context.uid - ctx = self.context.context.copy() - ctx.update(self.dctx) - calendar_obj = self.context._dirobj.pool.get('basic.calendar') - calendar = calendar_obj.browse(cr, uid, self.calendar_id, context=ctx) - res = '%s/%s' %(calendar.collection_id.name, calendar.name) - doc = xml.dom.minidom.getDOMImplementation().createDocument(None, 'href', None) - href = doc.documentElement - href.tagName = 'D:href' - huri = doc.createTextNode(urllib.quote('/%s/%s' % (cr.dbname, res))) - href.appendChild(huri) - return href + def _get_dav_supported_report_set(self, cr): + + return ('supported-report', 'DAV:', + ('report','DAV:', + ('principal-match','DAV:') + ) + ) - def _get_caldav_calendar_home_set(self, cr): - import xml.dom.minidom - import urllib - uid = self.context.uid - ctx = self.context.context.copy() - ctx.update(self.dctx) - doc = xml.dom.minidom.getDOMImplementation().createDocument(None, 'href', None) + def _get_caldav_supported_calendar_component_set(self, cr): + return ('comp', 'urn:ietf:params:xml:ns:caldav', None, + {'name': self.cal_type.upper()} ) + + def _get_caldav_calendar_timezone(self, cr): + return None #TODO + + def _get_caldav_supported_calendar_data(self, cr): + return ('calendar-data', 'urn:ietf:params:xml:ns:caldav', None, + {'content-type': "text/calendar", 'version': "2.0" } ) + + def _get_caldav_max_resource_size(self, cr): + return 65535 - calendar_obj = self.context._dirobj.pool.get('basic.calendar') - calendar = calendar_obj.browse(cr, uid, self.calendar_id, context=ctx) - huri = doc.createTextNode(urllib.quote('/%s/%s' % (cr.dbname, calendar.collection_id.name))) - href = doc.documentElement - href.tagName = 'D:href' - href.appendChild(huri) - return href + def _get_caldav_min_date_time(self, cr): + return "19700101T000000Z" - def _get_caldav_calendar_user_address_set(self, cr): - import xml.dom.minidom - uid = self.context.uid - ctx = self.context.context.copy() - ctx.update(self.dctx) - user_obj = self.context._dirobj.pool.get('res.users') - user = user_obj.browse(cr, uid, uid, context=ctx) - doc = xml.dom.minidom.getDOMImplementation().createDocument(None, 'href', None) - href = doc.documentElement - href.tagName = 'D:href' - huri = doc.createTextNode('MAILTO:' + str(user.email) or str(user.name)) - href.appendChild(huri) - return href - - def _get_caldav_schedule_outbox_URL(self, cr): - return self._get_caldav_schedule_inbox_URL(cr) - - def _get_caldav_schedule_inbox_URL(self, cr): - import xml.dom.minidom - import urllib - uid = self.context.uid - ctx = self.context.context.copy() - ctx.update(self.dctx) - calendar_obj = self.context._dirobj.pool.get('basic.calendar') - calendar = calendar_obj.browse(cr, uid, self.calendar_id, context=ctx) - res = '%s/%s' %(calendar.collection_id.name, calendar.name) - doc = xml.dom.minidom.getDOMImplementation().createDocument(None, 'href', None) - href = doc.documentElement - href.tagName = 'D:href' - huri = doc.createTextNode(urllib.quote('/%s/%s' % (cr.dbname, res))) - href.appendChild(huri) - return href + def _get_caldav_max_date_time(self, cr): + return "21001231T235959Z" # I will be dead by then + + def _get_apple_cal_calendar_color(self, cr): + return self.cal_color + def _get_apple_cal_calendar_order(self, cr): + return self.cal_order class res_node_calendar(nodes.node_class): our_type = 'file' DAV_PROPS = { - "http://calendarserver.org/ns/" : ('getctag'), + "http://calendarserver.org/ns/" : ('getctag',), "urn:ietf:params:xml:ns:caldav" : ( 'calendar-description', 'calendar-data', - 'calendar-home-set', - 'calendar-user-address-set', - 'schedule-inbox-URL', - 'schedule-outbox-URL',)} + )} DAV_M_NS = { "http://calendarserver.org/ns/" : '_get_dav', "urn:ietf:params:xml:ns:caldav" : '_get_caldav'} @@ -393,7 +448,7 @@ class res_node_calendar(nodes.node_class): self.calendar_id = hasattr(parent, 'calendar_id') and parent.calendar_id or False if res_obj: if not self.calendar_id: self.calendar_id = res_obj.id - pr = res_obj.perm_read()[0] + pr = res_obj.perm_read(context=context, details=False)[0] self.create_date = pr.get('create_date') self.write_date = pr.get('write_date') or pr.get('create_date') self.displayname = res_obj.name @@ -414,6 +469,7 @@ class res_node_calendar(nodes.node_class): uid = self.context.uid calendar_obj = self.context._dirobj.pool.get('basic.calendar') context = self.context.context.copy() + context.update(self.dctx) context.update({'model': self.model, 'res_id':self.res_id}) res = calendar_obj.export_cal(cr, uid, [self.calendar_id], context=context) return res @@ -426,8 +482,11 @@ class res_node_calendar(nodes.node_class): def set_data(self, cr, data, fil_obj = None): uid = self.context.uid + context = self.context.context.copy() + context.update(self.dctx) + context.update({'model': self.model, 'res_id':self.res_id}) calendar_obj = self.context._dirobj.pool.get('basic.calendar') - res = calendar_obj.import_cal(cr, uid, data, self.calendar_id) + res = calendar_obj.import_cal(cr, uid, data, self.calendar_id, context=context) return res def _get_ttag(self,cr): @@ -438,7 +497,6 @@ class res_node_calendar(nodes.node_class): res = '%d' % (self.calendar_id) return res - def rm(self, cr): uid = self.context.uid res = False diff --git a/addons/caldav/caldav_setup.xml b/addons/caldav/caldav_setup.xml new file mode 100644 index 00000000000..c33edbc75f7 --- /dev/null +++ b/addons/caldav/caldav_setup.xml @@ -0,0 +1,88 @@ + + + + + + [] + + + + + + directory + + calendars + + + [] + + + + + directory + + resources + + + [] + + + + + + ressource + + + __uids__ + + + [] + + + + + + + ressource + + + users + + + + user_id + res_id + + + [] + + + + + + directory + + c + + + + [] + + + + + + directory + + groups + + + + urn:ietf:params:xml:ns:caldav + + calendar-home-set + ('href','DAV:','/%s/%s/calendars/users/%s/c' % ('webdav',dbname,username) ) + + + + diff --git a/addons/caldav/caldav_view.xml b/addons/caldav/caldav_view.xml index 2e9719efead..97656a954ad 100644 --- a/addons/caldav/caldav_view.xml +++ b/addons/caldav/caldav_view.xml @@ -74,6 +74,9 @@ + + + diff --git a/addons/caldav/calendar.py b/addons/caldav/calendar.py index 068af56459f..ccc4bf39fac 100644 --- a/addons/caldav/calendar.py +++ b/addons/caldav/calendar.py @@ -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 @@ -63,12 +66,13 @@ def uid2openobjectid(cr, uidval, oomodel, rdate): return (False, None) qry = 'SELECT DISTINCT(id) FROM %s' % model_obj._table if rdate: - qry += " where recurrent_id='%s'" % (rdate) #TOFIX: sql injection - 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: @@ -232,6 +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 @@ -294,6 +299,7 @@ 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': @@ -329,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) @@ -576,6 +582,13 @@ class Calendar(CalDAV, osv.osv): '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 .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): @@ -592,7 +605,7 @@ 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)] @@ -606,6 +619,32 @@ class Calendar(CalDAV, osv.osv): 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 @@ -624,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) @@ -668,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)): @@ -728,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' diff --git a/addons/caldav/calendar_collection.py b/addons/caldav/calendar_collection.py index 8dd4ff053c9..52fe698647c 100644 --- a/addons/caldav/calendar_collection.py +++ b/addons/caldav/calendar_collection.py @@ -18,9 +18,11 @@ # along with this program. If not, see . # ############################################################################## + from osv import osv, fields from tools.translate import _ import caldav_node +import logging class calendar_collection(osv.osv): _inherit = 'document.directory' @@ -30,7 +32,8 @@ class calendar_collection(osv.osv): } _default = { 'calendar_collection' : False, - } + } + def _get_root_calendar_directory(self, cr, uid, context=None): objid = self.pool.get('ir.model.data') try: @@ -40,18 +43,25 @@ class calendar_collection(osv.osv): root_id = objid.read(cr, uid, mid, ['res_id'])['res_id'] root_cal_dir = self.browse(cr,uid, root_id, context=context) return root_cal_dir.name - except Exception, e: - import netsvc - logger = netsvc.Logger() - logger.notifyChannel("document", netsvc.LOG_WARNING, 'Cannot set root directory for Calendars:'+ str(e)) + except Exception: + logger = logging.getLogger('document') + logger.warning('Cannot set root directory for Calendars:', exc_info=True) return False return False - def _locate_child(self, cr, uid, root_id, uri,nparent, ncontext): - """ try to locate the node in uri, - Return a tuple (node_dir, remaining_path) - """ - return (caldav_node.node_database(context=ncontext), uri) + def get_node_class(self, cr, uid, ids, dbro=None, dynamic=False, context=None): + if dbro is None: + dbro = self.browse(cr, uid, ids, context=context) + + if dbro.calendar_collection: + if dynamic: + return caldav_node.node_calendar_res_col + else: + return caldav_node.node_calendar_collection + else: + return super(calendar_collection, self).\ + get_node_class(cr, uid, ids, dbro=dbro,dynamic=dynamic, + context=context) def get_description(self, cr, uid, ids, context=None): #TODO : return description of all calendars diff --git a/addons/caldav/doc/Path discovery.rst b/addons/caldav/doc/Path discovery.rst new file mode 100644 index 00000000000..d2d434b5fb6 --- /dev/null +++ b/addons/caldav/doc/Path discovery.rst @@ -0,0 +1,43 @@ +=============================== +Discovery of calendar resources +=============================== + +1. Srv record +-------------- +Calendar server and port should be advertised by a DNS _srv record. +Although this is beyond the capabilities of the OpenERP server, an +example setup is listed below: + -- TODO -- + +DNS -> http://our-host-ip:port/ + +2. Well-known uris +------------------- +The OpenERP server may have the 'well-known URIs' servlet activated, +which means that it will advertise its main database and the correct +location of the main CalDAV resource. +http://our-host-ip:port/.well-known/caldav -> http://our-host-ip:port/webdav/dbname/calendars/ + + +3. Caldav collection +--------------------- +The CalDAV "collection" is not necessarily a calendar or a folder just +containing calendars under it. It is a DAV resource (aka folder) which +has special DAV properties, so that clients are redirected to the right +urls (like per-user calendars etc.). + +http://our-host-ip:port/webdav/dbname/calendars/ -> http://our-host-ip:port/webdav/dbname/calendars/users/user-login/c/ + +4. Calendar home for user +-------------------------- +There can be one dynamic folder per user, which will in turn contain the calendars + +http://our-host-ip:port/webdav/dbname/calendars/users/user-login/c/ -> +http://our-host-ip:port/webdav/dbname/calendars/users/user-login/c/[Meetings, Tasks] + +5. Calendars +-------------- +Each calendar will contain the resource nodes: + .../c/Meetings/ -> .../c/Meetings/123.ics + +Principal url diff --git a/addons/caldav/doc/iPhone.rst b/addons/caldav/doc/iPhone.rst new file mode 100644 index 00000000000..4a3af7724d2 --- /dev/null +++ b/addons/caldav/doc/iPhone.rst @@ -0,0 +1,97 @@ +========================== +CalDAV with iPhone How-To +========================== + +As from OpenERP v6.0, document_webdav v2.2, the iPhone has been thoroughly +tested and supported as a Calendaring client for the OpenERP CalDAV module. + +However, keep in mind that OpenERP is not a straightforward calendaring +server, but an ERP application (with more data + structure) which exposes +that data to calendar clients. That said, the full features that would be +accessible through the Gtk or Web OpenERP clients cannot be crammed into +the Calendar clients (such as the iPhone). + +OpenERP server Setup +-------------------- +Some modules need to be installed at the OpenERP server. These are: + - caldav: Required, has the reference setup and the necessary + underlying code. Will also cause document, document_webdav + to be installed. + - crm_caldav: Optional, will export the CRM Meetings as a calendar. + - project_caldav: Optional, will export project tasks as calendar. + - http_well_known: Optional, experimental. Will ease bootstrapping, + but only when a DNS srv record is also used. + +These will also install a reference setup of the folders, ready to go. +The administrator of OpenERP can add more calendars and structure, if +needed. + +DNS server setup +------------------ +To be documented. + +SSL setup +---------- +It is highly advisable that you also setup SSL to work for the OpenERP +server. HTTPS is a server-wide feature in OpenERP, which means a +certificate will be set at the openerp-server.conf and will be the same +for XML-RPC, HTTP, WebDAV and CalDAV. +The iPhone also supports secure connections with SSL, although it does +not expect a self-signed certificate (or one that is not verified by +one of the "big" certificate authorities [1] ). + +Phone setup +------------- +The iPhone is fairly easy to setup. +IF you need SSL (and your certificate is not a verified one, as usual), +then you first will need to let the iPhone trust that. Follow these +steps: + s1. Open Safari and enter the https location of the OpenERP server: + https://my.server.ip:8071/ + (assuming you have the server at "my.server.ip" and the HTTPS port + is the default 8071) + s2. Safari will try to connect and issue a warning about the certificate + used. Inspect the certificate and click "Accept" so that iPhone + now trusts it. + +Now, to setup the calendars, you need to: +1. Click on the "Settings" and go to the "Mail, Contacts, Calendars" page. +2. Go to "Add account..." +3. Click on "Other" +4. From the "Calendars" group, select "Add CalDAV Account" +5. Enter the server's name or IP address at the "Server" entry, the + OpenERP username and password at the next ones. + As a description, you can either leave the server's name or + something like "OpenERP calendars". +6. You _will_ get the "Unable to verify account" error message. That is + because our server is not at the port iPhone expects[2]. But no + need to worry, click OK. +7. At the next page, enter the "Advanced Settings" to specify the right + ports and paths +8. If you have SSL, turn the switch on. Note that port will be changed + to 8443. +9. Specify the port for the OpenERP server: 8071 for SSL, 8069 without. +10. Set the "Account URL" to the right path of the OpenERP webdav: + https://my.server.ip:8071/webdav/dbname/calendars + Where "https://my.server.ip:8071" is the protocol, server name + and port as discussed above, "dbname" is the name of the database. + [Note that the default + "https://my.server.ip:8071/principals/users/username" might also + be edited to + "https://my.server.ip:8071/webdav/dbname/principals/users/username" ] +11. Click on Done. The phone will hopefully connect to the OpenERP server + and verify it can use the account. +12. Go to the main menu of the iPhone and enter the Calendar application. + Your OpenERP calendars will be visible inside the selection of the + "Calendars" button. + Note that when creating a new calendar entry, you will have to specify + which calendar it should be saved at. + + + + +[1] I remember one guy that made *lots* of money selling his CA business +off, and since then uses this money to create a software monopoly. +[2] This may not happen if the SRV records at DNS and the well-known URIs +are setup right. But we appreciate that a default OpenERP installation will +not have the DNS server of the company's domain configured. diff --git a/addons/caldav/orm_utils.py b/addons/caldav/orm_utils.py new file mode 100644 index 00000000000..ab0b4bbb8db --- /dev/null +++ b/addons/caldav/orm_utils.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2010 OpenERP SA (www.openerp.com) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +if True: # we need this indentation level ;) + + def get_last_modified(self, cr, user, args, context=None, access_rights_uid=None): + """Return the last modification date of objects in 'domain' + This function has similar semantics to orm.search(), apart from the + limit, offset and order arguments, which make no sense here. + It is useful when we want to find if the table (aka set of records) + has any modifications we should update at the client. + """ + if context is None: + context = {} + self.pool.get('ir.model.access').check(cr, access_rights_uid or user, self._name, 'read', context=context) + + query = self._where_calc(cr, user, args, context=context) + self._apply_ir_rules(cr, user, query, 'read', context=context) + from_clause, where_clause, where_clause_params = query.get_sql() + + where_str = where_clause and (" WHERE %s" % where_clause) or '' + + cr.execute('SELECT MAX(COALESCE("%s".write_date, "%s".create_date)) FROM ' % (self._table, self._table) + + from_clause + where_str , + where_clause_params) + res = cr.fetchall() + return res[0][0] + +#eof diff --git a/addons/caldav/security/ir.model.access.csv b/addons/caldav/security/ir.model.access.csv index bb16603b904..ce3f3c90e32 100644 --- a/addons/caldav/security/ir.model.access.csv +++ b/addons/caldav/security/ir.model.access.csv @@ -3,3 +3,4 @@ "access_basic_calendar_attributes","basic.calendar.attributes","model_basic_calendar_attributes","base.group_user",1,1,1,1 "access_basic_calendar_fields","basic.calendar.fields","model_basic_calendar_fields","base.group_user",1,1,1,1 "access_basic_calendar","basic.calendar","model_basic_calendar","base.group_user",1,1,1,1 +"access_basic_calendar_alias","basic.calendar.alias","model_basic_calendar_alias","base.group_user",1,1,1,1 diff --git a/addons/caldav/wizard/calendar_event_export.py b/addons/caldav/wizard/calendar_event_export.py index ea90686b347..0a55f8b4fbe 100644 --- a/addons/caldav/wizard/calendar_event_export.py +++ b/addons/caldav/wizard/calendar_event_export.py @@ -37,8 +37,11 @@ class calendar_event_export(osv.osv_memory): """ Get Default value for Name field. """ - if not context: + if context is None: context = {} + else: + context= context.copy() + context['uid'] = uid model = context.get('model', 'basic.calendar') model_obj = self.pool.get(model) res = super(calendar_event_export, self).default_get( cr, uid, fields, context=context) diff --git a/addons/caldav/wizard/calendar_event_import.py b/addons/caldav/wizard/calendar_event_import.py index 875e48d7d47..11f9f9b4afb 100644 --- a/addons/caldav/wizard/calendar_event_import.py +++ b/addons/caldav/wizard/calendar_event_import.py @@ -42,6 +42,11 @@ class calendar_event_import(osv.osv_memory): @param ids: List of calendar event import’s IDs @return: dictionary of calendar evet import window with Import successful msg. """ + if context is None: + context = {} + else: + context = context.copy() + context['uid'] = uid for data in self.read(cr, uid, ids): model = data.get('model', 'basic.calendar') diff --git a/addons/caldav/wizard/calendar_event_subscribe.py b/addons/caldav/wizard/calendar_event_subscribe.py index de673b5d7c0..f8dc7614507 100644 --- a/addons/caldav/wizard/calendar_event_subscribe.py +++ b/addons/caldav/wizard/calendar_event_subscribe.py @@ -44,6 +44,12 @@ class calendar_event_subscribe(osv.osv_memory): @return: dictionary of calendar evet subscribe window with Import successful msg. """ global cnt + if context is None: + context = {} + else: + context = context.copy() + context['uid'] = uid + for data in self.read(cr, uid, ids): try: f = urllib.urlopen(data['url_path']) diff --git a/addons/crm_caldav/__openerp__.py b/addons/crm_caldav/__openerp__.py index ce8f72a3e17..d0e04576b3b 100644 --- a/addons/crm_caldav/__openerp__.py +++ b/addons/crm_caldav/__openerp__.py @@ -22,24 +22,24 @@ { 'name': 'Extened Module to Add CalDav future on Meeting', - 'version': '1.0', + 'version': '1.1', 'category': 'Generic Modules/CRM & SRM', 'description': """ - New Futures in Meeting: - * Share meeting with other calendar clients like sunbird -""", - 'author': 'OpenERP SA', - 'website': 'http://www.openerp.com', - 'depends': ['caldav', 'crm' - ], - 'init_xml': [ - 'crm_caldav_data.xml', - ], + New Futures in Meeting: + * Share meeting with other calendar clients like sunbird +""", + 'author': 'OpenERP SA', + 'website': 'http://www.openerp.com', + 'depends': ['caldav', 'crm' ], + 'init_xml': [ + 'crm_caldav_data.xml', + 'crm_caldav_setup.xml', + ], 'update_xml': [], - 'demo_xml': [], - 'installable': True, - 'active': False, + 'demo_xml': [], + 'installable': True, + 'active': False, } # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/addons/crm_caldav/crm_caldav.py b/addons/crm_caldav/crm_caldav.py index 22f64f4d63d..1a2deae90be 100644 --- a/addons/crm_caldav/crm_caldav.py +++ b/addons/crm_caldav/crm_caldav.py @@ -77,7 +77,7 @@ class crm_meeting(osv.osv): try: for val in vals: # Compute value of duration - if 'date_deadline' in val and 'duration' not in val: + if val.get('date_deadline', False) 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 diff --git a/addons/crm_caldav/crm_caldav_setup.xml b/addons/crm_caldav/crm_caldav_setup.xml new file mode 100644 index 00000000000..1a103f61811 --- /dev/null +++ b/addons/crm_caldav/crm_caldav_setup.xml @@ -0,0 +1,287 @@ + + + + + + + Meetings per user + #1E25FF + + vevent + Meetings + + + + + [] + attendee + + + + + + [] + valarm + + + + + + [('user_id','=', dctx_user_id)] + vevent + + + + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + {'tentative': 'draft', 'confirmed': 'open', 'cancelled': 'cancel'} + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + diff --git a/addons/document/__openerp__.py b/addons/document/__openerp__.py index 16bbcd41c83..5741a7223e0 100644 --- a/addons/document/__openerp__.py +++ b/addons/document/__openerp__.py @@ -22,7 +22,7 @@ { 'name': 'Integrated Document Management System', - 'version': '1.1', + 'version': '2.0', 'category': 'Generic Modules/Others', 'description': """This is a complete document management system: * User Authentication diff --git a/addons/document/content_index.py b/addons/document/content_index.py index e3b37914f8c..df2b12b5456 100644 --- a/addons/document/content_index.py +++ b/addons/document/content_index.py @@ -21,11 +21,11 @@ import logging import os import tempfile +from subprocess import Popen, PIPE class NhException(Exception): pass -from subprocess import Popen, PIPE class indexer(object): """ An indexer knows how to parse the content of some file. diff --git a/addons/document/dict_tools.py b/addons/document/dict_tools.py new file mode 100644 index 00000000000..10d2fdc69f3 --- /dev/null +++ b/addons/document/dict_tools.py @@ -0,0 +1,57 @@ +# -*- encoding: utf-8 -*- +# Copyright P. Christeas 2008-2010 +# Copyright 2010 OpenERP SA. http://www.openerp.com +# +# This program is Free Software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public License +# as published by the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +############################################################################### + + +def dict_merge(*dicts): + """ Return a dict with all values of dicts + """ + res = {} + for d in dicts: + res.update(d) + return res + +def dict_merge2(*dicts): + """ Return a dict with all values of dicts. + If some key appears twice and contains iterable objects, the values + are merged (instead of overwritten). + """ + res = {} + for d in dicts: + for k in d.keys(): + if k in res and isinstance(res[k], (list, tuple)): + res[k] = res[k] + d[k] + elif k in res and isinstance(res[k], dict): + res[k].update(d[k]) + else: + res[k] = d[k] + return res + +def dict_filter(srcdic, keys, res=None): + ''' Return a copy of srcdic that has only keys set. + If any of keys are missing from srcdic, the result won't have them, + either. + @param res If given, result will be updated there, instead of a new dict. + ''' + if res is None: + res = {} + for k in keys: + if k in srcdic: + res[k] = srcdic[k] + return res + +#eof diff --git a/addons/document/document.py b/addons/document/document.py index 92a00f3222c..470d67c582b 100644 --- a/addons/document/document.py +++ b/addons/document/document.py @@ -28,6 +28,7 @@ import os import tools from tools.translate import _ import nodes +import logging DMS_ROOT_PATH = tools.config.get('document_path', os.path.join(tools.config['root_path'], 'filestore')) @@ -106,7 +107,7 @@ class document_file(osv.osv): 'parent_id': __get_def_directory } _sql_constraints = [ - ('filename_uniq', 'unique (name,parent_id,res_id,res_model)', 'The file name must be unique !') + # filename_uniq is not possible in pure SQL ] def _check_duplication(self, cr, uid, vals, ids=[], op='create'): name = vals.get('name', False) @@ -259,6 +260,9 @@ class document_file(osv.osv): r = stor.prepare_unlink(cr, uid, storage_id, f) if r: unres.append(r) + else: + logging.getLogger('document').warning("Unlinking attachment #%s %s that has no storage", + f.id, f.name) res = super(document_file, self).unlink(cr, uid, ids, context) stor.do_unlink(cr, uid, unres) return res diff --git a/addons/document/document_directory.py b/addons/document/document_directory.py index 47cea1e86bf..9b7ba18b16f 100644 --- a/addons/document/document_directory.py +++ b/addons/document/document_directory.py @@ -125,6 +125,10 @@ class document_directory(osv.osv): def get_full_path(self, cr, uid, dir_id, context=None): """ Return the full path to this directory, in a list, root first """ + if isinstance(dir_id, (tuple, list)): + assert len(dir_id) == 1 + dir_id = dir_id[0] + def _parent(dir_id, path): parent=self.browse(cr, uid, dir_id) if parent.parent_id and not parent.ressource_parent_type_id: @@ -174,6 +178,39 @@ class document_directory(osv.osv): return nodes.get_node_context(cr, uid, context).get_uri(cr, uri) + def get_node_class(self, cr, uid, ids, dbro=None, dynamic=False, context=None): + """Retrieve the class of nodes for this directory + + This function can be overriden by inherited classes ;) + @param dbro The browse object, if caller already has it + """ + if dbro is None: + dbro = self.browse(cr, uid, ids, context=context) + + if dynamic: + assert dbro.type == 'directory' + return nodes.node_res_obj + elif dbro.type == 'directory': + return nodes.node_dir + elif dbro.type == 'ressource': + return nodes.node_res_dir + else: + raise ValueError("dir node for %s type", dbro.type) + + def _prepare_context(self, cr, uid, nctx, context): + """ Fill nctx with properties for this database + @param nctx instance of nodes.node_context, to be filled + @param context ORM context (dict) for us + + Note that this function is called *without* a list of ids, + it should behave the same for the whole database (based on the + ORM instance of document.directory). + + Some databases may override this and attach properties to the + node_context. See WebDAV, CalDAV. + """ + return + def get_dir_permissions(self, cr, uid, ids ): """Check what permission user 'uid' has on directory 'id' """ @@ -215,6 +252,7 @@ class document_directory(osv.osv): name=directory.name if not parent_id: parent_id=directory.parent_id and directory.parent_id.id or False + # TODO fix algo if not ressource_parent_type_id: ressource_parent_type_id=directory.ressource_parent_type_id and directory.ressource_parent_type_id.id or False if not ressource_id: @@ -235,8 +273,11 @@ class document_directory(osv.osv): def create(self, cr, uid, vals, context=None): if not self._check_duplication(cr, uid, vals): raise osv.except_osv(_('ValidateError'), _('Directory name must be unique!')) - if vals.get('name',False) and (vals.get('name').find('/')+1 or vals.get('name').find('@')+1 or vals.get('name').find('$')+1 or vals.get('name').find('#')+1) : - raise osv.except_osv(_('ValidateError'), _('Directory name contains special characters!')) + newname = vals.get('name',False) + if newname: + for illeg in ('/', '@', '$', '#'): + if illeg in newname: + raise osv.except_osv(_('ValidateError'), _('Directory name contains special characters!')) return super(document_directory,self).create(cr, uid, vals, context) # TODO def unlink(... @@ -255,7 +296,7 @@ class document_directory_dctx(osv.osv): _name = 'document.directory.dctx' _description = 'Directory Dynamic Context' _columns = { - 'dir_id': fields.many2one('document.directory', 'Directory', required=True), + 'dir_id': fields.many2one('document.directory', 'Directory', required=True, ondelete="cascade"), 'field': fields.char('Field', size=20, required=True, select=1, help="The name of the field. Note that the prefix \"dctx_\" will be prepended to what is typed here."), 'expr': fields.char('Expression', size=64, required=True, help="A python expression used to evaluate the field.\n" + \ "You can use 'dir_id' for current dir, 'res_id', 'res_model' as a reference to the current record, in dynamic folders"), diff --git a/addons/document/nodes.py b/addons/document/nodes.py index b849396498d..68220885dff 100644 --- a/addons/document/nodes.py +++ b/addons/document/nodes.py @@ -61,16 +61,27 @@ def get_node_context(cr, uid, context): return node_context(cr, uid, context) class node_context(object): - """ This is the root node, representing access to some particular - context """ + """ This is the root node, representing access to some particular context + + A context is a set of persistent data, which may influence the structure + of the nodes. All other transient information during a data query should + be passed down with function arguments. + """ cached_roots = {} + node_file_class = None def __init__(self, cr, uid, context=None): self.dbname = cr.dbname self.uid = uid self.context = context + if context is None: + context = {} + context['uid'] = uid self._dirobj = pooler.get_pool(cr.dbname).get('document.directory') + self.node_file_class = node_file + self.extra_ctx = {} # Extra keys for context, that do _not_ trigger inequality assert self._dirobj + self._dirobj._prepare_context(cr, uid, self, context=context) self.rootdir = False #self._dirobj._get_root_directory(cr,uid,context) def __eq__(self, other): @@ -88,10 +99,14 @@ class node_context(object): def __ne__(self, other): return not self.__eq__(other) + + def get(self, name, default=None): + return self.context.get(name, default) def get_uri(self, cr, uri): """ Although this fn passes back to doc.dir, it is needed since - it is a potential caching point """ + it is a potential caching point. + """ (ndir, duri) = self._dirobj._locate_child(cr, self.uid, self.rootdir, uri, None, self) while duri: ndir = ndir.child(cr, duri[0]) @@ -104,14 +119,10 @@ class node_context(object): """Create (or locate) a node for a directory @param dbro a browse object of document.directory """ - fullpath = self._dirobj.get_full_path(cr, self.uid, dbro.id, self.context) - if dbro.type == 'directory': - return node_dir(fullpath, None ,self, dbro) - elif dbro.type == 'ressource': - assert dbro.ressource_parent_type_id == False - return node_res_dir(fullpath, None, self, dbro) - else: - raise ValueError("dir node for %s type", dbro.type) + + fullpath = dbro.get_full_path(context=self.context) + klass = dbro.get_node_class(dbro, context=self.context) + return klass(fullpath, None ,self, dbro) def get_file_node(self, cr, fbro): """ Create or locate a node for a static file @@ -121,7 +132,7 @@ class node_context(object): if fbro.parent_id: parent = self.get_dir_node(cr, fbro.parent_id) - return node_file(fbro.name, parent, self, fbro) + return self.node_file_class(fbro.name, parent, self, fbro) class node_descriptor(object): @@ -266,22 +277,24 @@ class node_class(object): """ Get a tag, unique per object + modification. see. http://tools.ietf.org/html/rfc2616#section-13.3.3 """ - return self._get_ttag(cr) + ':' + self._get_wtag(cr) + return '"%s-%s"' % (self._get_ttag(cr), self._get_wtag(cr)) def _get_wtag(self, cr): """ Return the modification time as a unique, compact string """ - return str(_str2time(self.write_date)) + return str(_str2time(self.write_date)).replace('.','') - def _get_ttag(self,cr): + def _get_ttag(self, cr): """ Get a unique tag for this type/id of object. Must be overriden, so that each node is uniquely identified. """ print "node_class.get_ttag()",self - raise NotImplementedError("get_etag stub()") + raise NotImplementedError("get_ttag stub()") def get_dav_props(self, cr): """ If this class has special behaviour for GroupDAV etc, export its capabilities """ + # This fn is placed here rather than WebDAV, because we want the + # baseclass methods to apply to all node subclasses return self.DAV_PROPS or {} def match_dav_eprop(self, cr, match, ns, prop): @@ -438,22 +451,20 @@ class node_database(node_class): if not domain: domain = [] - where2 = where + domain + [('type', '=', 'directory')] + where2 = where + domain + ['|', ('type', '=', 'directory'), \ + '&', ('type', '=', 'ressource'), ('ressource_parent_type_id','=',False)] ids = dirobj.search(cr, uid, where2, context=ctx) res = [] for dirr in dirobj.browse(cr, uid, ids, context=ctx): - res.append(node_dir(dirr.name, self, self.context,dirr)) - - where2 = where + domain + [('type', '=', 'ressource'), ('ressource_parent_type_id','=',False)] - ids = dirobj.search(cr, uid, where2, context=ctx) - for dirr in dirobj.browse(cr, uid, ids, context=ctx): - res.append(node_res_dir(dirr.name, self, self.context, dirr)) + klass = dirr.get_node_class(dirr, context=ctx) + res.append(klass(dirr.name, self, self.context,dirr)) fil_obj = dirobj.pool.get('ir.attachment') ids = fil_obj.search(cr, uid, where, context=ctx) if ids: for fil in fil_obj.browse(cr, uid, ids, context=ctx): - res.append(node_file(fil.name, self, self.context, fil)) + klass = self.context.node_file_class + res.append(klass(fil.name, self, self.context, fil)) return res def _file_get(self,cr, nodename=False): @@ -463,9 +474,6 @@ class node_database(node_class): def _get_ttag(self,cr): return 'db-%s' % cr.dbname - def get_dav_resourcetype(self, cr): - return ('collection', 'DAV:') - def mkdosname(company_name, default='noname'): """ convert a string to a dos-like name""" if not company_name: @@ -492,7 +500,10 @@ class node_dir(node_database): self.write_date = dirr and (dirr.write_date or dirr.create_date) or False self.content_length = 0 self.unixperms = 040750 - self.uuser = (dirr.user_id and dirr.user_id.login) or 'nobody' + try: + self.uuser = (dirr.user_id and dirr.user_id.login) or 'nobody' + except Exception: + self.uuser = 'nobody' self.ugroup = mkdosname(dirr.company_id and dirr.company_id.name, default='nogroup') self.uidperms = dirr.get_dir_permissions() if dctx: @@ -505,7 +516,7 @@ class node_dir(node_database): for dfld in dirr.dctx_ids: try: self.dctx['dctx_' + dfld.field] = safe_eval(dfld.expr,dc2) - except Exception,e: + except Exception: print "Cannot eval %s" % dfld.expr print e pass @@ -670,12 +681,14 @@ class node_dir(node_database): return ret class node_res_dir(node_class): - """ A special sibling to node_dir, which does only contain dynamically + """ A folder containing dynamic folders + A special sibling to node_dir, which does only contain dynamically created folders foreach resource in the foreign model. All folders should be of type node_res_obj and merely behave like node_dirs (with limited domain). """ our_type = 'collection' + res_obj_class = None def __init__(self, path, parent, context, dirr, dctx=None ): super(node_res_dir,self).__init__(path, parent, context) self.dir_id = dirr.id @@ -687,7 +700,10 @@ class node_res_dir(node_class): self.write_date = dirr.write_date or dirr.create_date self.content_length = 0 self.unixperms = 040750 - self.uuser = (dirr.user_id and dirr.user_id.login) or 'nobody' + try: + self.uuser = (dirr.user_id and dirr.user_id.login) or 'nobody' + except Exception: + self.uuser = 'nobody' self.ugroup = mkdosname(dirr.company_id and dirr.company_id.name, default='nogroup') self.uidperms = dirr.get_dir_permissions() self.res_model = dirr.ressource_type_id and dirr.ressource_type_id.model or False @@ -782,17 +798,15 @@ class node_res_dir(node_class): continue # Yes! we can't do better but skip nameless records. - res.append(node_res_obj(name, self.dir_id, self, self.context, self.res_model, bo)) + res.append(self.res_obj_class(name, self.dir_id, self, self.context, self.res_model, bo)) return res def _get_ttag(self,cr): return 'rdir-%d' % self.dir_id - def get_dav_resourcetype(self, cr): - return ('collection', 'DAV:') - class node_res_obj(node_class): - """ A special sibling to node_dir, which does only contain dynamically + """ A dynamically created folder. + A special sibling to node_dir, which does only contain dynamically created folders foreach resource in the foreign model. All folders should be of type node_res_obj and merely behave like node_dirs (with limited domain). @@ -828,7 +842,7 @@ class node_res_obj(node_class): for fld,expr in self.dctx_dict.items(): try: self.dctx[fld] = safe_eval(expr, dc2) - except Exception,e: + except Exception: print "Cannot eval %s for %s" % (expr, fld) print e pass @@ -886,7 +900,8 @@ class node_res_obj(node_class): return res - def get_dav_props(self, cr): + def get_dav_props_DEPR(self, cr): + # Deprecated! (but document_ics must be cleaned, first) res = {} cntobj = self.context._dirobj.pool.get('document.directory.content') uid = self.context.uid @@ -899,7 +914,8 @@ class node_res_obj(node_class): res['http://groupdav.org/'] = ('resourcetype',) return res - def get_dav_eprop(self, cr, ns, prop): + def get_dav_eprop_DEPR(self, cr, ns, prop): + # Deprecated! if ns != 'http://groupdav.org/' or prop != 'resourcetype': logger.warning("Who asked for %s:%s?" % (ns, prop)) return None @@ -910,6 +926,7 @@ class node_res_obj(node_class): where = [('directory_id','=',self.dir_id) ] ids = cntobj.search(cr,uid,where,context=ctx) for content in cntobj.browse(cr, uid, ids, context=ctx): + # TODO: remove relic of GroupDAV if content.extension == '.ics': # FIXME: call the content class! return ('vevent-collection','http://groupdav.org/') return None @@ -944,20 +961,21 @@ class node_res_obj(node_class): res_name = getattr(bo, namefield) if not res_name: continue - res.append(node_res_obj(res_name, self.dir_id, self, self.context, self.res_model, res_bo = bo)) + # TODO Revise + klass = directory.get_node_class(directory, dynamic=True, context=ctx) + res.append(klass(res_name, self.dir_id, self, self.context, self.res_model, res_bo = bo)) where2 = where + [('parent_id','=',self.dir_id) ] ids = dirobj.search(cr, uid, where2, context=ctx) for dirr in dirobj.browse(cr, uid, ids, context=ctx): if dirr.type == 'directory': - res.append(node_res_obj(dirr.name, dirr.id, self, self.context, self.res_model, res_bo = None, res_id = self.res_id)) + klass = dirr.get_node_class(dirr, dynamic=True, context=ctx) + res.append(klass(dirr.name, dirr.id, self, self.context, self.res_model, res_bo = None, res_id = self.res_id)) elif dirr.type == 'ressource': # child resources can be controlled by properly set dctx - res.append(node_res_dir(dirr.name,self,self.context, dirr, {'active_id': self.res_id})) - - - + klass = dirr.get_node_class(dirr, context=ctx) + res.append(klass(dirr.name,self,self.context, dirr, {'active_id': self.res_id})) fil_obj = dirobj.pool.get('ir.attachment') if self.res_find_all: @@ -967,7 +985,8 @@ class node_res_obj(node_class): ids = fil_obj.search(cr, uid, where3, context=ctx) if ids: for fil in fil_obj.browse(cr, uid, ids, context=ctx): - res.append(node_file(fil.name, self, self.context, fil)) + klass = self.context.node_file_class + res.append(klass(fil.name, self, self.context, fil)) # Get Child Ressource Directories @@ -979,9 +998,11 @@ class node_res_obj(node_class): dirids = dirids + dirobj.search(cr,uid, where5) for dirr in dirobj.browse(cr, uid, dirids, context=ctx): if dirr.type == 'directory' and not dirr.parent_id: - res.append(node_res_obj(dirr.name, dirr.id, self, self.context, self.res_model, res_bo = None, res_id = self.res_id)) + klass = dirr.get_node_class(dirr, dynamic=True, context=ctx) + res.append(klass(dirr.name, dirr.id, self, self.context, self.res_model, res_bo = None, res_id = self.res_id)) if dirr.type == 'ressource': - res.append(node_res_dir(dirr.name, self, self.context, dirr, {'active_id': self.res_id})) + klass = dirr.get_node_class(dirr, context=ctx) + res.append(klass(dirr.name, self, self.context, dirr, {'active_id': self.res_id})) return res def create_child_collection(self, cr, objname): @@ -993,9 +1014,9 @@ class node_res_obj(node_class): uid = self.context.uid ctx = self.context.context.copy() ctx.update(self.dctx) - res_obj = dirobj.pool.get(self.context.context['res_model']) + res_obj = dirobj.pool.get(self.res_model) - object2 = res_obj.browse(cr, uid, self.context.context['res_id']) or False + object2 = res_obj.browse(cr, uid, self.res_id) or False obj = dirobj.browse(cr, uid, self.dir_id) if obj and (obj.type == 'ressource') and not object2: @@ -1038,7 +1059,8 @@ class node_res_obj(node_class): fil_id = fil_obj.create(cr, uid, val, context=ctx) fil = fil_obj.browse(cr, uid, fil_id, context=ctx) - fnode = node_file(path, self, self.context, fil) + klass = self.context.node_file_class + fnode = klass(path, self, self.context, fil) if data is not None: fnode.set_data(cr, data, fil) return fnode @@ -1046,8 +1068,7 @@ class node_res_obj(node_class): def _get_ttag(self,cr): return 'rodir-%d-%d' % (self.dir_id, self.res_id) - def get_dav_resourcetype(self, cr): - return ('collection', 'DAV:') +node_res_dir.res_obj_class = node_res_obj class node_file(node_class): our_type = 'file' @@ -1069,7 +1090,10 @@ class node_file(node_class): elif not parent.check_perms('w'): self.uidperms = 4 - self.uuser = (fil.user_id and fil.user_id.login) or 'nobody' + try: + self.uuser = (fil.user_id and fil.user_id.login) or 'nobody' + except Exception: + self.uuser = 'nobody' self.ugroup = mkdosname(fil.company_id and fil.company_id.name, default='nogroup') # This only propagates the problem to get_data. Better @@ -1183,9 +1207,6 @@ class node_file(node_class): def _get_ttag(self,cr): return 'file-%d' % self.file_id - def get_dav_resourcetype(self, cr): - return '' - def move_to(self, cr, ndir_node, new_name=False, fil_obj=None, ndir_obj=None, in_write=False): if ndir_node and ndir_node.context != self.context: raise NotImplementedError("Cannot move files between contexts") @@ -1382,3 +1403,5 @@ class nodefd_content(StringIO, node_descriptor): finally: cr.close() StringIO.close(self) + +#eof \ No newline at end of file diff --git a/addons/document/test/document_test2.yml b/addons/document/test/document_test2.yml index 34e834a5306..65312eaf10d 100644 --- a/addons/document/test/document_test2.yml +++ b/addons/document/test/document_test2.yml @@ -12,7 +12,7 @@ I create a "Testing" folder where all the test data will go. - !record {model: document.directory, id: dir_tests }: - name: 'Testing' + name: 'Testing (will be deleted!)' parent_id: dir_root - I create an attachment into the root folder (w. empty fields, test that @@ -52,7 +52,7 @@ I search the testing folder for attachments. - !python {model: ir.attachment}: | - ids = self.search(cr, uid, [('parent_id.name','=', 'Testing'), ('name','=','Test renamed 2')]) + ids = self.search(cr, uid, [('parent_id.name','=', 'Testing (will be deleted!)'), ('name','=','Test renamed 2')]) assert ids == [ ref("file_test2") ], ids - I create an attachment to a 3rd resource, eg. a res.country @@ -70,7 +70,14 @@ ids = self.search(cr, uid, [('res_model', '=', 'res.country'), ('res_id', '=', ref("base.za"))]) assert ids == [ ref("attach_3rd")], ids - - I delete the attachment + I delete the attachments - !python {model: ir.attachment}: | + self.unlink(cr, uid, [ref('file_test2')]) self.unlink(cr, uid, [ref('attach_3rd')]) +- + I delete the tests folder +- + !python {model: document.directory}: | + self.unlink(cr, uid, [ref('dir_tests')]) + cr.commit() diff --git a/addons/document_ftp/ftpserver/abstracted_fs.py b/addons/document_ftp/ftpserver/abstracted_fs.py index f69a0bbbaad..9c16e7dde55 100644 --- a/addons/document_ftp/ftpserver/abstracted_fs.py +++ b/addons/document_ftp/ftpserver/abstracted_fs.py @@ -363,7 +363,6 @@ class abstracted_fs(object): """Remove the specified directory.""" cr, node, rem = datacr assert node - cr = self.get_node_cr(node) node.rmcol(cr) cr.commit() diff --git a/addons/document_ftp/test/document_ftp_test2.yml b/addons/document_ftp/test/document_ftp_test2.yml index 53d5a2b78bf..da55c353767 100644 --- a/addons/document_ftp/test/document_ftp_test2.yml +++ b/addons/document_ftp/test/document_ftp_test2.yml @@ -200,6 +200,11 @@ # TODO move - I remove the 'Test-Folder3' +- + !python {model: ir.attachment}: | + from document_ftp import test_easyftp as te + ftp = te.get_ftp_folder(cr, uid, self, 'Documents') + ftp.rmd("Test-Folder3") - I check that test3.txt is removed. - @@ -228,3 +233,22 @@ - I move the 200 files to 'Test-Folder2' # TODO + +- + I delete the 200 files +- + !python {model: ir.attachment}: | + from document_ftp import test_easyftp as te + from cStringIO import StringIO + ftp = te.get_ftp_folder(cr, uid, self, 'Documents/Test-Folder2') + # TODO speed + ftp.delete('test3.txt') + for i in range(0, 200): + ftp.delete('test-name%s.txt' %i) +- + I remove the 'Test-Folder2' +- + !python {model: ir.attachment}: | + from document_ftp import test_easyftp as te + ftp = te.get_ftp_folder(cr, uid, self, 'Documents') + ftp.rmd("Test-Folder2") diff --git a/addons/document_ftp/test/document_ftp_test3.yml b/addons/document_ftp/test/document_ftp_test3.yml index dbdab370b97..a51f259f44e 100644 --- a/addons/document_ftp/test/document_ftp_test3.yml +++ b/addons/document_ftp/test/document_ftp_test3.yml @@ -5,10 +5,10 @@ from document_ftp import test_easyftp as te ftp = te.get_plain_ftp(timeout=1.0) - - I create a folder called '������������ ��������' in the server. + I create in the server a folder called 'Äïêéìáóôéêüò ÖÜêåëëïò' - !record {model: document.directory, id: dir_itests }: - name: '������������ ��������' + name: 'Äïêéìáóôéêüò ÖÜêåëëïò' parent_id: document.dir_root - And then I create another folder, under it, through FTP @@ -16,67 +16,77 @@ !python {model: ir.attachment}: | cr.commit() from document_ftp import test_easyftp as te - ftp = te.get_ftp_folder(cr, uid, self, 'Documents/������������ ��������') - ftp.mkd("�������� ��� ����") + ftp = te.get_ftp_folder(cr, uid, self, 'Documents/Äïêéìáóôéêüò ÖÜêåëëïò') + ftp.mkd("ÖÜêåëëïò áðü êÜôù") - I check that this folder exists at the server - - !assert {model: document.directory, id: , search: "[('name','=','�������� ��� ����')]" }: + !assert {model: document.directory, id: , search: "[('name','=','ÖÜêåëëïò áðü êÜôù')]" }: - parent_id != False - - I login with FTP and check that '������������ ��������' is there + I login with FTP and check that 'Äïêéìáóôéêüò ÖÜêåëëïò' is there - !python {model: ir.attachment}: | from document_ftp import test_easyftp as te - ftp = te.get_ftp_folder(cr, uid, self, 'Documents/������������ ��������/�������� ��� ����') + ftp = te.get_ftp_folder(cr, uid, self, 'Documents/Äïêéìáóôéêüò ÖÜêåëëïò/ÖÜêåëëïò áðü êÜôù') - - I create a file named '������' into that folder + I create a file named 'ÄïêéìÞ' into that folder - !python {model: ir.attachment}: | from document_ftp import test_easyftp as te from cStringIO import StringIO - ftp = te.get_ftp_folder(cr, uid, self, 'Documents/������������ ��������/�������� ��� ����') - fdata = StringIO('������� �� utf-8') - ftp.storbinary('STOR ������.txt', fdata) + ftp = te.get_ftp_folder(cr, uid, self, 'Documents/Äïêéìáóôéêüò ÖÜêåëëïò/ÖÜêåëëïò áðü êÜôù') + fdata = StringIO('êåßìåíï ìå utf-8') + ftp.storbinary('STOR ÄïêéìÞ.txt', fdata) - - I remove the '������.txt' file + I remove the 'ÄïêéìÞ.txt' file - !python {model: ir.attachment}: | from document_ftp import test_easyftp as te from cStringIO import StringIO - ftp = te.get_ftp_folder(cr, uid, self, 'Documents/������������ ��������/�������� ��� ����') - ftp.delete('������.txt') + ftp = te.get_ftp_folder(cr, uid, self, 'Documents/Äïêéìáóôéêüò ÖÜêåëëïò/ÖÜêåëëïò áðü êÜôù') + ftp.delete('ÄïêéìÞ.txt') - - I rename '�������� ��� ����' into '�����' + I rename 'ÖÜêåëëïò áðü êÜôù' into 'Üëëïò' - !python {model: ir.attachment}: | from document_ftp import test_easyftp as te - ftp = te.get_ftp_folder(cr, uid, self, 'Documents/������������ ��������') - ftp.rename("�������� ��� ����", "�����") + ftp = te.get_ftp_folder(cr, uid, self, 'Documents/Äïêéìáóôéêüò ÖÜêåëëïò') + ftp.rename("ÖÜêåëëïò áðü êÜôù", "Üëëïò") - - I place a file 'file �3' in '�����' + I place a file 'file Ö3' in 'Üëëïò' - !python {model: ir.attachment}: | from document_ftp import test_easyftp as te from cStringIO import StringIO - ftp = te.get_ftp_folder(cr, uid, self, 'Documents/������������ ��������/�����') - fdata = StringIO('�� ���� �������') - ftp.storbinary('STOR file �3.txt', fdata) + ftp = te.get_ftp_folder(cr, uid, self, 'Documents/Äïêéìáóôéêüò ÖÜêåëëïò/Üëëïò') + fdata = StringIO('êé Üëëï êåßìåíï') + ftp.storbinary('STOR file Ö3.txt', fdata) - I rename the file into file+range(1..200) (large filename) - !python {model: ir.attachment}: | from document_ftp import test_easyftp as te from cStringIO import StringIO - ftp = te.get_ftp_folder(cr, uid, self, 'Documents/������������ ��������/�����') + ftp = te.get_ftp_folder(cr, uid, self, 'Documents/Äïêéìáóôéêüò ÖÜêåëëïò/Üëëïò') vuvuzela = 'b'+''.join('z' * 200)+'!' - ftp.rename("file �3.txt", vuvuzela) + ftp.rename("file Ö3.txt", vuvuzela) - I delete the file with the large name - !python {model: ir.attachment}: | from document_ftp import test_easyftp as te from cStringIO import StringIO - ftp = te.get_ftp_folder(cr, uid, self, 'Documents/������������ ��������/�����') + ftp = te.get_ftp_folder(cr, uid, self, 'Documents/Äïêéìáóôéêüò ÖÜêåëëïò/Üëëïò') vuvuzela = 'b'+''.join('z' * 200)+'!' ftp.delete(vuvuzela) + +- + I delete the testing folders +- + !python {model: ir.attachment}: | + from document_ftp import test_easyftp as te + from cStringIO import StringIO + ftp = te.get_ftp_folder(cr, uid, self, 'Documents') + ftp.rmd('Äïêéìáóôéêüò ÖÜêåëëïò/Üëëïò') + ftp.rmd('Äïêéìáóôéêüò ÖÜêåëëïò') diff --git a/addons/document_ftp/test/document_ftp_test4.yml b/addons/document_ftp/test/document_ftp_test4.yml index eeed4b17dda..2bb616dc1bd 100644 --- a/addons/document_ftp/test/document_ftp_test4.yml +++ b/addons/document_ftp/test/document_ftp_test4.yml @@ -118,3 +118,10 @@ - | Bonus Piste: I create a 'Partner3' under 'all' +- + I delete the Partners Testing folder +- + !python {model: document.directory}: | + self.unlink(cr, uid, [ref('dir_tests2')]) + self.unlink(cr, uid, [ref('dir_respart1')]) + cr.commit() \ No newline at end of file diff --git a/addons/document_webdav/__init__.py b/addons/document_webdav/__init__.py index 69312668a74..ac417e18087 100644 --- a/addons/document_webdav/__init__.py +++ b/addons/document_webdav/__init__.py @@ -21,3 +21,7 @@ import webdav import webdav_server + +import document_webdav + +#eof \ No newline at end of file diff --git a/addons/document_webdav/__openerp__.py b/addons/document_webdav/__openerp__.py index 39330f68dea..1c2e232e914 100644 --- a/addons/document_webdav/__openerp__.py +++ b/addons/document_webdav/__openerp__.py @@ -29,20 +29,23 @@ ############################################################################## { - "name" : "WebDAV server for Document Management", - "version" : "2.0", - "author" : "OpenERP SA", - "category" : "Generic Modules/Others", - "website": "http://www.openerp.com", - "description": """ With this module, the WebDAV server for the documents is activated. - You can then use any compatible browser to remotely see the attachments of OpenObject. + "name" : "WebDAV server for Document Management", + "version" : "2.2", + "author" : "OpenERP SA", + "category" : "Generic Modules/Others", + "website": "http://www.openerp.com", + "description": """ With this module, the WebDAV server for the documents is activated. + You can then use any compatible browser to remotely see the attachments of OpenObject. - After installation, the webDAV server can be controlled by a [webdav] section in the server's config. + After installation, the webDAV server can be controlled by a [webdav] section in the server's config. """, - "depends" : ["base", "document"], - "init_xml" : [], - "update_xml" : [], - "demo_xml" : [], - "active": False, - "installable": True + "depends" : ["base", "document"], + "init_xml" : [], + "update_xml" : ['security/ir.model.access.csv', + 'webdav_view.xml', + 'webdav_setup.xml', + ], + "demo_xml" : [], + "active": False, + "installable": True } diff --git a/addons/document_webdav/dav_fs.py b/addons/document_webdav/dav_fs.py index f3c8201bafc..3e8e1c2b3e5 100644 --- a/addons/document_webdav/dav_fs.py +++ b/addons/document_webdav/dav_fs.py @@ -26,8 +26,6 @@ import os import time from string import joinfields, split, lower -from service import security - import netsvc import urlparse @@ -37,9 +35,13 @@ from DAV.iface import * import urllib from DAV.davcmd import copyone, copytree, moveone, movetree, delone, deltree -from document.nodes import node_res_dir, node_res_obj from cache import memoize from tools import misc +try: + from tools.dict_tools import dict_merge2 +except ImportError: + from document.dict_tools import dict_merge2 + CACHE_SIZE=20000 #hack for urlparse: add webdav in the net protocols @@ -96,8 +98,7 @@ class openerp_dav_handler(dav_interface): return props node = self.uri2object(cr, uid, pool, uri2) if node: - props = props.copy() - props.update(node.get_dav_props(cr)) + props = dict_merge2(props, node.get_dav_props(cr)) cr.close() return props @@ -134,11 +135,11 @@ class openerp_dav_handler(dav_interface): self.parent.log_message("Exc: %s",traceback.format_exc()) raise default_exc("Operation failed") - def _get_dav_lockdiscovery(self, uri): - raise DAV_NotFound + #def _get_dav_lockdiscovery(self, uri): + # raise DAV_NotFound - def _get_dav_supportedlock(self, uri): - raise DAV_NotFound + #def A_get_dav_supportedlock(self, uri): + # raise DAV_NotFound def match_prop(self, uri, match, ns, propname): if self.M_NS.has_key(ns): @@ -186,6 +187,16 @@ class openerp_dav_handler(dav_interface): cr.close() return ret + def reduce_useragent(self): + ua = self.parent.headers.get('User-Agent', False) + ctx = {} + if ua: + if 'iPhone' in ua: + ctx['DAV-client'] = 'iPhone' + elif 'Konqueror' in ua: + ctx['DAV-client'] = 'GroupDAV' + return ctx + def get_prop(self, uri, ns, propname): """ return the value of a given property @@ -194,17 +205,23 @@ class openerp_dav_handler(dav_interface): pname -- name of the property """ if self.M_NS.has_key(ns): - return dav_interface.get_prop(self, uri, ns, propname) + try: + # if it's not in the interface class, a "DAV:" property + # may be at the node class. So shouldn't give up early. + return dav_interface.get_prop(self, uri, ns, propname) + except DAV_NotFound: + pass cr, uid, pool, dbname, uri2 = self.get_cr(uri) if not dbname: if cr: cr.close() raise DAV_NotFound - node = self.uri2object(cr, uid, pool, uri2) - if not node: + try: + node = self.uri2object(cr, uid, pool, uri2) + if not node: + raise DAV_NotFound + res = node.get_dav_eprop(cr, ns, propname) + finally: cr.close() - raise DAV_NotFound - res = node.get_dav_eprop(cr, ns, propname) - cr.close() return res def get_db(self, uri, rest_ret=False, allow_last=False): @@ -283,26 +300,55 @@ class openerp_dav_handler(dav_interface): result = [] node = self.uri2object(cr, uid, pool, uri2[:]) - if not node: - if cr: cr.close() - raise DAV_NotFound2(uri2) - else: - fp = node.full_path() - if fp and len(fp): - self.parent.log_message('childs: @%s' % fp) - fp = '/'.join(fp) + try: + if not node: + raise DAV_NotFound2(uri2) else: - fp = None - domain = None - if filters: - domain = node.get_domain(cr, filters) - for d in node.children(cr, domain): - self.parent.log_message('child: %s' % d.path) - if fp: - result.append( self.urijoin(dbname,fp,d.path) ) + fp = node.full_path() + if fp and len(fp): + fp = '/'.join(fp) + self.parent.log_message('childs for: %s' % fp) else: - result.append( self.urijoin(dbname,d.path) ) - if cr: cr.close() + fp = None + domain = None + if filters: + domain = node.get_domain(cr, filters) + + if hasattr(filters, 'getElementsByTagNameNS'): + hrefs = filters.getElementsByTagNameNS('DAV:', 'href') + if hrefs: + ul = self.parent.davpath + self.uri2local(uri) + for hr in hrefs: + turi = '' + for tx in hr.childNodes: + if tx.nodeType == hr.TEXT_NODE: + turi += tx.data + if not turi.startswith('/'): + # it may be an absolute URL, decode to the + # relative part, because ul is relative, anyway + uparts=urlparse.urlparse(turi) + turi=uparts[2] + if turi.startswith(ul): + result.append( turi[len(self.parent.davpath):]) + else: + self.parent.log_error("ignore href %s because it is not under request path %s", turi, ul) + return result + # We don't want to continue with the children found below + # Note the exceptions and that 'finally' will close the + # cursor + for d in node.children(cr, domain): + self.parent.log_message('child: %s' % d.path) + if fp: + result.append( self.urijoin(dbname,fp,d.path) ) + else: + result.append( self.urijoin(dbname,d.path) ) + except DAV_Error: + raise + except Exception, e: + self.parent.log_error("cannot get_childs: "+ str(e)) + raise + finally: + if cr: cr.close() return result def uri2local(self, uri): @@ -338,7 +384,8 @@ class openerp_dav_handler(dav_interface): def uri2object(self, cr, uid, pool, uri): if not uid: return None - return pool.get('document.directory').get_object(cr, uid, uri) + context = self.reduce_useragent() + return pool.get('document.directory').get_object(cr, uid, uri, context=context) def get_data(self,uri, rrange=None): self.parent.log_message('GET: %s' % uri) diff --git a/addons/document_webdav/document_webdav.py b/addons/document_webdav/document_webdav.py new file mode 100644 index 00000000000..28b70c8d7e4 --- /dev/null +++ b/addons/document_webdav/document_webdav.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2010 Tiny SPRL (). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +from osv import osv, fields +import nodes +from tools import config + +class document_davdir(osv.osv): + _inherit = 'document.directory' + + _columns = { + # Placed here just for a reference + 'dav_prop_ids': fields.one2many('document.webdav.dir.property', 'dir_id', 'DAV properties'), + } + + def get_node_class(self, cr, uid, ids, dbro=None, dynamic=False, context=None): + # Note: in this function, nodes come from document_webdav/nodes.py ! + if dbro is None: + dbro = self.browse(cr, uid, ids, context=context) + + if dynamic: + assert dbro.type == 'directory' + return nodes.node_res_obj + elif dbro.type == 'directory': + return nodes.node_dir + elif dbro.type == 'ressource': + return nodes.node_res_dir + else: + raise ValueError("dir node for %s type", dbro.type) + + def _prepare_context(self, cr, uid, nctx, context): + nctx.node_file_class = nodes.node_file + # We can fill some more fields, but avoid any expensive function + # that might be not worth preparing. + nctx.extra_ctx['webdav_path'] = '/'+config.get_misc('webdav','vdir','webdav') + usr_obj = self.pool.get('res.users') + res = usr_obj.read(cr, uid, uid, ['login']) + if res: + nctx.extra_ctx['username'] = res['login'] + # TODO group + return + + def _locate_child(self, cr, uid, root_id, uri,nparent, ncontext): + """ try to locate the node in uri, + Return a tuple (node_dir, remaining_path) + """ + return (nodes.node_database(context=ncontext), uri) + +document_davdir() + +class dav_dir_property(osv.osv): + """ Arbitrary WebDAV properties, attached to document.directories. + + Some DAV properties have to be settable at directories, depending + on the database directory structure. + + Example would be the principal-URL. + + There _can_ be properties without a directory, which means that they + globally apply to all the directories (aka. collections) of the + present database. + """ + _name = 'document.webdav.dir.property' + + _columns = { + 'dir_id': fields.many2one('document.directory', 'Directory', required=False, select=1), + 'namespace': fields.char('Namespace', size=127, required=True), + 'name': fields.char('Name', size=64, required=True), + 'value': fields.text('Value'), + 'do_subst': fields.boolean('Substitute', required=True), + } + + _defaults = { + 'do_subst': False, + } + +dav_dir_property() + +#eof \ No newline at end of file diff --git a/addons/document_webdav/nodes.py b/addons/document_webdav/nodes.py new file mode 100644 index 00000000000..8bea54ab7d3 --- /dev/null +++ b/addons/document_webdav/nodes.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2010 Tiny SPRL (). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +from document import nodes +from tools.safe_eval import safe_eval as eval +try: + from tools.dict_tools import dict_filter +except ImportError: + from document.dict_tools import dict_filter + +import urllib + + +class node_acl_mixin(object): + def _get_dav_owner(self, cr): + return self.uuser + + def _get_dav_group(self, cr): + return self.ugroup + + def _get_dav_supported_privilege_set(self, cr): + return '' # TODO + + def _get_dav_current_user_privilege_set(self, cr): + return '' # TODO + + def _get_dav_props_hlpr(self, cr, par_class, prop_model, + prop_ref_field, res_id): + """ Helper for dav properties, usable in subclasses + + @param par_class The parent class + @param prop_model The name of the orm model holding the properties + @param prop_ref_field The name of the field at prop_model pointing to us + @param res_id the id of self in the corresponing orm table, that should + match prop_model.prop_ref_field + """ + ret = par_class.get_dav_props(self, cr) + if prop_model: + propobj = self.context._dirobj.pool.get(prop_model) + uid = self.context.uid + ctx = self.context.context.copy() + ctx.update(self.dctx) + # Not really needed because we don't do eval here: + # ctx.update({'uid': uid, 'dbname': self.context.dbname }) + # dict_filter(self.context.extra_ctx, ['username', 'groupname', 'webdav_path'], ctx) + sdomain = [(prop_ref_field, '=', False),] + if res_id: + sdomain = ['|', (prop_ref_field, '=', res_id)] + sdomain + prop_ids = propobj.search(cr, uid, sdomain, context=ctx) + if prop_ids: + ret = ret.copy() + for pbro in propobj.browse(cr, uid, prop_ids, context=ctx): + ret[pbro.namespace] = ret.get(pbro.namespace, ()) + \ + (pbro.name,) + # Note that we cannot have properties to conditionally appear + # on the context, yet. + + return ret + + def _get_dav_eprop_hlpr(self, cr, ns, prop, + par_class, prop_model, + prop_ref_field, res_id): + """ Helper for get dav eprop, usable in subclasses + + @param namespace the one to search for + @param name Name to search for + @param par_class The parent class + @param prop_model The name of the orm model holding the properties + @param prop_ref_field The name of the field at prop_model pointing to us + @param res_id the id of self in the corresponing orm table, that should + match prop_model.prop_ref_field + """ + ret = par_class.get_dav_eprop(self, cr, ns, prop) + if ret is not None: + return ret + if prop_model: + propobj = self.context._dirobj.pool.get(prop_model) + uid = self.context.uid + ctx = self.context.context.copy() + ctx.update(self.dctx) + ctx.update({'uid': uid, 'dbname': self.context.dbname }) + ctx['node_classname'] = "%s.%s" % (self.__class__.__module__, self.__class__.__name__) + dict_filter(self.context.extra_ctx, ['username', 'groupname', 'webdav_path'], ctx) + sdomain = [(prop_ref_field, '=', False),('namespace', '=', ns), ('name','=', prop)] + if res_id: + sdomain = ['|', (prop_ref_field, '=', res_id)] + sdomain + prop_ids = propobj.search(cr, uid, sdomain, context=ctx) + if prop_ids: + pbro = propobj.browse(cr, uid, prop_ids[0], context=ctx) + val = pbro.value + if pbro.do_subst: + if val.startswith("('") and val.endswith(")"): + glbls = { 'urlquote': urllib.quote, } + val = eval(val, glbls, ctx) + else: + val = val % ctx + return val + return None + +class node_dir(node_acl_mixin, nodes.node_dir): + """ override node_dir and add DAV functionality + """ + DAV_PROPS = { "DAV:": ('owner', 'group', + 'supported-privilege-set', + 'current-user-privilege-set'), + } + DAV_M_NS = { "DAV:" : '_get_dav',} + http_options = { 'DAV': ['access-control',] } + + def get_dav_resourcetype(self, cr): + return ('collection', 'DAV:') + + def get_dav_props(self, cr): + return self._get_dav_props_hlpr(cr, nodes.node_dir, + 'document.webdav.dir.property', 'dir_id', self.dir_id) + + def get_dav_eprop(self, cr, ns, prop): + return self._get_dav_eprop_hlpr(cr, ns, prop, nodes.node_dir, + 'document.webdav.dir.property', 'dir_id', self.dir_id) + + +class node_file(node_acl_mixin, nodes.node_file): + DAV_PROPS = { "DAV:": ('owner', 'group', + 'supported-privilege-set', + 'current-user-privilege-set'), + } + DAV_M_NS = { "DAV:" : '_get_dav',} + http_options = { 'DAV': ['access-control', ] } + pass + + def get_dav_resourcetype(self, cr): + return '' + + def get_dav_props(self, cr): + return self._get_dav_props_hlpr(cr, nodes.node_dir, + None, 'file_id', self.file_id) + #'document.webdav.dir.property', 'dir_id', self.dir_id) + + #def get_dav_eprop(self, cr, ns, prop): + +class node_database(nodes.node_database): + def get_dav_resourcetype(self, cr): + return ('collection', 'DAV:') + + def get_dav_props(self, cr): + return self._get_dav_props_hlpr(cr, nodes.node_dir, + 'document.webdav.dir.property', 'dir_id', False) + + def get_dav_eprop(self, cr, ns, prop): + return self._get_dav_eprop_hlpr(cr, nodes.node_dir, ns, prop, + 'document.webdav.dir.property', 'dir_id', False) + +class node_res_obj(node_acl_mixin, nodes.node_res_obj): + DAV_PROPS = { "DAV:": ('owner', 'group', + 'supported-privilege-set', + 'current-user-privilege-set'), + } + DAV_M_NS = { "DAV:" : '_get_dav',} + http_options = { 'DAV': ['access-control',] } + + def get_dav_resourcetype(self, cr): + return ('collection', 'DAV:') + + def get_dav_props(self, cr): + return self._get_dav_props_hlpr(cr, nodes.node_res_obj, + 'document.webdav.dir.property', 'dir_id', self.dir_id) + + def get_dav_eprop(self, cr, ns, prop): + return self._get_dav_eprop_hlpr(cr, ns, prop, nodes.node_res_obj, + 'document.webdav.dir.property', 'dir_id', self.dir_id) + + +class node_res_dir(node_acl_mixin, nodes.node_res_dir): + DAV_PROPS = { "DAV:": ('owner', 'group', + 'supported-privilege-set', + 'current-user-privilege-set'), + } + DAV_M_NS = { "DAV:" : '_get_dav',} + http_options = { 'DAV': ['access-control',] } + res_obj_class = node_res_obj + + def get_dav_resourcetype(self, cr): + return ('collection', 'DAV:') + + def get_dav_props(self, cr): + return self._get_dav_props_hlpr(cr, nodes.node_res_dir, + 'document.webdav.dir.property', 'dir_id', self.dir_id) + + def get_dav_eprop(self, cr, ns, prop): + return self._get_dav_eprop_hlpr(cr, ns, prop, nodes.node_res_dir, + 'document.webdav.dir.property', 'dir_id', self.dir_id) + +# Some copies, so that this module can replace 'from document import nodes' +get_node_context = nodes.get_node_context +node_context = nodes.node_context +node_class = nodes.node_class +node_descriptor = nodes.node_descriptor + + +#eof diff --git a/addons/document_webdav/security/ir.model.access.csv b/addons/document_webdav/security/ir.model.access.csv new file mode 100644 index 00000000000..a77304eb297 --- /dev/null +++ b/addons/document_webdav/security/ir.model.access.csv @@ -0,0 +1,4 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_webdav_dir_property_all","webdav.dir.property all","model_document_webdav_dir_property",,1,0,0,0 +"access_webdav_dir_property_group_doc_manager","webdav.dir.property document manager","model_document_webdav_dir_property","base.group_system",1,1,1,1 +"access_webdav_dir_property_group_system","webdav.dir.property group system","model_document_webdav_dir_property","base.group_system",1,1,1,1 diff --git a/addons/document_webdav/webdav.py b/addons/document_webdav/webdav.py index 3b6cf582067..bb2b8caf470 100644 --- a/addons/document_webdav/webdav.py +++ b/addons/document_webdav/webdav.py @@ -22,6 +22,8 @@ import xml.dom.minidom domimpl = xml.dom.minidom.getDOMImplementation() +from xml.dom.minicompat import StringTypes + import urlparse import urllib from osv import osv @@ -29,12 +31,28 @@ from osv import osv try: from DAV import utils from DAV.propfind import PROPFIND + from DAV.report import REPORT except ImportError: raise osv.except_osv('PyWebDAV Import Error!','Please install PyWebDAV \ from http://code.google.com/p/pywebdav/downloads/detail?name=PyWebDAV-0.9.4.tar.gz&can=2&q=/') import tools +class Text2(xml.dom.minidom.Text): + def writexml(self, writer, indent="", addindent="", newl=""): + data = "%s%s%s" % (indent, self.data, newl) + data = data.replace("&", "&").replace("<", "<") + data = data.replace(">", ">") + writer.write(data) + +def createText2Node(doc, data): + if not isinstance(data, StringTypes): + raise TypeError, "node contents must be a string" + t = Text2() + t.data = data + t.ownerDocument = doc + return t + super_mk_prop_response = PROPFIND.mk_prop_response def mk_prop_response(self, uri, good_props, bad_props, doc): @@ -48,7 +66,7 @@ def mk_prop_response(self, uri, good_props, bad_props, doc): re=doc.createElement("D:response") # append namespaces to response nsnum=0 - namespaces = self.namespaces + namespaces = self.namespaces[:] if 'DAV:' in namespaces: namespaces.remove('DAV:') for nsname in namespaces: @@ -67,6 +85,8 @@ def mk_prop_response(self, uri, good_props, bad_props, doc): string: text node tuple ('elem', 'ns') for empty sub-node tuple ('elem', 'ns', sub-elems) for sub-node with elements + tuple ('elem', 'ns', sub-elems, {attrs}) for sub-node with + optional elements and attributes list, of above tuples """ if ns == 'DAV:': @@ -108,13 +128,21 @@ def mk_prop_response(self, uri, good_props, bad_props, doc): ve=doc.createElement(ns_prefix+v[0]) if need_ns: ve.setAttribute("xmlns:ns"+str(nsnum), v[1]) - if len(v) > 2 and isinstance(v[2], list): - # support nested elements like: - # ( 'elem', 'ns:', [('sub-elem1', 'ns1'), ...] - _prop_elem_child(ve, v[1], v[2], ns_prefix) + if len(v) > 2 and v[2] is not None: + if isinstance(v[2], (list, tuple)): + # support nested elements like: + # ( 'elem', 'ns:', [('sub-elem1', 'ns1'), ...] + _prop_elem_child(ve, v[1], v[2], ns_prefix) + else: + vt=createText2Node(doc,tools.ustr(v[2])) + ve.appendChild(vt) + if len(v) > 3 and v[3]: + assert isinstance(v[3], dict) + for ak, av in v[3].items(): + ve.setAttribute(ak, av) pnode.appendChild(ve) else: - ve=doc.createTextNode(tools.ustr(v)) + ve=createText2Node(doc, tools.ustr(v)) pnode.appendChild(ve) # write href information @@ -124,7 +152,12 @@ def mk_prop_response(self, uri, good_props, bad_props, doc): fileloc = fileloc.encode('utf-8') href=doc.createElement("D:href") davpath = self._dataclass.parent.get_davpath() - hurl = '%s://%s%s%s' % (uparts[0], uparts[1], davpath, urllib.quote(fileloc)) + if uparts[0] and uparts[1]: + hurl = '%s://%s%s%s' % (uparts[0], uparts[1], davpath, urllib.quote(fileloc)) + else: + # When the request has been relative, we don't have enough data to + # reply with absolute url here. + hurl = '%s%s' % (davpath, urllib.quote(fileloc)) huri=doc.createTextNode(hurl) href.appendChild(huri) re.appendChild(href) @@ -133,6 +166,10 @@ def mk_prop_response(self, uri, good_props, bad_props, doc): ps=doc.createElement("D:propstat") if good_props: re.appendChild(ps) + s=doc.createElement("D:status") + t=doc.createTextNode("HTTP/1.1 200 OK") + s.appendChild(t) + ps.appendChild(s) gp=doc.createElement("D:prop") for ns in good_props.keys(): @@ -141,15 +178,11 @@ def mk_prop_response(self, uri, good_props, bad_props, doc): else: ns_prefix="ns"+str(namespaces.index(ns))+":" for p,v in good_props[ns].items(): - if not v: + if v is None: continue _prop_child(gp, ns, p, v) ps.appendChild(gp) - s=doc.createElement("D:status") - t=doc.createTextNode("HTTP/1.1 200 OK") - s.appendChild(t) - ps.appendChild(s) re.appendChild(ps) # now write the errors! @@ -159,6 +192,10 @@ def mk_prop_response(self, uri, good_props, bad_props, doc): for ecode in bad_props.keys(): ps=doc.createElement("D:propstat") re.appendChild(ps) + s=doc.createElement("D:status") + t=doc.createTextNode(utils.gen_estring(ecode)) + s.appendChild(t) + ps.appendChild(s) bp=doc.createElement("D:prop") ps.appendChild(bp) @@ -172,10 +209,6 @@ def mk_prop_response(self, uri, good_props, bad_props, doc): pe=doc.createElement(ns_prefix+str(p)) bp.appendChild(pe) - s=doc.createElement("D:status") - t=doc.createTextNode(utils.gen_estring(ecode)) - s.appendChild(t) - ps.appendChild(s) re.appendChild(ps) # return the new response element @@ -198,7 +231,12 @@ def mk_propname_response(self,uri,propnames,doc): fileloc = fileloc.encode('utf-8') href=doc.createElement("D:href") davpath = self._dataclass.parent.get_davpath() - hurl = '%s://%s%s%s' % (uparts[0], uparts[1], davpath, urllib.quote(fileloc)) + if uparts[0] and uparts[1]: + hurl = '%s://%s%s%s' % (uparts[0], uparts[1], davpath, urllib.quote(fileloc)) + else: + # When the request has been relative, we don't have enough data to + # reply with absolute url here. + hurl = '%s%s' % (davpath, urllib.quote(fileloc)) huri=doc.createTextNode(hurl) href.appendChild(huri) re.appendChild(href) @@ -230,3 +268,18 @@ def mk_propname_response(self,uri,propnames,doc): PROPFIND.mk_prop_response = mk_prop_response PROPFIND.mk_propname_response = mk_propname_response +super_create_prop = REPORT.create_prop + +def create_prop(self): + try: + if (self.filter is not None) and self._depth == "0": + hrefs = self.filter.getElementsByTagNameNS('DAV:', 'href') + if hrefs: + self._depth = "1" + except Exception: + pass + return super_create_prop(self) + +REPORT.create_prop = create_prop + +#eof diff --git a/addons/document_webdav/webdav_demo.xml b/addons/document_webdav/webdav_demo.xml new file mode 100644 index 00000000000..45cd17ed288 --- /dev/null +++ b/addons/document_webdav/webdav_demo.xml @@ -0,0 +1,22 @@ + + + + + debug: + node_class + %(node_classname)s + + + + + + + diff --git a/addons/document_webdav/webdav_server.py b/addons/document_webdav/webdav_server.py index 44592243480..df2c4a871f6 100644 --- a/addons/document_webdav/webdav_server.py +++ b/addons/document_webdav/webdav_server.py @@ -57,7 +57,7 @@ def OpenDAVConfig(**kw): class DAVHandler(HttpOptions, FixSendError, DAVRequestHandler): verbose = False protocol_version = 'HTTP/1.1' - _HTTP_OPTIONS= { 'DAV' : ['1',], + _HTTP_OPTIONS= { 'DAV' : ['1', '2'], 'Allow' : [ 'GET', 'HEAD', 'COPY', 'MOVE', 'POST', 'PUT', 'PROPFIND', 'PROPPATCH', 'OPTIONS', 'MKCOL', 'DELETE', 'TRACE', 'REPORT', ] @@ -81,7 +81,15 @@ class DAVHandler(HttpOptions, FixSendError, DAVRequestHandler): def setup(self): self.davpath = '/'+config.get_misc('webdav','vdir','webdav') - self.baseuri = "http://%s:%d/"% (self.server.server_name, self.server.server_port) + addr, port = self.server.server_name, self.server.server_port + server_proto = getattr(self.server,'proto', 'http').lower() + try: + if hasattr(self.request, 'getsockname'): + addr, port = self.request.getsockname() + except Exception, e: + self.log_error("Cannot calculate own address: %s" , e) + # Too early here to use self.headers + self.baseuri = "%s://%s:%d/"% (server_proto, addr, port) self.IFACE_CLASS = openerp_dav_handler(self, self.verbose) def copymove(self, CLASS): @@ -156,8 +164,6 @@ class DAVHandler(HttpOptions, FixSendError, DAVRequestHandler): etag = None for match in self.headers['If-Match'].split(','): - if match.startswith('"') and match.endswith('"'): - match = match[1:-1] if match == '*': if dc.exists(uri): test = True @@ -208,8 +214,9 @@ class DAVHandler(HttpOptions, FixSendError, DAVRequestHandler): if self.headers.has_key("Content-Type"): ct=self.headers['Content-Type'] try: - location = dc.put(uri,body,ct) + location = dc.put(uri, body, ct) except DAV_Error, (ec,dd): + self._logger.warning("Cannot PUT to %s: %s", uri, dd, exc_info=True) return self.send_status(ec) headers = {} @@ -264,8 +271,7 @@ try: handler.debug = config.get_misc('webdav','debug',True) _dc = { 'verbose' : verbose, 'directory' : directory, - 'lockemulation' : False, - + 'lockemulation' : True, } conf = OpenDAVConfig(**_dc) diff --git a/addons/document_webdav/webdav_setup.xml b/addons/document_webdav/webdav_setup.xml new file mode 100644 index 00000000000..2bd41ddb69c --- /dev/null +++ b/addons/document_webdav/webdav_setup.xml @@ -0,0 +1,99 @@ + + + + + + + + [] + + + + + + directory + + principals + + + + [] + + + + + + + directory + + groups + + + + [] + + + + + + directory + + resources + + + [] + + + + + + + ressource + + + __uids__ + + + [] + + + + + + + + ressource + + + users + + + [] + + + + + + directory + + locations + + + DAV: + current-user-principal + ('href','DAV:','/%s/%s/principals/users/%s' % ('webdav',dbname, username ) ) + + + + DAV: + principal-URL + ('href','DAV:','/%s/%s/principals/users/%s' % ('webdav',dbname, username ) ) + + + + + diff --git a/addons/document_webdav/webdav_view.xml b/addons/document_webdav/webdav_view.xml new file mode 100644 index 00000000000..2ea0bca5279 --- /dev/null +++ b/addons/document_webdav/webdav_view.xml @@ -0,0 +1,98 @@ + + + + + + document.webdav.dir.property.form + document.webdav.dir.property + form + +
+ + + + + + + + + +
+ + + document.webdav.dir.property.tree + document.webdav.dir.property + tree + + + + + + + + + + + Search View: Directory DAV properties + document.webdav.dir.property + search + + + + + + + + + + + + + + + ir.actions.act_window + document.webdav.dir.property + form + tree,form + + + + + + + document.directory.webdav.inherit + document.directory + + form + + + + + + + + + +
+
diff --git a/addons/project_caldav/__openerp__.py b/addons/project_caldav/__openerp__.py index 1e78db79bf0..f298fc9e1d5 100644 --- a/addons/project_caldav/__openerp__.py +++ b/addons/project_caldav/__openerp__.py @@ -20,13 +20,13 @@ ############################################################################## { - "name": "Caldav task management", - "version": "1.0", + "name": "CalDAV for task management", + "version": "1.1", "author": "OpenERP SA", "category": "Generic Modules/Others", "description": """ Synchronize between Project task and Caldav Vtodo.""", "depends": ["project", "caldav", "base_calendar"], - "init_xml": ["project_caldav_data.xml"], + "init_xml": ["project_caldav_data.xml", 'project_caldav_setup.xml', ], "demo_xml": [], "update_xml": ["project_caldav_view.xml"], "active": False, diff --git a/addons/project_caldav/project_caldav_data.xml b/addons/project_caldav/project_caldav_data.xml index 0a4d36580fc..3030b28dba9 100644 --- a/addons/project_caldav/project_caldav_data.xml +++ b/addons/project_caldav/project_caldav_data.xml @@ -3,10 +3,10 @@ - Tasks - - vtodo - + Tasks + + vtodo + vtodo diff --git a/addons/project_caldav/project_caldav_setup.xml b/addons/project_caldav/project_caldav_setup.xml new file mode 100644 index 00000000000..96d9c0ad3b6 --- /dev/null +++ b/addons/project_caldav/project_caldav_setup.xml @@ -0,0 +1,272 @@ + + + + + + Tasks per user + #FFEF74 + + vtodo + Tasks + + + + + [] + attendee + + + + + [] + valarm + + + + + [('user_id','=', dctx_user_id)] + vtodo + + + + + + + + {'needs-action': 'draft', 'completed': 'done', 'in-process': 'open', 'cancelled': 'cancelled'} + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + {'1': '0', '2': '1', '3': '1','4': '1', '5': '2', '6': '3', '7': '3', '8': '3', '9': '4'} + field + + + + + + field + + + + + + field + + + + + + hours + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + + + field + + + + \ No newline at end of file