Browse Source

WIP leur travail

Signed-off-by: Valentin Lab <valentin.lab@kalysto.org>
dav
Valentin Lab 2 years ago
parent
commit
9fd0baf7cd
  1. 104
      base_dav/README.rst
  2. 5
      base_dav/__init__.py
  3. 24
      base_dav/__manifest__.py
  4. 3
      base_dav/controllers/__init__.py
  5. 65
      base_dav/controllers/main.py
  6. 34
      base_dav/demo/dav_collection.xml
  7. 215
      base_dav/i18n/base_dav.pot
  8. 4
      base_dav/models/__init__.py
  9. 303
      base_dav/models/dav_collection.py
  10. 180
      base_dav/models/dav_collection_field_mapping.py
  11. 5
      base_dav/radicale/__init__.py
  12. 19
      base_dav/radicale/auth.py
  13. 150
      base_dav/radicale/collection.py
  14. 31
      base_dav/radicale/rights.py
  15. 5
      base_dav/readme/CONFIGURE.rst
  16. 3
      base_dav/readme/CONTRIBUTORS.rst
  17. 2
      base_dav/readme/CREDITS.rst
  18. 3
      base_dav/readme/DESCRIPTION.rst
  19. 7
      base_dav/readme/ROADMAP.rst
  20. 3
      base_dav/security/ir.model.access.csv
  21. BIN
      base_dav/static/description/icon.png
  22. 452
      base_dav/static/description/index.html
  23. 3
      base_dav/tests/__init__.py
  24. 160
      base_dav/tests/test_base_dav.py
  25. 190
      base_dav/tests/test_collection.py
  26. 77
      base_dav/views/dav_collection.xml
  27. 4
      requirements.txt

104
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 <https://github.com/OCA/server-backend/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 <https://github.com/OCA/server-backend/issues/new?body=module:%20base_dav%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* initOS GmbH
* Therp BV
Contributors
~~~~~~~~~~~~
* Holger Brunn <hbrunn@therp.nl>
* Florian Kantelberg <florian.kantelberg@initos.com>
* César López Ramírez <cesar.lopez@coopdevs.org>
Other credits
~~~~~~~~~~~~~
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_
* All the actual work is done by `Radicale <https://radicale.org>`_
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 <https://github.com/OCA/server-backend/tree/12.0/base_dav>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

5
base_dav/__init__.py

@ -0,0 +1,5 @@
# Copyright 2018 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import models
from . import controllers
from . import radicale

24
base_dav/__manifest__.py

@ -0,0 +1,24 @@
# Copyright 2018 Therp BV <https://therp.nl>
# Copyright 2019-2020 initOS GmbH <https://initos.com>
# 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'],
},
}

3
base_dav/controllers/__init__.py

@ -0,0 +1,3 @@
# Copyright 2018 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import main

65
base_dav/controllers/main.py

@ -0,0 +1,65 @@
# Copyright 2018 Therp BV <https://therp.nl>
# 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/<path:davpath>' % 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

34
base_dav/demo/dav_collection.xml

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="collection_addressbook" model="dav.collection">
<field name="name">Addressbook</field>
<field name="dav_type">addressbook</field>
<field name="model_id" ref="base.model_res_partner" />
<field name="domain">[]</field>
</record>
<record id="field_mapping_addressbook_n" model="dav.collection.field_mapping">
<field name="name">N</field>
<field name="field_id" ref="base.field_res_partner__name" />
<field name="collection_id" ref="collection_addressbook" />
</record>
<record id="field_mapping_addressbook_fn" model="dav.collection.field_mapping">
<field name="name">FN</field>
<field name="field_id" ref="base.field_res_partner__display_name" />
<field name="collection_id" ref="collection_addressbook" />
</record>
<record id="field_mapping_addressbook_photo" model="dav.collection.field_mapping">
<field name="name">photo</field>
<field name="field_id" ref="base.field_res_partner__image" />
<field name="collection_id" ref="collection_addressbook" />
</record>
<record id="field_mapping_addressbook_email" model="dav.collection.field_mapping">
<field name="name">email</field>
<field name="field_id" ref="base.field_res_partner__email" />
<field name="collection_id" ref="collection_addressbook" />
</record>
<record id="field_mapping_addressbook_tel" model="dav.collection.field_mapping">
<field name="name">tel</field>
<field name="field_id" ref="base.field_res_partner__phone" />
<field name="collection_id" ref="collection_addressbook" />
</record>
</odoo>

