diff --git a/base_dav/README.rst b/base_dav/README.rst new file mode 100644 index 000000000..6efac7d97 --- /dev/null +++ b/base_dav/README.rst @@ -0,0 +1,122 @@ +========================== +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/18.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-18-0/server-backend-18-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=18.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: + +1. 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 +- `Cetmix `__ + + - Ivan Sokolov + - George Smirnov + - Dmitry Meita + +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..2e9e0c3e0 --- /dev/null +++ b/base_dav/__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 models +from . import controllers +from . import radicale diff --git a/base_dav/__manifest__.py b/base_dav/__manifest__.py new file mode 100644 index 000000000..f84d5365e --- /dev/null +++ b/base_dav/__manifest__.py @@ -0,0 +1,25 @@ +# 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": "18.0.1.0.1", + "author": "initOS GmbH,Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Extra Tools", + "summary": "Access Odoo data as calendar or address book", + "website": "https://github.com/OCA/server-backend", + "depends": [ + "base", + ], + "demo": [ + "demo/dav_collection.xml", + ], + "data": [ + "views/dav_collection.xml", + "security/ir.model.access.csv", + ], + "external_dependencies": { + "python": ["radicale"], + }, +} 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..201000c17 --- /dev/null +++ b/base_dav/controllers/main.py @@ -0,0 +1,135 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import io +import sys + +import werkzeug +from radicale import config as radicale_config +from radicale.app import Application +from werkzeug.wrappers.response import Response as WerkzeugResponse + +from odoo import http +from odoo.http import request + +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) -> WerkzeugResponse: + """ + Redirect well-known CalDAV/CardDAV/WebDAV endpoints to the Radicale mount point. + + This endpoint exists for client compatibility: many CalDAV/CardDAV clients + probe `/.well-known/caldav` or `/.well-known/carddav` and expect a redirect. + + :return: HTTP 301 redirect response to ``/.dav``. + :rtype: werkzeug.wrappers.response.Response + """ + return werkzeug.utils.redirect(PREFIX, 301) + + @http.route( + [PREFIX, f"{PREFIX}/"], + type="http", + auth="none", + csrf=False, + ) + def handle_dav_request(self, davpath=None, **kwargs): + """Handle WebDAV/CalDAV/CardDAV requests by proxying them to Radicale 3.x. + + The controller builds a WSGI environ from the current Odoo/Werkzeug request, + configures Radicale to use Odoo-backed plugins (auth/storage/rights), + executes the Radicale WSGI application, and returns an Odoo HTTP response. + + :param davpath: Path relative to the DAV mount point (``/.dav``), + e.g. ``"admin/2/123"``; if ``None`` the root path is used. + :type davpath: str, optional + :param kwargs: Extra keyword arguments passed by the routing layer (unused). + :type kwargs: Any + + :raises Exception: Any unexpected Radicale or Odoo/Werkzeug error + during request processing will propagate as an Odoo HTTP 500. + + :return: Response produced by Radicale, including status and headers. + :rtype: odoo.http.Response + """ + configuration = radicale_config.load() + configuration.update( + { + "auth": {"type": "odoo.addons.base_dav.radicale.auth"}, + "storage": {"type": "odoo.addons.base_dav.radicale.collection"}, + "rights": {"type": "odoo.addons.base_dav.radicale.rights"}, + "web": {"type": "none"}, + "hook": {"type": "none"}, + }, + "odoo", + ) + + app = Application(configuration) + + # Let's take WSGI environ from werkzeug/odoo + environ = dict(request.httprequest.environ) + + # Radicale 3.x requires wsgi.errors and wsgi.input + environ.setdefault("wsgi.errors", sys.stderr) + method = environ.get("REQUEST_METHOD") or request.httprequest.method + raw_body = request.httprequest.get_data(cache=False) or b"" + + if method == "PROPFIND" and len(raw_body) == 0: + raw_body = ( + b'' + b'' + ) + + # Force Radicale to read the body we provide + environ["wsgi.input"] = io.BytesIO(raw_body) + environ["CONTENT_LENGTH"] = str(len(raw_body)) + + # Ensure content type is present for XML parsing + environ.setdefault("CONTENT_TYPE", "application/xml; charset=utf-8") + + # Radicale should know that it is mounted under /.dav + environ["SCRIPT_NAME"] = PREFIX + environ["HTTP_X_SCRIPT_NAME"] = PREFIX + + # PATH_INFO must be absolute (with "/") + path_info = "/" + (davpath or "") + environ["PATH_INFO"] = path_info + + status_headers = {"status": "500 Internal Server Error", "headers": []} + + def start_response(status, headers, exc_info=None): + """WSGI start_response callback used by Radicale. + + :param status: HTTP status line, e.g. ``"207 Multi-Status"``. + :type status: str + :param headers: Sequence of ``(header_name, header_value)``. + :type headers: Sequence[tuple[str, str]] + :param exc_info: Optional exception info as per WSGI spec (unused). + :type exc_info: Any, optional + """ + status_headers["status"] = status + status_headers["headers"] = headers + + result_iter = app(environ, start_response) + try: + response_body = b"".join(result_iter) if result_iter else b"" + finally: + if hasattr(result_iter, "close"): + result_iter.close() + + headers = status_headers["headers"] + if isinstance(headers, dict): + headers = list(headers.items()) + + return http.Response( + response=response_body, + status=status_headers["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..ddb6de922 --- /dev/null +++ b/base_dav/demo/dav_collection.xml @@ -0,0 +1,39 @@ + + + Addressbook + addressbook + + [] + authenticated + + + + N + + + + + + FN + + + + + + photo + + + + + + email + + + + + + tel + + + + diff --git a/base_dav/i18n/base_dav.pot b/base_dav/i18n/base_dav.pot new file mode 100644 index 000000000..e1b012f25 --- /dev/null +++ b/base_dav/i18n/base_dav.pot @@ -0,0 +1,214 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_dav +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-02-23 19:13+0000\n" +"PO-Revision-Date: 2026-02-23 19:13+0000\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_terms:ir.ui.view,arch_db:base_dav.view_dav_collection_form +msgid "Access" +msgstr "" + +#. module: base_dav +#: model_terms:ir.ui.view,arch_db:base_dav.view_dav_collection_form +msgid "Additional field mapping" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields.selection,name:base_dav.selection__dav_collection__dav_type__addressbook +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 +#: model:ir.model.fields.selection,name:base_dav.selection__dav_collection__rights__authenticated +msgid "Authenticated" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields.selection,name:base_dav.selection__dav_collection__dav_type__calendar +msgid "Calendar" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields.selection,name:base_dav.selection__dav_collection_field_mapping__mapping_type__code +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 +#: model:ir.model.fields.selection,name:base_dav.selection__dav_collection__dav_type__files +msgid "Files" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection__id +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping__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__write_uid +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection__write_date +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping__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__model_id +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping__model_id +msgid "Model" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection__name +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping__name +msgid "Name" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields.selection,name:base_dav.selection__dav_collection__rights__owner_only +msgid "Owner Only" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields.selection,name:base_dav.selection__dav_collection__rights__owner_write_only +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 +#: model:ir.model.fields.selection,name:base_dav.selection__dav_collection_field_mapping__mapping_type__simple +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..3e6c8778b --- /dev/null +++ b/base_dav/models/__init__.py @@ -0,0 +1,4 @@ +# 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 diff --git a/base_dav/models/dav_collection.py b/base_dav/models/dav_collection.py new file mode 100644 index 000000000..fa0e69590 --- /dev/null +++ b/base_dav/models/dav_collection.py @@ -0,0 +1,528 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import posixpath +import time +from operator import itemgetter +from urllib.parse import quote_plus, unquote_plus + +import vobject +from dateutil import tz + +from odoo import api, fields, models +from odoo.exceptions import AccessError +from odoo.osv import expression +from odoo.tools.safe_eval import safe_eval + +from ..controllers.main import PREFIX + +_DAV_ITEM_EXTENSIONS = (".vcf", ".ics") + + +def _dav_strip_item_extension(name: str) -> str: + """Return DAV item identifier without common extensions (.vcf/.ics).""" + name = (name or "").strip() + lower = name.lower() + for ext in _DAV_ITEM_EXTENSIONS: + if lower.endswith(ext): + return name[: -len(ext)] + return 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", + required=True, + domain=[("transient", "=", False)], + ondelete="cascade", + ) + 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.depends("dav_type") + def _compute_tag(self): + """Compute DAV collection tag based on its type. + + Sets ``tag`` field to a DAV-specific container name: + - ``VCALENDAR`` for calendar + - ``VADDRESSBOOK`` for addressbook + - False for files + """ + for rec in self: + if rec.dav_type == "calendar": + rec.tag = "VCALENDAR" + elif rec.dav_type == "addressbook": + rec.tag = "VADDRESSBOOK" + else: + rec.tag = False + + def _compute_url(self): + """Compute absolute DAV access URL for the collection. + + URL is constructed using: + - system base URL + - DAV prefix + - current user login + - collection ID + """ + base_url = ( + self.env["ir.config_parameter"].sudo().get_param("web.base.url") or "" + ).rstrip("/") + login = self.env.user.login or "" + for rec in self: + rec.url = ( + f"{base_url}{PREFIX}/{login}/{rec.id}" + if base_url + else f"{PREFIX}/{login}/{rec.id}" + ) + + @api.constrains("domain") + def _check_domain(self): + """Validate domain expression. + + Ensures that the stored domain string can be safely evaluated. + + :raises Exception: If domain evaluation fails + """ + for rec in self: + rec._eval_domain() + + @api.model + def _eval_context(self): + """Return safe evaluation context for domain expressions. + + :return: Dictionary containing allowed evaluation variables + :rtype: Dict[str, Any] + """ + return { + "user": self.env.user, + } + + def _eval_domain(self): + """Evaluate stored domain expression into Odoo domain list. + + :raises ValueError: If domain string is invalid + :return: Evaluated domain + :rtype: List[Any] + """ + self.ensure_one() + return list(safe_eval(self.domain or "[]", self._eval_context())) + + def eval_domain_records(self): + """Search records matching the evaluated domain. + + :return: Recordset of matching records + :rtype: odoo.models.BaseModel + """ + self.ensure_one() + return self.env[self.model_id.model].search(self._eval_domain()) + + def get_record(self, components): + """Retrieve record from path components. + + :param components: Parsed DAV path components + :type components: Sequence[str] + + :return: Matching record or empty recordset + :rtype: odoo.models.BaseModel + """ + self.ensure_one() + collection_model = self.env[self.model_id.model] + raw_key = components[-1] if components else "" + key = _dav_strip_item_extension(raw_key) + if self.field_uuid: + field_name = self.field_uuid.name + if self.field_uuid.ttype in ("integer", "many2one"): + try: + key = int(key) + except (TypeError, ValueError): + return collection_model.browse() + else: + field_name = "id" + try: + key = int(key) + except (TypeError, ValueError): + return collection_model.browse() + + domain = expression.AND( + [ + [(field_name, "=", key)], + self._eval_domain(), + ] + ) + return collection_model.search(domain, limit=1) + + def from_vobject(self, item): + """Convert vobject item into Odoo field values. + + Supports: + - VEVENT for calendar + - VCARD for addressbook + + :param item: vobject instance + :type item: Any + + :return: Dictionary of field values or None if unsupported + :rtype: Optional[Dict[str, Any]] + """ + self.ensure_one() + + if self.dav_type == "calendar": + if item.name != "VCALENDAR" or not hasattr(item, "vevent"): + return None + item = item.vevent + elif self.dav_type == "addressbook": + if item.name != "VCARD": + return None + else: + return None + + result = {} + children = {c.name.lower(): c for c in item.getChildren()} + for mapping in self.field_mapping_ids: + child = children.get(mapping.name.lower()) + if not child: + continue + value = mapping.from_vobject(child) + if value is not None: + result[mapping.field_id.name] = value + return result + + def to_vobject(self, record): + """Convert Odoo record into vobject representation. + + Automatically adds: + - UID if missing + - REV based on write_date + + :param record: Odoo record + :type record: odoo.models.BaseModel + + :return: vobject instance or None for unsupported types + :rtype: Optional[Any] + """ + self.ensure_one() + + if self.dav_type == "calendar": + result = vobject.iCalendar() + vobj = result.add("vevent") + elif self.dav_type == "addressbook": + result = vobject.vCard() + vobj = result + else: + return None + + for mapping in self.field_mapping_ids: + value = mapping.to_vobject(record) + if value is None or value is False: + continue + if isinstance(value, bool): + continue + if isinstance(value, (int | float)): + value = str(value) + vobj.add(mapping.name).value = value + + if "uid" not in vobj.contents: + vobj.add("uid").value = f"{record._name},{record.id}" + + if ( + self.dav_type == "addressbook" + and "rev" not in vobj.contents + and "write_date" in record._fields + and record.write_date + ): + s = fields.Datetime.to_string(record.write_date) # YYYY-MM-DD HH:MM:SS + vobj.add("rev").value = ( + s.replace("-", "").replace(" ", "T").replace(":", "") + "Z" + ) + + if ( + self.dav_type == "calendar" + and "dtstamp" not in vobj.contents + and "write_date" in record._fields + and record.write_date + ): + vobj.add("dtstamp").value = record.write_date.replace(tzinfo=tz.UTC) + + return result + + @api.model + def _odoo_to_http_datetime(self, value): + """Convert Odoo datetime to HTTP-date format (RFC 7231). + + :param value: Datetime value (string or datetime) + :type value: Any + + :return: HTTP formatted datetime string or None + :rtype: Optional[str] + """ + if not value: + return None + if not isinstance(value, str): + value = fields.Datetime.to_string(value) + return time.strftime( + "%a, %d %b %Y %H:%M:%S GMT", + time.strptime(value, "%Y-%m-%d %H:%M:%S"), + ) + + @api.model + def _split_path(self, path): + """Split DAV path into normalized components. + + :param path: Raw path string + :type path: Optional[str] + + :return: List of path segments + :rtype: List[str] + """ + return list(filter(None, posixpath.normpath(path or "").strip("/").split("/"))) + + def dav_list( + self, + collection, + path_components, + ): + """List DAV resources under given path. + + Handles: + - file collections (attachments) + - record-based collections (calendar/addressbook) + + :param collection: Radicale collection instance + :type collection: Any + :param path_components: Parsed DAV path + :type path_components: Sequence[str] + + :return: List of resource href paths + :rtype: List[str] + """ + self.ensure_one() + + if self.dav_type == "files": + if len(path_components) == 3: + collection_model = self.env[self.model_id.model] + folder_name = unquote_plus(path_components[2]) + record = collection_model.browse( + map( + itemgetter(0), + collection_model.name_search( + folder_name, + operator="=", + limit=1, + ), + ) + ) + return [ + "/" + + "/".join(path_components + [quote_plus(attachment.name or "")]) + 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_domain_records() + ] + + if len(path_components) > 2: + return [] + + result = [] + for record in self.eval_domain_records(): + uuid = record[self.field_uuid.name] if self.field_uuid else str(record.id) + result.append("/" + "/".join(path_components + [str(uuid)])) + return result + + def dav_delete( + self, + collection, + href, + ): + """Delete DAV resource by href. + + :param collection: Radicale collection instance + :type collection: Any + :param href: Resource path + :type href: str + """ + self.ensure_one() + + if self.dav_type == "files": + # TODO: Handle deletion of attachments + return + + components = self._split_path(href) + rec = self.get_record(components) + if rec: + rec.unlink() + + def dav_upload(self, collection, href, item): + """Create or update DAV resource from uploaded vobject. + + :param collection: Radicale collection instance + :type collection: Any + :param href: Resource path + :type href: str + :param item: Uploaded vobject + :type item: Any + :raises AccessError: If created/updated record is outside collection domain + :return: Radicale Item instance or None + :rtype: Optional[Any] + """ + self.ensure_one() + + if self.dav_type == "files": + # TODO: Handle upload of attachments + return None + + components = self._split_path(href) + collection_model = self.env[self.model_id.model] + + data = self.from_vobject(item) + if not data: + return None + + rec = self.get_record(components) + if not rec: + if self.field_uuid: + clean_key = _dav_strip_item_extension( + components[-1] if components else "" + ) + if self.field_uuid.ttype in ("integer", "many2one"): + try: + clean_key = int(clean_key) + except (TypeError, ValueError): + clean_key = None + if clean_key is not None and self.field_uuid.name not in data: + data[self.field_uuid.name] = clean_key + + rec = collection_model.create(data) + else: + rec.write(data) + + domain = expression.AND([self._eval_domain(), [("id", "=", rec.id)]]) + if not collection_model.search(domain, limit=1): + raise AccessError(self.env._("Record is outside of DAV collection domain")) + + from ..radicale.collection import Item as DavItem + + return DavItem( + collection, + item=self.to_vobject(rec), + href=href, + last_modified=self._odoo_to_http_datetime(rec.write_date), + ) + + def dav_get(self, collection, href): + """Retrieve DAV resource. + + Supports: + - Folder access (files) + - Attachment download + - Calendar/addressbook items + + :param collection: Radicale collection instance + :type collection: Any + :param href: Resource path + :type href: str + + :return: Radicale Item/FileItem/Collection or None + :rtype: Optional[Any] + """ + 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: + from ..radicale.collection import Collection as DavFolder + + folder = DavFolder(href) + return folder + + if len(components) == 4: + folder_name = unquote_plus(components[2]) + record = collection_model.browse( + map( + itemgetter(0), + collection_model.name_search( + folder_name, + operator="=", + limit=1, + ), + ) + ) + att_name = unquote_plus(components[3]) + attachment = self.env["ir.attachment"].search( + [ + ("type", "=", "binary"), + ("res_model", "=", record._name), + ("res_id", "=", record.id), + ("name", "=", att_name), + ], + limit=1, + ) + if not attachment: + return None + + from ..radicale.collection import FileItem + + return FileItem( + collection, + href, + attachment, + last_modified=self._odoo_to_http_datetime(record.write_date), + ) + + record = self.get_record(components) + + if not record: + return None + + from ..radicale.collection import Item as DavItem + + return DavItem( + 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..146152a73 --- /dev/null +++ b/base_dav/models/dav_collection_field_mapping.py @@ -0,0 +1,411 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import base64 +import binascii +import datetime + +import dateutil +import vobject +from dateutil import tz + +from odoo import api, fields, models, tools +from odoo.tools import safe_eval as safe_eval_mod + + +def _safe_module(name, fallback_module, attributes_tree): + """Return safe_eval-wrapped module or fallback module. + + If a module with the given name is already pre-wrapped inside + ``safe_eval``, it is returned. Otherwise, the fallback module + is wrapped using the provided attribute tree. + + :param name: Module name to look up inside safe_eval + :type name: str + :param fallback_module: Python module used if not prewrapped + :type fallback_module: Any + :param attributes_tree: Allowed attributes structure + :type attributes_tree: Dict[str, Any] + + :return: Safe wrapped module proxy + :rtype: Any + """ + prewrapped = getattr(safe_eval_mod, name, None) + if prewrapped is not None: + return prewrapped + return safe_eval_mod.wrap_module(fallback_module, attributes_tree) + + +SAFE_DATETIME = _safe_module( + "datetime", + datetime, + { + "date": {}, + "datetime": {}, + "time": {}, + "timedelta": {}, + }, +) + +SAFE_DATEUTIL = _safe_module( + "dateutil", + dateutil, + { + "tz": {}, + }, +) + +SAFE_TZ = _safe_module( + "tz", + tz, + { + "UTC": {}, + "gettz": {}, + }, +) + +SAFE_VOBJECT = _safe_module( + "vobject", + vobject, + { + "vCard": {}, + "iCalendar": {}, + "vcard": {"Name": {}}, + "base": {}, + }, +) + + +class DavCollectionFieldMapping(models.Model): + _name = "dav.collection.field_mapping" + _description = "A field mapping for a WebDAV collection" + + collection_id = fields.Many2one( + comodel_name="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( + comodel_name="ir.model.fields", + required=True, + ondelete="cascade", + help="Field of the model the values are mapped to", + ) + model_id = fields.Many2one( + comodel_name="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): + """Convert vobject child element into Odoo field value. + + Delegates conversion depending on mapping type: + - simple + - code + + :param child: vobject child element + :type child: Any + + :return: Converted field value + :rtype: Any + """ + 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): + """Convert vobject child using custom Python code. + + Executes safe_eval code stored in ``import_code``. + The following variables are available: + - item + - result + - datetime + - dateutil + - tz + - vobject + + :param child: vobject child element + :type child: Any + + :return: Converted value stored in ``result`` + :rtype: Any + """ + self.ensure_one() + context = { + "item": child, + "result": None, + "datetime": SAFE_DATETIME, + "dateutil": SAFE_DATEUTIL, + "tz": SAFE_TZ, + "vobject": SAFE_VOBJECT, + "DEFAULT_SERVER_DATE_FORMAT": tools.DEFAULT_SERVER_DATE_FORMAT, + "DEFAULT_SERVER_DATETIME_FORMAT": tools.DEFAULT_SERVER_DATETIME_FORMAT, + } + safe_eval_mod.safe_eval( + self.import_code or "", context, mode="exec", nocopy=True + ) + return context.get("result", None) + + def _from_vobject_simple(self, child): + """Convert vobject child using automatic type-based mapping. + + Attempts conversion based on field type and attribute name. + + :param child: vobject child element + :type child: Any + + :return: Converted value + :rtype: Any + """ + self.ensure_one() + name = (self.name or "").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 is not None: + return value + + return child.value + + @api.model + def _from_vobject_datetime(self, item): + """Convert vobject datetime into Odoo datetime string (UTC). + + :param item: vobject datetime property + :type item: Any + + :return: Datetime string in server format or None + :rtype: Optional[str] + """ + if isinstance(item.value, datetime.datetime): + value = item.value.astimezone(dateutil.tz.UTC) + return value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + if isinstance(item.value, datetime.date): + return item.value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + return None + + @api.model + def _from_vobject_date(self, item): + """Convert vobject date into Odoo date string. + + :param item: vobject date property + :type item: Any + + :return: Date string in server format or None + :rtype: Optional[str] + """ + if isinstance(item.value, datetime.datetime): + value = item.value.astimezone(dateutil.tz.UTC) + return value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + if isinstance(item.value, datetime.date): + return item.value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + return None + + @api.model + def _from_vobject_binary(self, item): + """Convert vobject binary value into ASCII-encoded bytes. + + :param item: vobject binary property + :type item: Any + + :return: ASCII-encoded bytes + :rtype: bytes + """ + value = getattr(item, "value", None) + if not value: + return None + if isinstance(value, str): + raw = value.encode("ascii", errors="ignore") + elif isinstance(value, bytes): + raw = value + else: + raw = bytes(value) + if raw.startswith( + (b"\xff\xd8\xff", b"\x89PNG\r\n\x1a\n", b"GIF87a", b"GIF89a", b"BM") + ): + return base64.b64encode(raw) + compact = b"".join(raw.split()) + try: + decoded = base64.b64decode(compact, validate=True) + return base64.b64encode(decoded) + except (binascii.Error, ValueError): + return base64.b64encode(raw) + + @api.model + def _from_vobject_char_n(self, item): + """Extract family name from vCard Name object. + + :param item: vobject Name property + :type item: Any + + :return: Family name or None + :rtype: Optional[str] + """ + value = getattr(item, "value", None) + if hasattr(value, "family"): + return value.family + if isinstance(value, str): + return value.split(";", 1)[0] or None + return None + + def to_vobject(self, record): + """Convert Odoo record value into vobject-compatible value. + + Delegates conversion depending on mapping type. + Ensures timezone awareness for datetime values. + + :param record: Odoo record + :type record: odoo.models.BaseModel + + :return: Value suitable for vobject property + :rtype: Any + """ + 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): + """Convert Odoo record value using custom export code. + + Executes safe_eval code stored in ``export_code``. + + :param record: Odoo record + :type record: odoo.models.BaseModel + + :return: Converted value stored in ``result`` + :rtype: Any + """ + self.ensure_one() + context = { + "record": record, + "result": None, + "datetime": SAFE_DATETIME, + "dateutil": SAFE_DATEUTIL, + "tz": SAFE_TZ, + "vobject": SAFE_VOBJECT, + "DEFAULT_SERVER_DATE_FORMAT": tools.DEFAULT_SERVER_DATE_FORMAT, + "DEFAULT_SERVER_DATETIME_FORMAT": tools.DEFAULT_SERVER_DATETIME_FORMAT, + } + safe_eval_mod.safe_eval( + self.export_code or "", context, mode="exec", nocopy=True + ) + return context.get("result", None) + + def _to_vobject_simple(self, record): + """Convert Odoo field value using automatic type-based mapping. + + :param record: Odoo record + :type record: odoo.models.BaseModel + + :return: Converted value + :rtype: Any + """ + self.ensure_one() + conversion_funcs = [ + f"_to_vobject_{self.field_id.ttype}_{(self.name or '').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) + if value is False: + return None + return value + + @api.model + def _to_vobject_datetime(self, value): + """Convert Odoo datetime value into UTC datetime object. + + :param value: Odoo datetime value + :type value: Any + + :return: Timezone-aware datetime or None + :rtype: Optional[datetime.datetime] + """ + dt = fields.Datetime.to_datetime(value) + return dt.replace(tzinfo=tz.UTC) if dt else None + + @api.model + def _to_vobject_datetime_rev(self, value): + """Convert Odoo datetime into REV string format (RFC-style). + + :param value: Odoo datetime value + :type value: Any + + :return: REV formatted string or None + :rtype: Optional[str] + """ + s = fields.Datetime.to_string(value) if value else None + return s and s.replace("-", "").replace(" ", "T").replace(":", "") + "Z" + + @api.model + def _to_vobject_date(self, value): + """Convert Odoo date value into date object. + + :param value: Odoo date value + :type value: Any + + :return: Date object or None + :rtype: Optional[datetime.date] + """ + return fields.Date.to_date(value) + + @api.model + def _to_vobject_binary(self, value): + """Convert binary field value into ASCII string. + + :param value: Binary value + :type value: Optional[bytes] + + :return: ASCII-decoded string or None + :rtype: Optional[str] + """ + return value and value.decode("ascii") + + @api.model + def _to_vobject_char_n(self, value): + """Convert family name into vCard Name object. + + :param value: Family name + :type value: Optional[str] + + :return: vobject.vcard.Name instance + :rtype: Any + """ + # TODO: how are we going to handle compound types like this? + return vobject.vcard.Name(family=value) 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..736b6bb34 --- /dev/null +++ b/base_dav/radicale/auth.py @@ -0,0 +1,119 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import logging + +from radicale.auth import BaseAuth + +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def load(configuration): + """Create Radicale authentication backend instance. + + This function is used by Radicale to initialize the authentication + plugin for the current configuration. + + :param configuration: Radicale configuration object + :type configuration: Any + + :return: Auth backend instance + :rtype: Auth + """ + return Auth(configuration) + + +def _user_agent_env(ctx): + """Build user_agent_env dictionary for Odoo ``res.users._login``. + + Extracts: + - HTTP_USER_AGENT + - REMOTE_ADDR + + from Radicale request context. + + :param ctx: Radicale request context + :type ctx: Any + + :return: Environment dictionary for login + :rtype: Dict[str, str] + """ + env = {"interactive": False} + + user_agent = getattr(ctx, "user_agent", None) + if user_agent: + env["HTTP_USER_AGENT"] = str(user_agent) + + remote = ( + getattr(ctx, "remote_host", None) + or getattr(ctx, "remote_addr", None) + or getattr(ctx, "client", None) + ) + if remote: + if isinstance(remote, (tuple | list)): + remote = remote[0] if remote else None + if remote: + env["REMOTE_ADDR"] = str(remote) + + return env + + +def _set_request_user(base_env, uid): + """Switch current Odoo HTTP request to authenticated user. + + Updates: + - request.uid + - request.env (via update_env or manual environment rebuild) + + :param base_env: Original Odoo environment + :type base_env: odoo.models.BaseModel + :param uid: Authenticated user ID + :type uid: int + """ + request.update_env(user=uid) + + +class Auth(BaseAuth): + def _login_ext(self, login, password, context): + """Authenticate user against Odoo database. + + Uses ``res.users._login`` to validate credentials and + switches current request environment to authenticated user. + + :param login: User login + :type login: str + :param password: User password + :type password: str + :param context: Radicale request context + :type context: Any + + :return: Authenticated login name or empty string if failed + :rtype: str + """ + base_env = request.env + + credential = { + "login": login, + "password": password, + "type": "password", + } + + try: + res = base_env["res.users"]._login( + base_env.cr.dbname, + credential, + _user_agent_env(context), + ) + except Exception: + # Return empty result so Radicale responds with 401 instead of 500 + _logger.info("DAV login failed for %r", login, exc_info=True) + return "" + + uid = res.get("uid") if isinstance(res, dict) else res + if not uid: + return "" + + _set_request_user(base_env, uid) + return request.env["res.users"].sudo().browse(uid).login or login diff --git a/base_dav/radicale/collection.py b/base_dav/radicale/collection.py new file mode 100644 index 000000000..66bfef46a --- /dev/null +++ b/base_dav/radicale/collection.py @@ -0,0 +1,438 @@ +# 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 contextlib +import logging +from collections.abc import Callable +from contextlib import AbstractContextManager + +from radicale import pathutils +from radicale.item import Item as RadicaleItem +from radicale.storage import BaseCollection, BaseStorage + +from odoo.http import request + +_logger = logging.getLogger("radicale") + + +def _norm_path(path): + """Return sanitized Radicale path without leading slash. + + Applies Radicale path sanitization and removes leading slash. + + :param path: Raw path string + :type path: str + + :return: Normalized path without leading slash + :rtype: str + """ + return pathutils.strip_path(pathutils.sanitize_path(path or "")) + + +def _abs_href(collection_path, href): + """Build absolute href for Radicale item. + + Ensures href is prefixed with collection path and leading slash. + + :param collection_path: Collection base path + :type collection_path: str + :param href: Relative or raw href + :type href: str + + :return: Absolute href + :rtype: str + """ + h = pathutils.strip_path(pathutils.sanitize_path(href or "")) + prefix = _norm_path(collection_path) + if prefix: + pref = f"{prefix}/" + if h.startswith(pref): + return f"/{h}" + return f"/{pref}{h}" + return f"/{h}" + + +def _rel_href(collection_path, href): + """Convert absolute href to relative href. + + Removes collection prefix if present. + + :param collection_path: Collection base path + :type collection_path: str + :param href: Absolute href + :type href: str + + :return: Relative href + :rtype: str + """ + if not href: + return href + h = pathutils.strip_path(pathutils.sanitize_path(href)) + prefix = _norm_path(collection_path) + if prefix: + pref = f"{prefix}/" + if h.startswith(pref): + return h[len(pref) :] + return h + + +class Item(RadicaleItem): + def __init__(self, collection, item, href, last_modified): + """Initialize Radicale item wrapper for Odoo DAV. + + :param collection: DAV collection instance + :type collection: Collection + :param item: vobject instance + :type item: Any + :param href: Item href + :type href: str + :param last_modified: HTTP formatted datetime string + :type last_modified: str + """ + super().__init__( + collection=collection, + vobject_item=item, + href=_rel_href(collection.path, href), + last_modified=last_modified or "", + ) + + +class FileItem(RadicaleItem): + def __init__(self, collection, href, attachment, last_modified): + """Initialize Radicale file item from Odoo attachment. + + Decodes binary attachment data into UTF-8 text. + + :param collection: DAV collection instance + :type collection: Collection + :param href: File href + :type href: str + :param attachment: ir.attachment record + :type attachment: Any + :param last_modified: HTTP formatted datetime, defaults to "" + :type last_modified: str, optional + """ + raw = b"" + datas = getattr(attachment, "datas", None) + if datas: + try: + raw = base64.b64decode(datas) + except Exception: + raw = b"" + text = raw.decode("utf-8", errors="ignore") + + super().__init__( + collection=collection, + text=text, + href=_rel_href(collection.path, href), + last_modified=last_modified or "", + ) + + +class Collection(BaseCollection): + """ + Path forms: + - root: "" or "/" + - principal: "admin" + - odoo collection: "admin/" + - item: "admin//" + """ + + def __init__(self, path): + """Initialize DAV collection from Radicale path. + + Supports: + - root + - principal + - Odoo collection + - collection item + + :param path: Radicale collection path + :type path: str + """ + self.logger = _logger + self._path = _norm_path(path) + self.path_components = self._path.split("/", 2) if self._path else [""] + + env = request.env + self._record = None + if len(self.path_components) > 1 and (self.path_components[1] or "").isdigit(): + rec = env["dav.collection"].browse(int(self.path_components[1])).exists() + self._record = rec or None + + @property + def path(self): + """Return normalized collection path. + + :return: Collection path + :rtype: str + """ + return self._path + + def get_multi(self, hrefs): + """Retrieve multiple items by href. + + Skips duplicate hrefs. + + :param hrefs: Iterable of href strings + :type hrefs: Iterable[str] + + :return: Iterator of (href, item) + :rtype: Iterator[Tuple[str, Optional[Any]]] + """ + seen: set[str] = set() + for href in hrefs: + if href in seen: + continue + seen.add(href) + yield href, self.get(href) + + def get_all(self): + """Yield all items in collection. + + :return: Iterator of Radicale items + :rtype: Iterator[Any] + """ + for href in self.list(): + item = self.get(href) + if item is not None: + yield item + + def list(self): + """Return relative hrefs for items or child collections. + + Behavior depends on path type: + - collection + - principal + - root + + :return: Iterable of relative hrefs + :rtype: Iterable[str] + """ + env = request.env + + # real DAV collection -> list items + if self._record: + hrefs = self._record.dav_list(self, self.path_components) + for h in hrefs: + yield _rel_href(self.path, h) + return + + # principal -> list collections + if self.is_principal: + login = self.path_components[0] + for rec in env["dav.collection"].search([]): + yield f"{login}/{rec.id}" + return + + # root -> expose only current user principal + current = env.user.login + if current: + yield current + + def get(self, href): + """Retrieve single DAV item by href. + + :param href: Relative href + :type href: str + + :return: Radicale item or None + :rtype: Optional[Any] + """ + if not self._record: + return None + abs_href = _abs_href(self.path, href) + return self._record.dav_get(self, abs_href) + + def upload(self, href, item, **kwargs): + """Upload or update DAV item. + + :param href: Relative href + :type href: str + :param item: RadicaleItem or vobject + :type item: Any + :param kwargs: Additional Radicale parameters + :type kwargs: Any + + :return: Tuple of (uploaded_item, previous_item) + :rtype: Tuple[Optional[Any], Optional[Any]] + """ + if not self._record: + raise ValueError(f"Not a DAV collection: {self.path!r}") + + old_item = self.get(href) + + # tests may pass vobject directly; + # Radicale passes RadicaleItem with .vobject_item + vobj = getattr(item, "vobject_item", None) or item + abs_href = _abs_href(self.path, href) + + uploaded = self._record.dav_upload(self, abs_href, vobj) + return uploaded, old_item + + def delete(self, href: str | None = None): + """Delete DAV item. + + :param href: Relative href, defaults to None + :type href: Optional[str], optional + + :raises ValueError: If not a DAV collection + :raises NotImplementedError: If deleting collection root + """ + if not self._record: + raise ValueError(f"Not a DAV collection: {self.path!r}") + if not href: + raise NotImplementedError("Deleting collections is not supported") + abs_href = _abs_href(self.path, href) + self._record.dav_delete(self, abs_href) + + def get_meta(self, key: str | None = None): + """Return collection metadata value. + + :param key: Metadata key, defaults to None + :type key: Optional[str], optional + + :return: Metadata value or mapping + :rtype: Mapping[str, str] | str | None + """ + if key is None: + return {} + + if not self._record: + return None + + if key == "tag": + return self._record.tag + if key == "D:displayname": + return self._record.display_name or self._record.name + if key == "C:supported-calendar-component-set": + return "VTODO,VEVENT,VJOURNAL" + if key == "ICAL:calendar-color": + return "#48c9f4" + self.logger.warning("unsupported metadata %s", key) + return None + + @property + def last_modified(self): + """Return HTTP last modified timestamp for collection. + + :return: HTTP datetime string + :rtype: str + """ + if not self._record: + return "" + # reuse helper from dav.collection + return self._record._odoo_to_http_datetime(self._record.create_date) or "" + + +class Storage(BaseStorage): + def discover( + self, + path, + depth="0", + child_context_manager: Callable[[str, str | None], AbstractContextManager[None]] + | None = None, + user_groups: set[str] | None = None, + ): + """Discover collections or items for given path. + + Supports depth 0 and depth > 0 discovery. + + :param path: Radicale path + :type path: str + :param depth: Discovery depth, defaults to "0" + :type depth: str, optional + :param child_context_manager: Optional Radicale context manager + :type child_context_manager: Optional[Callable] + :param user_groups: Optional set of user groups + :type user_groups: Optional[set[str]] + + :return: Iterator of collections or items + :rtype: Iterator[Any] + """ + path = _norm_path(path) + parts = path.split("/", 2) if path else [""] + + # item path: "admin/2/2" + if len(parts) == 3 and (parts[1] or "").isdigit(): + coll = Collection("/".join(parts[:2])) + if not getattr(coll, "_record", None): + return iter(()) + item = coll.get(parts[2]) + return iter([item]) if item else iter(()) + + coll = Collection(path) + + if depth == "0": + return iter([coll]) + + # depth != 0 -> include direct children + children = [coll] + if getattr(coll, "_record", None): + children.extend(list(coll.get_all())) + else: + for child_path in coll.list(): + children.append(Collection(child_path)) + + return iter(children) + + def move(self, item: RadicaleItem, to_collection: BaseCollection, to_href): + """Move DAV item between collections. + + :raises NotImplementedError: MOVE is not supported + + :param item: Radicale item + :type item: RadicaleItem + :param to_collection: Target collection + :type to_collection: BaseCollection + :param to_href: Target href + :type to_href: str + """ + raise NotImplementedError("MOVE is not supported by Odoo DAV backend") + + def create_collection(self, href, items=None, props=None): + """Create DAV collection. + + :raises NotImplementedError: MKCOL not supported + + :param href: Collection href + :type href: str + :param items: Optional initial items + :type items: Optional[Iterable[Any]] + :param props: Optional collection properties + :type props: Optional[Mapping[str, Any]] + + :return: Collection instance + :rtype: BaseCollection + """ + raise NotImplementedError("MKCOL is not supported by Odoo DAV backend") + + @contextlib.contextmanager + def acquire_lock(self, mode, user=None, **kwargs): + """Acquire storage lock. + + Locking is delegated to database transactions. + + :param mode: Lock mode + :type mode: str + :param user: Optional user identifier + :type user: Optional[str] + :param kwargs: Additional parameters + :type kwargs: Any + + :yield: None + """ + # DB is the lock + yield + + def verify(self): + """Verify storage backend integrity. + + Always returns True for Odoo backend. + + :return: Verification status + :rtype: bool + """ + return True diff --git a/base_dav/radicale/rights.py b/base_dav/radicale/rights.py new file mode 100644 index 000000000..973722765 --- /dev/null +++ b/base_dav/radicale/rights.py @@ -0,0 +1,85 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +from radicale.rights import BaseRights + +from odoo.http import request + + +def load(configuration): + """Create Radicale rights backend instance. + + This function is used by Radicale to initialize + the rights/authorization plugin. + + :param configuration: Radicale configuration object + :type configuration: Any + + :return: Rights backend instance + :rtype: Rights + """ + return Rights(configuration) + + +def _split(path): + """Split DAV path into clean components. + + Removes leading/trailing slashes and empty segments. + + :param path: Raw DAV path, defaults to None + :type path: str | None + + :return: List of path components + :rtype: List[str] + """ + return [p for p in (path or "").strip("/").split("/") if p] + + +class Rights(BaseRights): + def authorization(self, user, path): + """Determine access rights for DAV resource. + + Authorization logic: + - Root path: full access + - Principal path: requires authentication + - Collection path: depends on collection.rights + (authenticated / owner_only / owner_write_only) + + :param user: Authenticated username or None + :type user: str | None + :param path: Requested DAV path + :type path: str | None + + :return: Access mode: + - "rw" for read/write + - "r" for read-only + - "" for no access + :rtype: str + """ + if not path or path == "/": + return "RWrw" if user else "" + + parts = _split(path) + + if len(parts) == 1: + return "RWrw" if user else "" + + if len(parts) >= 2 and (parts[1] or "").isdigit(): + collection = request.env["dav.collection"].sudo().browse(int(parts[1])) + if not collection.exists(): + return "" + + mode = collection.rights + is_owner = bool(user) and (user == parts[0]) + + if mode == "authenticated": + return "RWrw" if user else "" + if mode == "owner_only": + return "RWrw" if is_owner else "" + if mode == "owner_write_only": + if is_owner: + return "RWrw" + return "Rr" if user else "" + + return "" diff --git a/base_dav/readme/CONFIGURE.md b/base_dav/readme/CONFIGURE.md new file mode 100644 index 000000000..e79d6f7d4 --- /dev/null +++ b/base_dav/readme/CONFIGURE.md @@ -0,0 +1,8 @@ +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. + +Note that you need to configure a dbfilter if you use multiple +databases. diff --git a/base_dav/readme/CONTRIBUTORS.md b/base_dav/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..34c5befd1 --- /dev/null +++ b/base_dav/readme/CONTRIBUTORS.md @@ -0,0 +1,6 @@ +- Holger Brunn \ +- Florian Kantelberg \ +- [Cetmix](https://cetmix.com/) + - Ivan Sokolov + - George Smirnov + - Dmitry Meita diff --git a/base_dav/readme/DESCRIPTION.md b/base_dav/readme/DESCRIPTION.md new file mode 100644 index 000000000..3615c5ea7 --- /dev/null +++ b/base_dav/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +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.md b/base_dav/readme/ROADMAP.md new file mode 100644 index 000000000..4a2e742ef --- /dev/null +++ b/base_dav/readme/ROADMAP.md @@ -0,0 +1,19 @@ +- 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..fe701f5dc --- /dev/null +++ b/base_dav/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_dav_collection_user,dav.collection user,model_dav_collection,base.group_user,1,0,0,0 +access_dav_collection_system,dav.collection system,model_dav_collection,base.group_system,1,1,1,1 +access_dav_collection_field_mapping_user,dav.collection.field_mapping user,model_dav_collection_field_mapping,base.group_user,1,0,0,0 +access_dav_collection_field_mapping_system,dav.collection.field_mapping system,model_dav_collection_field_mapping,base.group_system,1,1,1,1 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..a30db3b9f --- /dev/null +++ b/base_dav/static/description/index.html @@ -0,0 +1,470 @@ + + + + + +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

