From 9fd0baf7cd2f2b7c862925439ee6dec705bd5ea0 Mon Sep 17 00:00:00 2001 From: Valentin Lab Date: Fri, 29 Jul 2022 09:43:24 +0200 Subject: [PATCH] WIP leur travail Signed-off-by: Valentin Lab --- base_dav/README.rst | 104 ++++ base_dav/__init__.py | 5 + base_dav/__manifest__.py | 24 + base_dav/controllers/__init__.py | 3 + base_dav/controllers/main.py | 65 +++ base_dav/demo/dav_collection.xml | 34 ++ base_dav/i18n/base_dav.pot | 215 +++++++++ base_dav/models/__init__.py | 4 + base_dav/models/dav_collection.py | 303 ++++++++++++ .../models/dav_collection_field_mapping.py | 180 +++++++ base_dav/radicale/__init__.py | 5 + base_dav/radicale/auth.py | 19 + base_dav/radicale/collection.py | 150 ++++++ base_dav/radicale/rights.py | 31 ++ base_dav/readme/CONFIGURE.rst | 5 + base_dav/readme/CONTRIBUTORS.rst | 3 + 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 | 452 ++++++++++++++++++ base_dav/tests/__init__.py | 3 + base_dav/tests/test_base_dav.py | 160 +++++++ base_dav/tests/test_collection.py | 190 ++++++++ base_dav/views/dav_collection.xml | 77 +++ requirements.txt | 4 + 27 files changed, 2051 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/i18n/base_dav.pot 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 create mode 100644 requirements.txt diff --git a/base_dav/README.rst b/base_dav/README.rst new file mode 100644 index 000000000..013b72dc7 --- /dev/null +++ b/base_dav/README.rst @@ -0,0 +1,104 @@ +========================== +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/12.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-12-0/server-backend-12-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/12.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 +====================== + +* 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. + +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 +~~~~~~~ + +* initOS GmbH +* Therp BV + +Contributors +~~~~~~~~~~~~ + +* Holger Brunn +* Florian Kantelberg +* César López Ramírez + +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..5c714e454 --- /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": "12.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..4aa6a11d4 --- /dev/null +++ b/base_dav/controllers/main.py @@ -0,0 +1,65 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from configparser import RawConfigParser as ConfigParser +import logging + +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.DEFAULT_CONFIG_SCHEMA.items(): + config.add_section(section) + for key, data in values.items(): + config.set(section, key, data["value"] if type(data) == dict else data) + 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') + request.httprequest.environ['wsgi.errors'] = logging.getLogger('radicale') + application = radicale.Application( + config + ) + + 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..122a016f9 --- /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/i18n/base_dav.pot b/base_dav/i18n/base_dav.pot new file mode 100644 index 000000000..2e0939ca5 --- /dev/null +++ b/base_dav/i18n/base_dav.pot @@ -0,0 +1,215 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_dav +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 11.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\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/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..62d530f7d --- /dev/null +++ b/base_dav/models/dav_collection.py @@ -0,0 +1,303 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import os +from operator import itemgetter +from urllib.parse import quote_plus + +from odoo import api, fields, models, tools +import logging + +import vobject + +# pylint: disable=missing-import-error +from ..controllers.main import PREFIX +from ..radicale.collection import Collection, FileItem, Item + +logger = logging.getLogger(__name__) + + +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.model + def get_logger(self): + return logger + + @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.strftime('%Y-%m-%dT%H%M%SZ') + return result + + @api.model + def _odoo_to_http_datetime(self, value): + return value.strftime('%a, %d %b %Y %H:%M:%S GMT') + + @api.model + def _split_path(self, path): + return list(filter( + None, os.path.normpath(path or '').strip('/').split('/') + )) + + @api.multi + def dav_list(self, collection, 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) + href = '/' + '/'.join(path_components + [uuid]) + result.append(self.dav_get(collection, href)) + return result + + @api.multi + def dav_delete(self, collection, 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, collection, 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( + collection=collection, + vobject_item=self.to_vobject(record), + href=href, + last_modified=self._odoo_to_http_datetime(record.write_date), + ) + + @api.multi + def dav_get(self, collection, 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( + collection, + 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( + collection=collection, + vobject_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..f516f5ed0 --- /dev/null +++ b/base_dav/models/dav_collection_field_mapping.py @@ -0,0 +1,180 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import datetime + +from odoo import api, fields, models, tools +from odoo.tools.safe_eval import safe_eval + +import dateutil +import vobject +from dateutil import tz + + +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", + ) + 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" + ) + 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, + } + 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, + } + 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..ba6c3b407 --- /dev/null +++ b/base_dav/radicale/auth.py @@ -0,0 +1,19 @@ +# 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 login(self, user, password): + env = request.env + uid = env['res.users']._login(env.cr.dbname, user, password) + login = request.env['res.users'].browse(uid).login + if uid: + request._env = env(user=uid) + return login diff --git a/base_dav/radicale/collection.py b/base_dav/radicale/collection.py new file mode 100644 index 000000000..989a407f4 --- /dev/null +++ b/base_dav/radicale/collection.py @@ -0,0 +1,150 @@ +# 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 odoo.http import request + +try: + from radicale.storage import BaseCollection, BaseStorage + from radicale.item import Item, get_etag + from radicale import types +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 Storage(BaseStorage): + + @classmethod + @types.contextmanager + def acquire_lock(cls, mode, user=None): + """We have a database for that""" + yield + + @classmethod + def discover(cls, path, depth=None): + depth = int(depth or "0") + components = cls._split_path(path) + collection = Collection(path) + collection.logger = collection.collection.get_logger() + 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 + def _split_path(cls, path): + return list(filter( + None, os.path.normpath(path or '').strip('/').split('/') + )) + + @classmethod + def create_collection(cls, href, collection=None, props=None): + return Collection(href) + + +class Collection(BaseCollection): + + @classmethod + def _split_path(cls, path): + return list(filter( + None, os.path.normpath(path or '').strip('/').split('/') + )) + + @property + def env(self): + return request.env + + @property + def last_modified(self): + return self._odoo_to_http_datetime(self.collection.create_date) + + @property + def path(self): + return '/'.join(self.path_components) or '/' + + 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', + value.timetuple(), + ) + + 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' + elif key == 'C:calendar-description': + return self.collection.name + self.logger.warning('unsupported metadata %s', key) + + def get_multi(self, hrefs): + return [self.collection.dav_get(self, href) for href in hrefs] + + def upload(self, href, vobject_item): + return self.collection.dav_upload(self, href, vobject_item) + + def delete(self, href): + return self.collection.dav_delete(self, self._split_path(href)) + + def get_all(self): + return self.collection.dav_list(self, self.path_components) diff --git a/base_dav/radicale/rights.py b/base_dav/radicale/rights.py new file mode 100644 index 000000000..45eef778a --- /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.owner_only import Rights as OwnerOnlyRights + from radicale.rights.authenticated import Rights as AuthenticatedRights + from radicale.rights.owner_write import Rights as OwnerWriteRights +except ImportError: + AuthenticatedRights = OwnerOnlyRights = OwnerWriteRights = None + + +class Rights(OwnerOnlyRights, OwnerWriteRights, AuthenticatedRights): + def authorization(self, user, path): + if path == '/': + return True + + collection = Collection(path) + if not collection.collection: + return "" + + rights = collection.collection.sudo().rights + cls = { + "owner_only": OwnerOnlyRights, + "owner_write_only": OwnerWriteRights, + "authenticated": AuthenticatedRights, + }.get(rights) + if not cls: + return False + return cls.authorization(self, user, path) 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..b562c1c9c --- /dev/null +++ b/base_dav/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Holger Brunn +* Florian Kantelberg +* César López Ramírez 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..1a1ed0522 --- /dev/null +++ b/base_dav/static/description/index.html @@ -0,0 +1,452 @@ + + + + + + +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

