diff --git a/README.md b/README.md index 5809322d9..843305145 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Available addons addon | version | maintainers | summary --- | --- | --- | --- [base_user_role](base_user_role/) | 17.0.1.0.1 | [![sebalix](https://github.com/sebalix.png?size=30px)](https://github.com/sebalix) [![jcdrubay](https://github.com/jcdrubay.png?size=30px)](https://github.com/jcdrubay) [![novawish](https://github.com/novawish.png?size=30px)](https://github.com/novawish) | User roles +[base_user_role_company](base_user_role_company/) | 17.0.1.0.0 | | User roles by company [//]: # (end addons) diff --git a/base_dav/README.rst b/base_dav/README.rst new file mode 100644 index 000000000..7f22e96a3 --- /dev/null +++ b/base_dav/README.rst @@ -0,0 +1,128 @@ +========================== +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/17.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-17-0/server-backend-17-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=17.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. + +In order to install this module and use it make sure you use right version of `radicale==2.1.12` library. + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* initOS GmbH +* Therp BV + +Contributors +------------ + +- Holger Brunn +- Florian Kantelberg + +Other credits +------------- + +- Odoo Community Association: + `Icon `__ +- All the actual work is done by `Radicale `__ + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-backend `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_dav/__init__.py b/base_dav/__init__.py new file mode 100644 index 000000000..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..9e6075592 --- /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": "17.0.1.0.0", + "author": "initOS GmbH,Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Extra Tools", + "summary": "Access Odoo data as calendar or address book", + "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==2.1.12"], + }, +} 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..967d7970a --- /dev/null +++ b/base_dav/controllers/main.py @@ -0,0 +1,67 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging +from configparser import RawConfigParser as ConfigParser + +import werkzeug + +from odoo import http +from odoo.http import request + +try: + import radicale + # from radicale import config +except ImportError: + radicale = None + +PREFIX = "/.dav" + + +class Main(http.Controller): + @http.route( + ["/.well-known/carddav", "/.well-known/caldav", "/.well-known/webdav"], + type="http", + auth="none", + csrf=False, + ) + def handle_well_known_request(self): + return werkzeug.utils.redirect(PREFIX, 301) + + @http.route( + [PREFIX, "%s/" % PREFIX], + type="http", + auth="none", + csrf=False, + ) + def handle_dav_request(self, davpath=None): + config = ConfigParser() + for section, values in radicale.config.INITIAL_CONFIG.items(): + config.add_section(section) + for key, data in values.items(): + config.set(section, key, data["value"]) + config.set("auth", "type", "odoo.addons.base_dav.radicale.auth") + config.set("storage", "type", "odoo.addons.base_dav.radicale.collection") + config.set("rights", "type", "odoo.addons.base_dav.radicale.rights") + config.set("web", "type", "none") + application = radicale.Application( + config, + logging.getLogger("radicale"), + ) + + response = None + + def start_response(status, headers): + nonlocal response + response = http.Response(status=status, headers=headers) + + result = application( + dict( + request.httprequest.environ, + HTTP_X_SCRIPT_NAME=PREFIX, + PATH_INFO=davpath or "", + ), + start_response, + ) + response.stream.write(result and result[0] or b"") + return response diff --git a/base_dav/demo/dav_collection.xml b/base_dav/demo/dav_collection.xml new file mode 100644 index 000000000..b038d687c --- /dev/null +++ b/base_dav/demo/dav_collection.xml @@ -0,0 +1,34 @@ + + + + Addressbook + addressbook + + [] + + + 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..2e0939ca5 --- /dev/null +++ b/base_dav/i18n/base_dav.pot @@ -0,0 +1,215 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_dav +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 11.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_dav +#: model:ir.model,name:base_dav.model_dav_collection +msgid "A collection accessible via WebDAV" +msgstr "" + +#. module: base_dav +#: model:ir.model,name:base_dav.model_dav_collection_field_mapping +msgid "A field mapping for a WebDAV collection" +msgstr "" + +#. module: base_dav +#: model:ir.ui.view,arch_db:base_dav.view_dav_collection_form +msgid "Access" +msgstr "" + +#. module: base_dav +#: model:ir.ui.view,arch_db:base_dav.view_dav_collection_form +msgid "Additional field mapping" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,dav_type:0 +msgid "Addressbook" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_name +msgid "Attribute name in the vobject" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,rights:0 +msgid "Authenticated" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,dav_type:0 +msgid "Calendar" +msgstr "" + +#. module: base_dav +#: selection:dav.collection.field_mapping,mapping_type:0 +msgid "Code" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_export_code +msgid "Code to export the value to a vobject. Use the variable result for the output of the value and record as input" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_import_code +msgid "Code to import the value from a vobject. Use the variable result for the output of the value and item as input" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_collection_id +msgid "Collection" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_create_uid +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_create_uid +msgid "Created by" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_create_date +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_create_date +msgid "Created on" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_display_name +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_display_name +msgid "Display Name" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_domain +msgid "Domain" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_export_code +msgid "Export Code" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_field_id +msgid "Field" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_uuid +msgid "Field Uuid" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_ids +msgid "Field mappings" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,help:base_dav.field_dav_collection_field_mapping_field_id +msgid "Field of the model the values are mapped to" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,dav_type:0 +msgid "Files" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_id +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_id +msgid "ID" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_import_code +msgid "Import Code" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection___last_update +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping___last_update +msgid "Last Modified on" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_write_uid +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_write_uid +msgid "Last Updated by" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_write_date +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_write_date +msgid "Last Updated on" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_mapping_type +msgid "Mapping Type" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_model_id +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_model_id +msgid "Model" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_field_mapping_name +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_name +msgid "Name" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,rights:0 +msgid "Owner Only" +msgstr "" + +#. module: base_dav +#: selection:dav.collection,rights:0 +msgid "Owner Write Only" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_rights +msgid "Rights" +msgstr "" + +#. module: base_dav +#: selection:dav.collection.field_mapping,mapping_type:0 +msgid "Simple" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_tag +msgid "Tag" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_dav_type +msgid "Type" +msgstr "" + +#. module: base_dav +#: model:ir.model.fields,field_description:base_dav.field_dav_collection_url +msgid "Url" +msgstr "" + +#. module: base_dav +#: model:ir.actions.act_window,name:base_dav.action_dav_collection +#: model:ir.ui.menu,name:base_dav.menu_dav_collection +msgid "WebDAV collections" +msgstr "" + diff --git a/base_dav/models/__init__.py b/base_dav/models/__init__.py new file mode 100644 index 000000000..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..9009468eb --- /dev/null +++ b/base_dav/models/dav_collection.py @@ -0,0 +1,301 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import os +import time +from operator import itemgetter +from urllib.parse import quote_plus + +import vobject + +from odoo import SUPERUSER_ID, api, fields, models +from odoo.tools.safe_eval import safe_eval + +from odoo.addons.base_dav.radicale.collection import Collection, FileItem, Item + +# pylint: disable=missing-import-error +from ..controllers.main import PREFIX + + +class DavCollection(models.Model): + _name = "dav.collection" + _description = "A collection accessible via WebDAV" + + name = fields.Char(required=True) + rights = fields.Selection( + [ + ("owner_only", "Owner Only"), + ("owner_write_only", "Owner Write Only"), + ("authenticated", "Authenticated"), + ], + required=True, + default="owner_only", + ) + dav_type = fields.Selection( + [ + ("calendar", "Calendar"), + ("addressbook", "Addressbook"), + ("files", "Files"), + ], + string="Type", + required=True, + default="calendar", + ) + tag = fields.Char(compute="_compute_tag") + model_id = fields.Many2one( + "ir.model", + string="Model", + required=True, + domain=[("transient", "=", False)], + 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") + + def _compute_tag(self): + for this in self: + if this.dav_type == "calendar": + this.tag = "VCALENDAR" + elif this.dav_type == "addressbook": + this.tag = "VADDRESSBOOK" + + def _compute_url(self): + base_url = self.env["ir.config_parameter"].get_param("web.base.url") + for this in self: + this.url = f"{base_url}{PREFIX}/{self.env.user.login}/{this.id}" + + @api.constrains("domain") + def _check_domain(self): + self._eval_domain() + + @api.model + def _eval_context(self): + return { + "user": self.env.user, + } + + def _eval_domain(self): + self.ensure_one() + return list(safe_eval(self.domain, self._eval_context())) + + def eval(self): + if not self: + return self.env["unknown"] + self.ensure_one() + self = self.with_user(SUPERUSER_ID) + return self.env[self.model_id.model].search(self._eval_domain()) + + def get_record(self, components): + self.ensure_one() + self = self.with_user(SUPERUSER_ID) + collection_model = self.env[self.model_id.model] + + field_name = self.field_uuid.name or "id" + domain = [(field_name, "=", components[-1])] + self._eval_domain() + return collection_model.search(domain, limit=1) + + def from_vobject(self, item): + self.ensure_one() + + result = {} + if self.dav_type == "calendar": + if item.name != "VCALENDAR": + return None + if not hasattr(item, "vevent"): + return None + item = item.vevent + elif self.dav_type == "addressbook" and item.name != "VCARD": + return None + + children = {c.name.lower(): c for c in item.getChildren()} + for mapping in self.field_mapping_ids: + name = mapping.name.lower() + if name not in children: + continue + + if name in children: + value = mapping.from_vobject(children[name]) + if value: + result[mapping.field_id.name] = value + + return result + + def to_vobject(self, record): + self.ensure_one() + self = self.with_user(SUPERUSER_ID) + result = None + vobj = None + if self.dav_type == "calendar": + result = vobject.iCalendar() + vobj = result.add("vevent") + if self.dav_type == "addressbook": + result = vobject.vCard() + vobj = result + for mapping in self.field_mapping_ids: + value = mapping.to_vobject(record) + if value: + vobj.add(mapping.name).value = value + + if "uid" not in vobj.contents: + vobj.add("uid").value = f"{record._name},{record.id}" + if "rev" not in vobj.contents and "write_date" in record._fields: + vobj.add("rev").value = ( + str(record.write_date) + .replace(":", "") + .replace(" ", "T") + .replace(".", "") + + "Z" + ) + return result + + @api.model + def _odoo_to_http_datetime(self, value): + value = str(value).split(".")[0] + return time.strftime( + "%a, %d %b %Y %H:%M:%S GMT", + time.strptime(value, "%Y-%m-%d %H:%M:%S"), + ) + + @api.model + def _split_path(self, path): + return list(filter(None, os.path.normpath(path or "").strip("/").split("/"))) + + def dav_list(self, collection, path_components): + self.ensure_one() + + if self.dav_type == "files": + if len(path_components) == 3: + collection_model = self.env[self.model_id.model] + record = collection_model.browse( + map( + itemgetter(0), + collection_model.name_search( + path_components[2], + operator="=", + limit=1, + ), + ) + ) + return [ + "/" + "/".join(path_components + [quote_plus(attachment.name)]) + for attachment in self.env["ir.attachment"].search( + [ + ("type", "=", "binary"), + ("res_model", "=", record._name), + ("res_id", "=", record.id), + ] + ) + ] + elif len(path_components) == 2: + return [ + "/" + "/".join(path_components + [quote_plus(record.display_name)]) + for record in self.eval() + ] + + if len(path_components) > 2: + return [] + + result = [] + for record in self.eval(): + if self.field_uuid: + uuid = record[self.field_uuid.name] + else: + uuid = str(record.id) + result.append("/" + "/".join(path_components + [uuid])) + return result + + def dav_delete(self, collection, components): + self.ensure_one() + + if self.dav_type == "files": + # TODO: Handle deletion of attachments + pass + else: + self.get_record(components).unlink() + + def dav_upload(self, collection, href, item): + self.ensure_one() + + components = self._split_path(href) + collection_model = self.env[self.model_id.model] + if self.dav_type == "files": + # TODO: Handle upload of attachments + return None + + data = self.from_vobject(item) + record = self.get_record(components) + + if not record: + if self.field_uuid: + data[self.field_uuid.name] = components[-1] + + record = collection_model.create(data) + uuid = components[-1] if self.field_uuid else record.id + href = f"{href}/{uuid}" + else: + record.write(data) + + return Item( + collection, + item=self.to_vobject(record), + href=href, + last_modified=self._odoo_to_http_datetime(record.write_date), + ) + + def dav_get(self, collection, href): + self.ensure_one() + + components = self._split_path(href) + collection_model = self.env[self.model_id.model] + if self.dav_type == "files": + if len(components) == 3: + result = Collection(href) + result.logger = self.logger + return result + if len(components) == 4: + record = collection_model.browse( + map( + itemgetter(0), + collection_model.name_search( + components[2], + operator="=", + limit=1, + ), + ) + ) + attachment = self.env["ir.attachment"].search( + [ + ("type", "=", "binary"), + ("res_model", "=", record._name), + ("res_id", "=", record.id), + ("name", "=", components[3]), + ], + limit=1, + ) + return FileItem( + collection, + item=attachment, + href=href, + last_modified=self._odoo_to_http_datetime(record.write_date), + ) + + record = self.get_record(components) + + if not record: + return None + + return Item( + collection, + item=self.to_vobject(record), + href=href, + last_modified=self._odoo_to_http_datetime(record.write_date), + ) diff --git a/base_dav/models/dav_collection_field_mapping.py b/base_dav/models/dav_collection_field_mapping.py new file mode 100644 index 000000000..17ee8fc6c --- /dev/null +++ b/base_dav/models/dav_collection_field_mapping.py @@ -0,0 +1,173 @@ +# Copyright 2019 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import datetime + +import dateutil +import vobject +from dateutil import tz + +from odoo import api, fields, models, tools +from odoo.tools import safe_eval + + +class DavCollectionFieldMapping(models.Model): + _name = "dav.collection.field_mapping" + _description = "A field mapping for a WebDAV collection" + + collection_id = fields.Many2one( + "dav.collection", + required=True, + ondelete="cascade", + ) + name = fields.Char( + required=True, + help="Attribute name in the vobject", + ) + mapping_type = fields.Selection( + [ + ("simple", "Simple"), + ("code", "Code"), + ], + default="simple", + required=True, + ) + field_id = fields.Many2one( + "ir.model.fields", + required=True, + help="Field of the model the values are mapped to", + ondelete="cascade", + ) + model_id = fields.Many2one( + "ir.model", related="collection_id.model_id", ondelete="cascade" + ) + import_code = fields.Text( + help="Code to import the value from a vobject. Use the variable " + "result for the output of the value and item as input" + ) + export_code = fields.Text( + help="Code to export the value to a vobject. Use the variable " + "result for the output of the value and record as input" + ) + + def from_vobject(self, child): + self.ensure_one() + if self.mapping_type == "code": + return self._from_vobject_code(child) + return self._from_vobject_simple(child) + + def _from_vobject_code(self, child): + self.ensure_one() + context = { + "datetime": safe_eval.datetime, + "dateutil": safe_eval.dateutil, + "item": child, + "result": None, + # "tools": tools, + # "tz": tz, + # "vobject": vobject, + } + safe_eval.safe_eval(self.import_code, context, mode="exec", nocopy=True) + return context.get("result", {}) + + def _from_vobject_simple(self, child): + self.ensure_one() + name = self.name.lower() + conversion_funcs = [ + f"_from_vobject_{self.field_id.ttype}_{name}", + f"_from_vobject_{self.field_id.ttype}", + ] + + for conversion_func in conversion_funcs: + if hasattr(self, conversion_func): + value = getattr(self, conversion_func)(child) + if value: + return value + + return child.value + + @api.model + def _from_vobject_datetime(self, item): + if isinstance(item.value, datetime.datetime): + value = item.value.astimezone(dateutil.tz.UTC) + return value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + elif isinstance(item.value, datetime.date): + return item.value.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) + return None + + @api.model + def _from_vobject_date(self, item): + if isinstance(item.value, datetime.datetime): + value = item.value.astimezone(dateutil.tz.UTC) + return value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + elif isinstance(item.value, datetime.date): + return item.value.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + return None + + @api.model + def _from_vobject_binary(self, item): + return item.value.encode("ascii") + + @api.model + def _from_vobject_char_n(self, item): + return item.family + + def to_vobject(self, record): + self.ensure_one() + if self.mapping_type == "code": + result = self._to_vobject_code(record) + else: + result = self._to_vobject_simple(record) + + if isinstance(result, datetime.datetime) and not result.tzinfo: + return result.replace(tzinfo=tz.UTC) + return result + + def _to_vobject_code(self, record): + self.ensure_one() + context = { + "datetime": safe_eval.datetime, + "dateutil": safe_eval.dateutil, + "record": record, + "result": None, + # "tools": tools, + # "tz": tz, + # "vobject": vobject, + } + safe_eval.safe_eval(self.export_code, context, mode="exec", nocopy=True) + return context.get("result", None) + + def _to_vobject_simple(self, record): + self.ensure_one() + conversion_funcs = [ + f"_to_vobject_{self.field_id.ttype}_{self.name.lower()}", + f"_to_vobject_{self.field_id.ttype}", + ] + value = record[self.field_id.name] + for conversion_func in conversion_funcs: + if hasattr(self, conversion_func): + return getattr(self, conversion_func)(value) + return value + + @api.model + def _to_vobject_datetime(self, value): + result = fields.Datetime.from_string(value) + return result.replace(tzinfo=tz.UTC) + + @api.model + def _to_vobject_datetime_rev(self, value): + return value and value.replace("-", "").replace(" ", "T").replace(":", "") + "Z" + + @api.model + def _to_vobject_date(self, value): + return fields.Date.from_string(value) + + @api.model + def _to_vobject_binary(self, value): + return value and value.decode("ascii") + + @api.model + def _to_vobject_char_n(self, value): + # TODO: how are we going to handle compound types like this? + return vobject.vcard.Name(family=value) 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..7c855a0d2 --- /dev/null +++ b/base_dav/radicale/auth.py @@ -0,0 +1,20 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo.http import request + +try: + from radicale.auth import BaseAuth +except ImportError: + BaseAuth = None + + +class Auth(BaseAuth): + def is_authenticated2(self, login, user, password): + env = request.env + uid = env["res.users"]._login( + env.cr.dbname, user, password, user_agent_env={"interactive": True} + ) + if uid: + request._env = env(user=uid) + return bool(uid) diff --git a/base_dav/radicale/collection.py b/base_dav/radicale/collection.py new file mode 100644 index 000000000..3be55436b --- /dev/null +++ b/base_dav/radicale/collection.py @@ -0,0 +1,130 @@ +# 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 os +import time +from contextlib import contextmanager + +from odoo.http import request + +try: + from radicale.storage import BaseCollection, Item, get_etag +except ImportError: + BaseCollection = None + Item = None + get_etag = None + + +class BytesPretendingToBeString(bytes): + # radicale expects a string as file content, so we provide the str + # functions needed + def encode(self, encoding): + return self + + +class FileItem(Item): + """this item tricks radicalev into serving a plain file""" + + @property + def name(self): + return "VCARD" + + def serialize(self): + return BytesPretendingToBeString(base64.b64decode(self.item.datas)) + + @property + def etag(self): + return get_etag(self.item.datas.decode("ascii")) + + +class Collection(BaseCollection): + @classmethod + def static_init(cls): + pass + + @classmethod + def _split_path(cls, path): + return list(filter(None, os.path.normpath(path or "").strip("/").split("/"))) + + @classmethod + def discover(cls, path, depth=None): + depth = int(depth or "0") + components = cls._split_path(path) + collection = cls(path) + if len(components) > 2: + # TODO: this probably better should happen in some dav.collection + # function + if collection.collection.dav_type == "files" and depth: + for href in collection.list(): + yield collection.get(href) + return + yield collection.get(path) + return + yield collection + if depth and len(components) == 1: + for collection in request.env["dav.collection"].search([]): + yield cls("/".join(components + ["/%d" % collection.id])) + if depth and len(components) == 2: + for href in collection.list(): + yield collection.get(href) + + @classmethod + @contextmanager + def acquire_lock(cls, mode, user=None): + """We have a database for that""" + yield + + @property + def env(self): + return request.env + + @property + def last_modified(self): + return self._odoo_to_http_datetime(self.collection.create_date) + + def __init__(self, path): + self.path_components = self._split_path(path) + self.path = "/".join(self.path_components) or "/" + self.collection = self.env["dav.collection"] + if len(self.path_components) >= 2 and str(self.path_components[1]).isdigit(): + self.collection = self.env["dav.collection"].browse( + int(self.path_components[1]) + ) + + def _odoo_to_http_datetime(self, value): + value = str(value).split(".")[0] + return time.strftime( + "%a, %d %b %Y %H:%M:%S GMT", + time.strptime(value, "%Y-%m-%d %H:%M:%S"), + ) + + def get_meta(self, key=None): + if key is None: + return {} + elif key == "tag": + return self.collection.tag + elif key == "D:displayname": + return self.collection.display_name + elif key == "C:supported-calendar-component-set": + return "VTODO,VEVENT,VJOURNAL" + elif key == "C:calendar-home-set": + return None + elif key == "D:principal-URL": + return None + elif key == "ICAL:calendar-color": + # TODO: set in dav.collection + return "#48c9f4" + self.logger.warning("unsupported metadata %s", key) + + def get(self, href): + return self.collection.dav_get(self, href) + + def upload(self, href, vobject_item): + return self.collection.dav_upload(self, href, vobject_item) + + def delete(self, href): + return self.collection.dav_delete(self, self._split_path(href)) + + def list(self): + return self.collection.dav_list(self, self.path_components) diff --git a/base_dav/radicale/rights.py b/base_dav/radicale/rights.py new file mode 100644 index 000000000..fb03c19bb --- /dev/null +++ b/base_dav/radicale/rights.py @@ -0,0 +1,33 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from .collection import Collection + +try: + from radicale.rights import ( + AuthenticatedRights, + OwnerOnlyRights, + OwnerWriteRights, + ) +except ImportError: + AuthenticatedRights = OwnerOnlyRights = OwnerWriteRights = None + + +class Rights(OwnerOnlyRights, OwnerWriteRights, AuthenticatedRights): + def authorized(self, user, path, perm): + if path == "/": + return True + + collection = Collection(path) + if not collection.collection: + return False + + rights = collection.collection.sudo().rights + cls = { + "owner_only": OwnerOnlyRights, + "owner_write_only": OwnerWriteRights, + "authenticated": AuthenticatedRights, + }.get(rights) + if not cls: + return False + return cls.authorized(self, user, path, perm) diff --git a/base_dav/readme/CONFIGURE.md b/base_dav/readme/CONFIGURE.md new file mode 100644 index 000000000..4857fccff --- /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/CONFIGURE.rst b/base_dav/readme/CONFIGURE.rst new file mode 100755 index 000000000..dc57f0e19 --- /dev/null +++ b/base_dav/readme/CONFIGURE.rst @@ -0,0 +1,5 @@ +To configure this module, you need to: + +#. go to `Settings / WebDAV Collections` and create or edit your collections. There, you'll also see the URL to point your clients to. + +Note that you need to configure a dbfilter if you use multiple databases. diff --git a/base_dav/readme/CONTRIBUTORS.md b/base_dav/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..647f55bc2 --- /dev/null +++ b/base_dav/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Holger Brunn \<\> +- Florian Kantelberg \<\> diff --git a/base_dav/readme/CONTRIBUTORS.rst b/base_dav/readme/CONTRIBUTORS.rst new file mode 100755 index 000000000..9c7447f2a --- /dev/null +++ b/base_dav/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Holger Brunn +* Florian Kantelberg diff --git a/base_dav/readme/CREDITS.md b/base_dav/readme/CREDITS.md new file mode 100644 index 000000000..af1341db0 --- /dev/null +++ b/base_dav/readme/CREDITS.md @@ -0,0 +1,3 @@ +- Odoo Community Association: + [Icon](https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg) +- All the actual work is done by [Radicale](https://radicale.org) diff --git a/base_dav/readme/CREDITS.rst b/base_dav/readme/CREDITS.rst new file mode 100755 index 000000000..b83250bd3 --- /dev/null +++ b/base_dav/readme/CREDITS.rst @@ -0,0 +1,2 @@ +* Odoo Community Association: `Icon `_ +* All the actual work is done by `Radicale `_ diff --git a/base_dav/readme/DESCRIPTION.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/DESCRIPTION.rst b/base_dav/readme/DESCRIPTION.rst new file mode 100755 index 000000000..5b4aad0b7 --- /dev/null +++ b/base_dav/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module adds WebDAV support to Odoo, specifically CalDAV and CardDAV. + +You can configure arbitrary objects as a calendar or an address book, thus make arbitrary information accessible in external systems or your mobile. diff --git a/base_dav/readme/ROADMAP.md b/base_dav/readme/ROADMAP.md new file mode 100644 index 000000000..a8273c508 --- /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/readme/ROADMAP.rst b/base_dav/readme/ROADMAP.rst new file mode 100755 index 000000000..9897f6237 --- /dev/null +++ b/base_dav/readme/ROADMAP.rst @@ -0,0 +1,7 @@ +* much better UX for configuring collections (probably provide a group that sees the current fully flexible field mappings, and by default show some dumbed down version where you can select some preselected vobject fields) +* support todo lists and journals +* support configuring default field mappings per model +* support plain WebDAV collections to make some model's records accessible as folders, and the records' attachments as files (r/w) +* support configuring lists of calendars so that you can have a calendar for every project and appointments are tasks, or a calendar for every sales team and appointments are sale orders. Lots of possibilities + +Backporting this to <=v10 will be tricky because radicale only supports python3. Probably it will be quite a hassle to backport the relevant code, so it might be more sensible to just backport the configuration part, and implement the rest as radicale auth/storage plugin that talks to Odoo via odoorpc. It should be possible to recycle most of the code from this addon, which actually implements those plugins, but then within Odoo. diff --git a/base_dav/security/ir.model.access.csv b/base_dav/security/ir.model.access.csv new file mode 100644 index 000000000..3d2d4d57b --- /dev/null +++ b/base_dav/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_dav_collection,access_dav_collection,model_dav_collection,base.group_user,1,0,0,0 +access_dav_collection_field_mapping,access_dav_collection_field_mapping,model_dav_collection_field_mapping,base.group_user,1,0,0,0 diff --git a/base_dav/static/description/icon.png b/base_dav/static/description/icon.png new file mode 100755 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 100755 index 000000000..7cf5816ad --- /dev/null +++ b/base_dav/static/description/index.html @@ -0,0 +1,471 @@ + + + + + +Caldav and Carddav support + + + +
+

Caldav and Carddav support

+ + +

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

+

This module adds WebDAV support to Odoo, specifically CalDAV and +CardDAV.

+

You can configure arbitrary objects as a calendar or an address book, +thus make arbitrary information accessible in external systems or your +mobile.

+

Table of contents

+ +
+

Configuration

+

To configure this module, you need to:

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

Note that you need to configure a dbfilter if you use multiple +databases.

+
+
+

Known issues / Roadmap

+
    +
  • much better UX for configuring collections (probably provide a group +that sees the current fully flexible field mappings, and by default +show some dumbed down version where you can select some preselected +vobject fields)
  • +
  • support todo lists and journals
  • +
  • support configuring default field mappings per model
  • +
  • support plain WebDAV collections to make some model’s records +accessible as folders, and the records’ attachments as files (r/w)
  • +
  • support configuring lists of calendars so that you can have a +calendar for every project and appointments are tasks, or a calendar +for every sales team and appointments are sale orders. Lots of +possibilities
  • +
+

Backporting this to <=v10 will be tricky because radicale only supports +python3. Probably it will be quite a hassle to backport the relevant +code, so it might be more sensible to just backport the configuration +part, and implement the rest as radicale auth/storage plugin that talks +to Odoo via odoorpc. It should be possible to recycle most of the code +from this addon, which actually implements those plugins, but then +within Odoo.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

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

Contributors

+ +
+
+

Other credits

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

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-backend project on GitHub.

+

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

+
+
+
+ + diff --git a/base_dav/tests/__init__.py b/base_dav/tests/__init__.py new file mode 100644 index 000000000..09e6f8403 --- /dev/null +++ b/base_dav/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import test_base_dav, test_collection diff --git a/base_dav/tests/test_base_dav.py b/base_dav/tests/test_base_dav.py new file mode 100644 index 000000000..ac00c0a24 --- /dev/null +++ b/base_dav/tests/test_base_dav.py @@ -0,0 +1,123 @@ +# Copyright 2018 Therp BV +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from base64 import b64encode +from unittest import mock +from urllib.parse import urlparse + +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + +from ..controllers.main import PREFIX +from ..controllers.main import Main as Controller + +MODULE_PATH = "odoo.addons.base_dav" +CONTROLLER_PATH = MODULE_PATH + ".controllers.main" +RADICALE_PATH = MODULE_PATH + ".radicale" + +ADMIN_PASSWORD = "RadicalePa$$word" + + +@mute_logger("radicale") +@mock.patch(CONTROLLER_PATH + ".request") +@mock.patch(RADICALE_PATH + ".auth.request") +@mock.patch(RADICALE_PATH + ".collection.request") +class TestBaseDav(TransactionCase): + def setUp(self): + super().setUp() + + self.collection = self.env["dav.collection"].create( + { + "name": "Test Collection", + "dav_type": "calendar", + "model_id": self.env.ref("base.model_res_users").id, + "domain": "[]", + } + ) + + self.dav_path = urlparse(self.collection.url).path.replace(PREFIX, "") + + self.controller = Controller() + self.env.user.password_crypt = ADMIN_PASSWORD + + self.test_user = self.env["res.users"].create( + { + "login": "tester", + "name": "tester", + } + ) + + self.auth_owner = self.auth_string(self.env.user, ADMIN_PASSWORD) + self.auth_tester = self.auth_string(self.test_user, ADMIN_PASSWORD) + + patcher = mock.patch("odoo.http.request") + self.addCleanup(patcher.stop) + patcher.start() + + def auth_string(self, user, password): + return b64encode((f"{user.login}:{password}").encode()).decode() + + def init_mocks(self, coll_mock, auth_mock, req_mock): + req_mock.env = self.env + req_mock.httprequest.environ = { + "HTTP_AUTHORIZATION": "Basic %s" % self.auth_owner, + "REQUEST_METHOD": "PROPFIND", + "HTTP_X_SCRIPT_NAME": PREFIX, + } + + auth_mock.env["res.users"]._login.return_value = self.env.uid + coll_mock.env = self.env + + def check_status_code(self, response, forbidden): + if forbidden: + self.assertNotEqual(response.status_code, 403) + else: + self.assertEqual(response.status_code, 403) + + def check_access(self, environ, auth_string, read, write): + environ.update( + { + "REQUEST_METHOD": "PROPFIND", + "HTTP_AUTHORIZATION": "Basic %s" % auth_string, + } + ) + response = self.controller.handle_dav_request(self.dav_path) + self.check_status_code(response, read) + + environ["REQUEST_METHOD"] = "PUT" + response = self.controller.handle_dav_request(self.dav_path) + self.check_status_code(response, write) + + def test_well_known(self, coll_mock, auth_mock, req_mock): + req_mock.env = self.env + + response = self.controller.handle_well_known_request() + self.assertEqual(response.status_code, 301) + + def test_authenticated(self, coll_mock, auth_mock, req_mock): + self.init_mocks(coll_mock, auth_mock, req_mock) + environ = req_mock.httprequest.environ + + self.collection.rights = "authenticated" + + self.check_access(environ, self.auth_owner, read=True, write=True) + self.check_access(environ, self.auth_tester, read=True, write=True) + + def test_owner_only(self, coll_mock, auth_mock, req_mock): + self.init_mocks(coll_mock, auth_mock, req_mock) + environ = req_mock.httprequest.environ + + self.collection.rights = "owner_only" + + self.check_access(environ, self.auth_owner, read=True, write=True) + self.check_access(environ, self.auth_tester, read=False, write=False) + + def test_owner_write_only(self, coll_mock, auth_mock, req_mock): + self.init_mocks(coll_mock, auth_mock, req_mock) + environ = req_mock.httprequest.environ + + self.collection.rights = "owner_write_only" + + self.check_access(environ, self.auth_owner, read=True, write=True) + self.check_access(environ, self.auth_tester, read=True, write=False) diff --git a/base_dav/tests/test_collection.py b/base_dav/tests/test_collection.py new file mode 100644 index 000000000..155cb8a82 --- /dev/null +++ b/base_dav/tests/test_collection.py @@ -0,0 +1,136 @@ +# Copyright 2019-2020 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from datetime import datetime, timedelta +from unittest import mock + +from odoo.tests.common import TransactionCase +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, mute_logger + +from ..radicale.collection import Collection + + +@mute_logger("radicale") +class TestCalendar(TransactionCase): + def setUp(self): + super().setUp() + + self.collection = self.env["dav.collection"].create( + { + "name": "Test Collection", + "dav_type": "calendar", + "model_id": self.env.ref("base.model_res_users").id, + "domain": "[]", + } + ) + + self.create_field_mapping( + "login", + "base.field_res_users__login", + excode="result = record.login", + imcode="result = item.value", + ) + self.create_field_mapping( + "name", + "base.field_res_users__name", + ) + self.create_field_mapping( + "dtstart", + "base.field_res_users__create_date", + ) + self.create_field_mapping( + "dtend", + "base.field_res_users__write_date", + ) + + start = datetime.now() + stop = start + timedelta(hours=1) + self.record = self.env["res.users"].create( + { + "login": "tester", + "name": "Test User", + "create_date": start.strftime(DEFAULT_SERVER_DATETIME_FORMAT), + "write_date": stop.strftime(DEFAULT_SERVER_DATETIME_FORMAT), + } + ) + patcher = mock.patch("odoo.http.request") + self.addCleanup(patcher.stop) + patcher.start() + + def create_field_mapping(self, name, field_ref, imcode=None, excode=None): + return self.env["dav.collection.field_mapping"].create( + { + "collection_id": self.collection.id, + "name": name, + "field_id": self.env.ref(field_ref).id, + "mapping_type": "code" if imcode or excode else "simple", + "import_code": imcode, + "export_code": excode, + } + ) + + def compare_record(self, vobj, rec=None): + tmp = self.collection.from_vobject(vobj) + + self.assertEqual((rec or self.record).login, tmp["login"]) + self.assertEqual((rec or self.record).name, tmp["name"]) + create_date = (rec or self.record).create_date + self.assertEqual( + create_date.strftime(DEFAULT_SERVER_DATETIME_FORMAT), tmp["create_date"] + ) + write_date = (rec or self.record).write_date + self.assertEqual( + write_date.strftime(DEFAULT_SERVER_DATETIME_FORMAT), tmp["write_date"] + ) + + def test_import_export(self): + # Exporting and importing should result in the same record + vobj = self.collection.to_vobject(self.record) + self.compare_record(vobj) + + def test_get_record(self): + rec = self.collection.get_record([self.record.id]) + self.assertEqual(rec, self.record) + + self.collection.field_uuid = self.env.ref( + "base.field_res_users__login", + ).id + rec = self.collection.get_record([self.record.login]) + self.assertEqual(rec, self.record) + + @mock.patch("odoo.addons.base_dav.radicale.collection.request") + def test_collection(self, request_mock): + request_mock.env = self.env + collection_url = f"/{self.env.user.login}/{self.collection.id}" + collection = list(Collection.discover(collection_url))[0] + + # Try to get the test record + record_url = f"{collection_url}/{self.record.id}" + self.assertIn(record_url, collection.list()) + + # Get the test record using the URL and compare it + item = collection.get(record_url) + self.compare_record(item.item) + self.assertEqual(item.href, record_url) + + # Get a non-existing record + self.assertFalse(collection.get(record_url + "0")) + + # Get the record and alter it later + item = self.collection.to_vobject(self.record) + self.record.login = "different" + with self.assertRaises(AssertionError): + self.compare_record(item) + + # Restore the record + item = collection.upload(record_url, item) + self.compare_record(item.item) + + # Delete an record + collection.delete(item.href) + self.assertFalse(self.record.exists()) + + # Create a new record + item = collection.upload(record_url + "0", item) + record = self.collection.get_record(collection._split_path(item.href)) + self.assertNotEqual(record, self.record) + self.compare_record(item.item, record) diff --git a/base_dav/views/dav_collection.xml b/base_dav/views/dav_collection.xml new file mode 100644 index 000000000..945b79911 --- /dev/null +++ b/base_dav/views/dav_collection.xml @@ -0,0 +1,82 @@ + + + + dav.collection + + + + + + + + + + + dav.collection + +
+ + + + + + + + + + + + + + + + +
+ + + dav.collection.field_mapping + + + + + + + + + + + dav.collection.field_mapping + +
+ + + + + + + + + +
+
+
+ + + WebDAV collections + ir.actions.act_window + dav.collection + tree,form + + + + +
diff --git a/base_user_role/i18n/es.po b/base_user_role/i18n/es.po index bfba5b704..3b825cf36 100644 --- a/base_user_role/i18n/es.po +++ b/base_user_role/i18n/es.po @@ -12,7 +12,7 @@ msgstr "" "Project-Id-Version: Odoo Server 10.0c\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2016-12-17 02:07+0000\n" -"PO-Revision-Date: 2023-10-09 09:15+0000\n" +"PO-Revision-Date: 2024-02-14 20:36+0000\n" "Last-Translator: Ivorra78 \n" "Language-Team: Spanish (https://www.transifex.com/oca/teams/23907/es/)\n" "Language: es\n" @@ -89,7 +89,7 @@ msgstr "Categoría del grupo asociado" #. module: base_user_role #: model_terms:ir.ui.view,arch_db:base_user_role.group_groups_into_role_wiz_view msgid "Cancel" -msgstr "" +msgstr "Cancelar" #. module: base_user_role #: model_terms:ir.ui.view,arch_db:base_user_role.create_from_user_wizard_view @@ -115,7 +115,7 @@ msgstr "Crear" #. module: base_user_role #: model:ir.actions.act_window,name:base_user_role.action_wizard_groups_into_role msgid "Create Role" -msgstr "" +msgstr "Crear Función" #. module: base_user_role #: model:ir.actions.act_window,name:base_user_role.create_from_user_wizard_action @@ -177,12 +177,12 @@ msgstr "" #. module: base_user_role #: model:ir.model,name:base_user_role.model_wizard_groups_into_role msgid "Group groups into a role" -msgstr "" +msgstr "Agrupar a los grupos en un rol" #. module: base_user_role #: model:ir.model.fields,help:base_user_role.field_wizard_groups_into_role__name msgid "Group groups into a role and specify a name for this role" -msgstr "" +msgstr "Agrupar grupos en un rol y especificar un nombre para este rol" #. module: base_user_role #: model_terms:ir.ui.view,arch_db:base_user_role.view_res_users_role_form diff --git a/base_user_role/i18n/it.po b/base_user_role/i18n/it.po index ca749ca9d..7204e33a3 100644 --- a/base_user_role/i18n/it.po +++ b/base_user_role/i18n/it.po @@ -12,8 +12,8 @@ msgstr "" "Project-Id-Version: Odoo Server 10.0c\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2016-12-17 02:07+0000\n" -"PO-Revision-Date: 2023-09-07 17:36+0000\n" -"Last-Translator: Francesco Foresti \n" +"PO-Revision-Date: 2024-03-06 10:37+0000\n" +"Last-Translator: mymage \n" "Language-Team: Italian (https://www.transifex.com/oca/teams/23907/it/)\n" "Language: it\n" "MIME-Version: 1.0\n" @@ -26,107 +26,107 @@ msgstr "" #: model:ir.model.fields,field_description:base_user_role.field_res_groups__role_count #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__role_count msgid "# Roles" -msgstr "" +msgstr "N° ruoli" #. module: base_user_role #. odoo-python #: code:addons/base_user_role/models/role.py:0 #, python-format msgid "%s (copy)" -msgstr "" +msgstr "%s (copia)" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__model_access msgid "Access Controls" -msgstr "" +msgstr "Controlli di accesso" #. module: base_user_role #: model:ir.model,name:base_user_role.model_res_groups msgid "Access Groups" -msgstr "" +msgstr "Gruppi di accesso" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__menu_access msgid "Access Menu" -msgstr "" +msgstr "Menu d'accesso" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__model_access_ids #: model_terms:ir.ui.view,arch_db:base_user_role.view_res_users_role_form msgid "Access Rights" -msgstr "" +msgstr "Diritti di accesso" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role_line__active msgid "Active" -msgstr "" +msgstr "Attivo" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__category_id msgid "Application" -msgstr "" +msgstr "Applicazione" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_wizard_create_role_from_user__assign_to_user msgid "Assign to user" -msgstr "" +msgstr "Assegna a utente" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__group_category_id msgid "Associated category" -msgstr "" +msgstr "Categoria associata" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__group_id msgid "Associated group" -msgstr "" +msgstr "Gruppo associato" #. module: base_user_role #: model:ir.model.fields,help:base_user_role.field_res_users_role__group_category_id msgid "Associated group's category" -msgstr "" +msgstr "Categoria del gruppo associato" #. module: base_user_role #: model_terms:ir.ui.view,arch_db:base_user_role.group_groups_into_role_wiz_view msgid "Cancel" -msgstr "" +msgstr "Annulla" #. module: base_user_role #: model_terms:ir.ui.view,arch_db:base_user_role.create_from_user_wizard_view msgid "Close" -msgstr "" +msgstr "Chiudi" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__color msgid "Color Index" -msgstr "" +msgstr "Indice colore" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__comment msgid "Comment" -msgstr "Coomento" +msgstr "Commento" #. module: base_user_role #: model_terms:ir.ui.view,arch_db:base_user_role.create_from_user_wizard_view #: model_terms:ir.ui.view,arch_db:base_user_role.group_groups_into_role_wiz_view msgid "Create" -msgstr "" +msgstr "Crea" #. module: base_user_role #: model:ir.actions.act_window,name:base_user_role.action_wizard_groups_into_role msgid "Create Role" -msgstr "" +msgstr "Crea ruolo" #. module: base_user_role #: model:ir.actions.act_window,name:base_user_role.create_from_user_wizard_action #: model_terms:ir.ui.view,arch_db:base_user_role.create_from_user_wizard_view msgid "Create role from user" -msgstr "" +msgstr "Crea ruolo dall'utente" #. module: base_user_role #: model:ir.model,name:base_user_role.model_wizard_create_role_from_user msgid "Create role from user wizard" -msgstr "" +msgstr "Procedura guidata creazione ruolo dall'utente" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__create_uid @@ -165,22 +165,24 @@ msgstr "Da" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__full_name msgid "Group Name" -msgstr "" +msgstr "Nome gruppo" #. module: base_user_role #: model:ir.model.fields,help:base_user_role.field_res_users_role__share msgid "Group created to set access rights for sharing data with some users." msgstr "" +"Gruppo creato per impostare i diritti di accesso per la condivisione dei " +"dati con alcuni utenti." #. module: base_user_role #: model:ir.model,name:base_user_role.model_wizard_groups_into_role msgid "Group groups into a role" -msgstr "" +msgstr "Raggruppa gruppi in un ruolo" #. module: base_user_role #: model:ir.model.fields,help:base_user_role.field_wizard_groups_into_role__name msgid "Group groups into a role and specify a name for this role" -msgstr "" +msgstr "Raggruppa gruppi in un ruolo e indica un nume per il ruolo" #. module: base_user_role #: model_terms:ir.ui.view,arch_db:base_user_role.view_res_users_role_form @@ -198,12 +200,12 @@ msgstr "ID" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__implied_ids msgid "Inherits" -msgstr "" +msgstr "Eredita" #. module: base_user_role #: model_terms:ir.ui.view,arch_db:base_user_role.view_res_users_role_form msgid "Internal Notes" -msgstr "" +msgstr "Note interne" #. module: base_user_role #: model:ir.model.fields,help:base_user_role.field_res_groups__parent_ids @@ -212,6 +214,8 @@ msgid "" "Inverse relation for the Inherits field. The groups from which this group is " "inheriting" msgstr "" +"Relazione inversa per il campo ereditato. I gruppi che ereditano da questo " +"gruppo" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__write_uid @@ -232,7 +236,7 @@ msgstr "Ultimo aggiornamento il" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__model_access_count msgid "Model Access Count" -msgstr "" +msgstr "Conteggio accesso modello" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__name @@ -245,38 +249,38 @@ msgstr "Nome" #: model:ir.model.fields,field_description:base_user_role.field_res_groups__trans_parent_ids #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__trans_parent_ids msgid "Parent Groups" -msgstr "" +msgstr "Gruppi padre" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_groups__parent_ids #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__parent_ids msgid "Parents" -msgstr "" +msgstr "Padri" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__rule_ids #: model_terms:ir.ui.view,arch_db:base_user_role.view_res_users_role_form msgid "Record Rules" -msgstr "" +msgstr "Regole record" #. module: base_user_role #: model:ir.model.fields,help:base_user_role.field_res_groups__role_id #: model:ir.model.fields,help:base_user_role.field_res_users_role__role_id msgid "Relation for the groups that represents a role" -msgstr "" +msgstr "Relazione per i gruppi che rappresentano un ruolo" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_groups__role_id #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__role_id #: model:ir.model.fields,field_description:base_user_role.field_res_users_role_line__role_id msgid "Role" -msgstr "" +msgstr "Ruolo" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users__role_line_ids #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__line_ids msgid "Role lines" -msgstr "" +msgstr "Righe del ruolo" #. module: base_user_role #: model:ir.actions.act_window,name:base_user_role.action_res_users_role_tree @@ -288,33 +292,33 @@ msgstr "" #: model_terms:ir.ui.view,arch_db:base_user_role.view_res_users_form_inherit #: model_terms:ir.ui.view,arch_db:base_user_role.view_res_users_role_search msgid "Roles" -msgstr "" +msgstr "Ruoli" #. module: base_user_role #: model:ir.model.constraint,message:base_user_role.constraint_res_users_role_line_user_role_uniq msgid "Roles can be assigned to a user only once at a time" -msgstr "" +msgstr "I ruoli possono essere assegnati all'utente solo uno alla volta" #. module: base_user_role #: model:ir.model.fields,help:base_user_role.field_res_groups__role_ids #: model:ir.model.fields,help:base_user_role.field_res_users_role__role_ids msgid "Roles in which the group is involved" -msgstr "" +msgstr "Ruoli nei quali il gruppo è coinvolo" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__rule_groups msgid "Rules" -msgstr "" +msgstr "Regole" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__rules_count msgid "Rules Count" -msgstr "" +msgstr "Numero regole" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__share msgid "Share Group" -msgstr "" +msgstr "Gruppo condivisione" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role_line__date_to @@ -324,12 +328,12 @@ msgstr "A" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__trans_implied_ids msgid "Transitively inherits" -msgstr "" +msgstr "Eredita transitivamente" #. module: base_user_role #: model:ir.actions.server,name:base_user_role.cron_update_users_ir_actions_server msgid "Update user roles" -msgstr "" +msgstr "Aggiorna i ruoli dell'utente" #. module: base_user_role #: model:ir.model,name:base_user_role.model_res_users @@ -340,12 +344,12 @@ msgstr "Utente" #. module: base_user_role #: model:ir.model,name:base_user_role.model_res_users_role msgid "User role" -msgstr "" +msgstr "Ruolo utente" #. module: base_user_role #: model:ir.module.category,name:base_user_role.ir_module_category_role msgid "User roles" -msgstr "" +msgstr "Ruoli utente" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__users @@ -356,18 +360,17 @@ msgstr "Utenti" #. module: base_user_role #: model:ir.model,name:base_user_role.model_res_users_role_line msgid "Users associated to a role" -msgstr "" +msgstr "Utenti associati al ruolo" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_users_role__user_ids -#, fuzzy msgid "Users list" -msgstr "Utenti" +msgstr "Elenco utenti" #. module: base_user_role #: model:ir.model.fields,help:base_user_role.field_res_users_role__implied_ids msgid "Users of this group automatically inherit those groups" -msgstr "" +msgstr "Gli utenti di questo gruppo ereditano automaticamente quei gruppi" #. module: base_user_role #: model:ir.model.fields,field_description:base_user_role.field_res_groups__view_access @@ -378,7 +381,7 @@ msgstr "Viste" #. module: base_user_role #: model_terms:ir.ui.view,arch_db:base_user_role.create_from_user_wizard_view msgid "or" -msgstr "" +msgstr "o" #~ msgid "Last Modified on" #~ msgstr "Ultima modifica il" diff --git a/base_user_role_company/README.rst b/base_user_role_company/README.rst new file mode 100644 index 000000000..8a18e12cc --- /dev/null +++ b/base_user_role_company/README.rst @@ -0,0 +1,131 @@ +===================== +User roles by company +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f410f78e1dea8bfa8c874dff8407a15398a7e65dd94f41a61568afd5d859ea2b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/17.0/base_user_role_company + :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-17-0/server-backend-17-0-base_user_role_company + :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=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Enable User Roles depending on the Companies selected. + +A company specific Role will only be enabled if it is set for **all** +the currently selected companies. + +For example, if a user is "Sales Manager" only for Company A, it will +see that role enabled only if Company A is selected. If the user selects +Company A and Company B, then the "Sales Manager" role won't be enabled. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Roles are set on the User form. + +The "Company" additional column allows to set a Role as only valid for +specific companies. + +There is also a "Active Role" techincal field, only visible in developer +mode. It shows what roles are active, after applying the company +selection rules. + +Usage +===== + +Select the active companies from the web client widget, near the top +right corner. When doing so, the User's security Groups are recomputed, +based on the Roles. + +When the user changes the company selection, only the groups available +to all active companies will be activated. + +For example: + +- A "SALES PERSON" and a "SALES MANAGER" roles are created. + +- A user is assigned to the roles: + + - "SALES PERSON", with no specific company assigned (meaning all) + - "SALES MANAGER" only to "My Company (Chicago)" + +- When selecting active companies from the UI widget: + + - If only "My Company (San Francisco)" is active, "SALES PERSON" + will be active. + - If only "My Company (Chicago)" is active, "SALES PERSON" and + "SALES MANAGER" will be active. + - If both "My Company (San Francisco)" and "My Company (Chicago)" is + active, "SALES PERSON" will be active. + +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 +------- + +* Open Source Integrators + +Contributors +------------ + +`Open Source Integrators `__ + + - Daniel Reis + - Chandresh Thakkar + - Urvisha Desai + +`WeSolved `__ + + - Robin Conjour + +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_user_role_company/__init__.py b/base_user_role_company/__init__.py new file mode 100644 index 000000000..efc2a3f33 --- /dev/null +++ b/base_user_role_company/__init__.py @@ -0,0 +1,5 @@ +# Copyright (C) 2021 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models diff --git a/base_user_role_company/__manifest__.py b/base_user_role_company/__manifest__.py new file mode 100644 index 000000000..c2246f73a --- /dev/null +++ b/base_user_role_company/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright (C) 2021 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "User roles by company", + "version": "17.0.1.0.0", + "category": "Tools", + "author": "Open Source Integrators, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/server-backend", + "depends": ["base_user_role"], + "data": [ + "views/role.xml", + "views/user.xml", + ], + "installable": True, + "auto_install": True, + "maintainer": "dreispt", + "development_status": "Beta", +} diff --git a/base_user_role_company/controllers/__init__.py b/base_user_role_company/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/base_user_role_company/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/base_user_role_company/controllers/main.py b/base_user_role_company/controllers/main.py new file mode 100644 index 000000000..7e75d6954 --- /dev/null +++ b/base_user_role_company/controllers/main.py @@ -0,0 +1,16 @@ +# Copyright (C) 2022 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import http + +from odoo.addons.web.controllers.home import Home + + +class HomeExtended(Home): + @http.route() + def web_load_menus(self, unique): + response = super().web_load_menus(unique) + # On logout & re-login we could see wrong menus being rendered + # To avoid this, menu http cache must be disabled + response.headers.remove("Cache-Control") + return response diff --git a/base_user_role_company/i18n/base_user_role_company.pot b/base_user_role_company/i18n/base_user_role_company.pot new file mode 100644 index 000000000..828370c9a --- /dev/null +++ b/base_user_role_company/i18n/base_user_role_company.pot @@ -0,0 +1,58 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_user_role_company +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_user_role_company +#: model:ir.model.fields,field_description:base_user_role_company.field_res_users_role_line__allowed_company_ids +msgid "Companies" +msgstr "" + +#. module: base_user_role_company +#: model:ir.model.fields,field_description:base_user_role_company.field_res_users_role_line__company_id +msgid "Company" +msgstr "" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_ir_http +msgid "HTTP Routing" +msgstr "" + +#. module: base_user_role_company +#: model:ir.model.fields,help:base_user_role_company.field_res_users_role_line__company_id +msgid "" +"If set, this role only applies when this is the main company selected. " +"Otherwise it applies to all companies." +msgstr "" + +#. module: base_user_role_company +#: model:ir.model.constraint,message:base_user_role_company.constraint_res_users_role_line_user_role_uniq +msgid "Roles can be assigned to a user only once at a time" +msgstr "" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_res_users +msgid "User" +msgstr "" + +#. module: base_user_role_company +#. odoo-python +#: code:addons/base_user_role_company/models/role.py:0 +#, python-format +msgid "User \"%(user)s\" does not have access to the company \"%(company)s\"" +msgstr "" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_res_users_role_line +msgid "Users associated to a role" +msgstr "" diff --git a/base_user_role_company/i18n/es.po b/base_user_role_company/i18n/es.po new file mode 100644 index 000000000..449ba51ab --- /dev/null +++ b/base_user_role_company/i18n/es.po @@ -0,0 +1,67 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_user_role_company +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-11-08 14:36+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: base_user_role_company +#: model:ir.model.fields,field_description:base_user_role_company.field_res_users_role_line__allowed_company_ids +msgid "Companies" +msgstr "Compañías" + +#. module: base_user_role_company +#: model:ir.model.fields,field_description:base_user_role_company.field_res_users_role_line__company_id +msgid "Company" +msgstr "Compañía" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_ir_http +msgid "HTTP Routing" +msgstr "Enrutamiento HTTP" + +#. module: base_user_role_company +#: model:ir.model.fields,help:base_user_role_company.field_res_users_role_line__company_id +msgid "" +"If set, this role only applies when this is the main company selected. " +"Otherwise it applies to all companies." +msgstr "" +"Si se establece, este rol sólo se aplica cuando ésta es la compañía " +"principal seleccionada. De lo contrario, se aplica a todas las compañías." + +#. module: base_user_role_company +#: model:ir.model.constraint,message:base_user_role_company.constraint_res_users_role_line_user_role_uniq +msgid "Roles can be assigned to a user only once at a time" +msgstr "Las funciones sólo pueden asignarse a un usuario una vez cada vez" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_res_users +msgid "User" +msgstr "Usuario" + +#. module: base_user_role_company +#. odoo-python +#: code:addons/base_user_role_company/models/role.py:0 +#, python-format +msgid "User \"%(user)s\" does not have access to the company \"%(company)s\"" +msgstr "El usuario \"%(user)s\" no tiene acceso a la empresa \"%(company)s\"" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_res_users_role_line +msgid "Users associated to a role" +msgstr "Usuarios asociados a un papel" + +#, python-format +#~ msgid "User \"{}\" does not have access to the company \"{}\"" +#~ msgstr "Usuario \"{}\" no tiene acceso a la compañía \"{}\"" diff --git a/base_user_role_company/i18n/it.po b/base_user_role_company/i18n/it.po new file mode 100644 index 000000000..26a5fcfba --- /dev/null +++ b/base_user_role_company/i18n/it.po @@ -0,0 +1,82 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_user_role_company +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-01-03 14:33+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: base_user_role_company +#: model:ir.model.fields,field_description:base_user_role_company.field_res_users_role_line__allowed_company_ids +msgid "Companies" +msgstr "Aziende" + +#. module: base_user_role_company +#: model:ir.model.fields,field_description:base_user_role_company.field_res_users_role_line__company_id +msgid "Company" +msgstr "Azienda" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_ir_http +msgid "HTTP Routing" +msgstr "Instradamento HTTP" + +#. module: base_user_role_company +#: model:ir.model.fields,help:base_user_role_company.field_res_users_role_line__company_id +msgid "" +"If set, this role only applies when this is the main company selected. " +"Otherwise it applies to all companies." +msgstr "" +"Se impostato, questo ruolo si applica solo quando questa è l'azienda " +"principale selezionata. Altrimenti vale per tutte le aziende." + +#. module: base_user_role_company +#: model:ir.model.constraint,message:base_user_role_company.constraint_res_users_role_line_user_role_uniq +msgid "Roles can be assigned to a user only once at a time" +msgstr "I ruoli possono essere assegnati all'utente solo uno alla volta" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_res_users +msgid "User" +msgstr "Utente" + +#. module: base_user_role_company +#. odoo-python +#: code:addons/base_user_role_company/models/role.py:0 +#, python-format +msgid "User \"%(user)s\" does not have access to the company \"%(company)s\"" +msgstr "L'utente \"%(user)s\" non ha accesso all'azienda \"%(company)s\"" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_res_users_role_line +msgid "Users associated to a role" +msgstr "Utenti associati al ruolo" + +#, python-format +#~ msgid "User \"{}\" does not have access to the company \"{}\"" +#~ msgstr "L'utente \"{}\" non ha l'accesso all'azienda \"{}\"" + +#~ msgid "Display Name" +#~ msgstr "Nome visualizzato" + +#~ msgid "ID" +#~ msgstr "ID" + +#~ msgid "Last Modified on" +#~ msgstr "Ultima modifica il" + +#~ msgid "Users" +#~ msgstr "Utenti" + +#~ msgid "Active Role" +#~ msgstr "Ruolo attivo" diff --git a/base_user_role_company/i18n/pt.po b/base_user_role_company/i18n/pt.po new file mode 100644 index 000000000..f0761ca21 --- /dev/null +++ b/base_user_role_company/i18n/pt.po @@ -0,0 +1,67 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_user_role_company +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2023-08-31 10:35+0000\n" +"Last-Translator: Pedro Castro Silva \n" +"Language-Team: none\n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: base_user_role_company +#: model:ir.model.fields,field_description:base_user_role_company.field_res_users_role_line__allowed_company_ids +msgid "Companies" +msgstr "Empresas" + +#. module: base_user_role_company +#: model:ir.model.fields,field_description:base_user_role_company.field_res_users_role_line__company_id +msgid "Company" +msgstr "Empresa" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_ir_http +msgid "HTTP Routing" +msgstr "Encaminhamento HTTP" + +#. module: base_user_role_company +#: model:ir.model.fields,help:base_user_role_company.field_res_users_role_line__company_id +msgid "" +"If set, this role only applies when this is the main company selected. " +"Otherwise it applies to all companies." +msgstr "" +"Se atribuída, esta função será aplicada apenas quando esta é a empresa " +"principal selecionada. Caso contrário, aplicar-se-á a todas as empresas." + +#. module: base_user_role_company +#: model:ir.model.constraint,message:base_user_role_company.constraint_res_users_role_line_user_role_uniq +msgid "Roles can be assigned to a user only once at a time" +msgstr "As funções podem ser atribuídas a um utilizador apenas uma vez" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_res_users +msgid "User" +msgstr "Utilizador" + +#. module: base_user_role_company +#. odoo-python +#: code:addons/base_user_role_company/models/role.py:0 +#, python-format +msgid "User \"%(user)s\" does not have access to the company \"%(company)s\"" +msgstr "" + +#. module: base_user_role_company +#: model:ir.model,name:base_user_role_company.model_res_users_role_line +msgid "Users associated to a role" +msgstr "Utilizadores associados a uma função" + +#, python-format +#~ msgid "User \"{}\" does not have access to the company \"{}\"" +#~ msgstr "O utilizador \"{}\" não tem acesso à empresa \"{}\"" diff --git a/base_user_role_company/models/__init__.py b/base_user_role_company/models/__init__.py new file mode 100644 index 000000000..32004dfac --- /dev/null +++ b/base_user_role_company/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright (C) 2021 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import role +from . import user +from . import ir_http diff --git a/base_user_role_company/models/ir_http.py b/base_user_role_company/models/ir_http.py new file mode 100644 index 000000000..972e66a21 --- /dev/null +++ b/base_user_role_company/models/ir_http.py @@ -0,0 +1,23 @@ +# Copyright (C) 2021 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.http import request + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + def session_info(self): + """ + Based on the selected companies (cids), + calculate the roles to enable. + A role should be enabled only when it applies to all selected companies. + """ + result = super().session_info() + if self.env.user.role_line_ids: + cids_str = request.httprequest.cookies.get("cids", str(self.env.company.id)) + cids = [int(cid) for cid in cids_str.split(",")] + # The first element of cids is the currently selected company + self.env.user.set_groups_from_roles(company_id=cids[0]) + return result diff --git a/base_user_role_company/models/role.py b/base_user_role_company/models/role.py new file mode 100644 index 000000000..4e03df169 --- /dev/null +++ b/base_user_role_company/models/role.py @@ -0,0 +1,41 @@ +# Copyright (C) 2021 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ResUsersRoleLine(models.Model): + _inherit = "res.users.role.line" + + allowed_company_ids = fields.Many2many(related="user_id.company_ids") + company_id = fields.Many2one( + "res.company", + "Company", + domain="[('id', 'in', allowed_company_ids)]", + help="If set, this role only applies when this is the main company selected." + " Otherwise it applies to all companies.", + ) + + @api.constrains("user_id", "company_id") + def _check_company(self): + for record in self: + if ( + record.company_id + and record.company_id != record.user_id.company_id + and record.company_id not in record.user_id.company_ids + ): + raise ValidationError( + _( + 'User "%(user)s" does not have access to the company "%(company)s"' + ) + % {"user": record.user_id.name, "company": record.company_id.name} + ) + + _sql_constraints = [ + ( + "user_role_uniq", + "unique (user_id,role_id,company_id)", + "Roles can be assigned to a user only once at a time", + ) + ] diff --git a/base_user_role_company/models/user.py b/base_user_role_company/models/user.py new file mode 100644 index 000000000..1a2f6296b --- /dev/null +++ b/base_user_role_company/models/user.py @@ -0,0 +1,35 @@ +# Copyright (C) 2021 Open Source Integrators +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + @classmethod + def authenticate(cls, db, login, password, user_agent_env): + uid = super().authenticate(db, login, password, user_agent_env) + # On login, ensure the proper roles are applied + # The last Role applied may not be the correct one, + # sonce the new session current company can be different + with cls.pool.cursor() as cr: + env = api.Environment(cr, uid, {}) + if env.user.role_line_ids: + env.user.set_groups_from_roles() + return uid + + def _get_enabled_roles(self): + res = super()._get_enabled_roles() + # Enable only the Roles corresponing to the currently selected company + if self.role_line_ids: + res = res.filtered( + lambda x: not x.company_id or x.company_id == self.env.company + ) + return res + + def set_groups_from_roles(self, force=False, company_id=False): + # When using the Company Switcher widget, the self.env.company is not yet set + if company_id: + self = self.with_company(company_id) + return super().set_groups_from_roles(force=force) diff --git a/base_user_role_company/pyproject.toml b/base_user_role_company/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/base_user_role_company/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_user_role_company/readme/CONFIGURE.md b/base_user_role_company/readme/CONFIGURE.md new file mode 100644 index 000000000..ae04da435 --- /dev/null +++ b/base_user_role_company/readme/CONFIGURE.md @@ -0,0 +1,8 @@ +Roles are set on the User form. + +The "Company" additional column allows to set a Role as only valid for +specific companies. + +There is also a "Active Role" techincal field, only visible in developer +mode. It shows what roles are active, after applying the company +selection rules. diff --git a/base_user_role_company/readme/CONTRIBUTORS.md b/base_user_role_company/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..8fa3c89a9 --- /dev/null +++ b/base_user_role_company/readme/CONTRIBUTORS.md @@ -0,0 +1,9 @@ +[Open Source Integrators](http://opensourceintegrators.com) + +> - Daniel Reis \<\> +> - Chandresh Thakkar \<\> +> - Urvisha Desai \<\> + +[WeSolved](http://wesolved.com) + +> - Robin Conjour \<\> diff --git a/base_user_role_company/readme/DESCRIPTION.md b/base_user_role_company/readme/DESCRIPTION.md new file mode 100644 index 000000000..2f93eb4b0 --- /dev/null +++ b/base_user_role_company/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +Enable User Roles depending on the Companies selected. + +A company specific Role will only be enabled if it is set for **all** +the currently selected companies. + +For example, if a user is "Sales Manager" only for Company A, it will +see that role enabled only if Company A is selected. If the user selects +Company A and Company B, then the "Sales Manager" role won't be enabled. diff --git a/base_user_role_company/readme/USAGE.md b/base_user_role_company/readme/USAGE.md new file mode 100644 index 000000000..37769b5da --- /dev/null +++ b/base_user_role_company/readme/USAGE.md @@ -0,0 +1,22 @@ +Select the active companies from the web client widget, near the top +right corner. When doing so, the User's security Groups are recomputed, +based on the Roles. + +When the user changes the company selection, only the groups available +to all active companies will be activated. + +For example: + +- A "SALES PERSON" and a "SALES MANAGER" roles are created. + +- A user is assigned to the roles: + - "SALES PERSON", with no specific company assigned (meaning all) + - "SALES MANAGER" only to "My Company (Chicago)" + +- When selecting active companies from the UI widget: + - If only "My Company (San Francisco)" is active, "SALES PERSON" will + be active. + - If only "My Company (Chicago)" is active, "SALES PERSON" and "SALES + MANAGER" will be active. + - If both "My Company (San Francisco)" and "My Company (Chicago)" is + active, "SALES PERSON" will be active. diff --git a/base_user_role_company/static/description/icon.png b/base_user_role_company/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/base_user_role_company/static/description/icon.png differ diff --git a/base_user_role_company/static/description/index.html b/base_user_role_company/static/description/index.html new file mode 100644 index 000000000..6435a7556 --- /dev/null +++ b/base_user_role_company/static/description/index.html @@ -0,0 +1,473 @@ + + + + + +User roles by company + + + +
+

User roles by company

+ + +

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

+

Enable User Roles depending on the Companies selected.

+

A company specific Role will only be enabled if it is set for all +the currently selected companies.

+

For example, if a user is “Sales Manager” only for Company A, it will +see that role enabled only if Company A is selected. If the user selects +Company A and Company B, then the “Sales Manager” role won’t be enabled.

+

Table of contents

+ +
+

Configuration

+

Roles are set on the User form.

+

The “Company” additional column allows to set a Role as only valid for +specific companies.

+

There is also a “Active Role” techincal field, only visible in developer +mode. It shows what roles are active, after applying the company +selection rules.

+
+
+

Usage

+

Select the active companies from the web client widget, near the top +right corner. When doing so, the User’s security Groups are recomputed, +based on the Roles.

+

When the user changes the company selection, only the groups available +to all active companies will be activated.

+

For example:

+
    +
  • A “SALES PERSON” and a “SALES MANAGER” roles are created.
  • +
  • A user is assigned to the roles:
      +
    • “SALES PERSON”, with no specific company assigned (meaning all)
    • +
    • “SALES MANAGER” only to “My Company (Chicago)”
    • +
    +
  • +
  • When selecting active companies from the UI widget:
      +
    • If only “My Company (San Francisco)” is active, “SALES PERSON” +will be active.
    • +
    • If only “My Company (Chicago)” is active, “SALES PERSON” and +“SALES MANAGER” will be active.
    • +
    • If both “My Company (San Francisco)” and “My Company (Chicago)” is +active, “SALES PERSON” will be active.
    • +
    +
  • +
+
+
+

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

+
    +
  • Open Source Integrators
  • +
+
+ +
+

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_user_role_company/tests/__init__.py b/base_user_role_company/tests/__init__.py new file mode 100644 index 000000000..cd7f7833f --- /dev/null +++ b/base_user_role_company/tests/__init__.py @@ -0,0 +1 @@ +from . import test_role_per_company diff --git a/base_user_role_company/tests/test_role_per_company.py b/base_user_role_company/tests/test_role_per_company.py new file mode 100644 index 000000000..9b1ac1fcc --- /dev/null +++ b/base_user_role_company/tests/test_role_per_company.py @@ -0,0 +1,63 @@ +# Copyright 2021 Open Source Integrators +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.tests.common import TransactionCase + + +class TestUserRoleCompany(TransactionCase): + def setUp(self): + super().setUp() + # COMPANIES + self.Company = self.env["res.company"] + self.company1 = self.env.ref("base.main_company") + self.company2 = self.Company.create({"name": "company2"}) + # GROUPS for roles + self.groupA = self.env.ref("base.group_user") + self.groupB = self.env.ref("base.group_system") + self.groupC = self.env.ref("base.group_partner_manager") + # ROLES + self.Role = self.env["res.users.role"] + self.roleA = self.Role.create({"name": "ROLE All Companies"}) + self.roleA.implied_ids |= self.groupA + self.roleB = self.Role.create({"name": "ROLE Company 1"}) + self.roleB.implied_ids |= self.groupB + self.roleC = self.Role.create({"name": "ROLE Company 1 and 2"}) + self.roleC.implied_ids |= self.groupC + # USER + # ==Role=== ==Company== C1 C2 C1+C2 + # Role A Yes Yes Yes + # Role B Company1 Yes + # Role C Company1 Yes Yes + # Role C Company2 Yes Yes + self.User = self.env["res.users"] + user_vals = { + "name": "ROLES TEST USER", + "login": "test_user", + "company_ids": [(6, 0, [self.company1.id, self.company2.id])], + "role_line_ids": [ + (0, 0, {"role_id": self.roleA.id}), + (0, 0, {"role_id": self.roleB.id, "company_id": self.company1.id}), + (0, 0, {"role_id": self.roleC.id, "company_id": self.company1.id}), + (0, 0, {"role_id": self.roleC.id, "company_id": self.company2.id}), + ], + } + self.test_user = self.User.create(user_vals) + + def test_110_company_1(self): + "Company 1 selected: Roles A, B and C are enabled" + self.test_user.set_groups_from_roles(company_id=self.company1.id) + expected = self.groupA | self.groupB | self.groupC + found = self.test_user.groups_id.filtered(lambda x: x in expected) + self.assertEqual(expected, found) + + def test_120_company_2(self): + "Company 2 selected: Roles A and C are enabled" + self.test_user.set_groups_from_roles(company_id=self.company2.id) + enabled = self.test_user.groups_id + expected = self.groupA | self.groupC + found = enabled.filtered(lambda x: x in expected) + self.assertEqual(expected, found) + + not_expected = self.groupB + found = enabled.filtered(lambda x: x in not_expected) + self.assertFalse(found) diff --git a/base_user_role_company/views/role.xml b/base_user_role_company/views/role.xml new file mode 100644 index 000000000..9cb06b7e9 --- /dev/null +++ b/base_user_role_company/views/role.xml @@ -0,0 +1,17 @@ + + + + res.users.form.inherit.company + res.users + + + + + + + + + diff --git a/base_user_role_company/views/user.xml b/base_user_role_company/views/user.xml new file mode 100644 index 000000000..51b4ea2a9 --- /dev/null +++ b/base_user_role_company/views/user.xml @@ -0,0 +1,18 @@ + + + + + res.users.form.inherit + res.users + + + + + [] + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..c123808a5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +radicale==2.1.12 diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml index 46f29d3df..561adb702 100644 --- a/setup/_metapackage/pyproject.toml +++ b/setup/_metapackage/pyproject.toml @@ -1,8 +1,9 @@ [project] name = "odoo-addons-oca-server-backend" -version = "17.0.20240209.0" +version = "17.0.20240309.0" dependencies = [ "odoo-addon-base_user_role>=17.0dev,<17.1dev", + "odoo-addon-base_user_role_company>=17.0dev,<17.1dev", ] classifiers=[ "Programming Language :: Python",