[ADD] base_dav

This commit is contained in:
Holger Brunn
2018-12-12 11:51:31 +01:00
committed by fkantelberg
parent c1349bb4bc
commit cb2ecfee46
26 changed files with 1665 additions and 0 deletions

103
base_dav/README.rst Normal file
View File

@@ -0,0 +1,103 @@
==========================
Caldav and Carddav support
==========================
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--backend-lightgray.png?logo=github
:target: https://github.com/OCA/server-backend/tree/11.0/base_dav
:alt: OCA/server-backend
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/server-backend-11-0/server-backend-11-0-base_dav
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
:target: https://runbot.odoo-community.org/runbot/253/11.0
:alt: Try me on Runbot
|badge1| |badge2| |badge3| |badge4| |badge5|
This module adds WebDAV support to Odoo, specifically CalDAV and CardDAV.
You can configure arbitrary objects as a calendar or an address book, thus make arbitrary information accessible in external systems or your mobile.
**Table of contents**
.. contents::
:local:
Configuration
=============
To configure this module, you need to:
#. go to `Settings / WebDAV Collections` and create or edit your collections. There, you'll also see the URL to point your clients to.
Note that you need to configure a dbfilter if you use multiple databases.
Known issues / Roadmap
======================
* better UX for configuring collections
* support writing
* support address books
* support todo lists and journals
* support configuring default field mappings per model
* support plain WebDAV collections to make some model's records accessible as folders, and the records' attachments as files (r/w)
* support configuring lists of calendars so that you can have a calendar for every project and appointments are tasks, or a calendar for every sales team and appointments are sale orders. Lots of possibilities
Backporting this to <=v10 will be tricky because radicale only supports python3. Probably it will be quite a hassle to backport the relevant code, so it might be more sensible to just backport the configuration part, and implement the rest as radicale auth/storage plugin that talks to Odoo via odoorpc. It should be possible to recycle most of the code from this addon, which actually implements those plugins, but then within Odoo.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <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:%2011.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
~~~~~~~
* Therp BV
Contributors
~~~~~~~~~~~~
* Holger Brunn <hbrunn@therp.nl>
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/11.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 Normal file
View File

@@ -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 Normal file
View File

@@ -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": "11.0.1.0.0",
"author": "initOS GmbH,Therp BV,Odoo Community Association (OCA)",
"license": "AGPL-3",
"category": "Extra Tools",
"summary": "Access Odoo data as calendar or address book",
"depends": [
'base',
],
"demo": [
"demo/dav_collection.xml",
],
"data": [
"views/dav_collection.xml",
'security/ir.model.access.csv',
],
"external_dependencies": {
'python': ['radicale'],
},
}

View File

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

View File

@@ -0,0 +1,64 @@
# Copyright 2018 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import logging
from configparser import RawConfigParser as ConfigParser
import werkzeug
from odoo import http
from odoo.http import request
try:
import radicale
except ImportError:
radicale = None
PREFIX = '/.dav'
class Main(http.Controller):
@http.route(
['/.well-known/carddav', '/.well-known/caldav', '/.well-known/webdav'],
type='http', auth='none', csrf=False,
)
def handle_well_known_request(self):
return werkzeug.utils.redirect(PREFIX, 301)
@http.route(
[PREFIX, '%s/<path:davpath>' % PREFIX], type='http', auth='none',
csrf=False,
)
def handle_dav_request(self, davpath=None):
config = ConfigParser()
for section, values in radicale.config.INITIAL_CONFIG.items():
config.add_section(section)
for key, data in values.items():
config.set(section, key, data["value"])
config.set('auth', 'type', 'odoo.addons.base_dav.radicale.auth')
config.set(
'storage', 'type', 'odoo.addons.base_dav.radicale.collection'
)
config.set(
'rights', 'type', 'odoo.addons.base_dav.radicale.rights'
)
config.set('web', 'type', 'none')
application = radicale.Application(
config, logging.getLogger('radicale'),
)
response = None
def start_response(status, headers):
nonlocal response
response = http.Response(status=status, headers=headers)
result = application(
dict(
request.httprequest.environ,
HTTP_X_SCRIPT_NAME=PREFIX,
PATH_INFO=davpath or '',
),
start_response,
)
response.stream.write(result and result[0] or b'')
return response