215
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 ""

4
base_dav/models/__init__.py

@ -0,0 +1,4 @@
# Copyright 2018 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import dav_collection
from . import dav_collection_field_mapping

303
base_dav/models/dav_collection.py

@ -0,0 +1,303 @@
# Copyright 2019 Therp BV <https://therp.nl>
# Copyright 2019-2020 initOS GmbH <https://initos.com>
# 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),
)

180
base_dav/models/dav_collection_field_mapping.py

@ -0,0 +1,180 @@
# Copyright 2019 Therp BV <https://therp.nl>
# Copyright 2019-2020 initOS GmbH <https://initos.com>
# 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)

5
base_dav/radicale/__init__.py

@ -0,0 +1,5 @@
# Copyright 2018 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import auth
from . import collection
from . import rights

19
base_dav/radicale/auth.py

@ -0,0 +1,19 @@
# Copyright 2018 Therp BV <https://therp.nl>
# Copyright 2019-2020 initOS GmbH <https://initos.com>
# 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

150
base_dav/radicale/collection.py

@ -0,0 +1,150 @@
# Copyright 2018 Therp BV <https://therp.nl>
# Copyright 2019-2020 initOS GmbH <https://initos.com>
# 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)

31
base_dav/radicale/rights.py

@ -0,0 +1,31 @@
# Copyright 2018 Therp BV <https://therp.nl>
# 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)

5
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.

3
base_dav/readme/CONTRIBUTORS.rst

@ -0,0 +1,3 @@
* Holger Brunn <hbrunn@therp.nl>
* Florian Kantelberg <florian.kantelberg@initos.com>
* César López Ramírez <cesar.lopez@coopdevs.org>

2
base_dav/readme/CREDITS.rst

@ -0,0 +1,2 @@
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_
* All the actual work is done by `Radicale <https://radicale.org>`_

3
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.

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

3
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

BIN
base_dav/static/description/icon.png

After

Width: 300  |  Height: 300  |  Size: 9.5 KiB

452
base_dav/static/description/index.html

@ -0,0 +1,452 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" />
<title>Caldav and Carddav support</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="caldav-and-carddav-support">
<h1 class="title">Caldav and Carddav support</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/server-backend/tree/12.0/base_dav"><img alt="OCA/server-backend" src="https://img.shields.io/badge/github-OCA%2Fserver--backend-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/server-backend-12-0/server-backend-12-0-base_dav"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/253/12.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
<p>This module adds WebDAV support to Odoo, specifically CalDAV and CardDAV.</p>
<p>You can configure arbitrary objects as a calendar or an address book, thus make arbitrary information accessible in external systems or your mobile.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="id1">Configuration</a></li>
<li><a class="reference internal" href="#known-issues-roadmap" id="id2">Known issues / Roadmap</a></li>
<li><a class="reference internal" href="#bug-tracker" id="id3">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id4">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id5">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id6">Contributors</a></li>
<li><a class="reference internal" href="#other-credits" id="id7">Other credits</a></li>
<li><a class="reference internal" href="#maintainers" id="id8">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#id1">Configuration</a></h1>
<p>To configure this module, you need to:</p>
<ol class="arabic simple">
<li>go to <cite>Settings / WebDAV Collections</cite> and create or edit your collections. There, you’ll also see the URL to point your clients to.</li>
</ol>
<p>Note that you need to configure a dbfilter if you use multiple databases.</p>
</div>
<div class="section" id="known-issues-roadmap">
<h1><a class="toc-backref" href="#id2">Known issues / Roadmap</a></h1>
<ul class="simple">
<li>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)</li>
<li>support todo lists and journals</li>
<li>support configuring default field mappings per model</li>
<li>support plain WebDAV collections to make some model’s records accessible as folders, and the records’ attachments as files (r/w)</li>
<li>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</li>
</ul>
<p>Backporting this to &lt;=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.</p>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id3">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/server-backend/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/server-backend/issues/new?body=module:%20base_dav%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id4">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id5">Authors</a></h2>
<ul class="simple">
<li>initOS GmbH</li>
<li>Therp BV</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id6">Contributors</a></h2>
<ul class="simple">
<li>Holger Brunn &lt;<a class="reference external" href="mailto:hbrunn&#64;therp.nl">hbrunn&#64;therp.nl</a>&gt;</li>
<li>Florian Kantelberg &lt;<a class="reference external" href="mailto:florian.kantelberg&#64;initos.com">florian.kantelberg&#64;initos.com</a>&gt;</li>
<li>César López Ramírez &lt;<a class="reference external" href="mailto:cesar.lopez&#64;coopdevs.org">cesar.lopez&#64;coopdevs.org</a>&gt;</li>
</ul>
</div>
<div class="section" id="other-credits">
<h2><a class="toc-backref" href="#id7">Other credits</a></h2>
<ul class="simple">
<li>Odoo Community Association: <a class="reference external" href="https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg">Icon</a></li>
<li>All the actual work is done by <a class="reference external" href="https://radicale.org">Radicale</a></li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id8">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>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.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/server-backend/tree/12.0/base_dav">OCA/server-backend</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