+ +
+
+

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/tests/__init__.py b/base_dav/tests/__init__.py new file mode 100644 index 000000000..e1482f6fa --- /dev/null +++ b/base_dav/tests/__init__.py @@ -0,0 +1,11 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_auth +from . import test_base_dav +from . import test_collection +from . import test_controller +from . import test_field_mapping +from . import test_vcard_false_values +from . import test_radicale_collection +from . import test_dav_collection_files diff --git a/base_dav/tests/test_auth.py b/base_dav/tests/test_auth.py new file mode 100644 index 000000000..fa3aae86c --- /dev/null +++ b/base_dav/tests/test_auth.py @@ -0,0 +1,141 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from types import SimpleNamespace +from unittest import mock + +from odoo import http +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.base_dav.radicale.auth import Auth, _set_request_user, _user_agent_env + + +@tagged("post_install", "-at_install") +class TestDavAuth(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = ( + cls.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "DAV Tester", + "login": "dav_tester", + "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + } + ) + ) + + def setUp(self): + super().setUp() + self.request_obj = SimpleNamespace( + env=self.env, + uid=self.env.uid, + update_env=mock.Mock(), + ) + http._request_stack.push(self.request_obj) + self.addCleanup(http._request_stack.pop) + + def test_user_agent_env_minimal(self): + self.assertEqual(_user_agent_env(SimpleNamespace()), {"interactive": False}) + + def test_user_agent_env_full(self): + ctx = SimpleNamespace( + user_agent="Thunderbird", + remote_host="10.0.0.5", + ) + self.assertEqual( + _user_agent_env(ctx), + { + "interactive": False, + "HTTP_USER_AGENT": "Thunderbird", + "REMOTE_ADDR": "10.0.0.5", + }, + ) + + def test_user_agent_env_remote_from_tuple_client(self): + ctx = SimpleNamespace( + user_agent=None, + remote_host=None, + remote_addr=None, + client=("10.10.10.10", 1234), + ) + self.assertEqual( + _user_agent_env(ctx), + { + "interactive": False, + "REMOTE_ADDR": "10.10.10.10", + }, + ) + + def test_set_request_user(self): + _set_request_user(self.env, self.user.id) + self.request_obj.update_env.assert_called_once_with(user=self.user.id) + + def test_login_ext_returns_empty_on_login_exception(self): + auth = object.__new__(Auth) + with ( + mock.patch.object( + type(self.env["res.users"]), + "_login", + side_effect=Exception("boom"), + ), + mock.patch("odoo.addons.base_dav.radicale.auth._logger") as logger, + ): + result = auth._login_ext("bad", "bad", SimpleNamespace()) + + self.assertEqual(result, "") + logger.info.assert_called_once() + + def test_login_ext_returns_empty_on_missing_uid(self): + auth = object.__new__(Auth) + with mock.patch.object( + type(self.env["res.users"]), + "_login", + return_value={}, + ): + result = auth._login_ext("bad", "bad", SimpleNamespace()) + self.assertEqual(result, "") + + def test_login_ext_accepts_dict_response(self): + auth = object.__new__(Auth) + with ( + mock.patch.object( + type(self.env["res.users"]), + "_login", + return_value={"uid": self.user.id}, + ), + mock.patch( + "odoo.addons.base_dav.radicale.auth._set_request_user" + ) as set_request_user, + ): + result = auth._login_ext( + self.user.login, + "secret", + SimpleNamespace(user_agent="TB", remote_addr="127.0.0.1"), + ) + + self.assertEqual(result, self.user.login) + set_request_user.assert_called_once_with(self.env, self.user.id) + + def test_login_ext_accepts_int_response(self): + auth = object.__new__(Auth) + with ( + mock.patch.object( + type(self.env["res.users"]), + "_login", + return_value=self.user.id, + ), + mock.patch( + "odoo.addons.base_dav.radicale.auth._set_request_user" + ) as set_request_user, + ): + result = auth._login_ext( + self.user.login, + "secret", + SimpleNamespace(), + ) + + self.assertEqual(result, self.user.login) + set_request_user.assert_called_once_with(self.env, self.user.id) diff --git a/base_dav/tests/test_base_dav.py b/base_dav/tests/test_base_dav.py new file mode 100644 index 000000000..f4c28a522 --- /dev/null +++ b/base_dav/tests/test_base_dav.py @@ -0,0 +1,159 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from types import SimpleNamespace + +import odoo.http as http +from odoo.tests.common import TransactionCase, tagged + +from ..controllers.main import PREFIX +from ..controllers.main import Main as Controller +from ..radicale.rights import Rights + + +@tagged("post_install", "-at_install") +class TestBaseDav(TransactionCase): + @classmethod + def setUpClass(cls): + """Prepare test users, DAV collection and controller for rights tests.""" + super().setUpClass() + + # Ensure we have a normal internal user for non-owner checks + group_user = cls.env.ref("base.group_user") + cls.test_user = cls.env["res.users"].create( + { + "login": "tester", + "name": "tester", + "groups_id": [(6, 0, [group_user.id])], + } + ) + + # Create a minimal dav.collection + # Use res.partner (safe to create/delete in tests) + cls.partner = cls.env["res.partner"].create({"name": "DAV Partner"}) + cls.collection = cls.env["dav.collection"].create( + { + "name": "Test Collection", + "dav_type": "calendar", + "model_id": cls.env.ref("base.model_res_partner").id, + "domain": f"[('id', '=', {cls.partner.id})]", + } + ) + + # NOTE: In our rights logic, owner is path's first segment (login) + cls.owner_login = cls.env.user.login + cls.tester_login = cls.test_user.login + + cls.controller = Controller() + + def setUp(self): + """Bind HTTP request context and initialize Rights instance.""" + super().setUp() + + # Bind odoo.http.request LocalProxy (needed because Rights uses request.env) + req = SimpleNamespace(env=self.env, uid=self.env.uid) + http._request_stack.push(req) + self.addCleanup(http._request_stack.pop) + + # Instantiate Rights without calling BaseRights.__init__ + self.rights = object.__new__(Rights) + + def _assert_perm(self, user_login, path, expect_r, expect_w): + """Assert expected read/write permissions for given user and path.""" + perms = self.rights.authorization(user_login, path) or "" + self.assertEqual( + "r" in perms, + expect_r, + f"permissions={perms!r} user={user_login!r} path={path!r}", + ) + self.assertEqual( + "w" in perms, + expect_w, + f"permissions={perms!r} user={user_login!r} path={path!r}", + ) + + def test_well_known(self): + """Verify that well-known DAV endpoints redirect to the DAV prefix.""" + resp = self.controller.handle_well_known_request() + self.assertEqual(resp.status_code, 301) + # redirect target must be /.dav + self.assertIn(PREFIX, resp.location) + + def test_authenticated(self): + """Verify access control for collections with 'authenticated' rights mode.""" + self.collection.rights = "authenticated" + + base = f"/{self.owner_login}/{self.collection.id}" + item = f"{base}/{self.partner.id}" + + # owner: rw + self._assert_perm(self.owner_login, base, True, True) + self._assert_perm(self.owner_login, item, True, True) + + # other authenticated user: rw + self._assert_perm(self.tester_login, base, True, True) + self._assert_perm(self.tester_login, item, True, True) + + # anonymous: none + self._assert_perm("", base, False, False) + self._assert_perm("", item, False, False) + + def test_owner_only(self): + """Verify access control for collections with 'owner_only' rights mode.""" + self.collection.rights = "owner_only" + + base = f"/{self.owner_login}/{self.collection.id}" + item = f"{base}/{self.partner.id}" + + # owner: rw + self._assert_perm(self.owner_login, base, True, True) + self._assert_perm(self.owner_login, item, True, True) + + # other authenticated user: none + self._assert_perm(self.tester_login, base, False, False) + self._assert_perm(self.tester_login, item, False, False) + + # anonymous: none + self._assert_perm("", base, False, False) + self._assert_perm("", item, False, False) + + def test_owner_write_only(self): + """Verify access control for collections with 'owner_write_only' rights mode.""" + self.collection.rights = "owner_write_only" + + base = f"/{self.owner_login}/{self.collection.id}" + item = f"{base}/{self.partner.id}" + + # owner: rw + self._assert_perm(self.owner_login, base, True, True) + self._assert_perm(self.owner_login, item, True, True) + + # other authenticated user: r only + self._assert_perm(self.tester_login, base, True, False) + self._assert_perm(self.tester_login, item, True, False) + + # anonymous: none + self._assert_perm("", base, False, False) + self._assert_perm("", item, False, False) + + def test_rights_root_and_principal_and_missing_collection(self): + """Verify root/principal paths and missing collection handling.""" + self._assert_perm(self.owner_login, "/", True, True) + self._assert_perm("", "/", False, False) + + self._assert_perm(self.owner_login, f"/{self.owner_login}", True, True) + self._assert_perm("", f"/{self.owner_login}", False, False) + + self._assert_perm(self.owner_login, f"/{self.owner_login}/999999", False, False) + self._assert_perm( + self.owner_login, f"/{self.owner_login}/not-a-number", False, False + ) + + def test_rights_split_helper(self): + """Verify DAV path splitting helper.""" + from ..radicale.rights import _split + + self.assertEqual(_split(None), []) + self.assertEqual(_split("/"), []) + self.assertEqual(_split("//a///b/"), ["a", "b"]) diff --git a/base_dav/tests/test_collection.py b/base_dav/tests/test_collection.py new file mode 100644 index 000000000..d137de62f --- /dev/null +++ b/base_dav/tests/test_collection.py @@ -0,0 +1,321 @@ +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from unittest import mock + +import odoo.http as http +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase, tagged + +from ..radicale.collection import Collection + + +@tagged("post_install", "-at_install") +class TestCalendar(TransactionCase): + @classmethod + def setUpClass(cls): + """Prepare DAV calendar collection, field mappings and test user record.""" + super().setUpClass() + + cls.collection = cls.env["dav.collection"].create( + { + "name": "Test Collection", + "dav_type": "calendar", + "model_id": cls.env.ref("base.model_res_users").id, + "domain": "[]", + } + ) + + cls._create_field_mapping( + "login", + "base.field_res_users__login", + excode="result = record.login", + imcode="result = item.value", + ) + cls._create_field_mapping( + "name", + "base.field_res_users__name", + ) + + cls.record = cls.env["res.users"].create( + { + "login": "tester", + "name": "Test User", + } + ) + + @classmethod + def _create_field_mapping(cls, name, field_xmlid, imcode=None, excode=None): + """Create field mapping for DAV collection with optional import/export code.""" + return cls.env["dav.collection.field_mapping"].create( + { + "collection_id": cls.collection.id, + "name": name, + "field_id": cls.env.ref(field_xmlid).id, + "mapping_type": "code" if (imcode or excode) else "simple", + "import_code": imcode, + "export_code": excode, + } + ) + + def _compare_record(self, vobj, rec=None): + """Assert that vobject data matches expected Odoo record values.""" + tmp = self.collection.from_vobject(vobj) + rec = rec or self.record + self.assertEqual(rec.login, tmp["login"]) + self.assertEqual(rec.name, tmp["name"]) + + def test_import_export(self): + """Verify that exporting and re-importing a record preserves its data.""" + # 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): + """Verify record lookup by ID and by custom UUID field.""" + rec = self.collection.get_record([str(self.record.id)]) + self.assertEqual(rec, self.record) + + self.collection.field_uuid = self.env.ref("base.field_res_users__login") + rec = self.collection.get_record([self.record.login]) + self.assertEqual(rec, self.record) + + def test_collection_wrapper_list_get_upload_delete(self): + """Verify DAV collection wrapper list, get, upload and delete operations.""" + req = mock.MagicMock() + req.env = self.env + http._request_stack.push(req) + self.addCleanup(http._request_stack.pop) + + login = self.env.user.login + collection_path = f"{login}/{self.collection.id}" + collection = Collection(collection_path) + + record_href = str(self.record.id) + + self.assertIn(record_href, list(collection.list())) + + item = collection.get(record_href) + self.assertTrue(item) + self._compare_record(item.vobject_item) + self.assertEqual(item.href, record_href) + + self.assertFalse(collection.get(record_href + "0")) + + exported = self.collection.to_vobject(self.record) + self.record.login = "different" + with self.assertRaises(AssertionError): + self._compare_record(exported) + + uploaded_item, _replaced = collection.upload(record_href, exported) + self.assertTrue(uploaded_item) + self._compare_record(uploaded_item.vobject_item) + + collection.delete(record_href) + self.assertFalse(self.record.exists()) + + new_href = record_href + "0" + created_item, _ = collection.upload(new_href, exported) + self.assertTrue(created_item) + + # created_item.href can differ from requested href (Odoo assigns new id) + vobj = created_item.vobject_item + uid_value = "" + if getattr(vobj, "uid", None): + uid_value = vobj.uid.value or "" + elif getattr(vobj, "vevent", None) and getattr(vobj.vevent, "uid", None): + uid_value = vobj.vevent.uid.value or "" + elif ( + getattr(vobj, "vevent_list", None) + and vobj.vevent_list + and getattr(vobj.vevent_list[0], "uid", None) + ): + uid_value = vobj.vevent_list[0].uid.value or "" + self.assertIn(",", uid_value, "Expected UID in format ','") + _created_model, created_id_str = uid_value.split(",", 1) + created_id = int(created_id_str) + new_record = ( + self.env[self.collection.model_id.model].browse(created_id).exists() + ) + self.assertTrue(new_record) + self.assertNotEqual(new_record, self.record) + self._compare_record(created_item.vobject_item, new_record) + + def test_collection_helpers_and_negative_paths(self): + self.assertEqual( + self.collection._split_path("/a//b/c/"), + ["a", "b", "c"], + ) + self.assertTrue( + self.collection._odoo_to_http_datetime(self.record.write_date).endswith( + "GMT" + ) + ) + self.assertEqual( + self.collection._compute_url.__name__, + "_compute_url", + ) + + self.collection.field_uuid = self.env.ref("base.field_res_users__login") + self.assertFalse(self.collection.get_record(["bad.ics"])) + self.assertEqual( + self.collection.get_record([f"{self.record.login}.ics"]), + self.record, + ) + + self.collection.dav_type = "files" + self.assertIsNone(self.collection.to_vobject(self.record)) + self.assertIsNone(self.collection.from_vobject(mock.Mock(name="whatever"))) + self.assertIsNone(self.collection.dav_upload(mock.Mock(), "/x", mock.Mock())) + self.assertIsNone(self.collection.dav_delete(mock.Mock(), "/x")) + + def test_compute_tag_and_url_and_datetime_helpers(self): + """Verify compute helpers and datetime conversion helpers.""" + self.collection.dav_type = "calendar" + self.collection._compute_tag() + self.assertEqual(self.collection.tag, "VCALENDAR") + + self.collection.dav_type = "addressbook" + self.collection._compute_tag() + self.assertEqual(self.collection.tag, "VADDRESSBOOK") + + self.collection.dav_type = "files" + self.collection._compute_tag() + self.assertFalse(self.collection.tag) + + self.env["ir.config_parameter"].sudo().set_param( + "web.base.url", "http://test.local" + ) + self.collection._compute_url() + self.assertEqual( + self.collection.url, + f"http://test.local/.dav/{self.env.user.login}/{self.collection.id}", + ) + + self.env["ir.config_parameter"].sudo().set_param("web.base.url", "") + self.collection._compute_url() + self.assertEqual( + self.collection.url, + f"/.dav/{self.env.user.login}/{self.collection.id}", + ) + + self.assertIsNone(self.collection._odoo_to_http_datetime(False)) + self.assertEqual(self.collection._split_path("/a//b/c/"), ["a", "b", "c"]) + + def test_dav_strip_extension_and_invalid_record_lookup(self): + """Verify DAV href extension stripping and invalid ID handling.""" + from ..models.dav_collection import _dav_strip_item_extension + + self.assertEqual(_dav_strip_item_extension("abc.vcf"), "abc") + self.assertEqual(_dav_strip_item_extension("abc.ics"), "abc") + self.assertEqual(_dav_strip_item_extension("abc.txt"), "abc.txt") + self.assertEqual(_dav_strip_item_extension(""), "") + + self.assertFalse(self.collection.get_record(["not-an-id"])) + self.collection.field_uuid = self.env.ref("base.field_res_users__login") + self.assertFalse(self.collection.get_record(["unknown-login"])) + self.assertEqual( + self.collection.get_record([f"{self.record.login}.ics"]), + self.record, + ) + + def test_from_vobject_guards_and_eval_context(self): + """Verify unsupported vobject structures and eval helpers.""" + self.assertIn("user", self.collection._eval_context()) + self.assertEqual(self.collection._eval_domain(), []) + + bogus = mock.Mock() + bogus.name = "VCARD" + self.collection.dav_type = "calendar" + self.assertIsNone(self.collection.from_vobject(bogus)) + + bogus.name = "VCALENDAR" + if hasattr(bogus, "vevent"): + del bogus.vevent + self.assertIsNone(self.collection.from_vobject(bogus)) + + self.collection.dav_type = "addressbook" + bogus.name = "VCALENDAR" + self.assertIsNone(self.collection.from_vobject(bogus)) + + self.collection.dav_type = "files" + self.assertIsNone(self.collection.from_vobject(bogus)) + self.assertIsNone(self.collection.to_vobject(self.record)) + + def test_domain_validation_and_access_error_on_upload_outside_domain(self): + """ + Verify invalid domain eval is rejected and + upload outside domain raises AccessError. + """ + + bad_collection = self.env["dav.collection"].new( + { + "name": "Bad Domain", + "dav_type": "calendar", + "model_id": self.env.ref("base.model_res_users").id, + "domain": "[", + } + ) + with self.assertRaises(SyntaxError): + bad_collection._eval_domain() + + partner = self.env["res.partner"].create( + { + "name": "DAV Restricted Partner", + "email": "restricted@example.com", + } + ) + source_collection = self.env["dav.collection"].create( + { + "name": "Source Partner Collection", + "dav_type": "addressbook", + "model_id": self.env.ref("base.model_res_partner").id, + "domain": f"[('id', '=', {partner.id})]", + } + ) + self.env["dav.collection.field_mapping"].create( + { + "collection_id": source_collection.id, + "name": "FN", + "field_id": self.env["ir.model.fields"]._get("res.partner", "name").id, + "mapping_type": "simple", + } + ) + self.env["dav.collection.field_mapping"].create( + { + "collection_id": source_collection.id, + "name": "EMAIL", + "field_id": self.env["ir.model.fields"]._get("res.partner", "email").id, + "mapping_type": "simple", + } + ) + + restricted_collection = self.env["dav.collection"].create( + { + "name": "Restricted Partner Collection", + "dav_type": "addressbook", + "model_id": self.env.ref("base.model_res_partner").id, + "domain": "[('id', '=', -1)]", + } + ) + self.env["dav.collection.field_mapping"].create( + { + "collection_id": restricted_collection.id, + "name": "FN", + "field_id": self.env["ir.model.fields"]._get("res.partner", "name").id, + "mapping_type": "simple", + } + ) + self.env["dav.collection.field_mapping"].create( + { + "collection_id": restricted_collection.id, + "name": "EMAIL", + "field_id": self.env["ir.model.fields"]._get("res.partner", "email").id, + "mapping_type": "simple", + } + ) + + vobj = source_collection.to_vobject(partner) + + with self.assertRaises(AccessError): + restricted_collection.dav_upload(mock.Mock(), "/x", vobj) diff --git a/base_dav/tests/test_controller.py b/base_dav/tests/test_controller.py new file mode 100644 index 000000000..11c663649 --- /dev/null +++ b/base_dav/tests/test_controller.py @@ -0,0 +1,126 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from types import SimpleNamespace +from unittest import mock + +from odoo import http +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.base_dav.controllers.main import PREFIX, Main + + +class _ClosableResult(list): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.closed = False + + def close(self): + self.closed = True + + +@tagged("post_install", "-at_install") +class TestDavController(TransactionCase): + def setUp(self): + super().setUp() + self.controller = Main() + + def _push_request(self, method="PROPFIND", body=b"", environ=None): + httprequest = SimpleNamespace( + environ=environ or {"REQUEST_METHOD": method}, + method=method, + get_data=lambda cache=False: body, + ) + req = SimpleNamespace( + httprequest=httprequest, + env=self.env, + uid=self.env.uid, + ) + http._request_stack.push(req) + self.addCleanup(http._request_stack.pop) + + def test_handle_dav_request_injects_propfind_body(self): + self._push_request(method="PROPFIND", body=b"") + + captured = {} + + def fake_app(environ, start_response): + captured.update(environ) + start_response("207 Multi-Status", [("Content-Type", "application/xml")]) + return _ClosableResult([b""]) + + with ( + mock.patch( + "odoo.addons.base_dav.controllers.main.radicale_config.load" + ) as load_config, + mock.patch( + "odoo.addons.base_dav.controllers.main.Application", + return_value=fake_app, + ), + ): + load_config.return_value = mock.Mock() + response = self.controller.handle_dav_request("demo/path") + + self.assertEqual(response.status_code, 207) + self.assertEqual(captured["SCRIPT_NAME"], PREFIX) + self.assertEqual(captured["HTTP_X_SCRIPT_NAME"], PREFIX) + self.assertEqual(captured["PATH_INFO"], "/demo/path") + self.assertEqual(captured["CONTENT_TYPE"], "application/xml; charset=utf-8") + self.assertEqual(captured["REQUEST_METHOD"], "PROPFIND") + body = captured["wsgi.input"].read() + self.assertIn(b"payload" + self._push_request(method="PUT", body=body, environ={"REQUEST_METHOD": "PUT"}) + + captured = {} + + def fake_app(environ, start_response): + captured.update(environ) + start_response("201 Created", [("X-Test", "yes")]) + return _ClosableResult([b"created"]) + + with ( + mock.patch( + "odoo.addons.base_dav.controllers.main.radicale_config.load" + ) as load_config, + mock.patch( + "odoo.addons.base_dav.controllers.main.Application", + return_value=fake_app, + ), + ): + load_config.return_value = mock.Mock() + response = self.controller.handle_dav_request() + + self.assertEqual(response.status_code, 201) + self.assertEqual(captured["PATH_INFO"], "/") + self.assertEqual(captured["CONTENT_LENGTH"], str(len(body))) + self.assertEqual(captured["wsgi.input"].read(), body) + self.assertEqual(response.headers.get("X-Test"), "yes") + + def test_handle_dav_request_accepts_dict_headers_and_closes_iterable(self): + self._push_request(method="GET", body=b"hello") + + result_iter = _ClosableResult([b"done"]) + + def fake_app(environ, start_response): + start_response("200 OK", {"X-Mode": "dict"}) + return result_iter + + with ( + mock.patch( + "odoo.addons.base_dav.controllers.main.radicale_config.load" + ) as load_config, + mock.patch( + "odoo.addons.base_dav.controllers.main.Application", + return_value=fake_app, + ), + ): + load_config.return_value = mock.Mock() + response = self.controller.handle_dav_request("x") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers.get("X-Mode"), "dict") + self.assertTrue(result_iter.closed) diff --git a/base_dav/tests/test_dav_collection_files.py b/base_dav/tests/test_dav_collection_files.py new file mode 100644 index 000000000..443072778 --- /dev/null +++ b/base_dav/tests/test_dav_collection_files.py @@ -0,0 +1,141 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import base64 +from types import SimpleNamespace + +from odoo import http +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.base_dav.radicale.collection import Collection + + +@tagged("post_install", "-at_install") +class TestDavCollectionFiles(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.partner = cls.env["res.partner"].create( + { + "name": "DAV Files Partner", + "email": "files@example.com", + } + ) + cls.files_collection = cls.env["dav.collection"].create( + { + "name": "Partner Files", + "dav_type": "files", + "model_id": cls.env.ref("base.model_res_partner").id, + "domain": f"[('id', '=', {cls.partner.id})]", + } + ) + cls.attachment = cls.env["ir.attachment"].create( + { + "name": "hello world.txt", + "type": "binary", + "datas": base64.b64encode(b"Hello DAV files").decode(), + "res_model": "res.partner", + "res_id": cls.partner.id, + "mimetype": "text/plain", + } + ) + + def setUp(self): + super().setUp() + req = SimpleNamespace(env=self.env, uid=self.env.uid) + http._request_stack.push(req) + self.addCleanup(http._request_stack.pop) + + def test_dav_list_returns_folders_for_files_collection(self): + collection = Collection(f"{self.env.user.login}/{self.files_collection.id}") + + hrefs = list(collection.list()) + + self.assertEqual(len(hrefs), 1) + self.assertEqual(hrefs[0], self.partner.display_name.replace(" ", "+")) + + def test_dav_list_returns_attachments_in_folder(self): + folder_href = self.partner.display_name.replace(" ", "+") + hrefs = self.files_collection.dav_list( + collection=Collection(f"{self.env.user.login}/{self.files_collection.id}"), + path_components=[ + self.env.user.login, + str(self.files_collection.id), + folder_href, + ], + ) + + self.assertEqual(len(hrefs), 1) + self.assertIn("hello+world.txt", hrefs[0]) + + def test_dav_get_returns_folder_wrapper_for_folder_href(self): + collection = Collection(f"{self.env.user.login}/{self.files_collection.id}") + folder_href = self.partner.display_name.replace(" ", "+") + + result = collection.get(folder_href) + + self.assertTrue(result) + self.assertEqual( + result.path, + f"{self.env.user.login}/{self.files_collection.id}/{folder_href}", + ) + + def test_dav_get_returns_file_item_for_existing_attachment(self): + collection = Collection(f"{self.env.user.login}/{self.files_collection.id}") + folder_href = self.partner.display_name.replace(" ", "+") + file_href = f"{folder_href}/hello+world.txt" + + result = collection.get(file_href) + + self.assertTrue(result) + self.assertEqual(result.href, file_href) + self.assertTrue(result.last_modified.endswith("GMT")) + self.assertEqual(result.__class__.__name__, "FileItem") + + def test_dav_get_returns_none_for_missing_attachment(self): + collection = Collection(f"{self.env.user.login}/{self.files_collection.id}") + folder_href = self.partner.display_name.replace(" ", "+") + file_href = f"{folder_href}/missing.txt" + + self.assertIsNone(collection.get(file_href)) + + def test_dav_delete_is_noop_for_files_collection(self): + collection = Collection(f"{self.env.user.login}/{self.files_collection.id}") + folder_href = self.partner.display_name.replace(" ", "+") + file_href = f"{folder_href}/hello+world.txt" + + collection.delete(file_href) + + self.assertTrue(self.attachment.exists()) + + def test_dav_upload_returns_none_for_files_collection(self): + collection = Collection(f"{self.env.user.login}/{self.files_collection.id}") + folder_href = self.partner.display_name.replace(" ", "+") + file_href = f"{folder_href}/hello+world.txt" + + uploaded, previous = collection.upload(file_href, object()) + + self.assertIsNone(uploaded) + self.assertTrue(previous) + + def test_dav_list_returns_empty_for_non_files_nested_path(self): + calendar_collection = self.env["dav.collection"].create( + { + "name": "Nested Calendar Guard", + "dav_type": "calendar", + "model_id": self.env.ref("base.model_res_partner").id, + "domain": f"[('id', '=', {self.partner.id})]", + } + ) + + result = calendar_collection.dav_list( + collection=Collection(f"{self.env.user.login}/{calendar_collection.id}"), + path_components=[ + self.env.user.login, + str(calendar_collection.id), + "nested", + ], + ) + + self.assertEqual(result, []) diff --git a/base_dav/tests/test_field_mapping.py b/base_dav/tests/test_field_mapping.py new file mode 100644 index 000000000..d83006422 --- /dev/null +++ b/base_dav/tests/test_field_mapping.py @@ -0,0 +1,201 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import base64 +import datetime +from types import SimpleNamespace + +import vobject +from dateutil import tz + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestDavCollectionFieldMapping(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.partner = cls.env["res.partner"].create( + { + "name": "John Doe", + "email": "john@example.com", + } + ) + cls.collection = cls.env["dav.collection"].create( + { + "name": "Address Book", + "dav_type": "addressbook", + "model_id": cls.env.ref("base.model_res_partner").id, + "domain": "[]", + } + ) + + cls.map_name = cls.env["dav.collection.field_mapping"].create( + { + "collection_id": cls.collection.id, + "name": "FN", + "mapping_type": "simple", + "field_id": cls.env["ir.model.fields"]._get("res.partner", "name").id, + } + ) + cls.map_email = cls.env["dav.collection.field_mapping"].create( + { + "collection_id": cls.collection.id, + "name": "EMAIL", + "mapping_type": "simple", + "field_id": cls.env["ir.model.fields"]._get("res.partner", "email").id, + } + ) + cls.map_code = cls.env["dav.collection.field_mapping"].create( + { + "collection_id": cls.collection.id, + "name": "X-CUSTOM", + "mapping_type": "code", + "field_id": cls.env["ir.model.fields"]._get("res.partner", "name").id, + "import_code": "result = (item.value or '').upper()", + "export_code": "result = (record.name or '').lower()", + } + ) + cls.map_binary = cls.env["dav.collection.field_mapping"].create( + { + "collection_id": cls.collection.id, + "name": "PHOTO", + "mapping_type": "simple", + "field_id": cls.env["ir.model.fields"] + ._get("res.partner", "image_1920") + .id, + } + ) + cls.map_n = cls.env["dav.collection.field_mapping"].create( + { + "collection_id": cls.collection.id, + "name": "N", + "mapping_type": "simple", + "field_id": cls.env["ir.model.fields"]._get("res.partner", "name").id, + } + ) + + def test_from_vobject_simple_and_code(self): + card = vobject.vCard() + card.add("fn").value = "Jane" + card.add("email").value = "jane@example.com" + card.add("x-custom").value = "abc" + + self.assertEqual(self.map_name.from_vobject(card.fn), "Jane") + self.assertEqual(self.map_email.from_vobject(card.email), "jane@example.com") + self.assertEqual( + self.map_code.from_vobject(card.contents["x-custom"][0]), "ABC" + ) + + def test_to_vobject_simple_and_code(self): + self.assertEqual(self.map_name.to_vobject(self.partner), "John Doe") + self.assertEqual(self.map_email.to_vobject(self.partner), "john@example.com") + self.assertEqual(self.map_code.to_vobject(self.partner), "john doe") + + def test_to_vobject_false_returns_none(self): + partner = self.env["res.partner"].create({"name": "No Email"}) + self.assertIsNone(self.map_email.to_vobject(partner)) + + def test_datetime_helpers(self): + aware_dt = datetime.datetime( + 2024, 1, 2, 12, 30, 0, tzinfo=tz.gettz("Europe/Kyiv") + ) + item_dt = SimpleNamespace(value=aware_dt) + item_date = SimpleNamespace(value=datetime.date(2024, 1, 2)) + item_other = SimpleNamespace(value="bad") + + dt_as_odoo = self.map_name._from_vobject_datetime(item_dt) + self.assertEqual(dt_as_odoo, "2024-01-02 10:30:00") + self.assertEqual( + self.map_name._from_vobject_datetime(item_date), + "2024-01-02 00:00:00", + ) + self.assertIsNone(self.map_name._from_vobject_datetime(item_other)) + + self.assertEqual(self.map_name._from_vobject_date(item_dt), "2024-01-02") + self.assertEqual(self.map_name._from_vobject_date(item_date), "2024-01-02") + self.assertIsNone(self.map_name._from_vobject_date(item_other)) + + def test_to_vobject_datetime_helpers(self): + naive_dt = datetime.datetime(2024, 1, 2, 12, 30, 0) + aware = self.map_name._to_vobject_datetime("2024-01-02 12:30:00") + self.assertEqual(aware.tzinfo, tz.UTC) + dt_value = datetime.datetime(2024, 1, 2, 12, 30, 0) + self.assertEqual( + self.map_name._to_vobject_datetime_rev(dt_value), + "20240102T123000Z", + ) + self.assertEqual( + self.map_name._to_vobject_date("2024-01-02"), + datetime.date(2024, 1, 2), + ) + + result = self.map_name.to_vobject(self.env["res.partner"].new({"name": "Tmp"})) + self.assertEqual(result, "Tmp") + + self.assertEqual( + self.map_name._to_vobject_datetime(naive_dt).tzinfo, + tz.UTC, + ) + + def test_binary_helpers(self): + raw_jpeg = b"\xff\xd8\xffabc" + encoded = self.map_binary._from_vobject_binary(SimpleNamespace(value=raw_jpeg)) + self.assertEqual(base64.b64decode(encoded), raw_jpeg) + + valid_b64 = base64.b64encode(b"hello-world").decode("ascii") + reencoded = self.map_binary._from_vobject_binary( + SimpleNamespace(value=valid_b64) + ) + self.assertEqual(base64.b64decode(reencoded), b"hello-world") + + invalid_b64 = self.map_binary._from_vobject_binary(SimpleNamespace(value="%%%")) + self.assertEqual(base64.b64decode(invalid_b64), b"%%%") + + self.assertIsNone( + self.map_binary._from_vobject_binary(SimpleNamespace(value="")) + ) + self.assertEqual( + self.map_binary._to_vobject_binary(base64.b64encode(b"abc")), + base64.b64encode(b"abc").decode("ascii"), + ) + + def test_name_helpers(self): + name_obj = vobject.vcard.Name(family="Doe") + self.assertEqual( + self.map_n._from_vobject_char_n(SimpleNamespace(value=name_obj)), + "Doe", + ) + self.assertEqual( + self.map_n._from_vobject_char_n(SimpleNamespace(value="Doe;John")), + "Doe", + ) + self.assertIsNone(self.map_n._from_vobject_char_n(SimpleNamespace(value=None))) + + converted = self.map_n._to_vobject_char_n("Doe") + self.assertEqual(converted.family, "Doe") + + def test_simple_fallback_and_bool_conversion_paths(self): + """Verify simple mapping fallback branches.""" + note_field = self.env["ir.model.fields"]._get("res.partner", "comment") + mapping = self.env["dav.collection.field_mapping"].create( + { + "collection_id": self.collection.id, + "name": "NOTE", + "mapping_type": "simple", + "field_id": note_field.id, + } + ) + + child = SimpleNamespace(value="Some note") + self.assertEqual(mapping.from_vobject(child), "Some note") + + partner = self.env["res.partner"].create( + { + "name": "Bool Test", + "comment": False, + } + ) + self.assertIsNone(mapping.to_vobject(partner)) diff --git a/base_dav/tests/test_radicale_collection.py b/base_dav/tests/test_radicale_collection.py new file mode 100644 index 000000000..27e3f1ec7 --- /dev/null +++ b/base_dav/tests/test_radicale_collection.py @@ -0,0 +1,220 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from types import SimpleNamespace +from unittest import mock + +from odoo import http +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.base_dav.radicale.collection import ( + Collection, + Storage, + _abs_href, + _norm_path, + _rel_href, +) + + +@tagged("post_install", "-at_install") +class TestDavRadicaleCollection(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.partner = cls.env["res.partner"].create( + { + "name": "DAV Partner", + "email": "dav@example.com", + } + ) + cls.collection_record = cls.env["dav.collection"].create( + { + "name": "Contacts", + "dav_type": "addressbook", + "model_id": cls.env.ref("base.model_res_partner").id, + "domain": f"[('id', '=', {cls.partner.id})]", + } + ) + cls.env["dav.collection.field_mapping"].create( + { + "collection_id": cls.collection_record.id, + "name": "FN", + "mapping_type": "simple", + "field_id": cls.env["ir.model.fields"]._get("res.partner", "name").id, + } + ) + cls.env["dav.collection.field_mapping"].create( + { + "collection_id": cls.collection_record.id, + "name": "EMAIL", + "mapping_type": "simple", + "field_id": cls.env["ir.model.fields"]._get("res.partner", "email").id, + } + ) + + def setUp(self): + super().setUp() + self.request_obj = SimpleNamespace(env=self.env, uid=self.env.uid) + http._request_stack.push(self.request_obj) + self.addCleanup(http._request_stack.pop) + + def test_path_helpers(self): + self.assertEqual(_norm_path("/demo//x/"), "demo/x") + self.assertEqual(_abs_href("user/7", "15"), "/user/7/15") + self.assertEqual(_abs_href("user/7", "/user/7/15"), "/user/7/15") + self.assertEqual(_rel_href("user/7", "/user/7/15"), "15") + self.assertEqual(_rel_href("", "/abc"), "abc") + + def test_root_and_principal_listing(self): + root = Collection("") + self.assertIn(self.env.user.login, list(root.list())) + + principal = Collection(self.env.user.login) + children = list(principal.list()) + self.assertIn(f"{self.env.user.login}/{self.collection_record.id}", children) + + def test_collection_list_get_and_get_multi(self): + collection = Collection(f"{self.env.user.login}/{self.collection_record.id}") + href = str(self.partner.id) + + listed = list(collection.list()) + self.assertIn(href, listed) + + item = collection.get(href) + self.assertTrue(item) + self.assertEqual(item.href, href) + + multi = list(collection.get_multi([href, href])) + self.assertEqual(len(multi), 1) + self.assertEqual(multi[0][0], href) + self.assertTrue(multi[0][1]) + + def test_collection_upload_delete_meta_and_last_modified(self): + collection = Collection(f"{self.env.user.login}/{self.collection_record.id}") + href = str(self.partner.id) + + old_item = collection.get(href) + vobj = old_item.vobject_item + vobj.email.value = "updated@example.com" + + uploaded, previous = collection.upload(href, vobj) + self.assertTrue(uploaded) + self.assertTrue(previous) + self.partner.invalidate_recordset(["email"]) + self.assertEqual(self.partner.email, "updated@example.com") + + self.assertEqual(collection.get_meta(), {}) + self.assertEqual(collection.get_meta("tag"), "VADDRESSBOOK") + self.assertEqual( + collection.get_meta("D:displayname"), + self.collection_record.display_name, + ) + self.assertEqual( + collection.get_meta("C:supported-calendar-component-set"), + "VTODO,VEVENT,VJOURNAL", + ) + self.assertEqual(collection.get_meta("ICAL:calendar-color"), "#48c9f4") + self.assertTrue(collection.last_modified) + + collection.delete(href) + self.assertFalse(self.partner.exists()) + + def test_collection_upload_delete_guards(self): + not_collection = Collection("unknown/path") + with self.assertRaises(ValueError): + not_collection.upload("1", SimpleNamespace()) + with self.assertRaises(ValueError): + not_collection.delete("1") + + real_collection = Collection( + f"{self.env.user.login}/{self.collection_record.id}" + ) + with self.assertRaises(NotImplementedError): + real_collection.delete() + + def test_storage_discover_and_guard_methods(self): + storage = Storage(configuration=mock.Mock()) + collection_path = f"{self.env.user.login}/{self.collection_record.id}" + item_path = f"{collection_path}/{self.partner.id}" + + zero = list(storage.discover(collection_path, depth="0")) + self.assertEqual(len(zero), 1) + self.assertIsInstance(zero[0], Collection) + + deep = list(storage.discover(collection_path, depth="1")) + self.assertTrue(deep) + self.assertEqual(deep[0].path, collection_path) + + item = list(storage.discover(item_path, depth="0")) + self.assertEqual(len(item), 1) + self.assertTrue(item[0]) + + self.assertEqual(list(storage.discover(f"{self.env.user.login}/999999/1")), []) + + with self.assertRaises(NotImplementedError): + storage.move(mock.Mock(), mock.Mock(), "x") + with self.assertRaises(NotImplementedError): + storage.create_collection("x") + + with storage.acquire_lock("r", user=self.env.user.login): + pass + + self.assertTrue(Storage(configuration=mock.Mock()).verify()) + + def test_collection_get_all_skips_missing_items(self): + """Verify get_all skips empty items returned by get().""" + collection = Collection(f"{self.env.user.login}/{self.collection_record.id}") + + with ( + mock.patch.object( + collection, + "list", + return_value=["1", "2", "3"], + ), + mock.patch.object( + collection, + "get", + side_effect=[object(), None, object()], + ), + ): + items = list(collection.get_all()) + + self.assertEqual(len(items), 2) + + def test_collection_get_meta_without_record(self): + """Verify get_meta for non-collection path.""" + collection = Collection("unknown/path") + + self.assertIsNone(collection.get_meta("tag")) + self.assertEqual(collection.get_meta(), {}) + self.assertEqual(collection.last_modified, "") + + def test_file_item_invalid_base64_is_handled(self): + """Verify FileItem tolerates invalid attachment base64.""" + from ..radicale.collection import FileItem + + attachment = SimpleNamespace(datas="%%%") + collection = Collection(f"{self.env.user.login}/{self.collection_record.id}") + + item = FileItem( + collection=collection, + href="bad.txt", + attachment=attachment, + last_modified="", + ) + + self.assertEqual(item.href, "bad.txt") + self.assertEqual(item.last_modified, "") + + def test_storage_discover_root_and_principal_depth_one(self): + """Verify discover returns child collections for root/principal paths.""" + storage = Storage(configuration=mock.Mock()) + + root_items = list(storage.discover("", depth="1")) + self.assertTrue(root_items) + self.assertEqual(root_items[0].path, "") + + principal_items = list(storage.discover(self.env.user.login, depth="1")) + self.assertTrue(principal_items) + self.assertEqual(principal_items[0].path, self.env.user.login) diff --git a/base_dav/tests/test_vcard_false_values.py b/base_dav/tests/test_vcard_false_values.py new file mode 100644 index 000000000..b8f46c0c2 --- /dev/null +++ b/base_dav/tests/test_vcard_false_values.py @@ -0,0 +1,51 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import vobject + +from odoo.tests.common import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestVCardFalseValues(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.partner = cls.env["res.partner"].create( + {"name": "DAV Partner"} + ) # email=False + cls.collection = cls.env["dav.collection"].create( + { + "name": "Test Addressbook", + "dav_type": "addressbook", + "model_id": cls.env.ref("base.model_res_partner").id, + "domain": f"[('id', '=', {cls.partner.id})]", + } + ) + + cls.map_email = cls.env["dav.collection.field_mapping"].create( + { + "collection_id": cls.collection.id, + "name": "EMAIL", + "mapping_type": "simple", + "field_id": cls.env["ir.model.fields"]._get("res.partner", "email").id, + } + ) + + def test_simple_mapping_false_must_not_break_vobject(self): + """Ensure empty Char fields are not exported as bool into vCard. + + Expected behavior: + - Before fix: to_vobject() returns False -> vobject.serialize() must crash + - After fix: to_vobject() returns None -> vobject.serialize() must succeed + """ + exported = self.map_email.to_vobject(self.partner) + card = vobject.vCard() + if exported is False: + card.add("email").value = exported + with self.assertRaises(AttributeError): + card.serialize() + else: + self.assertIsNone(exported) + card.add("fn").value = "DAV Partner" + card.serialize() # must not raise diff --git a/base_dav/views/dav_collection.xml b/base_dav/views/dav_collection.xml new file mode 100644 index 000000000..da1442b24 --- /dev/null +++ b/base_dav/views/dav_collection.xml @@ -0,0 +1,77 @@ + + + dav.collection + + + + + + + + + + + dav.collection + +
+ + + + + + + + + + + + + + + +
+
+
+ + + dav.collection.field_mapping + + + + + + + + + + + dav.collection.field_mapping + +
+ + + + + + + + + +
+
+
+ + + WebDAV collections + dav.collection + list,form + + + +
diff --git a/requirements.txt b/requirements.txt index bad81316e..3aeeb5bf0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,6 @@ mysqlclient pymssql<=2.2.5 ; python_version <= '3.10' pymssql<=2.2.8 ; python_version > '3.10' +radicale sqlalchemy vobject diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..1f6361c09 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-base_dav @ git+https://github.com/OCA/server-backend.git@refs/pull/431/head#subdirectory=base_dav