Valentin Lab
2 years ago
27 changed files with 2051 additions and 0 deletions
-
104base_dav/README.rst
-
5base_dav/__init__.py
-
24base_dav/__manifest__.py
-
3base_dav/controllers/__init__.py
-
65base_dav/controllers/main.py
-
34base_dav/demo/dav_collection.xml
-
215base_dav/i18n/base_dav.pot
-
4base_dav/models/__init__.py
-
303base_dav/models/dav_collection.py
-
180base_dav/models/dav_collection_field_mapping.py
-
5base_dav/radicale/__init__.py
-
19base_dav/radicale/auth.py
-
150base_dav/radicale/collection.py
-
31base_dav/radicale/rights.py
-
5base_dav/readme/CONFIGURE.rst
-
3base_dav/readme/CONTRIBUTORS.rst
-
2base_dav/readme/CREDITS.rst
-
3base_dav/readme/DESCRIPTION.rst
-
7base_dav/readme/ROADMAP.rst
-
3base_dav/security/ir.model.access.csv
-
BINbase_dav/static/description/icon.png
-
452base_dav/static/description/index.html
-
3base_dav/tests/__init__.py
-
160base_dav/tests/test_base_dav.py
-
190base_dav/tests/test_collection.py
-
77base_dav/views/dav_collection.xml
-
4requirements.txt
@ -0,0 +1,104 @@ |
|||
========================== |
|||
Caldav and Carddav support |
|||
========================== |
|||
|
|||
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! |
|||
!! This file is generated by oca-gen-addon-readme !! |
|||
!! changes will be overwritten. !! |
|||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! |
|||
|
|||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png |
|||
:target: https://odoo-community.org/page/development-status |
|||
:alt: Beta |
|||
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png |
|||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html |
|||
:alt: License: AGPL-3 |
|||
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--backend-lightgray.png?logo=github |
|||
:target: https://github.com/OCA/server-backend/tree/12.0/base_dav |
|||
:alt: OCA/server-backend |
|||
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png |
|||
:target: https://translation.odoo-community.org/projects/server-backend-12-0/server-backend-12-0-base_dav |
|||
:alt: Translate me on Weblate |
|||
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png |
|||
:target: https://runbot.odoo-community.org/runbot/253/12.0 |
|||
:alt: Try me on Runbot |
|||
|
|||
|badge1| |badge2| |badge3| |badge4| |badge5| |
|||
|
|||
This module adds WebDAV support to Odoo, specifically CalDAV and CardDAV. |
|||
|
|||
You can configure arbitrary objects as a calendar or an address book, thus make arbitrary information accessible in external systems or your mobile. |
|||
|
|||
**Table of contents** |
|||
|
|||
.. contents:: |
|||
:local: |
|||
|
|||
Configuration |
|||
============= |
|||
|
|||
To configure this module, you need to: |
|||
|
|||
#. go to `Settings / WebDAV Collections` and create or edit your collections. There, you'll also see the URL to point your clients to. |
|||
|
|||
Note that you need to configure a dbfilter if you use multiple databases. |
|||
|
|||
Known issues / Roadmap |
|||
====================== |
|||
|
|||
* much better UX for configuring collections (probably provide a group that sees the current fully flexible field mappings, and by default show some dumbed down version where you can select some preselected vobject fields) |
|||
* support todo lists and journals |
|||
* support configuring default field mappings per model |
|||
* support plain WebDAV collections to make some model's records accessible as folders, and the records' attachments as files (r/w) |
|||
* support configuring lists of calendars so that you can have a calendar for every project and appointments are tasks, or a calendar for every sales team and appointments are sale orders. Lots of possibilities |
|||
|
|||
Backporting this to <=v10 will be tricky because radicale only supports python3. Probably it will be quite a hassle to backport the relevant code, so it might be more sensible to just backport the configuration part, and implement the rest as radicale auth/storage plugin that talks to Odoo via odoorpc. It should be possible to recycle most of the code from this addon, which actually implements those plugins, but then within Odoo. |
|||
|
|||
Bug Tracker |
|||
=========== |
|||
|
|||
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-backend/issues>`_. |
|||
In case of trouble, please check there if your issue has already been reported. |
|||
If you spotted it first, help us smashing it by providing a detailed and welcomed |
|||
`feedback <https://github.com/OCA/server-backend/issues/new?body=module:%20base_dav%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_. |
|||
|
|||
Do not contact contributors directly about support or help with technical issues. |
|||
|
|||
Credits |
|||
======= |
|||
|
|||
Authors |
|||
~~~~~~~ |
|||
|
|||
* initOS GmbH |
|||
* Therp BV |
|||
|
|||
Contributors |
|||
~~~~~~~~~~~~ |
|||
|
|||
* Holger Brunn <hbrunn@therp.nl> |
|||
* Florian Kantelberg <florian.kantelberg@initos.com> |
|||
* César López Ramírez <cesar.lopez@coopdevs.org> |
|||
|
|||
Other credits |
|||
~~~~~~~~~~~~~ |
|||
|
|||
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_ |
|||
* All the actual work is done by `Radicale <https://radicale.org>`_ |
|||
|
|||
Maintainers |
|||
~~~~~~~~~~~ |
|||
|
|||
This module is maintained by the OCA. |
|||
|
|||
.. image:: https://odoo-community.org/logo.png |
|||
:alt: Odoo Community Association |
|||
:target: https://odoo-community.org |
|||
|
|||
OCA, or the Odoo Community Association, is a nonprofit organization whose |
|||
mission is to support the collaborative development of Odoo features and |
|||
promote its widespread use. |
|||
|
|||
This module is part of the `OCA/server-backend <https://github.com/OCA/server-backend/tree/12.0/base_dav>`_ project on GitHub. |
|||
|
|||
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. |
@ -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 |
@ -0,0 +1,24 @@ |
|||
# Copyright 2018 Therp BV <https://therp.nl> |
|||
# Copyright 2019-2020 initOS GmbH <https://initos.com> |
|||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
|||
{ |
|||
"name": "Caldav and Carddav support", |
|||
"version": "12.0.1.0.0", |
|||
"author": "initOS GmbH,Therp BV,Odoo Community Association (OCA)", |
|||
"license": "AGPL-3", |
|||
"category": "Extra Tools", |
|||
"summary": "Access Odoo data as calendar or address book", |
|||
"depends": [ |
|||
'base', |
|||
], |
|||
"demo": [ |
|||
"demo/dav_collection.xml", |
|||
], |
|||
"data": [ |
|||
"views/dav_collection.xml", |
|||
'security/ir.model.access.csv', |
|||
], |
|||
"external_dependencies": { |
|||
'python': ['radicale'], |
|||
}, |
|||
} |
@ -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 |
@ -0,0 +1,65 @@ |
|||
# Copyright 2018 Therp BV <https://therp.nl> |
|||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from configparser import RawConfigParser as ConfigParser |
|||
import logging |
|||
|
|||
import werkzeug |
|||
from odoo import http |
|||
from odoo.http import request |
|||
|
|||
try: |
|||
import radicale |
|||
except ImportError: |
|||
radicale = None |
|||
|
|||
PREFIX = '/.dav' |
|||
|
|||
|
|||
class Main(http.Controller): |
|||
@http.route( |
|||
['/.well-known/carddav', '/.well-known/caldav', '/.well-known/webdav'], |
|||
type='http', auth='none', csrf=False, |
|||
) |
|||
def handle_well_known_request(self): |
|||
return werkzeug.utils.redirect(PREFIX, 301) |
|||
|
|||
@http.route( |
|||
[PREFIX, '%s/<path:davpath>' % PREFIX], type='http', auth='none', |
|||
csrf=False, |
|||
) |
|||
def handle_dav_request(self, davpath=None): |
|||
config = ConfigParser() |
|||
for section, values in radicale.config.DEFAULT_CONFIG_SCHEMA.items(): |
|||
config.add_section(section) |
|||
for key, data in values.items(): |
|||
config.set(section, key, data["value"] if type(data) == dict else data) |
|||
config.set('auth', 'type', 'odoo.addons.base_dav.radicale.auth') |
|||
config.set( |
|||
'storage', 'type', 'odoo.addons.base_dav.radicale.collection' |
|||
) |
|||
config.set( |
|||
'rights', 'type', 'odoo.addons.base_dav.radicale.rights' |
|||
) |
|||
config.set('web', 'type', 'none') |
|||
request.httprequest.environ['wsgi.errors'] = logging.getLogger('radicale') |
|||
application = radicale.Application( |
|||
config |
|||
) |
|||
|
|||
response = None |
|||
|
|||
def start_response(status, headers): |
|||
nonlocal response |
|||
response = http.Response(status=status, headers=headers) |
|||
|
|||
result = application( |
|||
dict( |
|||
request.httprequest.environ, |
|||
HTTP_X_SCRIPT_NAME=PREFIX, |
|||
PATH_INFO=davpath or '', |
|||
), |
|||
start_response, |
|||
) |
|||
response.stream.write(result and result[0] or b'') |
|||
return response |
@ -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> |
@ -0,0 +1,215 @@ |
|||
# Translation of Odoo Server. |
|||
# This file contains the translation of the following modules: |
|||
# * base_dav |
|||
# |
|||
msgid "" |
|||
msgstr "" |
|||
"Project-Id-Version: Odoo Server 11.0\n" |
|||
"Report-Msgid-Bugs-To: \n" |
|||
"Last-Translator: <>\n" |
|||
"Language-Team: \n" |
|||
"MIME-Version: 1.0\n" |
|||
"Content-Type: text/plain; charset=UTF-8\n" |
|||
"Content-Transfer-Encoding: \n" |
|||
"Plural-Forms: \n" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model,name:base_dav.model_dav_collection |
|||
msgid "A collection accessible via WebDAV" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model,name:base_dav.model_dav_collection_field_mapping |
|||
msgid "A field mapping for a WebDAV collection" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.ui.view,arch_db:base_dav.view_dav_collection_form |
|||
msgid "Access" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.ui.view,arch_db:base_dav.view_dav_collection_form |
|||
msgid "Additional field mapping" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: selection:dav.collection,dav_type:0 |
|||
msgid "Addressbook" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_name |
|||
msgid "Attribute name in the vobject" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: selection:dav.collection,rights:0 |
|||
msgid "Authenticated" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: selection:dav.collection,dav_type:0 |
|||
msgid "Calendar" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: selection:dav.collection.field_mapping,mapping_type:0 |
|||
msgid "Code" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_export_code |
|||
msgid "Code to export the value to a vobject. Use the variable result for the output of the value and record as input" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_import_code |
|||
msgid "Code to import the value from a vobject. Use the variable result for the output of the value and item as input" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_collection_id |
|||
msgid "Collection" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_create_uid |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_create_uid |
|||
msgid "Created by" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_create_date |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_create_date |
|||
msgid "Created on" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_display_name |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_display_name |
|||
msgid "Display Name" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_domain |
|||
msgid "Domain" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_export_code |
|||
msgid "Export Code" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_field_id |
|||
msgid "Field" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_uuid |
|||
msgid "Field Uuid" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_ids |
|||
msgid "Field mappings" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_field_id |
|||
msgid "Field of the model the values are mapped to" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: selection:dav.collection,dav_type:0 |
|||
msgid "Files" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_id |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_id |
|||
msgid "ID" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_import_code |
|||
msgid "Import Code" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection___last_update |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping___last_update |
|||
msgid "Last Modified on" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_write_uid |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_write_uid |
|||
msgid "Last Updated by" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_write_date |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_write_date |
|||
msgid "Last Updated on" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_mapping_type |
|||
msgid "Mapping Type" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_model_id |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_model_id |
|||
msgid "Model" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_name |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_name |
|||
msgid "Name" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: selection:dav.collection,rights:0 |
|||
msgid "Owner Only" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: selection:dav.collection,rights:0 |
|||
msgid "Owner Write Only" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_rights |
|||
msgid "Rights" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: selection:dav.collection.field_mapping,mapping_type:0 |
|||
msgid "Simple" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_tag |
|||
msgid "Tag" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_dav_type |
|||
msgid "Type" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.model.fields,field_description:base_dav.field_dav_collection_url |
|||
msgid "Url" |
|||
msgstr "" |
|||
|
|||
#. module: base_dav |
|||
#: model:ir.actions.act_window,name:base_dav.action_dav_collection |
|||
#: model:ir.ui.menu,name:base_dav.menu_dav_collection |
|||
msgid "WebDAV collections" |
|||
msgstr "" |
|||
|
@ -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 |
@ -0,0 +1,303 @@ |
|||
# Copyright 2019 Therp BV <https://therp.nl> |
|||
# Copyright 2019-2020 initOS GmbH <https://initos.com> |
|||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
|||
|
|||
import os |
|||
from operator import itemgetter |
|||
from urllib.parse import quote_plus |
|||
|
|||
from odoo import api, fields, models, tools |
|||
import logging |
|||
|
|||
import vobject |
|||
|
|||
# pylint: disable=missing-import-error |
|||
from ..controllers.main import PREFIX |
|||
from ..radicale.collection import Collection, FileItem, Item |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
class DavCollection(models.Model): |
|||
_name = 'dav.collection' |
|||
_description = 'A collection accessible via WebDAV' |
|||
|
|||
name = fields.Char(required=True) |
|||
rights = fields.Selection( |
|||
[ |
|||
("owner_only", "Owner Only"), |
|||
("owner_write_only", "Owner Write Only"), |
|||
("authenticated", "Authenticated"), |
|||
], |
|||
required=True, |
|||
default="owner_only", |
|||
) |
|||
dav_type = fields.Selection( |
|||
[ |
|||
('calendar', 'Calendar'), |
|||
('addressbook', 'Addressbook'), |
|||
('files', 'Files'), |
|||
], |
|||
string='Type', |
|||
required=True, |
|||
default='calendar', |
|||
) |
|||
tag = fields.Char(compute='_compute_tag') |
|||
model_id = fields.Many2one( |
|||
'ir.model', |
|||
string='Model', |
|||
required=True, |
|||
domain=[('transient', '=', False)], |
|||
) |
|||
domain = fields.Char( |
|||
required=True, |
|||
default='[]', |
|||
) |
|||
field_uuid = fields.Many2one('ir.model.fields') |
|||
field_mapping_ids = fields.One2many( |
|||
'dav.collection.field_mapping', |
|||
'collection_id', |
|||
string='Field mappings', |
|||
) |
|||
url = fields.Char(compute='_compute_url') |
|||
|
|||
@api.multi |
|||
def _compute_tag(self): |
|||
for this in self: |
|||
if this.dav_type == 'calendar': |
|||
this.tag = 'VCALENDAR' |
|||
elif this.dav_type == 'addressbook': |
|||
this.tag = 'VADDRESSBOOK' |
|||
|
|||
@api.multi |
|||
def _compute_url(self): |
|||
base_url = self.env['ir.config_parameter'].get_param('web.base.url') |
|||
for this in self: |
|||
this.url = '%s%s/%s/%s' % ( |
|||
base_url, |
|||
PREFIX, |
|||
self.env.user.login, |
|||
this.id, |
|||
) |
|||
|
|||
@api.constrains('domain') |
|||
def _check_domain(self): |
|||
self._eval_domain() |
|||
|
|||
@api.model |
|||
def _eval_context(self): |
|||
return { |
|||
'user': self.env.user, |
|||
} |
|||
|
|||
@api.model |
|||
def get_logger(self): |
|||
return logger |
|||
|
|||
@api.multi |
|||
def _eval_domain(self): |
|||
self.ensure_one() |
|||
return list(tools.safe_eval(self.domain, self._eval_context())) |
|||
|
|||
@api.multi |
|||
def eval(self): |
|||
if not self: |
|||
return self.env['unknown'] |
|||
self.ensure_one() |
|||
return self.env[self.model_id.model].search(self._eval_domain()) |
|||
|
|||
@api.multi |
|||
def get_record(self, components): |
|||
self.ensure_one() |
|||
collection_model = self.env[self.model_id.model] |
|||
|
|||
field_name = self.field_uuid.name or "id" |
|||
domain = [(field_name, '=', components[-1])] + self._eval_domain() |
|||
return collection_model.search(domain, limit=1) |
|||
|
|||
@api.multi |
|||
def from_vobject(self, item): |
|||
self.ensure_one() |
|||
|
|||
result = {} |
|||
if self.dav_type == 'calendar': |
|||
if item.name != 'VCALENDAR': |
|||
return None |
|||
if not hasattr(item, 'vevent'): |
|||
return None |
|||
item = item.vevent |
|||
elif self.dav_type == 'addressbook' and item.name != 'VCARD': |
|||
return None |
|||
|
|||
children = {c.name.lower(): c for c in item.getChildren()} |
|||
for mapping in self.field_mapping_ids: |
|||
name = mapping.name.lower() |
|||
if name not in children: |
|||
continue |
|||
|
|||
if name in children: |
|||
value = mapping.from_vobject(children[name]) |
|||
if value: |
|||
result[mapping.field_id.name] = value |
|||
|
|||
return result |
|||
|
|||
@api.multi |
|||
def to_vobject(self, record): |
|||
self.ensure_one() |
|||
result = None |
|||
vobj = None |
|||
if self.dav_type == 'calendar': |
|||
result = vobject.iCalendar() |
|||
vobj = result.add('vevent') |
|||
if self.dav_type == 'addressbook': |
|||
result = vobject.vCard() |
|||
vobj = result |
|||
for mapping in self.field_mapping_ids: |
|||
value = mapping.to_vobject(record) |
|||
if value: |
|||
vobj.add(mapping.name).value = value |
|||
|
|||
if 'uid' not in vobj.contents: |
|||
vobj.add('uid').value = '%s,%s' % (record._name, record.id) |
|||
if 'rev' not in vobj.contents and 'write_date' in record._fields: |
|||
vobj.add('rev').value = record.write_date.strftime('%Y-%m-%dT%H%M%SZ') |
|||
return result |
|||
|
|||
@api.model |
|||
def _odoo_to_http_datetime(self, value): |
|||
return value.strftime('%a, %d %b %Y %H:%M:%S GMT') |
|||
|
|||
@api.model |
|||
def _split_path(self, path): |
|||
return list(filter( |
|||
None, os.path.normpath(path or '').strip('/').split('/') |
|||
)) |
|||
|
|||
@api.multi |
|||
def dav_list(self, collection, path_components): |
|||
self.ensure_one() |
|||
|
|||
if self.dav_type == 'files': |
|||
if len(path_components) == 3: |
|||
collection_model = self.env[self.model_id.model] |
|||
record = collection_model.browse(map( |
|||
itemgetter(0), |
|||
collection_model.name_search( |
|||
path_components[2], operator='=', limit=1, |
|||
) |
|||
)) |
|||
return [ |
|||
'/' + '/'.join( |
|||
path_components + [quote_plus(attachment.name)] |
|||
) |
|||
for attachment in self.env['ir.attachment'].search([ |
|||
('type', '=', 'binary'), |
|||
('res_model', '=', record._name), |
|||
('res_id', '=', record.id), |
|||
]) |
|||
] |
|||
elif len(path_components) == 2: |
|||
return [ |
|||
'/' + '/'.join( |
|||
path_components + [quote_plus(record.display_name)] |
|||
) |
|||
for record in self.eval() |
|||
] |
|||
|
|||
if len(path_components) > 2: |
|||
return [] |
|||
|
|||
result = [] |
|||
for record in self.eval(): |
|||
if self.field_uuid: |
|||
uuid = record[self.field_uuid.name] |
|||
else: |
|||
uuid = str(record.id) |
|||
href = '/' + '/'.join(path_components + [uuid]) |
|||
result.append(self.dav_get(collection, href)) |
|||
return result |
|||
|
|||
@api.multi |
|||
def dav_delete(self, collection, components): |
|||
self.ensure_one() |
|||
|
|||
if self.dav_type == "files": |
|||
# TODO: Handle deletion of attachments |
|||
pass |
|||
else: |
|||
self.get_record(components).unlink() |
|||
|
|||
@api.multi |
|||
def dav_upload(self, collection, href, item): |
|||
self.ensure_one() |
|||
|
|||
components = self._split_path(href) |
|||
collection_model = self.env[self.model_id.model] |
|||
if self.dav_type == 'files': |
|||
# TODO: Handle upload of attachments |
|||
return None |
|||
data = self.from_vobject(item) |
|||
record = self.get_record(components) |
|||
|
|||
if not record: |
|||
if self.field_uuid: |
|||
data[self.field_uuid.name] = components[-1] |
|||
|
|||
record = collection_model.create(data) |
|||
uuid = components[-1] if self.field_uuid else record.id |
|||
href = "%s/%s" % (href, uuid) |
|||
else: |
|||
record.write(data) |
|||
|
|||
return Item( |
|||
collection=collection, |
|||
vobject_item=self.to_vobject(record), |
|||
href=href, |
|||
last_modified=self._odoo_to_http_datetime(record.write_date), |
|||
) |
|||
|
|||
@api.multi |
|||
def dav_get(self, collection, href): |
|||
self.ensure_one() |
|||
|
|||
components = self._split_path(href) |
|||
collection_model = self.env[self.model_id.model] |
|||
if self.dav_type == 'files': |
|||
if len(components) == 3: |
|||
result = Collection(href) |
|||
result.logger = self.logger |
|||
return result |
|||
if len(components) == 4: |
|||
record = collection_model.browse(map( |
|||
itemgetter(0), |
|||
collection_model.name_search( |
|||
components[2], operator='=', limit=1, |
|||
) |
|||
)) |
|||
attachment = self.env['ir.attachment'].search([ |
|||
('type', '=', 'binary'), |
|||
('res_model', '=', record._name), |
|||
('res_id', '=', record.id), |
|||
('name', '=', components[3]), |
|||
], limit=1) |
|||
return FileItem( |
|||
collection, |
|||
item=attachment, |
|||
href=href, |
|||
last_modified=self._odoo_to_http_datetime( |
|||
record.write_date |
|||
), |
|||
) |
|||
|
|||
record = self.get_record(components) |
|||
|
|||
if not record: |
|||
return None |
|||
|
|||
return Item( |
|||
collection=collection, |
|||
vobject_item=self.to_vobject(record), |
|||
href=href, |
|||
last_modified=self._odoo_to_http_datetime(record.write_date), |
|||
) |
@ -0,0 +1,180 @@ |
|||
# Copyright 2019 Therp BV <https://therp.nl> |
|||
# Copyright 2019-2020 initOS GmbH <https://initos.com> |
|||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
|||
|
|||
import datetime |
|||
|
|||
from odoo import api, fields, models, tools |
|||
from odoo.tools.safe_eval import safe_eval |
|||
|
|||
import dateutil |
|||
import vobject |
|||
from dateutil import tz |
|||
|
|||
|
|||
class DavCollectionFieldMapping(models.Model): |
|||
_name = 'dav.collection.field_mapping' |
|||
_description = 'A field mapping for a WebDAV collection' |
|||
|
|||
collection_id = fields.Many2one( |
|||
'dav.collection', required=True, ondelete='cascade', |
|||
) |
|||
name = fields.Char( |
|||
required=True, |
|||
help="Attribute name in the vobject", |
|||
) |
|||
mapping_type = fields.Selection( |
|||
[ |
|||
('simple', 'Simple'), |
|||
('code', 'Code'), |
|||
], |
|||
default='simple', |
|||
required=True, |
|||
) |
|||
field_id = fields.Many2one( |
|||
'ir.model.fields', |
|||
required=True, |
|||
help="Field of the model the values are mapped to", |
|||
) |
|||
model_id = fields.Many2one( |
|||
'ir.model', |
|||
related='collection_id.model_id', |
|||
) |
|||
import_code = fields.Text( |
|||
help="Code to import the value from a vobject. Use the variable " |
|||
"result for the output of the value and item as input" |
|||
) |
|||
export_code = fields.Text( |
|||
help="Code to export the value to a vobject. Use the variable " |
|||
"result for the output of the value and record as input" |
|||
) |
|||
|
|||
@api.multi |
|||
def from_vobject(self, child): |
|||
self.ensure_one() |
|||
if self.mapping_type == 'code': |
|||
return self._from_vobject_code(child) |
|||
return self._from_vobject_simple(child) |
|||
|
|||
@api.multi |
|||
def _from_vobject_code(self, child): |
|||
self.ensure_one() |
|||
context = { |
|||
'datetime': datetime, |
|||
'dateutil': dateutil, |
|||
'item': child, |
|||
'result': None, |
|||
'tools': tools, |
|||
'tz': tz, |
|||
'vobject': vobject, |
|||
} |
|||
safe_eval(self.import_code, context, mode="exec", nocopy=True) |
|||
return context.get('result', {}) |
|||
|
|||
@api.multi |
|||
def _from_vobject_simple(self, child): |
|||
self.ensure_one() |
|||
name = self.name.lower() |
|||
conversion_funcs = [ |
|||
'_from_vobject_%s_%s' % (self.field_id.ttype, name), |
|||
'_from_vobject_%s' % self.field_id.ttype, |
|||
] |
|||
|
|||
for conversion_func in conversion_funcs: |
|||
if hasattr(self, conversion_func): |
|||
value = getattr(self, conversion_func)(child) |
|||
if value: |
|||
return value |
|||
|
|||
return child.value |
|||
|
|||
@api.model |
|||
def _from_vobject_datetime(self, item): |
|||
if isinstance(item.value, datetime.datetime): |
|||
value = item.value.astimezone(dateutil.tz.UTC) |
|||
return value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) |
|||
elif isinstance(item.value, datetime.date): |
|||
return item.value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) |
|||
return None |
|||
|
|||
@api.model |
|||
def _from_vobject_date(self, item): |
|||
if isinstance(item.value, datetime.datetime): |
|||
value = item.value.astimezone(dateutil.tz.UTC) |
|||
return value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) |
|||
elif isinstance(item.value, datetime.date): |
|||
return item.value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) |
|||
return None |
|||
|
|||
@api.model |
|||
def _from_vobject_binary(self, item): |
|||
return item.value.encode('ascii') |
|||
|
|||
@api.model |
|||
def _from_vobject_char_n(self, item): |
|||
return item.family |
|||
|
|||
@api.multi |
|||
def to_vobject(self, record): |
|||
self.ensure_one() |
|||
if self.mapping_type == 'code': |
|||
result = self._to_vobject_code(record) |
|||
else: |
|||
result = self._to_vobject_simple(record) |
|||
|
|||
if isinstance(result, datetime.datetime) and not result.tzinfo: |
|||
return result.replace(tzinfo=tz.UTC) |
|||
return result |
|||
|
|||
@api.multi |
|||
def _to_vobject_code(self, record): |
|||
self.ensure_one() |
|||
context = { |
|||
'datetime': datetime, |
|||
'dateutil': dateutil, |
|||
'record': record, |
|||
'result': None, |
|||
'tools': tools, |
|||
'tz': tz, |
|||
'vobject': vobject, |
|||
} |
|||
safe_eval(self.export_code, context, mode="exec", nocopy=True) |
|||
return context.get('result', None) |
|||
|
|||
@api.multi |
|||
def _to_vobject_simple(self, record): |
|||
self.ensure_one() |
|||
conversion_funcs = [ |
|||
'_to_vobject_%s_%s' % ( |
|||
self.field_id.ttype, self.name.lower() |
|||
), |
|||
'_to_vobject_%s' % self.field_id.ttype, |
|||
] |
|||
value = record[self.field_id.name] |
|||
for conversion_func in conversion_funcs: |
|||
if hasattr(self, conversion_func): |
|||
return getattr(self, conversion_func)(value) |
|||
return value |
|||
|
|||
@api.model |
|||
def _to_vobject_datetime(self, value): |
|||
result = fields.Datetime.from_string(value) |
|||
return result.replace(tzinfo=tz.UTC) |
|||
|
|||
@api.model |
|||
def _to_vobject_datetime_rev(self, value): |
|||
return value and value\ |
|||
.replace('-', '').replace(' ', 'T').replace(':', '') + 'Z' |
|||
|
|||
@api.model |
|||
def _to_vobject_date(self, value): |
|||
return fields.Date.from_string(value) |
|||
|
|||
@api.model |
|||
def _to_vobject_binary(self, value): |
|||
return value and value.decode('ascii') |
|||
|
|||
@api.model |
|||
def _to_vobject_char_n(self, value): |
|||
# TODO: how are we going to handle compound types like this? |
|||
return vobject.vcard.Name(family=value) |
@ -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 |
@ -0,0 +1,19 @@ |
|||
# Copyright 2018 Therp BV <https://therp.nl> |
|||
# Copyright 2019-2020 initOS GmbH <https://initos.com> |
|||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
|||
from odoo.http import request |
|||
|
|||
try: |
|||
from radicale.auth import BaseAuth |
|||
except ImportError: |
|||
BaseAuth = None |
|||
|
|||
|
|||
class Auth(BaseAuth): |
|||
def login(self, user, password): |
|||
env = request.env |
|||
uid = env['res.users']._login(env.cr.dbname, user, password) |
|||
login = request.env['res.users'].browse(uid).login |
|||
if uid: |
|||
request._env = env(user=uid) |
|||
return login |
@ -0,0 +1,150 @@ |
|||
# Copyright 2018 Therp BV <https://therp.nl> |
|||
# Copyright 2019-2020 initOS GmbH <https://initos.com> |
|||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
|||
import base64 |
|||
import os |
|||
import time |
|||
|
|||
from odoo.http import request |
|||
|
|||
try: |
|||
from radicale.storage import BaseCollection, BaseStorage |
|||
from radicale.item import Item, get_etag |
|||
from radicale import types |
|||
except ImportError: |
|||
BaseCollection = None |
|||
Item = None |
|||
get_etag = None |
|||
|
|||
|
|||
class BytesPretendingToBeString(bytes): |
|||
# radicale expects a string as file content, so we provide the str |
|||
# functions needed |
|||
def encode(self, encoding): |
|||
return self |
|||
|
|||
|
|||
class FileItem(Item): |
|||
"""this item tricks radicalev into serving a plain file""" |
|||
@property |
|||
def name(self): |
|||
return 'VCARD' |
|||
|
|||
def serialize(self): |
|||
return BytesPretendingToBeString(base64.b64decode(self.item.datas)) |
|||
|
|||
@property |
|||
def etag(self): |
|||
return get_etag(self.item.datas.decode('ascii')) |
|||
|
|||
|
|||
class Storage(BaseStorage): |
|||
|
|||
@classmethod |
|||
@types.contextmanager |
|||
def acquire_lock(cls, mode, user=None): |
|||
"""We have a database for that""" |
|||
yield |
|||
|
|||
@classmethod |
|||
def discover(cls, path, depth=None): |
|||
depth = int(depth or "0") |
|||
components = cls._split_path(path) |
|||
collection = Collection(path) |
|||
collection.logger = collection.collection.get_logger() |
|||
if len(components) > 2: |
|||
# TODO: this probably better should happen in some dav.collection |
|||
# function |
|||
if collection.collection.dav_type == 'files' and depth: |
|||
for href in collection.list(): |
|||
yield collection.get(href) |
|||
return |
|||
yield collection.get(path) |
|||
return |
|||
yield collection |
|||
if depth and len(components) == 1: |
|||
for collection in request.env['dav.collection'].search([]): |
|||
yield cls('/'.join(components + ['/%d' % collection.id])) |
|||
if depth and len(components) == 2: |
|||
for href in collection.list(): |
|||
yield collection.get(href) |
|||
|
|||
@classmethod |
|||
def _split_path(cls, path): |
|||
return list(filter( |
|||
None, os.path.normpath(path or '').strip('/').split('/') |
|||
)) |
|||
|
|||
@classmethod |
|||
def create_collection(cls, href, collection=None, props=None): |
|||
return Collection(href) |
|||
|
|||
|
|||
class Collection(BaseCollection): |
|||
|
|||
@classmethod |
|||
def _split_path(cls, path): |
|||
return list(filter( |
|||
None, os.path.normpath(path or '').strip('/').split('/') |
|||
)) |
|||
|
|||
@property |
|||
def env(self): |
|||
return request.env |
|||
|
|||
@property |
|||
def last_modified(self): |
|||
return self._odoo_to_http_datetime(self.collection.create_date) |
|||
|
|||
@property |
|||
def path(self): |
|||
return '/'.join(self.path_components) or '/' |
|||
|
|||
def __init__(self, path): |
|||
self.path_components = self._split_path(path) |
|||
self._path = '/'.join(self.path_components) or '/' |
|||
self.collection = self.env['dav.collection'] |
|||
if len(self.path_components) >= 2 and str( |
|||
self.path_components[1] |
|||
).isdigit(): |
|||
self.collection = self.env['dav.collection'].browse(int( |
|||
self.path_components[1] |
|||
)) |
|||
|
|||
def _odoo_to_http_datetime(self, value): |
|||
return time.strftime( |
|||
'%a, %d %b %Y %H:%M:%S GMT', |
|||
value.timetuple(), |
|||
) |
|||
|
|||
def get_meta(self, key=None): |
|||
if key is None: |
|||
return {} |
|||
elif key == 'tag': |
|||
return self.collection.tag |
|||
elif key == 'D:displayname': |
|||
return self.collection.display_name |
|||
elif key == 'C:supported-calendar-component-set': |
|||
return 'VTODO,VEVENT,VJOURNAL' |
|||
elif key == 'C:calendar-home-set': |
|||
return None |
|||
elif key == 'D:principal-URL': |
|||
return None |
|||
elif key == 'ICAL:calendar-color': |
|||
# TODO: set in dav.collection |
|||
return '#48c9f4' |
|||
elif key == 'C:calendar-description': |
|||
return self.collection.name |
|||
self.logger.warning('unsupported metadata %s', key) |
|||
|
|||
def get_multi(self, hrefs): |
|||
return [self.collection.dav_get(self, href) for href in hrefs] |
|||
|
|||
def upload(self, href, vobject_item): |
|||
return self.collection.dav_upload(self, href, vobject_item) |
|||
|
|||
def delete(self, href): |
|||
return self.collection.dav_delete(self, self._split_path(href)) |
|||
|
|||
def get_all(self): |
|||
return self.collection.dav_list(self, self.path_components) |
@ -0,0 +1,31 @@ |
|||
# Copyright 2018 Therp BV <https://therp.nl> |
|||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from .collection import Collection |
|||
|
|||
try: |
|||
from radicale.rights.owner_only import Rights as OwnerOnlyRights |
|||
from radicale.rights.authenticated import Rights as AuthenticatedRights |
|||
from radicale.rights.owner_write import Rights as OwnerWriteRights |
|||
except ImportError: |
|||
AuthenticatedRights = OwnerOnlyRights = OwnerWriteRights = None |
|||
|
|||
|
|||
class Rights(OwnerOnlyRights, OwnerWriteRights, AuthenticatedRights): |
|||
def authorization(self, user, path): |
|||
if path == '/': |
|||
return True |
|||
|
|||
collection = Collection(path) |
|||
if not collection.collection: |
|||
return "" |
|||
|
|||
rights = collection.collection.sudo().rights |
|||
cls = { |
|||
"owner_only": OwnerOnlyRights, |
|||
"owner_write_only": OwnerWriteRights, |
|||
"authenticated": AuthenticatedRights, |
|||
}.get(rights) |
|||
if not cls: |
|||
return False |
|||
return cls.authorization(self, user, path) |
@ -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. |
@ -0,0 +1,3 @@ |
|||
* Holger Brunn <hbrunn@therp.nl> |
|||
* Florian Kantelberg <florian.kantelberg@initos.com> |
|||
* César López Ramírez <cesar.lopez@coopdevs.org> |
@ -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>`_ |
@ -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. |
@ -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. |
@ -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 |
After Width: 300 | Height: 300 | Size: 9.5 KiB |
@ -0,0 +1,452 @@ |
|||
<?xml version="1.0" encoding="utf-8" ?> |
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> |
|||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> |
|||
<head> |
|||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> |
|||
<meta name="generator" content="Docutils: http://docutils.sourceforge.net/" /> |
|||
<title>Caldav and Carddav support</title> |
|||
<style type="text/css"> |
|||
|
|||
/* |
|||
:Author: David Goodger (goodger@python.org) |
|||
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $ |
|||
:Copyright: This stylesheet has been placed in the public domain. |
|||
|
|||
Default cascading style sheet for the HTML output of Docutils. |
|||
|
|||
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to |
|||
customize this style sheet. |
|||
*/ |
|||
|
|||
/* used to remove borders from tables and images */ |
|||
.borderless, table.borderless td, table.borderless th { |
|||
border: 0 } |
|||
|
|||
table.borderless td, table.borderless th { |
|||
/* Override padding for "table.docutils td" with "! important". |
|||
The right padding separates the table cells. */ |
|||
padding: 0 0.5em 0 0 ! important } |
|||
|
|||
.first { |
|||
/* Override more specific margin styles with "! important". */ |
|||
margin-top: 0 ! important } |
|||
|
|||
.last, .with-subtitle { |
|||
margin-bottom: 0 ! important } |
|||
|
|||
.hidden { |
|||
display: none } |
|||
|
|||
.subscript { |
|||
vertical-align: sub; |
|||
font-size: smaller } |
|||
|
|||
.superscript { |
|||
vertical-align: super; |
|||
font-size: smaller } |
|||
|
|||
a.toc-backref { |
|||
text-decoration: none ; |
|||
color: black } |
|||
|
|||
blockquote.epigraph { |
|||
margin: 2em 5em ; } |
|||
|
|||
dl.docutils dd { |
|||
margin-bottom: 0.5em } |
|||
|
|||
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] { |
|||
overflow: hidden; |
|||
} |
|||
|
|||
/* Uncomment (and remove this text!) to get bold-faced definition list terms |
|||
dl.docutils dt { |
|||
font-weight: bold } |
|||
*/ |
|||
|
|||
div.abstract { |
|||
margin: 2em 5em } |
|||
|
|||
div.abstract p.topic-title { |
|||
font-weight: bold ; |
|||
text-align: center } |
|||
|
|||
div.admonition, div.attention, div.caution, div.danger, div.error, |
|||
div.hint, div.important, div.note, div.tip, div.warning { |
|||
margin: 2em ; |
|||
border: medium outset ; |
|||
padding: 1em } |
|||
|
|||
div.admonition p.admonition-title, div.hint p.admonition-title, |
|||
div.important p.admonition-title, div.note p.admonition-title, |
|||
div.tip p.admonition-title { |
|||
font-weight: bold ; |
|||
font-family: sans-serif } |
|||
|
|||
div.attention p.admonition-title, div.caution p.admonition-title, |
|||
div.danger p.admonition-title, div.error p.admonition-title, |
|||
div.warning p.admonition-title, .code .error { |
|||
color: red ; |
|||
font-weight: bold ; |
|||
font-family: sans-serif } |
|||
|
|||
/* Uncomment (and remove this text!) to get reduced vertical space in |
|||
compound paragraphs. |
|||
div.compound .compound-first, div.compound .compound-middle { |
|||
margin-bottom: 0.5em } |
|||
|
|||
div.compound .compound-last, div.compound .compound-middle { |
|||
margin-top: 0.5em } |
|||
*/ |
|||
|
|||
div.dedication { |
|||
margin: 2em 5em ; |
|||
text-align: center ; |
|||
font-style: italic } |
|||
|
|||
div.dedication p.topic-title { |
|||
font-weight: bold ; |
|||
font-style: normal } |
|||
|
|||
div.figure { |
|||
margin-left: 2em ; |
|||
margin-right: 2em } |
|||
|
|||
div.footer, div.header { |
|||
clear: both; |
|||
font-size: smaller } |
|||
|
|||
div.line-block { |
|||
display: block ; |
|||
margin-top: 1em ; |
|||
margin-bottom: 1em } |
|||
|
|||
div.line-block div.line-block { |
|||
margin-top: 0 ; |
|||
margin-bottom: 0 ; |
|||
margin-left: 1.5em } |
|||
|
|||
div.sidebar { |
|||
margin: 0 0 0.5em 1em ; |
|||
border: medium outset ; |
|||
padding: 1em ; |
|||
background-color: #ffffee ; |
|||
width: 40% ; |
|||
float: right ; |
|||
clear: right } |
|||
|
|||
div.sidebar p.rubric { |
|||
font-family: sans-serif ; |
|||
font-size: medium } |
|||
|
|||
div.system-messages { |
|||
margin: 5em } |
|||
|
|||
div.system-messages h1 { |
|||
color: red } |
|||
|
|||
div.system-message { |
|||
border: medium outset ; |
|||
padding: 1em } |
|||
|
|||
div.system-message p.system-message-title { |
|||
color: red ; |
|||
font-weight: bold } |
|||
|
|||
div.topic { |
|||
margin: 2em } |
|||
|
|||
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle, |
|||
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle { |
|||
margin-top: 0.4em } |
|||
|
|||
h1.title { |
|||
text-align: center } |
|||
|
|||
h2.subtitle { |
|||
text-align: center } |
|||
|
|||
hr.docutils { |
|||
width: 75% } |
|||
|
|||
img.align-left, .figure.align-left, object.align-left, table.align-left { |
|||
clear: left ; |
|||
float: left ; |
|||
margin-right: 1em } |
|||
|
|||
img.align-right, .figure.align-right, object.align-right, table.align-right { |
|||
clear: right ; |
|||
float: right ; |
|||
margin-left: 1em } |
|||
|
|||
img.align-center, .figure.align-center, object.align-center { |
|||
display: block; |
|||
margin-left: auto; |
|||
margin-right: auto; |
|||
} |
|||
|
|||
table.align-center { |
|||
margin-left: auto; |
|||
margin-right: auto; |
|||
} |
|||
|
|||
.align-left { |
|||
text-align: left } |
|||
|
|||
.align-center { |
|||
clear: both ; |
|||
text-align: center } |
|||
|
|||
.align-right { |
|||
text-align: right } |
|||
|
|||
/* reset inner alignment in figures */ |
|||
div.align-right { |
|||
text-align: inherit } |
|||
|
|||
/* div.align-center * { */ |
|||
/* text-align: left } */ |
|||
|
|||
.align-top { |
|||
vertical-align: top } |
|||
|
|||
.align-middle { |
|||
vertical-align: middle } |
|||
|
|||
.align-bottom { |
|||
vertical-align: bottom } |
|||
|
|||
ol.simple, ul.simple { |
|||
margin-bottom: 1em } |
|||
|
|||
ol.arabic { |
|||
list-style: decimal } |
|||
|
|||
ol.loweralpha { |
|||
list-style: lower-alpha } |
|||
|
|||
ol.upperalpha { |
|||
list-style: upper-alpha } |
|||
|
|||
ol.lowerroman { |
|||
list-style: lower-roman } |
|||
|
|||
ol.upperroman { |
|||
list-style: upper-roman } |
|||
|
|||
p.attribution { |
|||
text-align: right ; |
|||
margin-left: 50% } |
|||
|
|||
p.caption { |
|||
font-style: italic } |
|||
|
|||
p.credits { |
|||
font-style: italic ; |
|||
font-size: smaller } |
|||
|
|||
p.label { |
|||
white-space: nowrap } |
|||
|
|||
p.rubric { |
|||
font-weight: bold ; |
|||
font-size: larger ; |
|||
color: maroon ; |
|||
text-align: center } |
|||
|
|||
p.sidebar-title { |
|||
font-family: sans-serif ; |
|||
font-weight: bold ; |
|||
font-size: larger } |
|||
|
|||
p.sidebar-subtitle { |
|||
font-family: sans-serif ; |
|||
font-weight: bold } |
|||
|
|||
p.topic-title { |
|||
font-weight: bold } |
|||
|
|||
pre.address { |
|||
margin-bottom: 0 ; |
|||
margin-top: 0 ; |
|||
font: inherit } |
|||
|
|||
pre.literal-block, pre.doctest-block, pre.math, pre.code { |
|||
margin-left: 2em ; |
|||
margin-right: 2em } |
|||
|
|||
pre.code .ln { color: grey; } /* line numbers */ |
|||
pre.code, code { background-color: #eeeeee } |
|||
pre.code .comment, code .comment { color: #5C6576 } |
|||
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } |
|||
pre.code .literal.string, code .literal.string { color: #0C5404 } |
|||
pre.code .name.builtin, code .name.builtin { color: #352B84 } |
|||
pre.code .deleted, code .deleted { background-color: #DEB0A1} |
|||
pre.code .inserted, code .inserted { background-color: #A3D289} |
|||
|
|||
span.classifier { |
|||
font-family: sans-serif ; |
|||
font-style: oblique } |
|||
|
|||
span.classifier-delimiter { |
|||
font-family: sans-serif ; |
|||
font-weight: bold } |
|||
|
|||
span.interpreted { |
|||
font-family: sans-serif } |
|||
|
|||
span.option { |
|||
white-space: nowrap } |
|||
|
|||
span.pre { |
|||
white-space: pre } |
|||
|
|||
span.problematic { |
|||
color: red } |
|||
|
|||
span.section-subtitle { |
|||
/* font-size relative to parent (h1..h6 element) */ |
|||
font-size: 80% } |
|||
|
|||
table.citation { |
|||
border-left: solid 1px gray; |
|||
margin-left: 1px } |
|||
|
|||
table.docinfo { |
|||
margin: 2em 4em } |
|||
|
|||
table.docutils { |
|||
margin-top: 0.5em ; |
|||
margin-bottom: 0.5em } |
|||
|
|||
table.footnote { |
|||
border-left: solid 1px black; |
|||
margin-left: 1px } |
|||
|
|||
table.docutils td, table.docutils th, |
|||
table.docinfo td, table.docinfo th { |
|||
padding-left: 0.5em ; |
|||
padding-right: 0.5em ; |
|||
vertical-align: top } |
|||
|
|||
table.docutils th.field-name, table.docinfo th.docinfo-name { |
|||
font-weight: bold ; |
|||
text-align: left ; |
|||
white-space: nowrap ; |
|||
padding-left: 0 } |
|||
|
|||
/* "booktabs" style (no vertical lines) */ |
|||
table.docutils.booktabs { |
|||
border: 0px; |
|||
border-top: 2px solid; |
|||
border-bottom: 2px solid; |
|||
border-collapse: collapse; |
|||
} |
|||
table.docutils.booktabs * { |
|||
border: 0px; |
|||
} |
|||
table.docutils.booktabs th { |
|||
border-bottom: thin solid; |
|||
text-align: left; |
|||
} |
|||
|
|||
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils, |
|||
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils { |
|||
font-size: 100% } |
|||
|
|||
ul.auto-toc { |
|||
list-style-type: none } |
|||
|
|||
</style> |
|||
</head> |
|||
<body> |
|||
<div class="document" id="caldav-and-carddav-support"> |
|||
<h1 class="title">Caldav and Carddav support</h1> |
|||
|
|||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! |
|||
!! This file is generated by oca-gen-addon-readme !! |
|||
!! changes will be overwritten. !! |
|||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> |
|||
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/server-backend/tree/12.0/base_dav"><img alt="OCA/server-backend" src="https://img.shields.io/badge/github-OCA%2Fserver--backend-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/server-backend-12-0/server-backend-12-0-base_dav"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/253/12.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p> |
|||
<p>This module adds WebDAV support to Odoo, specifically CalDAV and CardDAV.</p> |
|||
<p>You can configure arbitrary objects as a calendar or an address book, thus make arbitrary information accessible in external systems or your mobile.</p> |
|||
<p><strong>Table of contents</strong></p> |
|||
<div class="contents local topic" id="contents"> |
|||
<ul class="simple"> |
|||
<li><a class="reference internal" href="#configuration" id="id1">Configuration</a></li> |
|||
<li><a class="reference internal" href="#known-issues-roadmap" id="id2">Known issues / Roadmap</a></li> |
|||
<li><a class="reference internal" href="#bug-tracker" id="id3">Bug Tracker</a></li> |
|||
<li><a class="reference internal" href="#credits" id="id4">Credits</a><ul> |
|||
<li><a class="reference internal" href="#authors" id="id5">Authors</a></li> |
|||
<li><a class="reference internal" href="#contributors" id="id6">Contributors</a></li> |
|||
<li><a class="reference internal" href="#other-credits" id="id7">Other credits</a></li> |
|||
<li><a class="reference internal" href="#maintainers" id="id8">Maintainers</a></li> |
|||
</ul> |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
<div class="section" id="configuration"> |
|||
<h1><a class="toc-backref" href="#id1">Configuration</a></h1> |
|||
<p>To configure this module, you need to:</p> |
|||
<ol class="arabic simple"> |
|||
<li>go to <cite>Settings / WebDAV Collections</cite> and create or edit your collections. There, you’ll also see the URL to point your clients to.</li> |
|||
</ol> |
|||
<p>Note that you need to configure a dbfilter if you use multiple databases.</p> |
|||
</div> |
|||
<div class="section" id="known-issues-roadmap"> |
|||
<h1><a class="toc-backref" href="#id2">Known issues / Roadmap</a></h1> |
|||
<ul class="simple"> |
|||
<li>much better UX for configuring collections (probably provide a group that sees the current fully flexible field mappings, and by default show some dumbed down version where you can select some preselected vobject fields)</li> |
|||
<li>support todo lists and journals</li> |
|||
<li>support configuring default field mappings per model</li> |
|||
<li>support plain WebDAV collections to make some model’s records accessible as folders, and the records’ attachments as files (r/w)</li> |
|||
<li>support configuring lists of calendars so that you can have a calendar for every project and appointments are tasks, or a calendar for every sales team and appointments are sale orders. Lots of possibilities</li> |
|||
</ul> |
|||
<p>Backporting this to <=v10 will be tricky because radicale only supports python3. Probably it will be quite a hassle to backport the relevant code, so it might be more sensible to just backport the configuration part, and implement the rest as radicale auth/storage plugin that talks to Odoo via odoorpc. It should be possible to recycle most of the code from this addon, which actually implements those plugins, but then within Odoo.</p> |
|||
</div> |
|||
<div class="section" id="bug-tracker"> |
|||
<h1><a class="toc-backref" href="#id3">Bug Tracker</a></h1> |
|||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/server-backend/issues">GitHub Issues</a>. |
|||
In case of trouble, please check there if your issue has already been reported. |
|||
If you spotted it first, help us smashing it by providing a detailed and welcomed |
|||
<a class="reference external" href="https://github.com/OCA/server-backend/issues/new?body=module:%20base_dav%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p> |
|||
<p>Do not contact contributors directly about support or help with technical issues.</p> |
|||
</div> |
|||
<div class="section" id="credits"> |
|||
<h1><a class="toc-backref" href="#id4">Credits</a></h1> |
|||
<div class="section" id="authors"> |
|||
<h2><a class="toc-backref" href="#id5">Authors</a></h2> |
|||
<ul class="simple"> |
|||
<li>initOS GmbH</li> |
|||
<li>Therp BV</li> |
|||
</ul> |
|||
</div> |
|||
<div class="section" id="contributors"> |
|||
<h2><a class="toc-backref" href="#id6">Contributors</a></h2> |
|||
<ul class="simple"> |
|||
<li>Holger Brunn <<a class="reference external" href="mailto:hbrunn@therp.nl">hbrunn@therp.nl</a>></li> |
|||
<li>Florian Kantelberg <<a class="reference external" href="mailto:florian.kantelberg@initos.com">florian.kantelberg@initos.com</a>></li> |
|||
<li>César López Ramírez <<a class="reference external" href="mailto:cesar.lopez@coopdevs.org">cesar.lopez@coopdevs.org</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/12.0/base_dav">OCA/server-backend</a> project on GitHub.</p> |
|||
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</body> |
|||
</html> |
@ -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 |
@ -0,0 +1,160 @@ |
|||
# Copyright 2018 Therp BV <https://therp.nl> |
|||
# Copyright 2019-2020 initOS GmbH <https://initos.com> |
|||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
|||
|
|||
from base64 import b64encode |
|||
from unittest import mock |
|||
from urllib.parse import urlparse |
|||
|
|||
from odoo.tests.common import TransactionCase |
|||
from odoo.exceptions import AccessDenied |
|||
|
|||
from ..controllers.main import PREFIX |
|||
from ..controllers.main import Main as Controller |
|||
|
|||
from ..radicale.auth import Auth |
|||
|
|||
|
|||
MODULE_PATH = "odoo.addons.base_dav" |
|||
CONTROLLER_PATH = MODULE_PATH + ".controllers.main" |
|||
RADICALE_PATH = MODULE_PATH + ".radicale" |
|||
|
|||
ADMIN_PASSWORD = "RadicalePa$$word" |
|||
|
|||
|
|||
@mock.patch(CONTROLLER_PATH + ".request") |
|||
@mock.patch(RADICALE_PATH + ".auth.Auth.login") |
|||
@mock.patch(RADICALE_PATH + ".collection.request") |
|||
class TestBaseDav(TransactionCase): |
|||
def setUp(self): |
|||
super().setUp() |
|||
|
|||
self.collection = self.env["dav.collection"].create({ |
|||
"name": "Test Collection", |
|||
"dav_type": "calendar", |
|||
"model_id": self.env.ref("base.model_res_users").id, |
|||
"domain": "[]", |
|||
}) |
|||
|
|||
self.dav_path = urlparse(self.collection.url).path.replace(PREFIX, '') |
|||
|
|||
self.controller = Controller() |
|||
self.env.user.password_crypt = ADMIN_PASSWORD |
|||
|
|||
self.test_user = self.env["res.users"].create({ |
|||
"login": "tester", |
|||
"name": "tester", |
|||
}) |
|||
|
|||
self.auth_owner = self.auth_string(self.env.user, ADMIN_PASSWORD) |
|||
self.auth_tester = self.auth_string(self.test_user, ADMIN_PASSWORD) |
|||
|
|||
patcher = mock.patch('odoo.http.request') |
|||
self.addCleanup(patcher.stop) |
|||
patcher.start() |
|||
|
|||
def auth_string(self, user, password): |
|||
return b64encode( |
|||
("%s:%s" % (user.login, password)).encode() |
|||
).decode() |
|||
|
|||
def init_mocks(self, coll_mock, login_mock, req_mock): |
|||
req_mock.env = self.env |
|||
req_mock.httprequest.environ = { |
|||
"HTTP_AUTHORIZATION": "Basic %s" % self.auth_owner, |
|||
"REQUEST_METHOD": "PROPFIND", |
|||
"HTTP_X_SCRIPT_NAME": PREFIX, |
|||
} |
|||
|
|||
def side_effect(arg, _): |
|||
return arg |
|||
login_mock.side_effect = side_effect |
|||
coll_mock.env = self.env |
|||
|
|||
def check_status_code(self, response, forbidden): |
|||
if forbidden: |
|||
self.assertNotEqual(response.status_code, 403) |
|||
else: |
|||
self.assertEqual(response.status_code, 403) |
|||
|
|||
def check_access(self, environ, auth_string, read, write): |
|||
environ.update({ |
|||
"REQUEST_METHOD": "PROPFIND", |
|||
"HTTP_AUTHORIZATION": "Basic %s" % auth_string, |
|||
}) |
|||
response = self.controller.handle_dav_request(self.dav_path) |
|||
self.check_status_code(response, read) |
|||
|
|||
environ["REQUEST_METHOD"] = "PUT" |
|||
response = self.controller.handle_dav_request(self.dav_path) |
|||
self.check_status_code(response, write) |
|||
|
|||
def test_well_known(self, coll_mock, login_mock, req_mock): |
|||
req_mock.env = self.env |
|||
|
|||
response = self.controller.handle_well_known_request() |
|||
self.assertEqual(response.status_code, 301) |
|||
|
|||
def test_authenticated(self, coll_mock, login_mock, req_mock): |
|||
self.init_mocks(coll_mock, login_mock, req_mock) |
|||
environ = req_mock.httprequest.environ |
|||
|
|||
self.collection.rights = "authenticated" |
|||
|
|||
self.check_access(environ, self.auth_owner, read=True, write=True) |
|||
self.check_access(environ, self.auth_tester, read=True, write=True) |
|||
|
|||
def test_owner_only(self, coll_mock, login_mock, req_mock): |
|||
self.init_mocks(coll_mock, login_mock, req_mock) |
|||
environ = req_mock.httprequest.environ |
|||
|
|||
self.collection.rights = "owner_only" |
|||
|
|||
self.check_access(environ, self.auth_owner, read=True, write=True) |
|||
self.check_access(environ, self.auth_tester, read=False, write=False) |
|||
|
|||
def test_owner_write_only(self, coll_mock, login_mock, req_mock): |
|||
self.init_mocks(coll_mock, login_mock, req_mock) |
|||
environ = req_mock.httprequest.environ |
|||
|
|||
self.collection.rights = "owner_write_only" |
|||
|
|||
self.check_access(environ, self.auth_owner, read=True, write=True) |
|||
self.check_access(environ, self.auth_tester, read=True, write=False) |
|||
|
|||
|
|||
@mock.patch(RADICALE_PATH + ".auth.request") |
|||
class TestAuth(TransactionCase): |
|||
def init_mock(self, auth_mock): |
|||
def side_effect_login(dbname, user, password): |
|||
user = self.env['res.users'].search([('login', '=', user)]) |
|||
if user: |
|||
return user.id |
|||
else: |
|||
raise AccessDenied |
|||
auth_mock.env["res.users"]._login.side_effect = side_effect_login |
|||
|
|||
def side_effect_browse(uid): |
|||
return self.env['res.users'].browse(uid) |
|||
auth_mock.env['res.users'].browse.side_effect = side_effect_browse |
|||
|
|||
def setUp(self): |
|||
super().setUp() |
|||
self.test_user = self.env["res.users"].create({ |
|||
"login": "tester", |
|||
"name": "tester", |
|||
"password": ADMIN_PASSWORD, |
|||
}) |
|||
|
|||
def test_login_tester(self, auth_mock): |
|||
self.init_mock(auth_mock) |
|||
auth = Auth(mock.ANY) |
|||
self.assertEquals( |
|||
auth.login(self.test_user.login, ADMIN_PASSWORD), |
|||
self.test_user.login |
|||
) |
|||
|
|||
def test_login_fail(self, auth_mock): |
|||
self.init_mock(auth_mock) |
|||
auth = Auth(mock.ANY) |
|||
self.assertRaises(AccessDenied, auth.login, *('fake', 'fake')) |
@ -0,0 +1,190 @@ |
|||
# Copyright 2019-2020 initOS GmbH <https://initos.com> |
|||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). |
|||
from datetime import datetime, timedelta |
|||
from unittest import mock |
|||
|
|||
from odoo.tests.common import TransactionCase |
|||
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT |
|||
|
|||
from ..radicale.collection import Storage |
|||
|
|||
|
|||
class TestCalendar(TransactionCase): |
|||
def setUp(self): |
|||
super().setUp() |
|||
|
|||
self.collection = self.env["dav.collection"].create({ |
|||
"name": "Test Collection", |
|||
"dav_type": "calendar", |
|||
"model_id": self.env.ref("base.model_res_users").id, |
|||
"domain": "[]", |
|||
}) |
|||
|
|||
self.collection_partner = self.env["dav.collection"].create({ |
|||
"name": "Test Collection", |
|||
"dav_type": "calendar", |
|||
"model_id": self.env.ref("base.model_res_partner").id, |
|||
"domain": "[]", |
|||
}) |
|||
self.create_field_mapping( |
|||
"login", "base.field_res_users__login", |
|||
excode="result = record.login", |
|||
imcode="result = item.value", |
|||
) |
|||
self.create_field_mapping( |
|||
"name", "base.field_res_users__name", |
|||
) |
|||
self.create_field_mapping( |
|||
"dtstart", "base.field_res_users__create_date", |
|||
) |
|||
self.create_field_mapping( |
|||
"dtend", "base.field_res_users__write_date", |
|||
) |
|||
self.create_field_mapping_partner( |
|||
"dtstart", "base.field_res_partner__date", |
|||
) |
|||
|
|||
self.create_field_mapping_partner( |
|||
"dtend", "base.field_res_partner__date", |
|||
) |
|||
self.create_field_mapping( |
|||
"name", "base.field_res_partner__name", |
|||
) |
|||
start = datetime.now() |
|||
stop = start + timedelta(hours=1) |
|||
self.record = self.env["res.users"].create({ |
|||
"login": "tester", |
|||
"name": "Test User", |
|||
"create_date": start.strftime(DEFAULT_SERVER_DATETIME_FORMAT), |
|||
"write_date": stop.strftime(DEFAULT_SERVER_DATETIME_FORMAT), |
|||
}) |
|||
self.partner_record = self.env['res.partner'].create({ |
|||
'date': '2011-04-30', |
|||
'name': 'Test partner', |
|||
}) |
|||
|
|||
def create_field_mapping(self, name, field_ref, imcode=None, excode=None): |
|||
return self.env["dav.collection.field_mapping"].create({ |
|||
"collection_id": self.collection.id, |
|||
"name": name, |
|||
"field_id": self.env.ref(field_ref).id, |
|||
"mapping_type": "code" if imcode or excode else "simple", |
|||
"import_code": imcode, |
|||
"export_code": excode, |
|||
}) |
|||
|
|||
def create_field_mapping_partner(self, name, field_ref, imcode=None, excode=None): |
|||
return self.env["dav.collection.field_mapping"].create({ |
|||
"collection_id": self.collection_partner.id, |
|||
"name": name, |
|||
"field_id": self.env.ref(field_ref).id, |
|||
"mapping_type": "code" if imcode or excode else "simple", |
|||
"import_code": imcode, |
|||
"export_code": excode, |
|||
}) |
|||
|
|||
def compare_record(self, vobj, rec=None): |
|||
tmp = self.collection.from_vobject(vobj) |
|||
|
|||
self.assertEqual((rec or self.record).login, tmp["login"]) |
|||
self.assertEqual((rec or self.record).name, tmp["name"]) |
|||
self.assertEqual((rec or self.record).create_date.strftime( |
|||
DEFAULT_SERVER_DATETIME_FORMAT), tmp["create_date"] |
|||
) |
|||
self.assertEqual((rec or self.record).write_date.strftime( |
|||
DEFAULT_SERVER_DATETIME_FORMAT), tmp["write_date"] |
|||
) |
|||
|
|||
def compare_record_partner(self, vobj, rec=None): |
|||
tmp = self.collection_partner.from_vobject(vobj) |
|||
|
|||
self.assertEqual((rec or self.partner_record).date.strftime( |
|||
DEFAULT_SERVER_DATE_FORMAT), tmp["date"] |
|||
) |
|||
|
|||
def test_import_export(self): |
|||
# Exporting and importing should result in the same record |
|||
vobj = self.collection.to_vobject(self.record) |
|||
self.compare_record(vobj) |
|||
|
|||
def test_import_export_partner(self): |
|||
# Exporting and importing should result in the same record |
|||
vobj = self.collection_partner.to_vobject(self.partner_record) |
|||
self.compare_record_partner(vobj) |
|||
|
|||
def test_from_vobject_bad_name(self): |
|||
vobj = self.collection_partner.to_vobject(self.partner_record) |
|||
vobj.name = 'FAKE' |
|||
self.assertFalse(self.collection_partner.from_vobject(vobj)) |
|||
|
|||
def test_from_vobject_has_not_vevent(self): |
|||
vobj = self.collection_partner.to_vobject(self.partner_record) |
|||
delattr(vobj, 'vevent') |
|||
self.assertFalse(self.collection_partner.from_vobject(vobj)) |
|||
|
|||
def test_from_vobject_bad_vcard(self): |
|||
vobj = self.collection_partner.to_vobject(self.partner_record) |
|||
self.collection_partner.dav_type = 'addressbook' |
|||
vobj.name = 'FAKE' |
|||
self.assertFalse(self.collection_partner.from_vobject(vobj)) |
|||
|
|||
def test_from_vobject_missing_field(self): |
|||
vobj = self.collection.to_vobject(self.record) |
|||
children = list(next(vobj.getChildren()).getChildren()) |
|||
dtstart = next(e for e in children if e.name.lower() == 'dtstart') |
|||
vevent = list(vobj.getChildren())[0] |
|||
vevent.remove(dtstart) |
|||
tmp = self.collection.from_vobject(vobj) |
|||
self.assertNotIn('create_date', tmp) |
|||
self.assertIn('name', tmp) |
|||
self.assertIn('login', tmp) |
|||
self.assertIn('write_date', tmp) |
|||
|
|||
def test_get_record(self): |
|||
rec = self.collection.get_record([self.record.id]) |
|||
self.assertEqual(rec, self.record) |
|||
|
|||
self.collection.field_uuid = self.env.ref( |
|||
"base.field_res_users__login", |
|||
).id |
|||
rec = self.collection.get_record([self.record.login]) |
|||
self.assertEqual(rec, self.record) |
|||
|
|||
@mock.patch("odoo.addons.base_dav.radicale.collection.request") |
|||
def test_collection(self, request_mock): |
|||
request_mock.env = self.env |
|||
collection_url = "/%s/%s" % (self.env.user.login, self.collection.id) |
|||
collection = list(Storage.discover(collection_url))[0] |
|||
|
|||
# Try to get the test record |
|||
record_url = "%s/%s" % (collection_url, self.record.id) |
|||
self.assertIn(record_url, [c.href for c in collection.get_all()]) |
|||
|
|||
# Get the test record using the URL and compare it |
|||
items = collection.get_multi([record_url]) |
|||
item = items[0] |
|||
self.compare_record(item._vobject_item) |
|||
self.assertEqual(item.href, record_url) |
|||
|
|||
# Get a non-existing record |
|||
self.assertFalse(collection.get_multi([record_url + "0"])[0]) |
|||
|
|||
# Get the record and alter it later |
|||
item = self.collection.to_vobject(self.record) |
|||
self.record.login = "different" |
|||
with self.assertRaises(AssertionError): |
|||
self.compare_record(item) |
|||
|
|||
# Restore the record |
|||
item = collection.upload(record_url, item) |
|||
self.compare_record(item._vobject_item) |
|||
|
|||
# Delete an record |
|||
collection.delete(item.href) |
|||
self.assertFalse(self.record.exists()) |
|||
|
|||
# Create a new record |
|||
item = collection.upload(record_url + "0", item._vobject_item) |
|||
record = self.collection.get_record(collection._split_path(item.href)) |
|||
self.assertNotEqual(record, self.record) |
|||
self.compare_record(item._vobject_item, record) |
@ -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> |
@ -0,0 +1,4 @@ |
|||
sqlalchemy |
|||
mysqlclient==2.0.1 |
|||
pymssql |
|||
radicale==3.1.1 |
Write
Preview
Loading…
Cancel
Save
Reference in new issue