From e762095114cac97c3b66a47b3b43760d5c65fb80 Mon Sep 17 00:00:00 2001 From: Holger Brunn Date: Wed, 12 Dec 2018 11:51:31 +0100 Subject: [PATCH 01/13] [ADD] base_dav --- base_dav/README.rst | 103 +++++ base_dav/__init__.py | 5 + base_dav/__manifest__.py | 24 + base_dav/controllers/__init__.py | 3 + base_dav/controllers/main.py | 64 +++ base_dav/demo/dav_collection.xml | 34 ++ base_dav/models/__init__.py | 4 + base_dav/models/dav_collection.py | 300 ++++++++++++ .../models/dav_collection_field_mapping.py | 174 +++++++ base_dav/radicale/__init__.py | 5 + base_dav/radicale/auth.py | 18 + base_dav/radicale/collection.py | 132 ++++++ base_dav/radicale/rights.py | 31 ++ base_dav/readme/CONFIGURE.rst | 5 + base_dav/readme/CONTRIBUTORS.rst | 2 + base_dav/readme/CREDITS.rst | 2 + base_dav/readme/DESCRIPTION.rst | 3 + base_dav/readme/ROADMAP.rst | 7 + base_dav/security/ir.model.access.csv | 3 + base_dav/static/description/icon.png | Bin 0 -> 9718 bytes base_dav/static/description/index.html | 429 ++++++++++++++++++ base_dav/tests/__init__.py | 3 + base_dav/tests/test_base_dav.py | 117 +++++ base_dav/tests/test_collection.py | 118 +++++ base_dav/views/dav_collection.xml | 78 ++++ 25 files changed, 1664 insertions(+) create mode 100644 base_dav/README.rst create mode 100644 base_dav/__init__.py create mode 100644 base_dav/__manifest__.py create mode 100644 base_dav/controllers/__init__.py create mode 100644 base_dav/controllers/main.py create mode 100644 base_dav/demo/dav_collection.xml create mode 100644 base_dav/models/__init__.py create mode 100644 base_dav/models/dav_collection.py create mode 100644 base_dav/models/dav_collection_field_mapping.py create mode 100644 base_dav/radicale/__init__.py create mode 100644 base_dav/radicale/auth.py create mode 100644 base_dav/radicale/collection.py create mode 100644 base_dav/radicale/rights.py create mode 100644 base_dav/readme/CONFIGURE.rst create mode 100644 base_dav/readme/CONTRIBUTORS.rst create mode 100644 base_dav/readme/CREDITS.rst create mode 100644 base_dav/readme/DESCRIPTION.rst create mode 100644 base_dav/readme/ROADMAP.rst create mode 100644 base_dav/security/ir.model.access.csv create mode 100644 base_dav/static/description/icon.png create mode 100644 base_dav/static/description/index.html create mode 100644 base_dav/tests/__init__.py create mode 100644 base_dav/tests/test_base_dav.py create mode 100644 base_dav/tests/test_collection.py create mode 100644 base_dav/views/dav_collection.xml diff --git a/base_dav/README.rst b/base_dav/README.rst new file mode 100644 index 000000000..8270a8937 --- /dev/null +++ b/base_dav/README.rst @@ -0,0 +1,103 @@ +========================== +Caldav and Carddav support +========================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--backend-lightgray.png?logo=github + :target: https://github.com/OCA/server-backend/tree/11.0/base_dav + :alt: OCA/server-backend +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-backend-11-0/server-backend-11-0-base_dav + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/253/11.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds WebDAV support to Odoo, specifically CalDAV and CardDAV. + +You can configure arbitrary objects as a calendar or an address book, thus make arbitrary information accessible in external systems or your mobile. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure this module, you need to: + +#. go to `Settings / WebDAV Collections` and create or edit your collections. There, you'll also see the URL to point your clients to. + +Note that you need to configure a dbfilter if you use multiple databases. + +Known issues / Roadmap +====================== + +* better UX for configuring collections +* support writing +* support address books +* support todo lists and journals +* support configuring default field mappings per model +* support plain WebDAV collections to make some model's records accessible as folders, and the records' attachments as files (r/w) +* support configuring lists of calendars so that you can have a calendar for every project and appointments are tasks, or a calendar for every sales team and appointments are sale orders. Lots of possibilities + +Backporting this to <=v10 will be tricky because radicale only supports python3. Probably it will be quite a hassle to backport the relevant code, so it might be more sensible to just backport the configuration part, and implement the rest as radicale auth/storage plugin that talks to Odoo via odoorpc. It should be possible to recycle most of the code from this addon, which actually implements those plugins, but then within Odoo. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Therp BV + +Contributors +~~~~~~~~~~~~ + +* Holger Brunn + +Other credits +~~~~~~~~~~~~~ + +* Odoo Community Association: `Icon `_ +* All the actual work is done by `Radicale `_ + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-backend `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_dav/__init__.py b/base_dav/__init__.py new file mode 100644 index 000000000..2e9e0c3e0 --- /dev/null +++ b/base_dav/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models +from . import controllers +from . import radicale diff --git a/base_dav/__manifest__.py b/base_dav/__manifest__.py new file mode 100644 index 000000000..78070e1e6 --- /dev/null +++ b/base_dav/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Caldav and Carddav support", + "version": "11.0.1.0.0", + "author": "initOS GmbH,Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Extra Tools", + "summary": "Access Odoo data as calendar or address book", + "depends": [ + 'base', + ], + "demo": [ + "demo/dav_collection.xml", + ], + "data": [ + "views/dav_collection.xml", + 'security/ir.model.access.csv', + ], + "external_dependencies": { + 'python': ['radicale'], + }, +} diff --git a/base_dav/controllers/__init__.py b/base_dav/controllers/__init__.py new file mode 100644 index 000000000..665f08cea --- /dev/null +++ b/base_dav/controllers/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import main diff --git a/base_dav/controllers/main.py b/base_dav/controllers/main.py new file mode 100644 index 000000000..0df64d898 --- /dev/null +++ b/base_dav/controllers/main.py @@ -0,0 +1,64 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging +from configparser import RawConfigParser as ConfigParser + +import werkzeug +from odoo import http +from odoo.http import request + +try: + import radicale +except ImportError: + radicale = None + +PREFIX = '/.dav' + + +class Main(http.Controller): + @http.route( + ['/.well-known/carddav', '/.well-known/caldav', '/.well-known/webdav'], + type='http', auth='none', csrf=False, + ) + def handle_well_known_request(self): + return werkzeug.utils.redirect(PREFIX, 301) + + @http.route( + [PREFIX, '%s/' % PREFIX], type='http', auth='none', + csrf=False, + ) + def handle_dav_request(self, davpath=None): + config = ConfigParser() + for section, values in radicale.config.INITIAL_CONFIG.items(): + config.add_section(section) + for key, data in values.items(): + config.set(section, key, data["value"]) + config.set('auth', 'type', 'odoo.addons.base_dav.radicale.auth') + config.set( + 'storage', 'type', 'odoo.addons.base_dav.radicale.collection' + ) + config.set( + 'rights', 'type', 'odoo.addons.base_dav.radicale.rights' + ) + config.set('web', 'type', 'none') + application = radicale.Application( + config, logging.getLogger('radicale'), + ) + + response = None + + def start_response(status, headers): + nonlocal response + response = http.Response(status=status, headers=headers) + + result = application( + dict( + request.httprequest.environ, + HTTP_X_SCRIPT_NAME=PREFIX, + PATH_INFO=davpath or '', + ), + start_response, + ) + response.stream.write(result and result[0] or b'') + return response diff --git a/base_dav/demo/dav_collection.xml b/base_dav/demo/dav_collection.xml new file mode 100644 index 000000000..637c087a3 --- /dev/null +++ b/base_dav/demo/dav_collection.xml @@ -0,0 +1,34 @@ + + + + Addressbook + addressbook + + [] + + + N + + + + + FN + + + + + photo + + + + + email + + + + + tel + + + + diff --git a/base_dav/models/__init__.py b/base_dav/models/__init__.py new file mode 100644 index 000000000..3e6c8778b --- /dev/null +++ b/base_dav/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import dav_collection +from . import dav_collection_field_mapping diff --git a/base_dav/models/dav_collection.py b/base_dav/models/dav_collection.py new file mode 100644 index 000000000..2e0ee1aa2 --- /dev/null +++ b/base_dav/models/dav_collection.py @@ -0,0 +1,300 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import os +import time +from operator import itemgetter +from urllib.parse import quote_plus + +import vobject +from odoo import api, fields, models, tools + +# pylint: disable=missing-import-error +from ..controllers.main import PREFIX +from ..radicale.collection import Collection, FileItem, Item + + +class DavCollection(models.Model): + _name = 'dav.collection' + _description = 'A collection accessible via WebDAV' + + name = fields.Char(required=True) + rights = fields.Selection( + [ + ("owner_only", "Owner Only"), + ("owner_write_only", "Owner Write Only"), + ("authenticated", "Authenticated"), + ], + required=True, + default="owner_only", + ) + dav_type = fields.Selection( + [ + ('calendar', 'Calendar'), + ('addressbook', 'Addressbook'), + ('files', 'Files'), + ], + string='Type', + required=True, + default='calendar', + ) + tag = fields.Char(compute='_compute_tag') + model_id = fields.Many2one( + 'ir.model', + string='Model', + required=True, + domain=[('transient', '=', False)], + ) + domain = fields.Char( + required=True, + default='[]', + ) + field_uuid = fields.Many2one('ir.model.fields') + field_mapping_ids = fields.One2many( + 'dav.collection.field_mapping', + 'collection_id', + string='Field mappings', + ) + url = fields.Char(compute='_compute_url') + + @api.multi + def _compute_tag(self): + for this in self: + if this.dav_type == 'calendar': + this.tag = 'VCALENDAR' + elif this.dav_type == 'addressbook': + this.tag = 'VADDRESSBOOK' + + @api.multi + def _compute_url(self): + base_url = self.env['ir.config_parameter'].get_param('web.base.url') + for this in self: + this.url = '%s%s/%s/%s' % ( + base_url, + PREFIX, + self.env.user.login, + this.id, + ) + + @api.constrains('domain') + def _check_domain(self): + self._eval_domain() + + @api.model + def _eval_context(self): + return { + 'user': self.env.user, + } + + @api.multi + def _eval_domain(self): + self.ensure_one() + return list(tools.safe_eval(self.domain, self._eval_context())) + + @api.multi + def eval(self): + if not self: + return self.env['unknown'] + self.ensure_one() + return self.env[self.model_id.model].search(self._eval_domain()) + + @api.multi + def get_record(self, components): + self.ensure_one() + collection_model = self.env[self.model_id.model] + + field_name = self.field_uuid.name or "id" + domain = [(field_name, '=', components[-1])] + self._eval_domain() + return collection_model.search(domain, limit=1) + + @api.multi + def from_vobject(self, item): + self.ensure_one() + + result = {} + if self.dav_type == 'calendar': + if item.name != 'VCALENDAR': + return None + if not hasattr(item, 'vevent'): + return None + item = item.vevent + elif self.dav_type == 'addressbook' and item.name != 'VCARD': + return None + + children = {c.name.lower(): c for c in item.getChildren()} + for mapping in self.field_mapping_ids: + name = mapping.name.lower() + if name not in children: + continue + + if name in children: + value = mapping.from_vobject(children[name]) + if value: + result[mapping.field_id.name] = value + + return result + + @api.multi + def to_vobject(self, record): + self.ensure_one() + result = None + vobj = None + if self.dav_type == 'calendar': + result = vobject.iCalendar() + vobj = result.add('vevent') + if self.dav_type == 'addressbook': + result = vobject.vCard() + vobj = result + for mapping in self.field_mapping_ids: + value = mapping.to_vobject(record) + if value: + vobj.add(mapping.name).value = value + + if 'uid' not in vobj.contents: + vobj.add('uid').value = '%s,%s' % (record._name, record.id) + if 'rev' not in vobj.contents and 'write_date' in record._fields: + vobj.add('rev').value = record.write_date.\ + replace(':', '').replace(' ', 'T').replace('.', '') + 'Z' + return result + + @api.model + def _odoo_to_http_datetime(self, value): + return time.strftime( + '%a, %d %b %Y %H:%M:%S GMT', + time.strptime(value, '%Y-%m-%d %H:%M:%S'), + ) + + @api.model + def _split_path(self, path): + return list(filter( + None, os.path.normpath(path or '').strip('/').split('/') + )) + + @api.multi + def dav_list(self, path_components): + self.ensure_one() + + if self.dav_type == 'files': + if len(path_components) == 3: + collection_model = self.env[self.model_id.model] + record = collection_model.browse(map( + itemgetter(0), + collection_model.name_search( + path_components[2], operator='=', limit=1, + ) + )) + return [ + '/' + '/'.join( + path_components + [quote_plus(attachment.name)] + ) + for attachment in self.env['ir.attachment'].search([ + ('type', '=', 'binary'), + ('res_model', '=', record._name), + ('res_id', '=', record.id), + ]) + ] + elif len(path_components) == 2: + return [ + '/' + '/'.join( + path_components + [quote_plus(record.display_name)] + ) + for record in self.eval() + ] + + if len(path_components) > 2: + return [] + + result = [] + for record in self.eval(): + if self.field_uuid: + uuid = record[self.field_uuid.name] + else: + uuid = str(record.id) + result.append('/' + '/'.join(path_components + [uuid])) + return result + + @api.multi + def dav_delete(self, components): + self.ensure_one() + + if self.dav_type == "files": + # TODO: Handle deletion of attachments + pass + else: + self.get_record(components).unlink() + + @api.multi + def dav_upload(self, href, item): + self.ensure_one() + + components = self._split_path(href) + collection_model = self.env[self.model_id.model] + if self.dav_type == 'files': + # TODO: Handle upload of attachments + return None + + data = self.from_vobject(item) + record = self.get_record(components) + + if not record: + if self.field_uuid: + data[self.field_uuid.name] = components[-1] + + record = collection_model.create(data) + uuid = components[-1] if self.field_uuid else record.id + href = "%s/%s" % (href, uuid) + else: + record.write(data) + + return Item( + self, + item=self.to_vobject(record), + href=href, + last_modified=self._odoo_to_http_datetime(record.write_date), + ) + + @api.multi + def dav_get(self, href): + self.ensure_one() + + components = self._split_path(href) + collection_model = self.env[self.model_id.model] + if self.dav_type == 'files': + if len(components) == 3: + result = Collection(href) + result.logger = self.logger + return result + if len(components) == 4: + record = collection_model.browse(map( + itemgetter(0), + collection_model.name_search( + components[2], operator='=', limit=1, + ) + )) + attachment = self.env['ir.attachment'].search([ + ('type', '=', 'binary'), + ('res_model', '=', record._name), + ('res_id', '=', record.id), + ('name', '=', components[3]), + ], limit=1) + return FileItem( + self, + item=attachment, + href=href, + last_modified=self._odoo_to_http_datetime( + record.write_date + ), + ) + + record = self.get_record(components) + + if not record: + return None + + return Item( + self, + item=self.to_vobject(record), + href=href, + last_modified=self._odoo_to_http_datetime(record.write_date), + ) diff --git a/base_dav/models/dav_collection_field_mapping.py b/base_dav/models/dav_collection_field_mapping.py new file mode 100644 index 000000000..8ef7e3047 --- /dev/null +++ b/base_dav/models/dav_collection_field_mapping.py @@ -0,0 +1,174 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import datetime + +import dateutil +import vobject +from dateutil import tz +from odoo import api, fields, models, tools + + +class DavCollectionFieldMapping(models.Model): + _name = 'dav.collection.field_mapping' + _description = 'A field mapping for a WebDAV collection' + + collection_id = fields.Many2one( + 'dav.collection', required=True, ondelete='cascade', + ) + name = fields.Char( + required=True, + help="Attribute name in the vobject", + ) + mapping_type = fields.Selection( + [ + ('simple', 'Simple'), + ('code', 'Code'), + ], + default='simple', + required=True, + ) + field_id = fields.Many2one( + 'ir.model.fields', + required=True, + help="Field of the model the values are mapped to", + ) + import_code = fields.Text( + help="Code to import the value from a vobject. Use the variable " + "result for the output of the value and item as input" + ) + export_code = fields.Text( + help="Code to export the value to a vobject. Use the variable " + "result for the output of the value and record as input" + ) + + @api.multi + def from_vobject(self, child): + self.ensure_one() + if self.mapping_type == 'code': + return self._from_vobject_code(child) + return self._from_vobject_simple(child) + + @api.multi + def _from_vobject_code(self, child): + self.ensure_one() + context = { + 'datetime': datetime, + 'dateutil': dateutil, + 'item': child, + 'result': None, + 'tools': tools, + 'tz': tz, + 'vobject': vobject, + } + tools.safe_eval(self.import_code, context, mode="exec", nocopy=True) + return context.get('result', {}) + + @api.multi + def _from_vobject_simple(self, child): + self.ensure_one() + name = self.name.lower() + conversion_funcs = [ + '_from_vobject_%s_%s' % (self.field_id.ttype, name), + '_from_vobject_%s' % self.field_id.ttype, + ] + + for conversion_func in conversion_funcs: + if hasattr(self, conversion_func): + value = getattr(self, conversion_func)(child) + if value: + return value + + return child.value + + @api.model + def _from_vobject_datetime(self, item): + if isinstance(item.value, datetime.datetime): + value = item.value.astimezone(dateutil.tz.UTC) + return value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + elif isinstance(item.value, datetime.date): + return item.value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + return None + + @api.model + def _from_vobject_date(self, item): + if isinstance(item.value, datetime.datetime): + value = item.value.astimezone(dateutil.tz.UTC) + return value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + elif isinstance(item.value, datetime.date): + return item.value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + return None + + @api.model + def _from_vobject_binary(self, item): + return item.value.encode('ascii') + + @api.model + def _from_vobject_char_n(self, item): + return item.family + + @api.multi + def to_vobject(self, record): + self.ensure_one() + if self.mapping_type == 'code': + result = self._to_vobject_code(record) + else: + result = self._to_vobject_simple(record) + + if isinstance(result, datetime.datetime) and not result.tzinfo: + return result.replace(tzinfo=tz.UTC) + return result + + @api.multi + def _to_vobject_code(self, record): + self.ensure_one() + context = { + 'datetime': datetime, + 'dateutil': dateutil, + 'record': record, + 'result': None, + 'tools': tools, + 'tz': tz, + 'vobject': vobject, + } + tools.safe_eval(self.export_code, context, mode="exec", nocopy=True) + return context.get('result', None) + + @api.multi + def _to_vobject_simple(self, record): + self.ensure_one() + conversion_funcs = [ + '_to_vobject_%s_%s' % ( + self.field_id.ttype, self.name.lower() + ), + '_to_vobject_%s' % self.field_id.ttype, + ] + value = record[self.field_id.name] + for conversion_func in conversion_funcs: + if hasattr(self, conversion_func): + return getattr(self, conversion_func)(value) + return value + + @api.model + def _to_vobject_datetime(self, value): + result = fields.Datetime.from_string(value) + return result.replace(tzinfo=tz.UTC) + + @api.model + def _to_vobject_datetime_rev(self, value): + return value and value\ + .replace('-', '').replace(' ', 'T').replace(':', '') + 'Z' + + @api.model + def _to_vobject_date(self, value): + return fields.Date.from_string(value) + + @api.model + def _to_vobject_binary(self, value): + return value and value.decode('ascii') + + @api.model + def _to_vobject_char_n(self, value): + # TODO: how are we going to handle compound types like this? + return vobject.vcard.Name(family=value) diff --git a/base_dav/radicale/__init__.py b/base_dav/radicale/__init__.py new file mode 100644 index 000000000..dd8d0f16c --- /dev/null +++ b/base_dav/radicale/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import auth +from . import collection +from . import rights diff --git a/base_dav/radicale/auth.py b/base_dav/radicale/auth.py new file mode 100644 index 000000000..fb6de24ef --- /dev/null +++ b/base_dav/radicale/auth.py @@ -0,0 +1,18 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo.http import request + +try: + from radicale.auth import BaseAuth +except ImportError: + BaseAuth = None + + +class Auth(BaseAuth): + def is_authenticated2(self, login, user, password): + env = request.env + uid = env['res.users']._login(env.cr.dbname, user, password) + if uid: + request._env = env(user=uid) + return bool(uid) diff --git a/base_dav/radicale/collection.py b/base_dav/radicale/collection.py new file mode 100644 index 000000000..9a37c5bfb --- /dev/null +++ b/base_dav/radicale/collection.py @@ -0,0 +1,132 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import base64 +import os +import time +from contextlib import contextmanager + +from odoo.http import request + +try: + from radicale.storage import BaseCollection, Item, get_etag +except ImportError: + BaseCollection = None + Item = None + get_etag = None + + +class BytesPretendingToBeString(bytes): + # radicale expects a string as file content, so we provide the str + # functions needed + def encode(self, encoding): + return self + + +class FileItem(Item): + """this item tricks radicalev into serving a plain file""" + @property + def name(self): + return 'VCARD' + + def serialize(self): + return BytesPretendingToBeString(base64.b64decode(self.item.datas)) + + @property + def etag(self): + return get_etag(self.item.datas.decode('ascii')) + + +class Collection(BaseCollection): + @classmethod + def static_init(cls): + pass + + @classmethod + def _split_path(cls, path): + return list(filter( + None, os.path.normpath(path or '').strip('/').split('/') + )) + + @classmethod + def discover(cls, path, depth=None): + depth = int(depth or "0") + components = cls._split_path(path) + collection = cls(path) + if len(components) > 2: + # TODO: this probably better should happen in some dav.collection + # function + if collection.collection.dav_type == 'files' and depth: + for href in collection.list(): + yield collection.get(href) + return + yield collection.get(path) + return + yield collection + if depth and len(components) == 1: + for collection in request.env['dav.collection'].search([]): + yield cls('/'.join(components + ['/%d' % collection.id])) + if depth and len(components) == 2: + for href in collection.list(): + yield collection.get(href) + + @classmethod + @contextmanager + def acquire_lock(cls, mode, user=None): + """We have a database for that""" + yield + + @property + def env(self): + return request.env + + @property + def last_modified(self): + return self._odoo_to_http_datetime(self.collection.create_date) + + def __init__(self, path): + self.path_components = self._split_path(path) + self.path = '/'.join(self.path_components) or '/' + self.collection = self.env['dav.collection'] + if len(self.path_components) >= 2 and str( + self.path_components[1] + ).isdigit(): + self.collection = self.env['dav.collection'].browse(int( + self.path_components[1] + )) + + def _odoo_to_http_datetime(self, value): + return time.strftime( + '%a, %d %b %Y %H:%M:%S GMT', + time.strptime(value, '%Y-%m-%d %H:%M:%S'), + ) + + def get_meta(self, key=None): + if key is None: + return {} + elif key == 'tag': + return self.collection.tag + elif key == 'D:displayname': + return self.collection.display_name + elif key == 'C:supported-calendar-component-set': + return 'VTODO,VEVENT,VJOURNAL' + elif key == 'C:calendar-home-set': + return None + elif key == 'D:principal-URL': + return None + elif key == 'ICAL:calendar-color': + # TODO: set in dav.collection + return '#48c9f4' + self.logger.warning('unsupported metadata %s', key) + + def get(self, href): + return self.collection.dav_get(href) + + def upload(self, href, vobject_item): + return self.collection.dav_upload(href, vobject_item) + + def delete(self, href): + return self.collection.dav_delete(self._split_path(href)) + + def list(self): + return self.collection.dav_list(self.path_components) diff --git a/base_dav/radicale/rights.py b/base_dav/radicale/rights.py new file mode 100644 index 000000000..abad671df --- /dev/null +++ b/base_dav/radicale/rights.py @@ -0,0 +1,31 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from .collection import Collection + +try: + from radicale.rights import ( + AuthenticatedRights, OwnerWriteRights, OwnerOnlyRights, + ) +except ImportError: + AuthenticatedRights = OwnerOnlyRights = OwnerWriteRights = None + + +class Rights(OwnerOnlyRights, OwnerWriteRights, AuthenticatedRights): + def authorized(self, user, path, perm): + if path == '/': + return True + + collection = Collection(path) + if not collection.collection: + return False + + rights = collection.collection.sudo().rights + cls = { + "owner_only": OwnerOnlyRights, + "owner_write_only": OwnerWriteRights, + "authenticated": AuthenticatedRights, + }.get(rights) + if not cls: + return False + return cls.authorized(self, user, path, perm) diff --git a/base_dav/readme/CONFIGURE.rst b/base_dav/readme/CONFIGURE.rst new file mode 100644 index 000000000..dc57f0e19 --- /dev/null +++ b/base_dav/readme/CONFIGURE.rst @@ -0,0 +1,5 @@ +To configure this module, you need to: + +#. go to `Settings / WebDAV Collections` and create or edit your collections. There, you'll also see the URL to point your clients to. + +Note that you need to configure a dbfilter if you use multiple databases. diff --git a/base_dav/readme/CONTRIBUTORS.rst b/base_dav/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..9c7447f2a --- /dev/null +++ b/base_dav/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Holger Brunn +* Florian Kantelberg diff --git a/base_dav/readme/CREDITS.rst b/base_dav/readme/CREDITS.rst new file mode 100644 index 000000000..b83250bd3 --- /dev/null +++ b/base_dav/readme/CREDITS.rst @@ -0,0 +1,2 @@ +* Odoo Community Association: `Icon `_ +* All the actual work is done by `Radicale `_ diff --git a/base_dav/readme/DESCRIPTION.rst b/base_dav/readme/DESCRIPTION.rst new file mode 100644 index 000000000..5b4aad0b7 --- /dev/null +++ b/base_dav/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module adds WebDAV support to Odoo, specifically CalDAV and CardDAV. + +You can configure arbitrary objects as a calendar or an address book, thus make arbitrary information accessible in external systems or your mobile. diff --git a/base_dav/readme/ROADMAP.rst b/base_dav/readme/ROADMAP.rst new file mode 100644 index 000000000..9897f6237 --- /dev/null +++ b/base_dav/readme/ROADMAP.rst @@ -0,0 +1,7 @@ +* much better UX for configuring collections (probably provide a group that sees the current fully flexible field mappings, and by default show some dumbed down version where you can select some preselected vobject fields) +* support todo lists and journals +* support configuring default field mappings per model +* support plain WebDAV collections to make some model's records accessible as folders, and the records' attachments as files (r/w) +* support configuring lists of calendars so that you can have a calendar for every project and appointments are tasks, or a calendar for every sales team and appointments are sale orders. Lots of possibilities + +Backporting this to <=v10 will be tricky because radicale only supports python3. Probably it will be quite a hassle to backport the relevant code, so it might be more sensible to just backport the configuration part, and implement the rest as radicale auth/storage plugin that talks to Odoo via odoorpc. It should be possible to recycle most of the code from this addon, which actually implements those plugins, but then within Odoo. diff --git a/base_dav/security/ir.model.access.csv b/base_dav/security/ir.model.access.csv new file mode 100644 index 000000000..3d2d4d57b --- /dev/null +++ b/base_dav/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_dav_collection,access_dav_collection,model_dav_collection,base.group_user,1,0,0,0 +access_dav_collection_field_mapping,access_dav_collection_field_mapping,model_dav_collection_field_mapping,base.group_user,1,0,0,0 diff --git a/base_dav/static/description/icon.png b/base_dav/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f0eebfd17427b74c00e59bdafdd785abf7e8dcc7 GIT binary patch literal 9718 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 YooIC{JT?$`LJLDxNmH>#-ZK3E0V&OZSpWb4 literal 0 HcmV?d00001 diff --git a/base_dav/static/description/index.html b/base_dav/static/description/index.html new file mode 100644 index 000000000..3c659e7f0 --- /dev/null +++ b/base_dav/static/description/index.html @@ -0,0 +1,429 @@ + + + + + + +Caldav and Carddav support + + + +

