diff --git a/base_dav/README.rst b/base_dav/README.rst new file mode 100644 index 000000000..d9449d2d5 --- /dev/null +++ b/base_dav/README.rst @@ -0,0 +1,106 @@ +========================== +Caldav and Carddav support +========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:6cc5b91f1cff865b4527a2097534adb50c8bade6eaed9f3b820275b7b8ab19d3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--backend-lightgray.png?logo=github + :target: https://github.com/OCA/server-backend/tree/11.0/base_dav + :alt: OCA/server-backend +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-backend-11-0/server-backend-11-0-base_dav + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-backend&target_branch=11.0 + :alt: Try me on Runboat + +|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 `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* initOS GmbH +* Therp BV + +Contributors +~~~~~~~~~~~~ + +* Holger Brunn +* Florian Kantelberg + +Other credits +~~~~~~~~~~~~~ + +* Odoo Community Association: `Icon `_ +* All the actual work is done by `Radicale `_ + +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 `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_dav/__init__.py b/base_dav/__init__.py new file mode 100644 index 000000000..ee99f9d4a --- /dev/null +++ b/base_dav/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models +from . import controllers +from . import radicale +from .hooks import post_init_hook diff --git a/base_dav/__manifest__.py b/base_dav/__manifest__.py new file mode 100644 index 000000000..33224df6b --- /dev/null +++ b/base_dav/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Caldav and Carddav support", + "version": "17.0.1.0.0", + "author": "initOS GmbH,Therp BV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-backend", + "license": "AGPL-3", + "category": "Extra Tools", + "summary": "Access Odoo data as calendar or address book", + "depends": [ + "base", + ], + # Demo data removed for Odoo 17 compatibility (field xmlids no longer exist). + "data": [ + "views/dav_collection.xml", + "views/res_users.xml", + "security/ir.model.access.csv", + ], + "assets": { + "web.assets_backend": [ + "base_dav/static/src/js/carddav_copy.js", + ], + }, + "post_init_hook": "post_init_hook", + "external_dependencies": { + "python": ["radicale", "vobject"], + }, +} diff --git a/base_dav/controllers/__init__.py b/base_dav/controllers/__init__.py new file mode 100644 index 000000000..665f08cea --- /dev/null +++ b/base_dav/controllers/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import main diff --git a/base_dav/controllers/main.py b/base_dav/controllers/main.py new file mode 100644 index 000000000..1000c9301 --- /dev/null +++ b/base_dav/controllers/main.py @@ -0,0 +1,474 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import base64 +import hashlib +import hmac +import io +import logging +import re +import secrets +import time +from configparser import RawConfigParser as ConfigParser +from urllib.parse import urlparse + +import werkzeug +from odoo import http +from odoo.http import request + +try: + import radicale +except ImportError: + radicale = None + +PREFIX = "/.dav" +DIGEST_REALM = "Odoo CardDAV" +NONCE_TTL_SECONDS = 300 +_LOGGER = logging.getLogger(__name__) + + +def _parse_digest_header(value): + if not value or not value.startswith("Digest "): + return {} + value = value[len("Digest ") :] + result = {} + for key, _quoted, qvalue, uvalue in re.findall( + r'(\\w+)=("([^"]*)"|([^,]*))', value + ): + result[key] = qvalue or uvalue.strip() + return result + + +def _get_digest_secret(): + icp = request.env["ir.config_parameter"].sudo() + secret = icp.get_param("base_dav.digest_secret") + if not secret: + secret = secrets.token_urlsafe(32) + icp.set_param("base_dav.digest_secret", secret) + return secret + + +def _normalize_digest_value(value): + if value is None: + return None + if isinstance(value, bytes): + return value.decode() + return str(value) + + +def _build_nonce(secret): + timestamp = str(int(time.time())) + signature = hmac.new( + secret.encode(), + timestamp.encode(), + hashlib.sha256, + ).hexdigest() + payload = f"{timestamp}:{signature}".encode() + return base64.b64encode(payload).decode() + + +def _validate_nonce(nonce, secret): + try: + raw = base64.b64decode(nonce.encode()).decode() + timestamp, signature = raw.split(":", 1) + expected = hmac.new( + secret.encode(), + timestamp.encode(), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected): + return False, False + if time.time() - int(timestamp) > NONCE_TTL_SECONDS: + return False, True + return True, False + except Exception: + return False, False + + +def _digest_challenge(stale=False): + secret = _get_digest_secret() + nonce = _build_nonce(secret) + opaque = hashlib.md5(secret.encode()).hexdigest() + header = ( + f'Digest realm="{DIGEST_REALM}", ' + f'nonce="{nonce}", algorithm=MD5, qop="auth", opaque="{opaque}"' + ) + if stale: + header += ", stale=true" + return http.Response( + status=401, + headers=[("WWW-Authenticate", header)], + ) + + +def _ha2(method, uri, qop, body): + if qop == "auth-int": + body_hash = hashlib.md5(body or b"").hexdigest() + data = f"{method}:{uri}:{body_hash}" + else: + data = f"{method}:{uri}" + return hashlib.md5(data.encode()).hexdigest() + + +def _ha1(username, realm, token): + if isinstance(token, bytes): + data = ( + username.encode() + + b":" + realm.encode() + + b":" + token + ) + else: + data = f"{username}:{realm}:{token}".encode() + return hashlib.md5(data).hexdigest() + + +def _digest_response(username, realm, token, method, uri, nonce, nc, cnonce, + qop, body): + ha1 = _ha1(username, realm, token) + ha2 = _ha2(method, uri, qop, body) + if qop: + return hashlib.md5( + f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode() + ).hexdigest() + return hashlib.md5( + f"{ha1}:{nonce}:{ha2}".encode() + ).hexdigest() + + +def _digest_response_sess(username, realm, token, method, uri, nonce, cnonce, + qop, nc, body): + ha1 = _ha1(username, realm, token) + ha1 = hashlib.md5( + f"{ha1}:{nonce}:{cnonce}".encode() + ).hexdigest() + ha2 = _ha2(method, uri, qop, body) + if qop: + return hashlib.md5( + f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode() + ).hexdigest() + return hashlib.md5( + f"{ha1}:{nonce}:{ha2}".encode() + ).hexdigest() + + +def _find_user_by_login(login): + if not login: + return request.env["res.users"] + users = request.env["res.users"].sudo().search( + [("login", "=", login)], limit=1 + ) + if users: + return users + return request.env["res.users"].sudo().search( + [("email", "=", login)], limit=1 + ) + + +def _authenticate_digest(method, path, body=None): # noqa: C901 + auth_header = request.httprequest.headers.get("Authorization") + if not auth_header: + environ = request.httprequest.environ + auth_header = ( + environ.get("HTTP_AUTHORIZATION") + or environ.get("Authorization") + or environ.get("REDIRECT_HTTP_AUTHORIZATION") + ) + digest = _parse_digest_header(auth_header) + if not digest: + auth = getattr(request.httprequest, "authorization", None) + if auth and getattr(auth, "type", "").lower() == "digest": + params = dict(getattr(auth, "parameters", {}) or {}) + for key in ( + "username", + "realm", + "nonce", + "uri", + "response", + "opaque", + "algorithm", + "qop", + "nc", + "cnonce", + ): + value = getattr(auth, key, None) + if value is not None and key not in params: + params[key] = value + digest = {k: _normalize_digest_value(v) for k, v in params.items()} + if digest: + _LOGGER.info( + "CardDAV Digest: parsed from request.authorization for %s (keys=%s)", + path, + sorted(digest.keys()), + ) + + if not digest: + header_names = [ + key for key in request.httprequest.headers.keys() + ] + _LOGGER.info( + "CardDAV Digest: no Authorization header for %s (headers=%s)", + path, + header_names, + ) + return False, _digest_challenge() + _LOGGER.info( + ( + "CardDAV Digest: auth header present for %s " + "(user=%s realm=%s uri=%s algo=%s qop=%s)" + ), + path, + digest.get("username"), + digest.get("realm"), + digest.get("uri"), + (digest.get("algorithm") or "MD5"), + digest.get("qop"), + ) + + if digest.get("realm") != DIGEST_REALM: + _LOGGER.info( + "CardDAV Digest: realm mismatch for %s (got %s)", + path, + digest.get("realm"), + ) + return False, _digest_challenge() + + secret = _get_digest_secret() + nonce = digest.get("nonce") + valid, stale = _validate_nonce(nonce or "", secret) + if not valid: + _LOGGER.info( + "CardDAV Digest: invalid nonce for %s (stale=%s)", + path, + stale, + ) + return False, _digest_challenge(stale=stale) + + username = digest.get("username") + if not username: + _LOGGER.info("CardDAV Digest: missing username for %s", path) + return False, _digest_challenge() + + user = _find_user_by_login(username) + token = (user.carddav_token or "").strip() if user else "" + if not user or not token: + _LOGGER.info( + "CardDAV Digest: unknown user or missing token for %s (%s)", + path, + username, + ) + return False, _digest_challenge() + + token_candidates = [token] + b64_variant = token.replace("-", "+").replace("_", "/") + if b64_variant != token: + token_candidates.append(b64_variant) + for candidate in list(token_candidates): + try: + padded = candidate + ("=" * (-len(candidate) % 4)) + decoded = base64.b64decode(padded) + if decoded: + token_candidates.append(decoded) + except Exception: # pragma: no cover + _LOGGER.debug( + "CardDAV Digest: failed to decode token candidate for %s", + path, + exc_info=True, + ) + continue + # de-dupe while preserving order + seen_token = set() + deduped = [] + for t in token_candidates: + key = t if isinstance(t, str) else t.hex() + if key in seen_token: + continue + seen_token.add(key) + deduped.append(t) + token_candidates = deduped + + uri = digest.get("uri") or path + qop = (digest.get("qop") or "").split(",")[0].strip() + nc = digest.get("nc", "") + if isinstance(nc, int): + nc = f"{nc:08x}" + cnonce = digest.get("cnonce", "") + nonce_candidates = [nonce] + if isinstance(nonce, str) and ":" in nonce: + try: + encoded = base64.b64encode(nonce.encode()).decode() + nonce_candidates.append(encoded) + except Exception: # pragma: no cover + _LOGGER.debug( + "CardDAV Digest: failed to encode nonce candidate for %s", + path, + exc_info=True, + ) + if qop and (not nc or not cnonce): + # Some clients send qop without nc/cnonce; fall back to RFC 2069. + qop = "" + algorithm = (digest.get("algorithm") or "MD5").upper() + uris = [uri] + parsed = urlparse(uri) + if parsed.scheme and parsed.netloc: + normalized = parsed.path or "" + if parsed.query: + normalized = f"{normalized}?{parsed.query}" + uris.append(normalized) + if uri.endswith("/") and uri != "/": + uris.append(uri.rstrip("/")) + else: + uris.append(f"{uri}/") + uris.append(uri.lstrip("/")) + try: + from urllib.parse import unquote + + uris.append(unquote(uri)) + except Exception: # pragma: no cover + _LOGGER.debug( + "CardDAV Digest: failed to unquote URI candidate for %s", + path, + exc_info=True, + ) + # de-dupe while preserving order + seen = set() + uris = [u for u in uris if not (u in seen or seen.add(u))] + + expected_values = [] + qop_candidates = [qop] + if qop: + # Accept RFC 2069-style response even if qop is provided. + qop_candidates.append("") + if body is not None: + qop_candidates.append("auth-int") + # de-dupe while preserving order + seen_qop = set() + qop_candidates = [ + q for q in qop_candidates if not (q in seen_qop or seen_qop.add(q)) + ] + methods = [method, method.upper(), method.lower()] + algorithms = [algorithm, "MD5", "MD5-SESS"] + seen_alg = set() + algorithms = [a for a in algorithms if not (a in seen_alg or seen_alg.add(a))] + for candidate_uri in uris: + for candidate_nonce in nonce_candidates: + for candidate_method in methods: + for candidate_qop in qop_candidates: + for candidate_alg in algorithms: + for candidate_token in token_candidates: + if candidate_alg == "MD5-SESS": + expected_values.append( + _digest_response_sess( + username=username, + realm=DIGEST_REALM, + token=candidate_token, + method=candidate_method, + uri=candidate_uri, + nonce=candidate_nonce, + nc=nc, + cnonce=cnonce, + qop=candidate_qop, + body=body, + ) + ) + else: + expected_values.append( + _digest_response( + username=username, + realm=DIGEST_REALM, + token=candidate_token, + method=candidate_method, + uri=candidate_uri, + nonce=candidate_nonce, + nc=nc, + cnonce=cnonce, + qop=candidate_qop, + body=body, + ) + ) + + response = digest.get("response", "") + if not any(hmac.compare_digest(response, value) for value in expected_values): + _LOGGER.info( + "CardDAV Digest: response mismatch for %s (%s, algo=%s, uri=%s)", + path, + username, + algorithm, + uri, + ) + _LOGGER.info( + "CardDAV Digest: expected=%s got=%s qop=%s nc=%s cnonce=%s", + expected_values[0] if expected_values else "", + response, + qop, + nc, + cnonce, + ) + return False, _digest_challenge() + + _LOGGER.info("CardDAV Digest: authenticated user %s for %s", username, path) + return True, user + + +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/' % PREFIX], type='http', auth='none', + csrf=False, + ) + def handle_dav_request(self, davpath=None): + body = request.httprequest.get_data() or b"" + ok, result = _authenticate_digest( + request.httprequest.method, request.httprequest.path, body + ) + if not ok: + return result + user = result + if not user: + return _digest_challenge() + if hasattr(request, "update_env"): + request.update_env(user=user.id) + else: + request._env = request.env(user=user.id) + + config = ConfigParser() + for section, values in radicale.config.INITIAL_CONFIG.items(): + config.add_section(section) + for key, data in values.items(): + config.set(section, key, data["value"]) + config.set('auth', 'type', 'remote_user') + config.set( + 'storage', 'type', 'odoo.addons.base_dav.radicale.collection' + ) + config.set( + 'rights', 'type', 'odoo.addons.base_dav.radicale.rights' + ) + config.set('web', 'type', 'none') + application = radicale.Application( + config, logging.getLogger('radicale'), + ) + + status = None + headers = None + + def start_response(response_status, response_headers): + nonlocal status, headers + status = response_status + headers = response_headers + + environ = dict( + request.httprequest.environ, + HTTP_X_SCRIPT_NAME=PREFIX, + PATH_INFO=davpath or '', + REMOTE_USER=user.login, + ) + environ["wsgi.input"] = io.BytesIO(body) + environ["CONTENT_LENGTH"] = str(len(body)) + + result = application(environ, start_response) + return http.Response(response=result, status=status, headers=headers) diff --git a/base_dav/demo/dav_collection.xml b/base_dav/demo/dav_collection.xml new file mode 100644 index 000000000..c448819b0 --- /dev/null +++ b/base_dav/demo/dav_collection.xml @@ -0,0 +1,49 @@ + + + + Addressbook + addressbook + + [] + + + N + + + + + FN + + + + + photo + + + + + email + + + + + tel + + + + diff --git a/base_dav/hooks.py b/base_dav/hooks.py new file mode 100644 index 000000000..155b75eee --- /dev/null +++ b/base_dav/hooks.py @@ -0,0 +1,13 @@ +# Copyright 2026 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, SUPERUSER_ID + + +def post_init_hook(env_or_cr, registry=None): + if isinstance(env_or_cr, api.Environment): + env = env_or_cr + else: + env = api.Environment(env_or_cr, SUPERUSER_ID, {}) + env["dav.collection"]._ensure_default_addressbook() + env["res.users"]._ensure_carddav_token() diff --git a/base_dav/i18n/base_dav.pot b/base_dav/i18n/base_dav.pot new file mode 100644 index 000000000..2e0939ca5 --- /dev/null +++ b/base_dav/i18n/base_dav.pot @@ -0,0 +1,215 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_dav +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 11.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_dav +#: model:ir.model,name:base_dav.model_dav_collection +msgid "A collection accessible via WebDAV" +msgstr "" + +#. module: base_dav +#: model:ir.model,name:base_dav.model_dav_collection_field_mapping +msgid "A field mapping for a WebDAV collection" +msgstr "" + +#. module: base_dav +#: model:ir.ui.view,arch_db:base_dav.view_dav_collection_form +msgid "Access" +msgstr "" + +#. module: base_dav +#: model:ir.ui.view,arch_db:base_dav.view_dav_collection_form +msgid "Additional field mapping" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,dav_type:0 +msgid "Addressbook" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_name +msgid "Attribute name in the vobject" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,rights:0 +msgid "Authenticated" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,dav_type:0 +msgid "Calendar" +msgstr "" + +#. module: base_dav +#: selection:dav.collection.field_mapping,mapping_type:0 +msgid "Code" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_export_code +msgid "Code to export the value to a vobject. Use the variable result for the output of the value and record as input" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_import_code +msgid "Code to import the value from a vobject. Use the variable result for the output of the value and item as input" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_collection_id +msgid "Collection" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_create_uid +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_create_uid +msgid "Created by" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_create_date +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_create_date +msgid "Created on" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_display_name +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_display_name +msgid "Display Name" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_domain +msgid "Domain" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_export_code +msgid "Export Code" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_field_id +msgid "Field" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_uuid +msgid "Field Uuid" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_ids +msgid "Field mappings" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_field_id +msgid "Field of the model the values are mapped to" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,dav_type:0 +msgid "Files" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_id +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_id +msgid "ID" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_import_code +msgid "Import Code" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection___last_update +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping___last_update +msgid "Last Modified on" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_write_uid +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_write_uid +msgid "Last Updated by" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_write_date +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_write_date +msgid "Last Updated on" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_mapping_type +msgid "Mapping Type" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_model_id +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_model_id +msgid "Model" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_name +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_name +msgid "Name" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,rights:0 +msgid "Owner Only" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,rights:0 +msgid "Owner Write Only" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_rights +msgid "Rights" +msgstr "" + +#. module: base_dav +#: selection:dav.collection.field_mapping,mapping_type:0 +msgid "Simple" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_tag +msgid "Tag" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_dav_type +msgid "Type" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_url +msgid "Url" +msgstr "" + +#. module: base_dav +#: model:ir.actions.act_window,name:base_dav.action_dav_collection +#: model:ir.ui.menu,name:base_dav.menu_dav_collection +msgid "WebDAV collections" +msgstr "" + diff --git a/base_dav/models/__init__.py b/base_dav/models/__init__.py new file mode 100644 index 000000000..a5b2e8522 --- /dev/null +++ b/base_dav/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import dav_collection +from . import dav_collection_field_mapping +from . import res_users diff --git a/base_dav/models/dav_collection.py b/base_dav/models/dav_collection.py new file mode 100644 index 000000000..320452c94 --- /dev/null +++ b/base_dav/models/dav_collection.py @@ -0,0 +1,394 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import os +from datetime import timezone +from operator import itemgetter +from urllib.parse import quote_plus + +from odoo import api, fields, models, tools +from odoo.tools.safe_eval import safe_eval + +import vobject + +# pylint: disable=missing-import-error +from ..controllers.main import PREFIX +from ..radicale.collection import Collection, FileItem, Item + + +class DavCollection(models.Model): + _name = 'dav.collection' + _description = 'A collection accessible via WebDAV' + + name = fields.Char(required=True) + rights = fields.Selection( + [ + ("owner_only", "Owner Only"), + ("owner_write_only", "Owner Write Only"), + ("authenticated", "Authenticated"), + ], + required=True, + default="owner_only", + ) + dav_type = fields.Selection( + [ + ('calendar', 'Calendar'), + ('addressbook', 'Addressbook'), + ('files', 'Files'), + ], + string='Type', + required=True, + default='calendar', + ) + tag = fields.Char(compute='_compute_tag') + model_id = fields.Many2one( + 'ir.model', + string='Model', + required=True, + ondelete='cascade', + 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.model + def _ensure_default_addressbook(self): + icp = self.env["ir.config_parameter"].sudo() + collection_id = icp.get_param("base_dav.default_addressbook_id") + collection = ( + self.sudo().browse(int(collection_id)) + if collection_id else self.browse() + ) + if not (collection and collection.exists()): + collection = self.sudo().search( + [ + ("dav_type", "=", "addressbook"), + ("model_id.model", "=", "res.partner"), + ], + limit=1, + ) + if not collection: + partner_model = self.env["ir.model"].sudo().search( + [("model", "=", "res.partner")], limit=1 + ) + collection = self.sudo().create( + { + "name": "Contacts", + "dav_type": "addressbook", + "model_id": partner_model.id, + "domain": "[]", + } + ) + + icp.set_param("base_dav.default_addressbook_id", str(collection.id)) + + mappings = [ + ("N", "name"), + ("FN", "display_name"), + ("EMAIL", "email"), + ("TEL", "phone"), + ("TEL", "mobile"), + ] + mapping_model = self.env["dav.collection.field_mapping"].sudo() + fields_model = self.env["ir.model.fields"].sudo() + for vcard_name, field_name in mappings: + field_id = fields_model.search( + [ + ("model", "=", "res.partner"), + ("name", "=", field_name), + ], + limit=1, + ) + if not field_id: + continue + exists = mapping_model.search( + [ + ("collection_id", "=", collection.id), + ("name", "=", vcard_name), + ("field_id", "=", field_id.id), + ], + limit=1, + ) + if exists: + continue + mapping_model.create( + { + "collection_id": collection.id, + "name": vcard_name, + "field_id": field_id.id, + "mapping_type": "simple", + } + ) + + return collection + + @api.depends("dav_type") + def _compute_tag(self): + for record in self: + if record.dav_type == "calendar": + record.tag = "VCALENDAR" + elif record.dav_type == "addressbook": + record.tag = "VADDRESSBOOK" + else: + record.tag = False + + def _compute_url(self): + base_url = self.env['ir.config_parameter'].get_param('web.base.url') + for record in self: + if base_url and record.id: + record.url = ( + f"{base_url}{PREFIX}/{self.env.user.login}/{record.id}" + ) + else: + record.url = False + + @api.constrains('domain') + def _check_domain(self): + self._eval_domain() + + @api.model + def _eval_context(self): + return { + 'user': self.env.user, + } + + def _eval_domain(self): + self.ensure_one() + return list(safe_eval(self.domain, self._eval_context())) + + def eval(self): + if not self: + return self.env['unknown'] + self.ensure_one() + return self.env[self.model_id.model].search(self._eval_domain()) + + 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) + + 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 self.dav_type == "addressbook" and name == "photo": + continue + 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 + + 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 + if 'version' in vobj.contents: + vobj.contents['version'][0].value = '4.0' + else: + vobj.add('version').value = '4.0' + if 'kind' not in vobj.contents: + vobj.add('kind').value = 'individual' + for mapping in self.field_mapping_ids: + if self.dav_type == "addressbook" and mapping.name.lower() == "photo": + continue + value = mapping.to_vobject(record) + if value: + vobj.add(mapping.name).value = value + + if self.dav_type == 'addressbook' and 'fn' not in vobj.contents: + display_name = record.display_name or record.name or '' + if display_name: + vobj.add('fn').value = display_name + if 'uid' not in vobj.contents: + vobj.add("uid").value = f"{record._name},{record.id}" + if 'rev' not in vobj.contents and 'write_date' in record._fields: + write_date = fields.Datetime.to_datetime(record.write_date) + if write_date: + if write_date.tzinfo: + write_date = write_date.astimezone(timezone.utc) + else: + write_date = write_date.replace(tzinfo=timezone.utc) + vobj.add('rev').value = write_date.strftime( + '%Y%m%dT%H%M%SZ' + ) + return result + + @api.model + def _odoo_to_http_datetime(self, value): + if not value: + return None + date_value = fields.Datetime.to_datetime(value) + if not date_value: + return None + if date_value.tzinfo: + date_value = date_value.astimezone(timezone.utc) + else: + date_value = date_value.replace(tzinfo=timezone.utc) + return date_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('/') + )) + + 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) + # Use absolute item hrefs so rights checks receive the full DAV path. + result.append('/' + '/'.join(path_components + [str(uuid)])) + return result + + 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() + + 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 = f"{href}/{uuid}" + else: + record.write(data) + + return Item( + collection, + item=self.to_vobject(record), + href=href, + last_modified=self._odoo_to_http_datetime(record.write_date), + ) + + def dav_get(self, collection, href): + self.ensure_one() + + components = self._split_path(href) + collection_model = self.env[self.model_id.model] + if self.dav_type == 'files': + if len(components) == 3: + result = Collection(href) + result.logger = self.logger + return result + if len(components) == 4: + record = collection_model.browse(map( + itemgetter(0), + collection_model.name_search( + components[2], operator='=', limit=1, + ) + )) + attachment = self.env['ir.attachment'].search([ + ('type', '=', 'binary'), + ('res_model', '=', record._name), + ('res_id', '=', record.id), + ('name', '=', components[3]), + ], limit=1) + return FileItem( + collection, + item=attachment, + href=href, + last_modified=self._odoo_to_http_datetime( + record.write_date + ), + ) + + record = self.get_record(components) + + if not record: + return None + + return Item( + collection, + item=self.to_vobject(record), + href=href, + last_modified=self._odoo_to_http_datetime(record.write_date), + ) diff --git a/base_dav/models/dav_collection_field_mapping.py b/base_dav/models/dav_collection_field_mapping.py new file mode 100644 index 000000000..1c327d0e9 --- /dev/null +++ b/base_dav/models/dav_collection_field_mapping.py @@ -0,0 +1,204 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# 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 import safe_eval as safe_eval_tools +from odoo.tools.safe_eval import safe_eval + +import dateutil +import vobject +from dateutil import tz + +TOOLS_SAFE_ATTRS = ( + "DEFAULT_SERVER_DATETIME_FORMAT", + "DEFAULT_SERVER_DATE_FORMAT", +) +TZ_SAFE_ATTRS = ( + "gettz", + "tzutc", + "tzoffset", + "UTC", +) +VOBJECT_SAFE_ATTRS = ( + "iCalendar", + "vCard", + "readOne", + "newFromBehavior", + "readComponents", +) + + +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, + ondelete='cascade', + 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" + ) + + 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) + + def _from_vobject_code(self, child): + self.ensure_one() + context = { + 'datetime': safe_eval_tools.datetime, + 'dateutil': safe_eval_tools.dateutil, + 'item': child, + 'result': None, + 'tools': safe_eval_tools.wrap_module(tools, TOOLS_SAFE_ATTRS), + 'tz': safe_eval_tools.wrap_module(tz, TZ_SAFE_ATTRS), + 'vobject': safe_eval_tools.wrap_module(vobject, VOBJECT_SAFE_ATTRS), + } + safe_eval(self.import_code, context, mode="exec", nocopy=True) + return context.get('result', {}) + + def _from_vobject_simple(self, child): + self.ensure_one() + name = self.name.lower() + conversion_funcs = [ + f"_from_vobject_{self.field_id.ttype}_{name}", + f"_from_vobject_{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 + + 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 + + def _to_vobject_code(self, record): + self.ensure_one() + context = { + 'datetime': safe_eval_tools.datetime, + 'dateutil': safe_eval_tools.dateutil, + 'record': record, + 'result': None, + 'tools': safe_eval_tools.wrap_module(tools, TOOLS_SAFE_ATTRS), + 'tz': safe_eval_tools.wrap_module(tz, TZ_SAFE_ATTRS), + 'vobject': safe_eval_tools.wrap_module(vobject, VOBJECT_SAFE_ATTRS), + } + safe_eval(self.export_code, context, mode="exec", nocopy=True) + return context.get('result', None) + + def _to_vobject_simple(self, record): + self.ensure_one() + conversion_funcs = [ + f"_to_vobject_{self.field_id.ttype}_{self.name.lower()}", + f"_to_vobject_{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.to_datetime(value) + if not result: + return result + if result.tzinfo: + return result.astimezone(tz.UTC) + return result.replace(tzinfo=tz.UTC) + + @api.model + def _to_vobject_datetime_rev(self, value): + if not value: + return value + result = fields.Datetime.to_datetime(value) + if not result: + return value + if result.tzinfo: + result = result.astimezone(tz.UTC) + else: + result = result.replace(tzinfo=tz.UTC) + return result.strftime('%Y%m%dT%H%M%SZ') + + @api.model + def _to_vobject_date(self, value): + return fields.Date.to_date(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) diff --git a/base_dav/models/res_users.py b/base_dav/models/res_users.py new file mode 100644 index 000000000..473458f32 --- /dev/null +++ b/base_dav/models/res_users.py @@ -0,0 +1,63 @@ +# Copyright 2026 +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import secrets + +from odoo import api, fields, models + +from ..controllers.main import PREFIX + + +class ResUsers(models.Model): + _inherit = "res.users" + + @api.model + def _generate_carddav_token(self): + return secrets.token_urlsafe(24) + + carddav_url = fields.Char( + compute="_compute_carddav_url", + string="CardDAV URL", + ) + carddav_token = fields.Char( + string="CardDAV Token", + help="Password used for CardDAV Digest authentication.", + ) + + @api.model_create_multi + def create(self, vals_list): + users = super().create(vals_list) + users._ensure_carddav_token() + return users + + def write(self, vals): + result = super().write(vals) + if "carddav_token" not in vals: + self._ensure_carddav_token() + return result + + @api.depends("login") + def _compute_carddav_url(self): + base_url = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("web.base.url") + ) + collection = self.env["dav.collection"]._ensure_default_addressbook() + + for user in self: + if base_url and collection and user.login: + user.carddav_url = ( + f"{base_url}{PREFIX}/{user.login}/{collection.id}" + ) + else: + user.carddav_url = False + + def _ensure_carddav_token(self): + for user in self.filtered(lambda u: not u.carddav_token): + user.carddav_token = self._generate_carddav_token() + + def action_generate_carddav_token(self): + for user in self: + user.carddav_token = self._generate_carddav_token() + return True diff --git a/base_dav/pyproject.toml b/base_dav/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/base_dav/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_dav/radicale/__init__.py b/base_dav/radicale/__init__.py new file mode 100644 index 000000000..dd8d0f16c --- /dev/null +++ b/base_dav/radicale/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import auth +from . import collection +from . import rights diff --git a/base_dav/radicale/auth.py b/base_dav/radicale/auth.py new file mode 100644 index 000000000..32be5d9f6 --- /dev/null +++ b/base_dav/radicale/auth.py @@ -0,0 +1,41 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo.http import request + +try: + from radicale.auth import BaseAuth +except ImportError: + BaseAuth = None + + +class Auth(BaseAuth): + def is_authenticated2(self, login, user, password): + env = request.env + users = env['res.users'] + user_agent_env = getattr( + getattr(request, "httprequest", None), "environ", {} + ) + if hasattr(users, "_login"): + try: + uid = users._login( + env.cr.dbname, + user, + password, + user_agent_env=user_agent_env, + ) + except TypeError: + uid = users._login(env.cr.dbname, user, password) + else: + uid = users.authenticate( + env.cr.dbname, + user, + password, + user_agent_env=user_agent_env, + ) + if uid: + if hasattr(request, "update_env"): + request.update_env(user=uid) + else: + request._env = env(user=uid) + return bool(uid) diff --git a/base_dav/radicale/collection.py b/base_dav/radicale/collection.py new file mode 100644 index 000000000..a7a582eab --- /dev/null +++ b/base_dav/radicale/collection.py @@ -0,0 +1,231 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import base64 +import logging +import os +from datetime import timezone +from contextlib import contextmanager + +from odoo import fields +from odoo.http import request + +try: + from radicale.storage import BaseCollection, Item, get_etag +except ImportError: + BaseCollection = None + Item = None + get_etag = None + +_LOGGER = logging.getLogger(__name__) + + +class BytesPretendingToBeString(bytes): + # radicale expects a string as file content, so we provide the str + # functions needed + def encode(self, encoding): + return self + + +class FileItem(Item): + """this item tricks radicalev into serving a plain file""" + @property + def name(self): + return 'VCARD' + + def serialize(self): + return BytesPretendingToBeString(base64.b64decode(self.item.datas)) + + @property + def etag(self): + return get_etag(self.item.datas.decode('ascii')) + + +class Collection(BaseCollection): + @classmethod + def static_init(cls): + pass + + @classmethod + def _split_path(cls, path): + return list(filter( + None, os.path.normpath(path or '').strip('/').split('/') + )) + + @classmethod + def discover(cls, path, depth=None): + depth = int(depth or "0") + components = cls._split_path(path) + collection = cls(path) + if len(components) > 2: + # TODO: this probably better should happen in some dav.collection + # function + if collection.collection.dav_type == 'files' and depth: + for href in collection.list(): + yield collection.get(href) + return + yield collection.get(path) + return + yield collection + if depth and len(components) == 1: + for collection in request.env['dav.collection'].search([]): + yield cls('/'.join(components + ['/%d' % collection.id])) + if depth and len(components) == 2: + for href in collection.list(): + yield collection.get(href) + + @classmethod + @contextmanager + def acquire_lock(cls, mode, user=None): + """We have a database for that""" + yield + + @property + def env(self): + return request.env + + @property + def last_modified(self): + return self._odoo_to_http_datetime(self.collection.create_date) + + def __init__(self, path): + self.path_components = self._split_path(path) + self.path = '/'.join(self.path_components) or '/' + self.collection = self.env['dav.collection'] + if len(self.path_components) >= 2 and str( + self.path_components[1] + ).isdigit(): + self.collection = self.env['dav.collection'].browse(int( + self.path_components[1] + )) + + def _odoo_to_http_datetime(self, value): + if not value: + return None + date_value = fields.Datetime.to_datetime(value) + if not date_value: + return None + if date_value.tzinfo: + date_value = date_value.astimezone(timezone.utc) + else: + date_value = date_value.replace(tzinfo=timezone.utc) + return date_value.strftime('%a, %d %b %Y %H:%M:%S GMT') + + def get_meta(self, key=None): + if key is None: + return {} + elif key == 'tag': + return self.collection.tag + elif key == 'D:displayname': + return self.collection.display_name + elif key == 'C:supported-calendar-component-set': + return 'VTODO,VEVENT,VJOURNAL' + elif key == 'C:calendar-home-set': + return None + elif key == 'D:principal-URL': + return None + elif key == 'ICAL:calendar-color': + # TODO: set in dav.collection + return '#48c9f4' + self.logger.warning('unsupported metadata %s', key) + + def get(self, href): + if hasattr(href, "href"): + href = href.href + item = self.collection.dav_get(self, href) + if not item: + _LOGGER.info( + "CardDAV Storage: get miss path=%s href=%s", + self.path, + href, + ) + return item + + def upload(self, href, vobject_item): + return self.collection.dav_upload(self, href, vobject_item) + + def delete(self, href): + return self.collection.dav_delete(self, self._split_path(href)) + + def list(self): + hrefs = self.collection.dav_list(self, self.path_components) + _LOGGER.info( + "CardDAV Storage: list path=%s count=%s sample=%s", + self.path, + len(hrefs), + hrefs[:3], + ) + return hrefs + + def _relative_hrefs(self, hrefs): + rel = [] + for href in hrefs: + parts = self._split_path(href) + if not parts: + continue + rel.append(parts[-1]) + return rel + + # Radicale v2 uses pre_filtered_list() for REPORT queries. + # Return hrefs and let Radicale fetch items via get()/get_multi(). + def pre_filtered_list(self, filters): + hrefs = self._relative_hrefs(self.list()) + _LOGGER.info( + "CardDAV Storage: pre_filtered_list path=%s filters=%s count=%s", + self.path, + bool(filters), + len(hrefs), + ) + return hrefs + + def get_multi(self, hrefs): + items = [item for item in (self.get(href) for href in hrefs) if item] + _LOGGER.info( + "CardDAV Storage: get_multi path=%s requested=%s returned=%s", + self.path, + len(hrefs), + len(items), + ) + return items + + def get_all(self): + hrefs = self._relative_hrefs(self.list()) + items = [item for item in (self.get(href) for href in hrefs) if item] + _LOGGER.info( + "CardDAV Storage: get_all path=%s returned=%s", + self.path, + len(items), + ) + return items + + def get_all_filtered(self, filters): + items = self.get_all() + empty_filter = bool(filters) and all( + len(filter_) == 0 for filter_ in filters + ) + _LOGGER.info( + ( + "CardDAV Storage: get_all_filtered path=%s filters=%s " + "empty_filter=%s returned=%s" + ), + self.path, + len(filters or []), + empty_filter, + len(items), + ) + # Linphone sends (no children) for addressbook-query. + # Radicale's generic matcher treats that as no match, so mark filters as + # already matched in this case to return all addressbook items. + # Keep this behavior unless client-side filter semantics are revisited. + return ((item, empty_filter) for item in items) + + def get_filtered(self, filters): + items = self.get_all() + _LOGGER.info( + "CardDAV Storage: get_filtered path=%s filters=%s returned=%s", + self.path, + bool(filters), + len(items), + ) + for item in items: + yield item, True diff --git a/base_dav/radicale/rights.py b/base_dav/radicale/rights.py new file mode 100644 index 000000000..cd9835d44 --- /dev/null +++ b/base_dav/radicale/rights.py @@ -0,0 +1,61 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +from .collection import Collection + +try: + from radicale.rights import ( + AuthenticatedRights, OwnerWriteRights, OwnerOnlyRights, + ) +except ImportError: + AuthenticatedRights = OwnerOnlyRights = OwnerWriteRights = None + +_LOGGER = logging.getLogger(__name__) + + +class Rights(OwnerOnlyRights, OwnerWriteRights, AuthenticatedRights): + def authorized(self, user, path, perm): + if path == '/': + return True + if path.endswith('/') and path != '/': + path = path[:-1] + components = list(filter(None, path.split('/'))) + if len(components) == 1: + # Allow access to the principal path (e.g. /admin) + if components[0] == user: + return True + # Some Radicale flows pass item hrefs relative to the collection + # path (e.g. "10"). Resolve those under the authenticated user. + path = f"/{user}/{components[0]}" + components = list(filter(None, path.split('/'))) + + collection = Collection(path) + if not collection.collection: + _LOGGER.info( + "CardDAV Rights: denied path=%s perm=%s user=%s (no collection)", + path, + perm, + user, + ) + return False + + rights = collection.collection.sudo().rights + cls = { + "owner_only": OwnerOnlyRights, + "owner_write_only": OwnerWriteRights, + "authenticated": AuthenticatedRights, + }.get(rights) + if not cls: + return False + allowed = cls.authorized(self, user, path, perm) + if not allowed: + _LOGGER.info( + "CardDAV Rights: denied path=%s perm=%s user=%s rights=%s", + path, + perm, + user, + rights, + ) + return allowed diff --git a/base_dav/readme/CONFIGURE.rst b/base_dav/readme/CONFIGURE.rst new file mode 100644 index 000000000..dc57f0e19 --- /dev/null +++ b/base_dav/readme/CONFIGURE.rst @@ -0,0 +1,5 @@ +To configure this module, you need to: + +#. go to `Settings / WebDAV Collections` and create or edit your collections. There, you'll also see the URL to point your clients to. + +Note that you need to configure a dbfilter if you use multiple databases. diff --git a/base_dav/readme/CONTRIBUTORS.rst b/base_dav/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..9c7447f2a --- /dev/null +++ b/base_dav/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Holger Brunn +* Florian Kantelberg diff --git a/base_dav/readme/CREDITS.rst b/base_dav/readme/CREDITS.rst new file mode 100644 index 000000000..b83250bd3 --- /dev/null +++ b/base_dav/readme/CREDITS.rst @@ -0,0 +1,2 @@ +* Odoo Community Association: `Icon `_ +* All the actual work is done by `Radicale `_ diff --git a/base_dav/readme/DESCRIPTION.rst b/base_dav/readme/DESCRIPTION.rst new file mode 100644 index 000000000..5b4aad0b7 --- /dev/null +++ b/base_dav/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module adds WebDAV support to Odoo, specifically CalDAV and CardDAV. + +You can configure arbitrary objects as a calendar or an address book, thus make arbitrary information accessible in external systems or your mobile. diff --git a/base_dav/readme/ROADMAP.rst b/base_dav/readme/ROADMAP.rst new file mode 100644 index 000000000..9897f6237 --- /dev/null +++ b/base_dav/readme/ROADMAP.rst @@ -0,0 +1,7 @@ +* much better UX for configuring collections (probably provide a group that sees the current fully flexible field mappings, and by default show some dumbed down version where you can select some preselected vobject fields) +* support todo lists and journals +* support configuring default field mappings per model +* support plain WebDAV collections to make some model's records accessible as folders, and the records' attachments as files (r/w) +* support configuring lists of calendars so that you can have a calendar for every project and appointments are tasks, or a calendar for every sales team and appointments are sale orders. Lots of possibilities + +Backporting this to <=v10 will be tricky because radicale only supports python3. Probably it will be quite a hassle to backport the relevant code, so it might be more sensible to just backport the configuration part, and implement the rest as radicale auth/storage plugin that talks to Odoo via odoorpc. It should be possible to recycle most of the code from this addon, which actually implements those plugins, but then within Odoo. diff --git a/base_dav/security/ir.model.access.csv b/base_dav/security/ir.model.access.csv new file mode 100644 index 000000000..3d2d4d57b --- /dev/null +++ b/base_dav/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_dav_collection,access_dav_collection,model_dav_collection,base.group_user,1,0,0,0 +access_dav_collection_field_mapping,access_dav_collection_field_mapping,model_dav_collection_field_mapping,base.group_user,1,0,0,0 diff --git a/base_dav/static/description/icon.png b/base_dav/static/description/icon.png new file mode 100644 index 000000000..f0eebfd17 Binary files /dev/null and b/base_dav/static/description/icon.png differ diff --git a/base_dav/static/description/index.html b/base_dav/static/description/index.html new file mode 100644 index 000000000..f1984ef77 --- /dev/null +++ b/base_dav/static/description/index.html @@ -0,0 +1,453 @@ + + + + + + +Caldav and Carddav support + + + +
+