View File

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

View File

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

View File

@@ -0,0 +1,300 @@
# 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
import time
from operator import itemgetter
from urllib.parse import quote_plus
import vobject
from odoo import api, fields, models, tools
# pylint: disable=missing-import-error
from ..controllers.main import PREFIX
from ..radicale.collection import Collection, FileItem, Item
class DavCollection(models.Model):
_name = 'dav.collection'
_description = 'A collection accessible via WebDAV'
name = fields.Char(required=True)
rights = fields.Selection(
[
("owner_only", "Owner Only"),
("owner_write_only", "Owner Write Only"),
("authenticated", "Authenticated"),
],
required=True,
default="owner_only",
)
dav_type = fields.Selection(
[
('calendar', 'Calendar'),
('addressbook', 'Addressbook'),
('files', 'Files'),
],
string='Type',
required=True,
default='calendar',
)
tag = fields.Char(compute='_compute_tag')
model_id = fields.Many2one(
'ir.model',
string='Model',
required=True,
domain=[('transient', '=', False)],
)
domain = fields.Char(
required=True,
default='[]',
)
field_uuid = fields.Many2one('ir.model.fields')
field_mapping_ids = fields.One2many(
'dav.collection.field_mapping',
'collection_id',
string='Field mappings',
)
url = fields.Char(compute='_compute_url')
@api.multi
def _compute_tag(self):
for this in self:
if this.dav_type == 'calendar':
this.tag = 'VCALENDAR'
elif this.dav_type == 'addressbook':
this.tag = 'VADDRESSBOOK'
@api.multi
def _compute_url(self):
base_url = self.env['ir.config_parameter'].get_param('web.base.url')
for this in self:
this.url = '%s%s/%s/%s' % (
base_url,
PREFIX,
self.env.user.login,
this.id,
)
@api.constrains('domain')
def _check_domain(self):
self._eval_domain()
@api.model
def _eval_context(self):
return {
'user': self.env.user,
}
@api.multi
def _eval_domain(self):
self.ensure_one()
return list(tools.safe_eval(self.domain, self._eval_context()))
@api.multi
def eval(self):
if not self:
return self.env['unknown']
self.ensure_one()
return self.env[self.model_id.model].search(self._eval_domain())
@api.multi
def get_record(self, components):
self.ensure_one()
collection_model = self.env[self.model_id.model]
field_name = self.field_uuid.name or "id"
domain = [(field_name, '=', components[-1])] + self._eval_domain()
return collection_model.search(domain, limit=1)
@api.multi
def from_vobject(self, item):
self.ensure_one()
result = {}
if self.dav_type == 'calendar':
if item.name != 'VCALENDAR':
return None
if not hasattr(item, 'vevent'):
return None
item = item.vevent
elif self.dav_type == 'addressbook' and item.name != 'VCARD':
return None
children = {c.name.lower(): c for c in item.getChildren()}
for mapping in self.field_mapping_ids:
name = mapping.name.lower()
if name not in children:
continue
if name in children:
value = mapping.from_vobject(children[name])
if value:
result[mapping.field_id.name] = value
return result
@api.multi
def to_vobject(self, record):
self.ensure_one()
result = None
vobj = None
if self.dav_type == 'calendar':
result = vobject.iCalendar()
vobj = result.add('vevent')
if self.dav_type == 'addressbook':
result = vobject.vCard()
vobj = result
for mapping in self.field_mapping_ids:
value = mapping.to_vobject(record)
if value:
vobj.add(mapping.name).value = value
if 'uid' not in vobj.contents:
vobj.add('uid').value = '%s,%s' % (record._name, record.id)
if 'rev' not in vobj.contents and 'write_date' in record._fields:
vobj.add('rev').value = record.write_date.\
replace(':', '').replace(' ', 'T').replace('.', '') + 'Z'
return result
@api.model
def _odoo_to_http_datetime(self, value):
return time.strftime(
'%a, %d %b %Y %H:%M:%S GMT',
time.strptime(value, '%Y-%m-%d %H:%M:%S'),
)
@api.model
def _split_path(self, path):
return list(filter(
None, os.path.normpath(path or '').strip('/').split('/')
))
@api.multi
def dav_list(self, path_components):
self.ensure_one()
if self.dav_type == 'files':
if len(path_components) == 3:
collection_model = self.env[self.model_id.model]
record = collection_model.browse(map(
itemgetter(0),
collection_model.name_search(
path_components[2], operator='=', limit=1,
)
))
return [
'/' + '/'.join(
path_components + [quote_plus(attachment.name)]
)
for attachment in self.env['ir.attachment'].search([
('type', '=', 'binary'),
('res_model', '=', record._name),
('res_id', '=', record.id),
])
]
elif len(path_components) == 2:
return [
'/' + '/'.join(
path_components + [quote_plus(record.display_name)]
)
for record in self.eval()
]
if len(path_components) > 2:
return []
result = []
for record in self.eval():
if self.field_uuid:
uuid = record[self.field_uuid.name]
else:
uuid = str(record.id)
result.append('/' + '/'.join(path_components + [uuid]))
return result
@api.multi
def dav_delete(self, components):
self.ensure_one()
if self.dav_type == "files":
# TODO: Handle deletion of attachments
pass
else:
self.get_record(components).unlink()
@api.multi
def dav_upload(self, href, item):
self.ensure_one()
components = self._split_path(href)
collection_model = self.env[self.model_id.model]
if self.dav_type == 'files':
# TODO: Handle upload of attachments
return None
data = self.from_vobject(item)
record = self.get_record(components)
if not record:
if self.field_uuid:
data[self.field_uuid.name] = components[-1]
record = collection_model.create(data)
uuid = components[-1] if self.field_uuid else record.id
href = "%s/%s" % (href, uuid)
else:
record.write(data)
return Item(
self,
item=self.to_vobject(record),
href=href,
last_modified=self._odoo_to_http_datetime(record.write_date),
)
@api.multi
def dav_get(self, href):
self.ensure_one()
components = self._split_path(href)
collection_model = self.env[self.model_id.model]
if self.dav_type == 'files':
if len(components) == 3:
result = Collection(href)
result.logger = self.logger
return result
if len(components) == 4:
record = collection_model.browse(map(
itemgetter(0),
collection_model.name_search(
components[2], operator='=', limit=1,
)
))
attachment = self.env['ir.attachment'].search([
('type', '=', 'binary'),
('res_model', '=', record._name),
('res_id', '=', record.id),
('name', '=', components[3]),
], limit=1)
return FileItem(
self,
item=attachment,
href=href,
last_modified=self._odoo_to_http_datetime(
record.write_date
),
)
record = self.get_record(components)
if not record:
return None
return Item(
self,
item=self.to_vobject(record),
href=href,
last_modified=self._odoo_to_http_datetime(record.write_date),
)

