mirror of
https://github.com/OCA/server-backend.git
synced 2025-02-18 09:52:42 +02:00
103
base_dav/README.rst
Normal file
103
base_dav/README.rst
Normal 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
5
base_dav/__init__.py
Normal 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
24
base_dav/__manifest__.py
Normal 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'],
|
||||||
|
},
|
||||||
|
}
|
||||||
3
base_dav/controllers/__init__.py
Normal file
3
base_dav/controllers/__init__.py
Normal 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
|
||||||
64
base_dav/controllers/main.py
Normal file
64
base_dav/controllers/main.py
Normal 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
|
||||||
34
base_dav/demo/dav_collection.xml
Normal file
34
base_dav/demo/dav_collection.xml
Normal 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>
|
||||||
4
base_dav/models/__init__.py
Normal file
4
base_dav/models/__init__.py
Normal 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
|
||||||
301
base_dav/models/dav_collection.py
Normal file
301
base_dav/models/dav_collection.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
from odoo import api, fields, models, tools
|
||||||
|
|
||||||
|
import vobject
|
||||||
|
|
||||||
|
# pylint: disable=missing-import-error
|
||||||
|
from ..controllers.main import PREFIX
|
||||||
|
from ..radicale.collection import Collection, FileItem, Item
|
||||||
|
|
||||||
|
|
||||||
|
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, 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)
|
||||||
|
result.append('/' + '/'.join(path_components + [uuid]))
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
item=self.to_vobject(record),
|
||||||
|
href=href,
|
||||||
|
last_modified=self._odoo_to_http_datetime(record.write_date),
|
||||||
|
)
|
||||||
179
base_dav/models/dav_collection_field_mapping.py
Normal file
179
base_dav/models/dav_collection_field_mapping.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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)
|
||||||
5
base_dav/radicale/__init__.py
Normal file
5
base_dav/radicale/__init__.py
Normal 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
18
base_dav/radicale/auth.py
Normal 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)
|
||||||
132
base_dav/radicale/collection.py
Normal file
132
base_dav/radicale/collection.py
Normal 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(self, href)
|
||||||
|
|
||||||
|
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 list(self):
|
||||||
|
return self.collection.dav_list(self, self.path_components)
|
||||||
31
base_dav/radicale/rights.py
Normal file
31
base_dav/radicale/rights.py
Normal 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)
|
||||||
5
base_dav/readme/CONFIGURE.rst
Normal file
5
base_dav/readme/CONFIGURE.rst
Normal 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.
|
||||||
2
base_dav/readme/CONTRIBUTORS.rst
Normal file
2
base_dav/readme/CONTRIBUTORS.rst
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
* Holger Brunn <hbrunn@therp.nl>
|
||||||
|
* Florian Kantelberg <florian.kantelberg@initos.com>
|
||||||
2
base_dav/readme/CREDITS.rst
Normal file
2
base_dav/readme/CREDITS.rst
Normal 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>`_
|
||||||
3
base_dav/readme/DESCRIPTION.rst
Normal file
3
base_dav/readme/DESCRIPTION.rst
Normal 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.
|
||||||
7
base_dav/readme/ROADMAP.rst
Normal file
7
base_dav/readme/ROADMAP.rst
Normal 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.
|
||||||
3
base_dav/security/ir.model.access.csv
Normal file
3
base_dav/security/ir.model.access.csv
Normal 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
|
||||||
|
BIN
base_dav/static/description/icon.png
Normal file
BIN
base_dav/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
429
base_dav/static/description/index.html
Normal file
429
base_dav/static/description/index.html
Normal 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, 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>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 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 <=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 <<a class="reference external" href="mailto:hbrunn@therp.nl">hbrunn@therp.nl</a>></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>
|
||||||
3
base_dav/tests/__init__.py
Normal file
3
base_dav/tests/__init__.py
Normal 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
|
||||||
119
base_dav/tests/test_base_dav.py
Normal file
119
base_dav/tests/test_base_dav.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# 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.tools import mute_logger
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
@mute_logger("radicale")
|
||||||
|
@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)
|
||||||
116
base_dav/tests/test_collection.py
Normal file
116
base_dav/tests/test_collection.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.assertFalse(self.record.exists())
|
||||||
|
|
||||||
|
# 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)
|
||||||
77
base_dav/views/dav_collection.xml
Normal file
77
base_dav/views/dav_collection.xml
Normal file
@@ -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>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
sqlalchemy
|
sqlalchemy
|
||||||
|
radicale<3.0.0
|
||||||
mysqlclient==2.0.1
|
mysqlclient==2.0.1
|
||||||
pymssql<3.0
|
pymssql<3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user