Caldav and Carddav support

+ + +

Beta License: AGPL-3 OCA/server-backend Translate me on Weblate Try me on Runboat

+

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

+ +
+

Configuration

+

To configure this module, you need to:

+
    +
  1. go to Settings / WebDAV Collections and create or edit your collections. There, you’ll also see the URL to point your clients to.
  2. +
+

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. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • initOS GmbH
  • +
  • Therp BV
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+
    +
  • Odoo Community Association: Icon
  • +
  • All the actual work is done by Radicale
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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 project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_dav/static/src/js/carddav_copy.js b/base_dav/static/src/js/carddav_copy.js new file mode 100644 index 000000000..377e7d3ff --- /dev/null +++ b/base_dav/static/src/js/carddav_copy.js @@ -0,0 +1,81 @@ +/** @odoo-module **/ + +(() => { + "use strict"; + + function getValueFromContainer(container) { + if (!container) { + return ""; + } + const input = container.querySelector("input, textarea"); + if (input) { + return input.value || ""; + } + const link = container.querySelector("a"); + if (link) { + return (link.textContent || "").trim(); + } + const span = container.querySelector(".o_field_widget, span"); + if (span) { + return (span.textContent || "").trim(); + } + return ""; + } + + function findFieldElement(scope, fieldName) { + if (!fieldName) { + return null; + } + return scope.querySelector( + [ + `[name="${fieldName}"]`, + `.o_field_widget[name="${fieldName}"]`, + `.o_field_widget[data-name="${fieldName}"]`, + `.o_field_widget[data-field="${fieldName}"]`, + `[data-name="${fieldName}"]`, + `[data-field="${fieldName}"]`, + ].join(", ") + ); + } + + function copyText(value) { + if (!value) { + return; + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value).catch(() => undefined); + return; + } + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + document.execCommand("copy"); + } catch (err) { + // Ignore copy errors for older browsers. + } + document.body.removeChild(textarea); + } + + document.addEventListener("click", (event) => { + const button = event.target.closest(".o_carddav_copy"); + if (!button) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.stopImmediatePropagation) { + event.stopImmediatePropagation(); + } + const fieldName = button.dataset.copyField; + const scope = button.closest(".o_form_view") || document; + const field = findFieldElement(scope, fieldName); + const container = field || button.closest(".o_row") || button.parentElement; + const value = getValueFromContainer(container); + copyText(value); + }); +})(); diff --git a/base_dav/tests/__init__.py b/base_dav/tests/__init__.py new file mode 100644 index 000000000..09e6f8403 --- /dev/null +++ b/base_dav/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import test_base_dav, test_collection diff --git a/base_dav/tests/test_base_dav.py b/base_dav/tests/test_base_dav.py new file mode 100644 index 000000000..e0e3859bc --- /dev/null +++ b/base_dav/tests/test_base_dav.py @@ -0,0 +1,124 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from base64 import b64encode +from unittest import mock +from urllib.parse import urlparse + +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + +from ..controllers.main import PREFIX +from ..controllers.main import Main as Controller + +ADMIN_PASSWORD = "RadicalePa$$word" + + +@mute_logger("radicale") +class TestBaseDav(TransactionCase): + def _patch_request(self, module): + original = module.request + request_mock = mock.Mock() + module.request = request_mock + self.addCleanup(setattr, module, "request", original) + return request_mock + + def setUp(self): + super().setUp() + + from ..controllers import main as controllers_main + from ..radicale import auth as radicale_auth + from ..radicale import collection as radicale_collection + + self.req_mock = self._patch_request(controllers_main) + self.auth_mock = self._patch_request(radicale_auth) + self.coll_mock = self._patch_request(radicale_collection) + + 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) + + def auth_string(self, user, password): + return b64encode( + (f"{user.login}:{password}").encode() + ).decode() + + def init_mocks(self): + self.req_mock.env = self.env + self.req_mock.httprequest.environ = { + "HTTP_AUTHORIZATION": f"Basic {self.auth_owner}", + "REQUEST_METHOD": "PROPFIND", + "HTTP_X_SCRIPT_NAME": PREFIX, + } + + self.auth_mock.env["res.users"]._login.return_value = self.env.uid + self.auth_mock.env["res.users"].authenticate.return_value = self.env.uid + self.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": f"Basic {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): + self.req_mock.env = self.env + + response = self.controller.handle_well_known_request() + self.assertEqual(response.status_code, 301) + + def test_authenticated(self): + self.init_mocks() + environ = self.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): + self.init_mocks() + environ = self.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): + self.init_mocks() + environ = self.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) diff --git a/base_dav/tests/test_collection.py b/base_dav/tests/test_collection.py new file mode 100644 index 000000000..fee68f6c0 --- /dev/null +++ b/base_dav/tests/test_collection.py @@ -0,0 +1,212 @@ +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from unittest import mock + +from odoo import fields +from odoo.tests.common import TransactionCase + +from ..radicale.collection import Collection + + +class TestCalendar(TransactionCase): + def setUp(self): + super().setUp() + + self.collection = self.env["dav.collection"].create({ + "name": "Test Collection", + "dav_type": "calendar", + "model_id": self.env.ref("base.model_res_users").id, + "domain": "[]", + }) + + self.create_field_mapping( + "login", "res.users", "login", + excode="result = record.login", + imcode="result = item.value", + ) + self.create_field_mapping( + "name", "res.users", "name", + ) + self.create_field_mapping( + "dtstart", "res.users", "create_date", + ) + self.create_field_mapping( + "dtend", "res.users", "write_date", + ) + + self.record = self.env["res.users"].create({ + "login": "tester", + "name": "Test User", + }) + + def create_field_mapping( + self, name, model_name, field_name, imcode=None, excode=None + ): + field_id = self.env["ir.model.fields"].search([ + ("model", "=", model_name), + ("name", "=", field_name), + ], limit=1).id + return self.env["dav.collection.field_mapping"].create({ + "collection_id": self.collection.id, + "name": name, + "field_id": field_id, + "mapping_type": "code" if imcode or excode else "simple", + "import_code": imcode, + "export_code": excode, + }) + + def _normalize_dt(self, value): + dt_val = fields.Datetime.to_datetime(value) + return dt_val.replace(microsecond=0) if dt_val else dt_val + + 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( + self._normalize_dt((rec or self.record).create_date), + self._normalize_dt(tmp["create_date"]), + ) + self.assertEqual( + self._normalize_dt((rec or self.record).write_date), + self._normalize_dt(tmp["write_date"]), + ) + + def test_import_export(self): + # Exporting and importing should result in the same record + vobj = self.collection.to_vobject(self.record) + self.compare_record(vobj) + + def test_get_record(self): + rec = self.collection.get_record([self.record.id]) + self.assertEqual(rec, self.record) + + self.collection.field_uuid = self.env["ir.model.fields"].search([ + ("model", "=", "res.users"), + ("name", "=", "login"), + ], limit=1).id + rec = self.collection.get_record([self.record.login]) + self.assertEqual(rec, self.record) + + def test_collection(self): + from ..radicale import collection as radicale_collection + original_request = radicale_collection.request + radicale_collection.request = mock.Mock() + self.addCleanup( + setattr, radicale_collection, "request", original_request + ) + radicale_collection.request.env = self.env + collection_url = f"/{self.env.user.login}/{self.collection.id}" + collection = list(Collection.discover(collection_url))[0] + + # Try to get the test record + record_url = f"{collection_url}/{self.record.id}" + self.assertIn(record_url, collection.list()) + + # Get the test record using the URL and compare it + item = collection.get(record_url) + self.compare_record(item.item) + self.assertEqual(item.href, record_url) + + # Get a non-existing record + self.assertFalse(collection.get(record_url + "0")) + + # Get the record and alter it later + item = self.collection.to_vobject(self.record) + self.record.login = "different" + with self.assertRaises(AssertionError): + self.compare_record(item) + + # Restore the record + item = collection.upload(record_url, item) + self.compare_record(item.item) + + # Delete an record + collection.delete(item.href) + self.assertFalse(self.record.exists()) + + # Create a new record + item = collection.upload(record_url + "0", item) + record = self.collection.get_record(collection._split_path(item.href)) + self.assertNotEqual(record, self.record) + self.compare_record(item.item, record) + + +class TestAddressbookPhone(TransactionCase): + def setUp(self): + super().setUp() + self.phone_number = "+15550000001" + self.collection = self.env["dav.collection"].create({ + "name": "Contacts", + "dav_type": "addressbook", + "model_id": self.env.ref("base.model_res_partner").id, + "domain": "[]", + }) + self.create_field_mapping("TEL", "res.partner", "phone") + self.record = self.env["res.partner"].create({ + "name": "Test Contact", + "phone": self.phone_number, + }) + + def create_field_mapping(self, name, model_name, field_name): + field_id = self.env["ir.model.fields"].search([ + ("model", "=", model_name), + ("name", "=", field_name), + ], limit=1).id + return self.env["dav.collection.field_mapping"].create({ + "collection_id": self.collection.id, + "name": name, + "field_id": field_id, + "mapping_type": "simple", + }) + + def test_addressbook_export_phone(self): + vobj = self.collection.to_vobject(self.record) + self.assertEqual(vobj.name, "VCARD") + self.assertEqual(vobj.contents["version"][0].value, "4.0") + self.assertEqual(vobj.contents["tel"][0].value, self.phone_number) + + def test_addressbook_import_phone(self): + vobj = self.collection.to_vobject(self.record) + data = self.collection.from_vobject(vobj) + self.assertEqual(data.get("phone"), self.phone_number) + + +class TestAddressbookPhoneAndMobileExport(TransactionCase): + def setUp(self): + super().setUp() + self.phone_number = "+15550000001" + self.mobile_number = "+15550000002" + self.collection = self.env["dav.collection"].create({ + "name": "Contacts", + "dav_type": "addressbook", + "model_id": self.env.ref("base.model_res_partner").id, + "domain": "[]", + }) + self.create_field_mapping("TEL", "res.partner", "phone") + self.create_field_mapping("TEL", "res.partner", "mobile") + self.record = self.env["res.partner"].create({ + "name": "Test Contact", + "phone": self.phone_number, + "mobile": self.mobile_number, + }) + + def create_field_mapping(self, name, model_name, field_name): + field_id = self.env["ir.model.fields"].search([ + ("model", "=", model_name), + ("name", "=", field_name), + ], limit=1).id + return self.env["dav.collection.field_mapping"].create({ + "collection_id": self.collection.id, + "name": name, + "field_id": field_id, + "mapping_type": "simple", + }) + + def test_addressbook_export_phone_and_mobile_as_tel(self): + vobj = self.collection.to_vobject(self.record) + tel_values = [entry.value for entry in vobj.contents.get("tel", [])] + self.assertIn(self.phone_number, tel_values) + self.assertIn(self.mobile_number, tel_values) + self.assertEqual(len(tel_values), 2) diff --git a/base_dav/views/dav_collection.xml b/base_dav/views/dav_collection.xml new file mode 100644 index 000000000..968b1f717 --- /dev/null +++ b/base_dav/views/dav_collection.xml @@ -0,0 +1,80 @@ + + + + dav.collection + + + + + + + + + + + dav.collection + +
+ + + + + + + + + + + + + + + +
+
+
+ + + dav.collection.field_mapping + + + + + + + + + + + dav.collection.field_mapping + +
+ + + + + + + + + +
+
+
+ + + WebDAV collections + dav.collection + tree,form + + +
diff --git a/base_dav/views/res_users.xml b/base_dav/views/res_users.xml new file mode 100644 index 000000000..e727964b9 --- /dev/null +++ b/base_dav/views/res_users.xml @@ -0,0 +1,36 @@ + + + + res.users.form.carddav + res.users + + + +
+ + Copy URL +
+
+ +
+
+
+
+
diff --git a/requirements.txt b/requirements.txt index 91c072924..fb07a0919 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ # generated from manifests external_dependencies mysqlclient +radicale sqlalchemy +vobject