View File

@@ -0,0 +1,174 @@
# 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
import dateutil
import vobject
from dateutil import tz
from odoo import api, fields, models, tools
class DavCollectionFieldMapping(models.Model):
_name = 'dav.collection.field_mapping'
_description = 'A field mapping for a WebDAV collection'
collection_id = fields.Many2one(
'dav.collection', required=True, ondelete='cascade',
)
name = fields.Char(
required=True,
help="Attribute name in the vobject",
)
mapping_type = fields.Selection(
[
('simple', 'Simple'),
('code', 'Code'),
],
default='simple',
required=True,
)
field_id = fields.Many2one(
'ir.model.fields',
required=True,
help="Field of the model the values are mapped to",
)
import_code = fields.Text(
help="Code to import the value from a vobject. Use the variable "
"result for the output of the value and item as input"
)
export_code = fields.Text(
help="Code to export the value to a vobject. Use the variable "
"result for the output of the value and record as input"
)
@api.multi
def from_vobject(self, child):
self.ensure_one()
if self.mapping_type == 'code':
return self._from_vobject_code(child)
return self._from_vobject_simple(child)
@api.multi
def _from_vobject_code(self, child):
self.ensure_one()
context = {
'datetime': datetime,
'dateutil': dateutil,
'item': child,
'result': None,
'tools': tools,
'tz': tz,
'vobject': vobject,
}
tools.safe_eval(self.import_code, context, mode="exec", nocopy=True)
return context.get('result', {})
@api.multi
def _from_vobject_simple(self, child):
self.ensure_one()
name = self.name.lower()
conversion_funcs = [
'_from_vobject_%s_%s' % (self.field_id.ttype, name),
'_from_vobject_%s' % self.field_id.ttype,
]
for conversion_func in conversion_funcs:
if hasattr(self, conversion_func):
value = getattr(self, conversion_func)(child)
if value:
return value
return child.value
@api.model
def _from_vobject_datetime(self, item):
if isinstance(item.value, datetime.datetime):
value = item.value.astimezone(dateutil.tz.UTC)
return value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
elif isinstance(item.value, datetime.date):
return item.value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
return None
@api.model
def _from_vobject_date(self, item):
if isinstance(item.value, datetime.datetime):
value = item.value.astimezone(dateutil.tz.UTC)
return value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT)
elif isinstance(item.value, datetime.date):
return item.value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT)
return None
@api.model
def _from_vobject_binary(self, item):
return item.value.encode('ascii')
@api.model
def _from_vobject_char_n(self, item):
return item.family
@api.multi
def to_vobject(self, record):
self.ensure_one()
if self.mapping_type == 'code':
result = self._to_vobject_code(record)
else:
result = self._to_vobject_simple(record)
if isinstance(result, datetime.datetime) and not result.tzinfo:
return result.replace(tzinfo=tz.UTC)
return result
@api.multi
def _to_vobject_code(self, record):
self.ensure_one()
context = {
'datetime': datetime,
'dateutil': dateutil,
'record': record,
'result': None,
'tools': tools,
'tz': tz,
'vobject': vobject,
}
tools.safe_eval(self.export_code, context, mode="exec", nocopy=True)
return context.get('result', None)
@api.multi
def _to_vobject_simple(self, record):
self.ensure_one()
conversion_funcs = [
'_to_vobject_%s_%s' % (
self.field_id.ttype, self.name.lower()
),
'_to_vobject_%s' % self.field_id.ttype,
]
value = record[self.field_id.name]
for conversion_func in conversion_funcs:
if hasattr(self, conversion_func):
return getattr(self, conversion_func)(value)
return value
@api.model
def _to_vobject_datetime(self, value):
result = fields.Datetime.from_string(value)
return result.replace(tzinfo=tz.UTC)
@api.model
def _to_vobject_datetime_rev(self, value):
return value and value\
.replace('-', '').replace(' ', 'T').replace(':', '') + 'Z'
@api.model
def _to_vobject_date(self, value):
return fields.Date.from_string(value)
@api.model
def _to_vobject_binary(self, value):
return value and value.decode('ascii')
@api.model
def _to_vobject_char_n(self, value):
# TODO: how are we going to handle compound types like this?
return vobject.vcard.Name(family=value)

