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