3
base_dav/tests/__init__.py

@ -0,0 +1,3 @@
# Copyright 2018 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import test_base_dav, test_collection

160
base_dav/tests/test_base_dav.py

@ -0,0 +1,160 @@
# Copyright 2018 Therp BV <https://therp.nl>
# Copyright 2019-2020 initOS GmbH <https://initos.com>
# 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'))

190
base_dav/tests/test_collection.py

@ -0,0 +1,190 @@
# Copyright 2019-2020 initOS GmbH <https://initos.com>
# 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)

77
base_dav/views/dav_collection.xml

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_dav_collection_tree" model="ir.ui.view">
<field name="model">dav.collection</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="dav_type"/>
<field name="model_id"/>
<field name="domain"/>
</tree>
</field>
</record>
<record id="view_dav_collection_form" model="ir.ui.view">
<field name="model">dav.collection</field>
<field name="arch" type="xml">
<form>
<group>
<field name="name"/>
<field name="dav_type"/>
<field name="url" widget="url" attrs="{'invisible': [('url', '=', False)]}"/>
</group>
<group string="Access">
<field name="model_id"/>
<!-- TODO: use widget="domain" when we can set the model dynamically /-->
<field name="domain"/>
<field name="field_uuid" domain="[('model_id', '=', model_id)]"/>
<field name="rights"/>
</group>
<group string="Additional field mapping">
<field name="field_mapping_ids" nolabel="1" context="{'default_collection_id': active_id}"/>
</group>
</form>
</field>
</record>
<record id="view_dav_collection_mapping_tree" model="ir.ui.view">
<field name="model">dav.collection.field_mapping</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="field_id"/>
<field name="mapping_type"/>
</tree>
</field>
</record>
<record id="view_dav_collection_mapping_form" model="ir.ui.view">
<field name="model">dav.collection.field_mapping</field>
<field name="arch" type="xml">
<form>
<group>
<field name="collection_id" invisible="1"/>
<field name="model_id" invisible="1"/>
<field name="name"/>
<field name="mapping_type"/>
<field name="field_id" domain="[('model_id', '=', model_id)]"/>
<field name="import_code" attrs="{'invisible': [('mapping_type', '!=', 'code')]}"/>
<field name="export_code" attrs="{'invisible': [('mapping_type', '!=', 'code')]}"/>
</group>
</form>
</field>
</record>
<act_window
id="action_dav_collection"
name="WebDAV collections"
res_model="dav.collection"
view_mode="tree,form"
/>
<menuitem
id="menu_dav_collection"
parent="base.menu_administration"
action="action_dav_collection"
sequence="100"
/>
</odoo>

4
requirements.txt

@ -0,0 +1,4 @@
sqlalchemy
mysqlclient==2.0.1
pymssql
radicale==3.1.1
Loading…
Cancel
Save