View File

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

18
base_dav/radicale/auth.py Normal file
View File

@@ -0,0 +1,18 @@
# 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 is_authenticated2(self, login, user, password):
env = request.env
uid = env['res.users']._login(env.cr.dbname, user, password)
if uid:
request._env = env(user=uid)
return bool(uid)

View File

@@ -0,0 +1,132 @@
# 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 contextlib import contextmanager
from odoo.http import request
try:
from radicale.storage import BaseCollection, Item, get_etag
except ImportError:
BaseCollection = None
Item = None
get_etag = None
class BytesPretendingToBeString(bytes):
# radicale expects a string as file content, so we provide the str
# functions needed
def encode(self, encoding):
return self
class FileItem(Item):
"""this item tricks radicalev into serving a plain file"""
@property
def name(self):
return 'VCARD'
def serialize(self):
return BytesPretendingToBeString(base64.b64decode(self.item.datas))
@property
def etag(self):
return get_etag(self.item.datas.decode('ascii'))
class Collection(BaseCollection):
@classmethod
def static_init(cls):
pass
@classmethod
def _split_path(cls, path):
return list(filter(
None, os.path.normpath(path or '').strip('/').split('/')
))
@classmethod
def discover(cls, path, depth=None):
depth = int(depth or "0")
components = cls._split_path(path)
collection = cls(path)
if len(components) > 2:
# TODO: this probably better should happen in some dav.collection
# function
if collection.collection.dav_type == 'files' and depth:
for href in collection.list():
yield collection.get(href)
return
yield collection.get(path)
return
yield collection
if depth and len(components) == 1:
for collection in request.env['dav.collection'].search([]):
yield cls('/'.join(components + ['/%d' % collection.id]))
if depth and len(components) == 2:
for href in collection.list():
yield collection.get(href)
@classmethod
@contextmanager
def acquire_lock(cls, mode, user=None):
"""We have a database for that"""
yield
@property
def env(self):
return request.env
@property
def last_modified(self):
return self._odoo_to_http_datetime(self.collection.create_date)
def __init__(self, path):
self.path_components = self._split_path(path)
self.path = '/'.join(self.path_components) or '/'
self.collection = self.env['dav.collection']
if len(self.path_components) >= 2 and str(
self.path_components[1]
).isdigit():
self.collection = self.env['dav.collection'].browse(int(
self.path_components[1]
))
def _odoo_to_http_datetime(self, value):
return time.strftime(
'%a, %d %b %Y %H:%M:%S GMT',
time.strptime(value, '%Y-%m-%d %H:%M:%S'),
)
def get_meta(self, key=None):
if key is None:
return {}
elif key == 'tag':
return self.collection.tag
elif key == 'D:displayname':
return self.collection.display_name
elif key == 'C:supported-calendar-component-set':
return 'VTODO,VEVENT,VJOURNAL'
elif key == 'C:calendar-home-set':
return None
elif key == 'D:principal-URL':
return None
elif key == 'ICAL:calendar-color':
# TODO: set in dav.collection
return '#48c9f4'
self.logger.warning('unsupported metadata %s', key)
def get(self, href):
return self.collection.dav_get(href)
def upload(self, href, vobject_item):
return self.collection.dav_upload(href, vobject_item)
def delete(self, href):
return self.collection.dav_delete(self._split_path(href))
def list(self):
return self.collection.dav_list(self.path_components)