+
    +
  • 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.

+
+
+

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

+
    +
  • initOS GmbH
  • +
  • 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..4caea2836 --- /dev/null +++ b/base_dav/tests/test_base_dav.py @@ -0,0 +1,160 @@ +# 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 odoo.exceptions import AccessDenied + +from ..controllers.main import PREFIX +from ..controllers.main import Main as Controller + +from ..radicale.auth import Auth + + +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.Auth.login") +@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, login_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, + } + + def side_effect(arg, _): + return arg + login_mock.side_effect = side_effect + 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, login_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, login_mock, req_mock): + self.init_mocks(coll_mock, login_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, login_mock, req_mock): + self.init_mocks(coll_mock, login_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, login_mock, req_mock): + self.init_mocks(coll_mock, login_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) + + +@mock.patch(RADICALE_PATH + ".auth.request") +class TestAuth(TransactionCase): + def init_mock(self, auth_mock): + def side_effect_login(dbname, user, password): + user = self.env['res.users'].search([('login', '=', user)]) + if user: + return user.id + else: + raise AccessDenied + auth_mock.env["res.users"]._login.side_effect = side_effect_login + + def side_effect_browse(uid): + return self.env['res.users'].browse(uid) + auth_mock.env['res.users'].browse.side_effect = side_effect_browse + + def setUp(self): + super().setUp() + self.test_user = self.env["res.users"].create({ + "login": "tester", + "name": "tester", + "password": ADMIN_PASSWORD, + }) + + def test_login_tester(self, auth_mock): + self.init_mock(auth_mock) + auth = Auth(mock.ANY) + self.assertEquals( + auth.login(self.test_user.login, ADMIN_PASSWORD), + self.test_user.login + ) + + def test_login_fail(self, auth_mock): + self.init_mock(auth_mock) + auth = Auth(mock.ANY) + self.assertRaises(AccessDenied, auth.login, *('fake', 'fake')) diff --git a/base_dav/tests/test_collection.py b/base_dav/tests/test_collection.py new file mode 100644 index 000000000..bed83b9ca --- /dev/null +++ b/base_dav/tests/test_collection.py @@ -0,0 +1,190 @@ +# 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.tests.common import TransactionCase +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT + +from ..radicale.collection import Storage + + +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.collection_partner = self.env["dav.collection"].create({ + "name": "Test Collection", + "dav_type": "calendar", + "model_id": self.env.ref("base.model_res_partner").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", + ) + self.create_field_mapping_partner( + "dtstart", "base.field_res_partner__date", + ) + + self.create_field_mapping_partner( + "dtend", "base.field_res_partner__date", + ) + self.create_field_mapping( + "name", "base.field_res_partner__name", + ) + 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), + }) + self.partner_record = self.env['res.partner'].create({ + 'date': '2011-04-30', + 'name': 'Test partner', + }) + + 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 create_field_mapping_partner(self, name, field_ref, imcode=None, excode=None): + return self.env["dav.collection.field_mapping"].create({ + "collection_id": self.collection_partner.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.strftime( + DEFAULT_SERVER_DATETIME_FORMAT), tmp["create_date"] + ) + self.assertEqual((rec or self.record).write_date.strftime( + DEFAULT_SERVER_DATETIME_FORMAT), tmp["write_date"] + ) + + def compare_record_partner(self, vobj, rec=None): + tmp = self.collection_partner.from_vobject(vobj) + + self.assertEqual((rec or self.partner_record).date.strftime( + DEFAULT_SERVER_DATE_FORMAT), tmp["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_import_export_partner(self): + # Exporting and importing should result in the same record + vobj = self.collection_partner.to_vobject(self.partner_record) + self.compare_record_partner(vobj) + + def test_from_vobject_bad_name(self): + vobj = self.collection_partner.to_vobject(self.partner_record) + vobj.name = 'FAKE' + self.assertFalse(self.collection_partner.from_vobject(vobj)) + + def test_from_vobject_has_not_vevent(self): + vobj = self.collection_partner.to_vobject(self.partner_record) + delattr(vobj, 'vevent') + self.assertFalse(self.collection_partner.from_vobject(vobj)) + + def test_from_vobject_bad_vcard(self): + vobj = self.collection_partner.to_vobject(self.partner_record) + self.collection_partner.dav_type = 'addressbook' + vobj.name = 'FAKE' + self.assertFalse(self.collection_partner.from_vobject(vobj)) + + def test_from_vobject_missing_field(self): + vobj = self.collection.to_vobject(self.record) + children = list(next(vobj.getChildren()).getChildren()) + dtstart = next(e for e in children if e.name.lower() == 'dtstart') + vevent = list(vobj.getChildren())[0] + vevent.remove(dtstart) + tmp = self.collection.from_vobject(vobj) + self.assertNotIn('create_date', tmp) + self.assertIn('name', tmp) + self.assertIn('login', tmp) + self.assertIn('write_date', tmp) + + 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(Storage.discover(collection_url))[0] + + # Try to get the test record + record_url = "%s/%s" % (collection_url, self.record.id) + self.assertIn(record_url, [c.href for c in collection.get_all()]) + + # Get the test record using the URL and compare it + items = collection.get_multi([record_url]) + item = items[0] + self.compare_record(item._vobject_item) + self.assertEqual(item.href, record_url) + + # Get a non-existing record + self.assertFalse(collection.get_multi([record_url + "0"])[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._vobject_item) + + # Delete an record + collection.delete(item.href) + self.assertFalse(self.record.exists()) + + # Create a new record + item = collection.upload(record_url + "0", item._vobject_item) + record = self.collection.get_record(collection._split_path(item.href)) + self.assertNotEqual(record, self.record) + self.compare_record(item._vobject_item, record) diff --git a/base_dav/views/dav_collection.xml b/base_dav/views/dav_collection.xml new file mode 100644 index 000000000..03b0116b4 --- /dev/null +++ b/base_dav/views/dav_collection.xml @@ -0,0 +1,77 @@ + + + + dav.collection + + + + + + + + + + + dav.collection + +
+ + + + + + + + + + + + + + + +
+
+
+ + + dav.collection.field_mapping + + + + + + + + + + + dav.collection.field_mapping + +
+ + + + + + + + + +
+
+
+ + + +
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..66d6d61ae --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +sqlalchemy +mysqlclient==2.0.1 +pymssql +radicale==3.1.1