+

Caldav and Carddav support

+ + +

Beta License: AGPL-3 OCA/server-backend Translate me on Weblate Try me on Runbot

+

This module adds WebDAV support to Odoo, specifically CalDAV and CardDAV.

+

You can configure arbitrary objects as a calendar or an address book, thus make arbitrary information accessible in external systems or your mobile.

+

Table of contents

+ +
+

Configuration

+

To configure this module, you need to:

+
    +
  1. go to Settings / WebDAV Collections and create or edit your collections. There, you’ll also see the URL to point your clients to.
  2. +
+

Note that you need to configure a dbfilter if you use multiple databases.

+
+
+

Known issues / Roadmap

+
    +
  • better UX for configuring collections
  • +
  • support writing
  • +
  • support address books
  • +
  • support todo lists and journals
  • +
  • support configuring default field mappings per model
  • +
  • support plain WebDAV collections to make some model’s records accessible as folders, and the records’ attachments as files (r/w)
  • +
  • support configuring lists of calendars so that you can have a calendar for every project and appointments are tasks, or a calendar for every sales team and appointments are sale orders. Lots of possibilities
  • +
+

Backporting this to <=v10 will be tricky because radicale only supports python3. Probably it will be quite a hassle to backport the relevant code, so it might be more sensible to just backport the configuration part, and implement the rest as radicale auth/storage plugin that talks to Odoo via odoorpc. It should be possible to recycle most of the code from this addon, which actually implements those plugins, but then within Odoo.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Therp BV
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+
    +
  • Odoo Community Association: Icon
  • +
  • All the actual work is done by Radicale
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-backend project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_dav/tests/__init__.py b/base_dav/tests/__init__.py new file mode 100644 index 000000000..09e6f8403 --- /dev/null +++ b/base_dav/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import test_base_dav, test_collection diff --git a/base_dav/tests/test_base_dav.py b/base_dav/tests/test_base_dav.py new file mode 100644 index 000000000..b76cec111 --- /dev/null +++ b/base_dav/tests/test_base_dav.py @@ -0,0 +1,117 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from base64 import b64encode +from unittest import mock +from urllib.parse import urlparse + +from odoo.tests.common import TransactionCase + +from ..controllers.main import PREFIX +from ..controllers.main import Main as Controller + +MODULE_PATH = "odoo.addons.base_dav" +CONTROLLER_PATH = MODULE_PATH + ".controllers.main" +RADICALE_PATH = MODULE_PATH + ".radicale" + +ADMIN_PASSWORD = "RadicalePa$$word" + + +@mock.patch(CONTROLLER_PATH + ".request") +@mock.patch(RADICALE_PATH + ".auth.request") +@mock.patch(RADICALE_PATH + ".collection.request") +class TestBaseDav(TransactionCase): + def setUp(self): + super().setUp() + + self.collection = self.env["dav.collection"].create({ + "name": "Test Collection", + "dav_type": "calendar", + "model_id": self.env.ref("base.model_res_users").id, + "domain": "[]", + }) + + self.dav_path = urlparse(self.collection.url).path.replace(PREFIX, '') + + self.controller = Controller() + self.env.user.password_crypt = ADMIN_PASSWORD + + self.test_user = self.env["res.users"].create({ + "login": "tester", + "name": "tester", + }) + + self.auth_owner = self.auth_string(self.env.user, ADMIN_PASSWORD) + self.auth_tester = self.auth_string(self.test_user, ADMIN_PASSWORD) + + patcher = mock.patch('odoo.http.request') + self.addCleanup(patcher.stop) + patcher.start() + + def auth_string(self, user, password): + return b64encode( + ("%s:%s" % (user.login, password)).encode() + ).decode() + + def init_mocks(self, coll_mock, auth_mock, req_mock): + req_mock.env = self.env + req_mock.httprequest.environ = { + "HTTP_AUTHORIZATION": "Basic %s" % self.auth_owner, + "REQUEST_METHOD": "PROPFIND", + "HTTP_X_SCRIPT_NAME": PREFIX, + } + + auth_mock.env["res.users"]._login.return_value = self.env.uid + coll_mock.env = self.env + + def check_status_code(self, response, forbidden): + if forbidden: + self.assertNotEqual(response.status_code, 403) + else: + self.assertEqual(response.status_code, 403) + + def check_access(self, environ, auth_string, read, write): + environ.update({ + "REQUEST_METHOD": "PROPFIND", + "HTTP_AUTHORIZATION": "Basic %s" % auth_string, + }) + response = self.controller.handle_dav_request(self.dav_path) + self.check_status_code(response, read) + + environ["REQUEST_METHOD"] = "PUT" + response = self.controller.handle_dav_request(self.dav_path) + self.check_status_code(response, write) + + def test_well_known(self, coll_mock, auth_mock, req_mock): + req_mock.env = self.env + + response = self.controller.handle_well_known_request() + self.assertEqual(response.status_code, 301) + + def test_authenticated(self, coll_mock, auth_mock, req_mock): + self.init_mocks(coll_mock, auth_mock, req_mock) + environ = req_mock.httprequest.environ + + self.collection.rights = "authenticated" + + self.check_access(environ, self.auth_owner, read=True, write=True) + self.check_access(environ, self.auth_tester, read=True, write=True) + + def test_owner_only(self, coll_mock, auth_mock, req_mock): + self.init_mocks(coll_mock, auth_mock, req_mock) + environ = req_mock.httprequest.environ + + self.collection.rights = "owner_only" + + self.check_access(environ, self.auth_owner, read=True, write=True) + self.check_access(environ, self.auth_tester, read=False, write=False) + + def test_owner_write_only(self, coll_mock, auth_mock, req_mock): + self.init_mocks(coll_mock, auth_mock, req_mock) + environ = req_mock.httprequest.environ + + self.collection.rights = "owner_write_only" + + self.check_access(environ, self.auth_owner, read=True, write=True) + self.check_access(environ, self.auth_tester, read=True, write=False) diff --git a/base_dav/tests/test_collection.py b/base_dav/tests/test_collection.py new file mode 100644 index 000000000..47e9cbfcb --- /dev/null +++ b/base_dav/tests/test_collection.py @@ -0,0 +1,118 @@ +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from datetime import datetime, timedelta +from unittest import mock + +from odoo.exceptions import MissingError +from odoo.tests.common import TransactionCase +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + +from ..radicale.collection import Collection + + +class TestCalendar(TransactionCase): + def setUp(self): + super().setUp() + + self.collection = self.env["dav.collection"].create({ + "name": "Test Collection", + "dav_type": "calendar", + "model_id": self.env.ref("base.model_res_users").id, + "domain": "[]", + }) + + self.create_field_mapping( + "login", "base.field_res_users_login", + excode="result = record.login", + imcode="result = item.value", + ) + self.create_field_mapping( + "name", "base.field_res_users_name", + ) + self.create_field_mapping( + "dtstart", "base.field_res_users_create_date", + ) + self.create_field_mapping( + "dtend", "base.field_res_users_write_date", + ) + + start = datetime.now() + stop = start + timedelta(hours=1) + self.record = self.env["res.users"].create({ + "login": "tester", + "name": "Test User", + "create_date": start.strftime(DEFAULT_SERVER_DATETIME_FORMAT), + "write_date": stop.strftime(DEFAULT_SERVER_DATETIME_FORMAT), + }) + + def create_field_mapping(self, name, field_ref, imcode=None, excode=None): + return self.env["dav.collection.field_mapping"].create({ + "collection_id": self.collection.id, + "name": name, + "field_id": self.env.ref(field_ref).id, + "mapping_type": "code" if imcode or excode else "simple", + "import_code": imcode, + "export_code": excode, + }) + + def compare_record(self, vobj, rec=None): + tmp = self.collection.from_vobject(vobj) + + self.assertEqual((rec or self.record).login, tmp["login"]) + self.assertEqual((rec or self.record).name, tmp["name"]) + self.assertEqual((rec or self.record).create_date, tmp["create_date"]) + self.assertEqual((rec or self.record).write_date, tmp["write_date"]) + + def test_import_export(self): + # Exporting and importing should result in the same record + vobj = self.collection.to_vobject(self.record) + self.compare_record(vobj) + + def test_get_record(self): + rec = self.collection.get_record([self.record.id]) + self.assertEqual(rec, self.record) + + self.collection.field_uuid = self.env.ref( + "base.field_res_users_login", + ).id + rec = self.collection.get_record([self.record.login]) + self.assertEqual(rec, self.record) + + @mock.patch("odoo.addons.base_dav.radicale.collection.request") + def test_collection(self, request_mock): + request_mock.env = self.env + collection_url = "/%s/%s" % (self.env.user.login, self.collection.id) + collection = list(Collection.discover(collection_url))[0] + + # Try to get the test record + record_url = "%s/%s" % (collection_url, self.record.id) + self.assertIn(record_url, collection.list()) + + # Get the test record using the URL and compare it + item = collection.get(record_url) + self.compare_record(item.item) + self.assertEqual(item.href, record_url) + + # Get a non-existing record + self.assertFalse(collection.get(record_url + "0")) + + # Get the record and alter it later + item = self.collection.to_vobject(self.record) + self.record.login = "different" + with self.assertRaises(AssertionError): + self.compare_record(item) + + # Restore the record + item = collection.upload(record_url, item) + self.compare_record(item.item) + + # Delete an record + collection.delete(item.href) + with self.assertRaises(MissingError): + self.record.name + + # Create a new record + item = collection.upload(record_url + "0", item) + record = self.collection.get_record(collection._split_path(item.href)) + self.assertNotEqual(record, self.record) + self.compare_record(item.item, record) diff --git a/base_dav/views/dav_collection.xml b/base_dav/views/dav_collection.xml new file mode 100644 index 000000000..602b50748 --- /dev/null +++ b/base_dav/views/dav_collection.xml @@ -0,0 +1,78 @@ + + + + dav.collection + + + + + + + + + + + dav.collection + +
+ + + + + + + + + + + + + + + +
+
+
+ + + dav.collection.field_mapping + + + + + + + + + + dav.collection.field_mapping + +
+ + + + + + + + +
+
+
+ + + + +
From bed6264598c3a0a5d9c1428be7f3286cbad1852a Mon Sep 17 00:00:00 2001 From: fkantelberg Date: Sun, 12 Jan 2020 21:39:32 +0100 Subject: [PATCH 02/13] Fix CI errors --- base_dav/radicale/collection.py | 4 ++++ base_dav/tests/test_collection.py | 4 +--- base_dav/views/dav_collection.xml | 5 ++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/base_dav/radicale/collection.py b/base_dav/radicale/collection.py index 9a37c5bfb..5fd10cf5d 100644 --- a/base_dav/radicale/collection.py +++ b/base_dav/radicale/collection.py @@ -130,3 +130,7 @@ def delete(self, href): def list(self): return self.collection.dav_list(self.path_components) + + @classmethod + def create_collection(cls, href, collection=None, props=None): + return None diff --git a/base_dav/tests/test_collection.py b/base_dav/tests/test_collection.py index 47e9cbfcb..6839515a4 100644 --- a/base_dav/tests/test_collection.py +++ b/base_dav/tests/test_collection.py @@ -3,7 +3,6 @@ from datetime import datetime, timedelta from unittest import mock -from odoo.exceptions import MissingError from odoo.tests.common import TransactionCase from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT @@ -108,8 +107,7 @@ def test_collection(self, request_mock): # Delete an record collection.delete(item.href) - with self.assertRaises(MissingError): - self.record.name + self.assertFalse(self.record.exists()) # Create a new record item = collection.upload(record_url + "0", item) diff --git a/base_dav/views/dav_collection.xml b/base_dav/views/dav_collection.xml index 602b50748..d0cbfb218 100644 --- a/base_dav/views/dav_collection.xml +++ b/base_dav/views/dav_collection.xml @@ -39,6 +39,7 @@ + @@ -52,9 +53,7 @@ - + From 00288002cf120e35c0c217339242baa7967a1603 Mon Sep 17 00:00:00 2001 From: fkantelberg Date: Sun, 12 Jan 2020 22:16:13 +0100 Subject: [PATCH 03/13] Mute radicale logger for testing --- base_dav/radicale/collection.py | 4 ---- base_dav/tests/test_base_dav.py | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/base_dav/radicale/collection.py b/base_dav/radicale/collection.py index 5fd10cf5d..9a37c5bfb 100644 --- a/base_dav/radicale/collection.py +++ b/base_dav/radicale/collection.py @@ -130,7 +130,3 @@ def delete(self, href): def list(self): return self.collection.dav_list(self.path_components) - - @classmethod - def create_collection(cls, href, collection=None, props=None): - return None diff --git a/base_dav/tests/test_base_dav.py b/base_dav/tests/test_base_dav.py index b76cec111..6ed014f94 100644 --- a/base_dav/tests/test_base_dav.py +++ b/base_dav/tests/test_base_dav.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger from ..controllers.main import PREFIX from ..controllers.main import Main as Controller @@ -18,6 +19,7 @@ ADMIN_PASSWORD = "RadicalePa$$word" +@mute_logger("radicale") @mock.patch(CONTROLLER_PATH + ".request") @mock.patch(RADICALE_PATH + ".auth.request") @mock.patch(RADICALE_PATH + ".collection.request") From 1d280f3a9c553d8e65a676a760d954ac4e0d760b Mon Sep 17 00:00:00 2001 From: fkantelberg Date: Thu, 16 Jan 2020 14:35:05 +0100 Subject: [PATCH 04/13] Use the collection as first argument in the creation of the items --- base_dav/models/dav_collection.py | 14 +++++++------- base_dav/radicale/collection.py | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/base_dav/models/dav_collection.py b/base_dav/models/dav_collection.py index 2e0ee1aa2..c98e53202 100644 --- a/base_dav/models/dav_collection.py +++ b/base_dav/models/dav_collection.py @@ -172,7 +172,7 @@ def _split_path(self, path): )) @api.multi - def dav_list(self, path_components): + def dav_list(self, collection, path_components): self.ensure_one() if self.dav_type == 'files': @@ -215,7 +215,7 @@ def dav_list(self, path_components): return result @api.multi - def dav_delete(self, components): + def dav_delete(self, collection, components): self.ensure_one() if self.dav_type == "files": @@ -225,7 +225,7 @@ def dav_delete(self, components): self.get_record(components).unlink() @api.multi - def dav_upload(self, href, item): + def dav_upload(self, collection, href, item): self.ensure_one() components = self._split_path(href) @@ -248,14 +248,14 @@ def dav_upload(self, href, item): record.write(data) return Item( - self, + collection, item=self.to_vobject(record), href=href, last_modified=self._odoo_to_http_datetime(record.write_date), ) @api.multi - def dav_get(self, href): + def dav_get(self, collection, href): self.ensure_one() components = self._split_path(href) @@ -279,7 +279,7 @@ def dav_get(self, href): ('name', '=', components[3]), ], limit=1) return FileItem( - self, + collection, item=attachment, href=href, last_modified=self._odoo_to_http_datetime( @@ -293,7 +293,7 @@ def dav_get(self, href): return None return Item( - self, + collection, item=self.to_vobject(record), href=href, last_modified=self._odoo_to_http_datetime(record.write_date), diff --git a/base_dav/radicale/collection.py b/base_dav/radicale/collection.py index 9a37c5bfb..0fe39abe8 100644 --- a/base_dav/radicale/collection.py +++ b/base_dav/radicale/collection.py @@ -120,13 +120,13 @@ def get_meta(self, key=None): self.logger.warning('unsupported metadata %s', key) def get(self, href): - return self.collection.dav_get(href) + return self.collection.dav_get(self, href) def upload(self, href, vobject_item): - return self.collection.dav_upload(href, vobject_item) + return self.collection.dav_upload(self, href, vobject_item) def delete(self, href): - return self.collection.dav_delete(self._split_path(href)) + return self.collection.dav_delete(self, self._split_path(href)) def list(self): - return self.collection.dav_list(self.path_components) + return self.collection.dav_list(self, self.path_components) From 581284638c3ed31137e37fecd960f80a8f44b327 Mon Sep 17 00:00:00 2001 From: fkantelberg Date: Mon, 13 Jul 2020 08:52:44 +0200 Subject: [PATCH 05/13] Use related model_id field to build domain --- base_dav/models/dav_collection.py | 3 ++- base_dav/models/dav_collection_field_mapping.py | 7 ++++++- base_dav/views/dav_collection.xml | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/base_dav/models/dav_collection.py b/base_dav/models/dav_collection.py index c98e53202..f7a4d96d9 100644 --- a/base_dav/models/dav_collection.py +++ b/base_dav/models/dav_collection.py @@ -7,9 +7,10 @@ from operator import itemgetter from urllib.parse import quote_plus -import vobject from odoo import api, fields, models, tools +import vobject + # pylint: disable=missing-import-error from ..controllers.main import PREFIX from ..radicale.collection import Collection, FileItem, Item diff --git a/base_dav/models/dav_collection_field_mapping.py b/base_dav/models/dav_collection_field_mapping.py index 8ef7e3047..9f90715e3 100644 --- a/base_dav/models/dav_collection_field_mapping.py +++ b/base_dav/models/dav_collection_field_mapping.py @@ -4,10 +4,11 @@ import datetime +from odoo import api, fields, models, tools + import dateutil import vobject from dateutil import tz -from odoo import api, fields, models, tools class DavCollectionFieldMapping(models.Model): @@ -34,6 +35,10 @@ class DavCollectionFieldMapping(models.Model): required=True, help="Field of the model the values are mapped to", ) + model_id = fields.Many2one( + 'ir.model', + related='collection_id.model_id', + ) import_code = fields.Text( help="Code to import the value from a vobject. Use the variable " "result for the output of the value and item as input" diff --git a/base_dav/views/dav_collection.xml b/base_dav/views/dav_collection.xml index d0cbfb218..dcea34147 100644 --- a/base_dav/views/dav_collection.xml +++ b/base_dav/views/dav_collection.xml @@ -51,9 +51,10 @@
+ - + @@ -73,5 +74,4 @@ action="action_dav_collection" sequence="100" /> - From ea33b07296a2ba600a5c3d4449034076bddc4538 Mon Sep 17 00:00:00 2001 From: fkantelberg <39951254+fkantelberg@users.noreply.github.com> Date: Wed, 29 Jul 2020 11:54:36 +0200 Subject: [PATCH 06/13] Set default id for fields Co-authored-by: Yannick Vaucher --- base_dav/views/dav_collection.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base_dav/views/dav_collection.xml b/base_dav/views/dav_collection.xml index dcea34147..94d56f341 100644 --- a/base_dav/views/dav_collection.xml +++ b/base_dav/views/dav_collection.xml @@ -28,7 +28,7 @@ - + \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_dav +#: model:ir.model,name:base_dav.model_dav_collection +msgid "A collection accessible via WebDAV" +msgstr "" + +#. module: base_dav +#: model:ir.model,name:base_dav.model_dav_collection_field_mapping +msgid "A field mapping for a WebDAV collection" +msgstr "" + +#. module: base_dav +#: model:ir.ui.view,arch_db:base_dav.view_dav_collection_form +msgid "Access" +msgstr "" + +#. module: base_dav +#: model:ir.ui.view,arch_db:base_dav.view_dav_collection_form +msgid "Additional field mapping" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,dav_type:0 +msgid "Addressbook" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_name +msgid "Attribute name in the vobject" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,rights:0 +msgid "Authenticated" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,dav_type:0 +msgid "Calendar" +msgstr "" + +#. module: base_dav +#: selection:dav.collection.field_mapping,mapping_type:0 +msgid "Code" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_export_code +msgid "Code to export the value to a vobject. Use the variable result for the output of the value and record as input" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_import_code +msgid "Code to import the value from a vobject. Use the variable result for the output of the value and item as input" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_collection_id +msgid "Collection" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_create_uid +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_create_uid +msgid "Created by" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_create_date +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_create_date +msgid "Created on" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_display_name +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_display_name +msgid "Display Name" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_domain +msgid "Domain" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_export_code +msgid "Export Code" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_field_id +msgid "Field" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_uuid +msgid "Field Uuid" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_ids +msgid "Field mappings" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_field_id +msgid "Field of the model the values are mapped to" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,dav_type:0 +msgid "Files" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_id +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_id +msgid "ID" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_import_code +msgid "Import Code" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection___last_update +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping___last_update +msgid "Last Modified on" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_write_uid +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_write_uid +msgid "Last Updated by" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_write_date +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_write_date +msgid "Last Updated on" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_mapping_type +msgid "Mapping Type" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_model_id +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_model_id +msgid "Model" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_name +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_name +msgid "Name" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,rights:0 +msgid "Owner Only" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,rights:0 +msgid "Owner Write Only" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_rights +msgid "Rights" +msgstr "" + +#. module: base_dav +#: selection:dav.collection.field_mapping,mapping_type:0 +msgid "Simple" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_tag +msgid "Tag" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_dav_type +msgid "Type" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_url +msgid "Url" +msgstr "" + +#. module: base_dav +#: model:ir.actions.act_window,name:base_dav.action_dav_collection +#: model:ir.ui.menu,name:base_dav.menu_dav_collection +msgid "WebDAV collections" +msgstr "" + diff --git a/base_dav/static/description/index.html b/base_dav/static/description/index.html index 3c659e7f0..f1984ef77 100644 --- a/base_dav/static/description/index.html +++ b/base_dav/static/description/index.html @@ -1,20 +1,20 @@ - + - + Caldav and Carddav support