View File

@@ -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 import (
AuthenticatedRights, OwnerWriteRights, OwnerOnlyRights,
)
except ImportError:
AuthenticatedRights = OwnerOnlyRights = OwnerWriteRights = None
class Rights(OwnerOnlyRights, OwnerWriteRights, AuthenticatedRights):
def authorized(self, user, path, perm):
if path == '/':
return True
collection = Collection(path)
if not collection.collection:
return False
rights = collection.collection.sudo().rights
cls = {
"owner_only": OwnerOnlyRights,
"owner_write_only": OwnerWriteRights,
"authenticated": AuthenticatedRights,
}.get(rights)
if not cls:
return False
return cls.authorized(self, user, path, perm)

View File

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

View File

@@ -0,0 +1,2 @@
* Holger Brunn <hbrunn@therp.nl>
* Florian Kantelberg <florian.kantelberg@initos.com>

View File

@@ -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>`_

View File

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

View File

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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_dav_collection access_dav_collection model_dav_collection base.group_user 1 0 0 0
3 access_dav_collection_field_mapping access_dav_collection_field_mapping model_dav_collection_field_mapping base.group_user 1 0 0 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,429 @@
<?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 0.12: http://docutils.sourceforge.net/" />
<title>Caldav and Carddav support</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7614 2013-02-21 15:55:51Z 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 }
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 {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.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;
}
.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 } */
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/11.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-11-0/server-backend-11-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/11.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, youll 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>better UX for configuring collections</li>
<li>support writing</li>
<li>support address books</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 models 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:%2011.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>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>
</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/11.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>

View File

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

View File

@@ -0,0 +1,117 @@
# 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 ..controllers.main import PREFIX
from ..controllers.main import Main as Controller
MODULE_PATH = "odoo.addons.base_dav"
CONTROLLER_PATH = MODULE_PATH + ".controllers.main"
RADICALE_PATH = MODULE_PATH + ".radicale"
ADMIN_PASSWORD = "RadicalePa$$word"
@mock.patch(CONTROLLER_PATH + ".request")
@mock.patch(RADICALE_PATH + ".auth.request")
@mock.patch(RADICALE_PATH + ".collection.request")
class TestBaseDav(TransactionCase):
def setUp(self):
super().setUp()
self.collection = self.env["dav.collection"].create({
"name": "Test Collection",
"dav_type": "calendar",
"model_id": self.env.ref("base.model_res_users").id,
"domain": "[]",
})
self.dav_path = urlparse(self.collection.url).path.replace(PREFIX, '')
self.controller = Controller()
self.env.user.password_crypt = ADMIN_PASSWORD
self.test_user = self.env["res.users"].create({
"login": "tester",
"name": "tester",
})
self.auth_owner = self.auth_string(self.env.user, ADMIN_PASSWORD)
self.auth_tester = self.auth_string(self.test_user, ADMIN_PASSWORD)
patcher = mock.patch('odoo.http.request')
self.addCleanup(patcher.stop)
patcher.start()
def auth_string(self, user, password):
return b64encode(
("%s:%s" % (user.login, password)).encode()
).decode()
def init_mocks(self, coll_mock, auth_mock, req_mock):
req_mock.env = self.env
req_mock.httprequest.environ = {
"HTTP_AUTHORIZATION": "Basic %s" % self.auth_owner,
"REQUEST_METHOD": "PROPFIND",
"HTTP_X_SCRIPT_NAME": PREFIX,
}
auth_mock.env["res.users"]._login.return_value = self.env.uid
coll_mock.env = self.env
def check_status_code(self, response, forbidden):
if forbidden:
self.assertNotEqual(response.status_code, 403)
else:
self.assertEqual(response.status_code, 403)
def check_access(self, environ, auth_string, read, write):
environ.update({
"REQUEST_METHOD": "PROPFIND",
"HTTP_AUTHORIZATION": "Basic %s" % auth_string,
})
response = self.controller.handle_dav_request(self.dav_path)
self.check_status_code(response, read)
environ["REQUEST_METHOD"] = "PUT"
response = self.controller.handle_dav_request(self.dav_path)
self.check_status_code(response, write)
def test_well_known(self, coll_mock, auth_mock, req_mock):
req_mock.env = self.env
response = self.controller.handle_well_known_request()
self.assertEqual(response.status_code, 301)
def test_authenticated(self, coll_mock, auth_mock, req_mock):
self.init_mocks(coll_mock, auth_mock, req_mock)
environ = req_mock.httprequest.environ
self.collection.rights = "authenticated"
self.check_access(environ, self.auth_owner, read=True, write=True)
self.check_access(environ, self.auth_tester, read=True, write=True)
def test_owner_only(self, coll_mock, auth_mock, req_mock):
self.init_mocks(coll_mock, auth_mock, req_mock)
environ = req_mock.httprequest.environ
self.collection.rights = "owner_only"
self.check_access(environ, self.auth_owner, read=True, write=True)
self.check_access(environ, self.auth_tester, read=False, write=False)
def test_owner_write_only(self, coll_mock, auth_mock, req_mock):
self.init_mocks(coll_mock, auth_mock, req_mock)
environ = req_mock.httprequest.environ
self.collection.rights = "owner_write_only"
self.check_access(environ, self.auth_owner, read=True, write=True)
self.check_access(environ, self.auth_tester, read=True, write=False)

View File

@@ -0,0 +1,118 @@
# 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.exceptions import MissingError
from odoo.tests.common import TransactionCase
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
from ..radicale.collection import Collection
class TestCalendar(TransactionCase):
def setUp(self):
super().setUp()
self.collection = self.env["dav.collection"].create({
"name": "Test Collection",
"dav_type": "calendar",
"model_id": self.env.ref("base.model_res_users").id,
"domain": "[]",
})
self.create_field_mapping(
"login", "base.field_res_users_login",
excode="result = record.login",
imcode="result = item.value",
)
self.create_field_mapping(
"name", "base.field_res_users_name",
)
self.create_field_mapping(
"dtstart", "base.field_res_users_create_date",
)
self.create_field_mapping(
"dtend", "base.field_res_users_write_date",
)
start = datetime.now()
stop = start + timedelta(hours=1)
self.record = self.env["res.users"].create({
"login": "tester",
"name": "Test User",
"create_date": start.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
"write_date": stop.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
})
def create_field_mapping(self, name, field_ref, imcode=None, excode=None):
return self.env["dav.collection.field_mapping"].create({
"collection_id": self.collection.id,
"name": name,
"field_id": self.env.ref(field_ref).id,
"mapping_type": "code" if imcode or excode else "simple",
"import_code": imcode,
"export_code": excode,
})
def compare_record(self, vobj, rec=None):
tmp = self.collection.from_vobject(vobj)
self.assertEqual((rec or self.record).login, tmp["login"])
self.assertEqual((rec or self.record).name, tmp["name"])
self.assertEqual((rec or self.record).create_date, tmp["create_date"])
self.assertEqual((rec or self.record).write_date, tmp["write_date"])
def test_import_export(self):
# Exporting and importing should result in the same record
vobj = self.collection.to_vobject(self.record)
self.compare_record(vobj)
def test_get_record(self):
rec = self.collection.get_record([self.record.id])
self.assertEqual(rec, self.record)
self.collection.field_uuid = self.env.ref(
"base.field_res_users_login",
).id
rec = self.collection.get_record([self.record.login])
self.assertEqual(rec, self.record)
@mock.patch("odoo.addons.base_dav.radicale.collection.request")
def test_collection(self, request_mock):
request_mock.env = self.env
collection_url = "/%s/%s" % (self.env.user.login, self.collection.id)
collection = list(Collection.discover(collection_url))[0]
# Try to get the test record
record_url = "%s/%s" % (collection_url, self.record.id)
self.assertIn(record_url, collection.list())
# Get the test record using the URL and compare it
item = collection.get(record_url)
self.compare_record(item.item)
self.assertEqual(item.href, record_url)
# Get a non-existing record
self.assertFalse(collection.get(record_url + "0"))
# Get the record and alter it later
item = self.collection.to_vobject(self.record)
self.record.login = "different"
with self.assertRaises(AssertionError):
self.compare_record(item)
# Restore the record
item = collection.upload(record_url, item)
self.compare_record(item.item)
# Delete an record
collection.delete(item.href)
with self.assertRaises(MissingError):
self.record.name
# Create a new record
item = collection.upload(record_url + "0", item)
record = self.collection.get_record(collection._split_path(item.href))
self.assertNotEqual(record, self.record)
self.compare_record(item.item, record)

View File

@@ -0,0 +1,78 @@
<?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"/>
</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="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="name"/>
<field name="mapping_type"/>
<field name="field_id"
domain="[('model_id', '=', collection_id.model_id)]"
attrs="{'invisible': [('mapping_type', '!=', 'simple')]}"/>
<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>

View File

@@ -1,3 +1,4 @@
sqlalchemy
mysqlclient
pymssql
radicale