From 6bb12b830cd935a7755b83535df9062bc03e7d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Wed, 25 Mar 2026 16:21:58 +0100 Subject: [PATCH 01/36] [ADD] add oca_vcp --- Dockerfile | 1 + oca_vcp/README.rst | 76 ++++ oca_vcp/__init__.py | 1 + oca_vcp/__manifest__.py | 30 ++ oca_vcp/models/__init__.py | 5 + oca_vcp/models/vcp_odoo_module.py | 12 + oca_vcp/models/vcp_odoo_module_version.py | 14 + oca_vcp/models/vcp_repository.py | 11 + oca_vcp/models/vcp_repository_category.py | 12 + oca_vcp/models/vcp_rule.py | 62 +++ oca_vcp/pyproject.toml | 3 + oca_vcp/readme/DESCRIPTION.md | 1 + oca_vcp/security/ir.model.access.csv | 3 + oca_vcp/static/description/index.html | 422 ++++++++++++++++++ oca_vcp/views/vcp_odoo_module_view.xml | 12 + .../views/vcp_repository_category_view.xml | 49 ++ oca_vcp/views/vcp_repository_view.xml | 29 ++ 17 files changed, 743 insertions(+) create mode 100644 oca_vcp/README.rst create mode 100644 oca_vcp/__init__.py create mode 100644 oca_vcp/__manifest__.py create mode 100644 oca_vcp/models/__init__.py create mode 100644 oca_vcp/models/vcp_odoo_module.py create mode 100644 oca_vcp/models/vcp_odoo_module_version.py create mode 100644 oca_vcp/models/vcp_repository.py create mode 100644 oca_vcp/models/vcp_repository_category.py create mode 100644 oca_vcp/models/vcp_rule.py create mode 100644 oca_vcp/pyproject.toml create mode 100644 oca_vcp/readme/DESCRIPTION.md create mode 100644 oca_vcp/security/ir.model.access.csv create mode 100644 oca_vcp/static/description/index.html create mode 100644 oca_vcp/views/vcp_odoo_module_view.xml create mode 100644 oca_vcp/views/vcp_repository_category_view.xml create mode 100644 oca_vcp/views/vcp_repository_view.xml diff --git a/Dockerfile b/Dockerfile index 08639b6a..0a3c2993 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN set -e \ # github_connector* Odoo modules cloc \ git \ + pandoc \ && apt -y clean \ && rm -rf /var/lib/apt/lists/* diff --git a/oca_vcp/README.rst b/oca_vcp/README.rst new file mode 100644 index 00000000..e99b822e --- /dev/null +++ b/oca_vcp/README.rst @@ -0,0 +1,76 @@ +======= +Oca VCP +======= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8f1354671a3bf370703add6aaaec83a866593dfeb82b8aad68373b70ba214bcc + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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%2Foca--custom-lightgray.png?logo=github + :target: https://github.com/OCA/oca-custom/tree/18.0/oca_vcp + :alt: OCA/oca-custom +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/oca-custom-18-0/oca-custom-18-0-oca_vcp + :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/oca-custom&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Custom OCA for VCP + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* Akretion + +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/oca-custom `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/oca_vcp/__init__.py b/oca_vcp/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/oca_vcp/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/oca_vcp/__manifest__.py b/oca_vcp/__manifest__.py new file mode 100644 index 00000000..372bac28 --- /dev/null +++ b/oca_vcp/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +{ + "name": "Oca VCP", + "summary": "OCA VCP customisation", + "version": "18.0.1.0.0", + "development_status": "Alpha", + "category": "Custom", + "website": "https://github.com/OCA/oca-custom", + "author": " Akretion,Odoo Community Association (OCA)", + "license": "AGPL-3", + "external_dependencies": { + "python": [], + "bin": [], + }, + "depends": [ + "vcp_odoo", + "vcp_github", + ], + "data": [ + "security/ir.model.access.csv", + "views/vcp_odoo_module_view.xml", + "views/vcp_repository_view.xml", + "views/vcp_repository_category_view.xml", + ], + "demo": [], +} diff --git a/oca_vcp/models/__init__.py b/oca_vcp/models/__init__.py new file mode 100644 index 00000000..c3760154 --- /dev/null +++ b/oca_vcp/models/__init__.py @@ -0,0 +1,5 @@ +from . import vcp_odoo_module +from . import vcp_odoo_module_version +from . import vcp_repository +from . import vcp_repository_category +from . import vcp_rule diff --git a/oca_vcp/models/vcp_odoo_module.py b/oca_vcp/models/vcp_odoo_module.py new file mode 100644 index 00000000..06b82dc7 --- /dev/null +++ b/oca_vcp/models/vcp_odoo_module.py @@ -0,0 +1,12 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class VcpOdooModule(models.Model): + _inherit = "vcp.odoo.module" + + must_have = fields.Boolean() diff --git a/oca_vcp/models/vcp_odoo_module_version.py b/oca_vcp/models/vcp_odoo_module_version.py new file mode 100644 index 00000000..fe5a8ab8 --- /dev/null +++ b/oca_vcp/models/vcp_odoo_module_version.py @@ -0,0 +1,14 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class VcpOdooModuleVersion(models.Model): + _inherit = "vcp.odoo.module.version" + _name = "vcp.odoo.module.version" + + readme_fragments = fields.Json() + icon_url = fields.Char() diff --git a/oca_vcp/models/vcp_repository.py b/oca_vcp/models/vcp_repository.py new file mode 100644 index 00000000..97ac9c66 --- /dev/null +++ b/oca_vcp/models/vcp_repository.py @@ -0,0 +1,11 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class VcpRepository(models.Model): + _inherit = "vcp.repository" + + category_id = fields.Many2one("vcp.repository.category", "Category") diff --git a/oca_vcp/models/vcp_repository_category.py b/oca_vcp/models/vcp_repository_category.py new file mode 100644 index 00000000..08461878 --- /dev/null +++ b/oca_vcp/models/vcp_repository_category.py @@ -0,0 +1,12 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class VcpRepositoryCategory(models.Model): + _name = "vcp.repository.category" + + name = fields.Char() diff --git a/oca_vcp/models/vcp_rule.py b/oca_vcp/models/vcp_rule.py new file mode 100644 index 00000000..469a6a7c --- /dev/null +++ b/oca_vcp/models/vcp_rule.py @@ -0,0 +1,62 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +import logging +import os +from pathlib import Path + +import pypandoc + +from odoo import models + +_logger = logging.getLogger(__name__) + +# see https://github.com/OCA/maintainer-tools/blob/master/tools/gen_addon_readme.py +PANDOC_MARKDOWN_FORMAT = "gfm-raw_html-gfm_auto_identifiers" + + +class VcpRule(models.Model): + _inherit = "vcp.rule" + + def _process_rule_odoo_module_prepare_vals( + self, repository_branch, module_id, manifest_path + ): + vals = super()._process_rule_odoo_module_prepare_vals( + repository_branch, module_id, manifest_path + ) + module_path = os.path.dirname(manifest_path) + readme_path = Path(module_path, "readme") + vals["readme_fragments"] = {} + if readme_path.exists(): + for item in readme_path.iterdir(): + if item.is_file(): + filename = item.stem.lower() + extension = item.suffix + data = item.read_text() + if not data: + continue + if extension == ".md": + vals["readme_fragments"][filename] = data + elif extension == ".rst": + vals["readme_fragments"][filename] = pypandoc.convert_text( + data, + format="rst", + to=PANDOC_MARKDOWN_FORMAT, + extra_args=["--shift-heading-level=1"], + sandbox=True, + ) + else: + _logger.error("Unsupported format in readme path %s".format()) + + if Path(module_path, "static/description/icon.png").exists(): + module = self.env["vcp.odoo.module"].browse(module_id) + repo_name = repository_branch.repository_id.name + orga_name = repository_branch.repository_id.platform_id.name + vals["icon_url"] = ( + f"https://raw.githubusercontent.com/{orga_name}/{repo_name}" + f"/refs/heads/{repository_branch.branch_id.name}/" + f"{module.name}/static/description/icon.png" + ) + return vals diff --git a/oca_vcp/pyproject.toml b/oca_vcp/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/oca_vcp/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/oca_vcp/readme/DESCRIPTION.md b/oca_vcp/readme/DESCRIPTION.md new file mode 100644 index 00000000..0abaaab3 --- /dev/null +++ b/oca_vcp/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Custom OCA for VCP diff --git a/oca_vcp/security/ir.model.access.csv b/oca_vcp/security/ir.model.access.csv new file mode 100644 index 00000000..48aa0eb9 --- /dev/null +++ b/oca_vcp/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_vcp_repository_category,Access Vcp Repository Category,model_vcp_repository_category,vcp_management.group_vcp_user,1,0,0,0 +manage_vcp_repository_category,Manage Vcp Repository Category,model_vcp_repository_category,vcp_management.group_vcp_manager,1,1,1,1 diff --git a/oca_vcp/static/description/index.html b/oca_vcp/static/description/index.html new file mode 100644 index 00000000..20d32e1a --- /dev/null +++ b/oca_vcp/static/description/index.html @@ -0,0 +1,422 @@ + + + + + +Oca VCP + + + +
+

Oca VCP

+ + +

Alpha License: AGPL-3 OCA/oca-custom Translate me on Weblate Try me on Runboat

+

Custom OCA for VCP

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

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

+
    +
  • Akretion
  • +
+
+
+

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

+

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

+
+
+
+ + diff --git a/oca_vcp/views/vcp_odoo_module_view.xml b/oca_vcp/views/vcp_odoo_module_view.xml new file mode 100644 index 00000000..dc192e78 --- /dev/null +++ b/oca_vcp/views/vcp_odoo_module_view.xml @@ -0,0 +1,12 @@ + + + + vcp.odoo.module + + + + + + + + diff --git a/oca_vcp/views/vcp_repository_category_view.xml b/oca_vcp/views/vcp_repository_category_view.xml new file mode 100644 index 00000000..c17eb77c --- /dev/null +++ b/oca_vcp/views/vcp_repository_category_view.xml @@ -0,0 +1,49 @@ + + + + vcp.repository.category + + + + + + + + + vcp.repository.category + +
+
+ + + + + + + + + vcp.repository.category + + + + + + + + + Label + ir.actions.act_window + vcp.repository.category + list,form + + [] + {} + + + + diff --git a/oca_vcp/views/vcp_repository_view.xml b/oca_vcp/views/vcp_repository_view.xml new file mode 100644 index 00000000..effd1126 --- /dev/null +++ b/oca_vcp/views/vcp_repository_view.xml @@ -0,0 +1,29 @@ + + + + vcp.repository + + + + + + + + + + vcp.repository + + + + + + + + + + From a476dba9ce0d3a61c2ff0f09ee20fc88642a0b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 9 Mar 2026 22:01:28 +0100 Subject: [PATCH 02/36] [ADD] add oca_search_engine --- oca_all/__manifest__.py | 1 + oca_search_engine/README.rst | 76 ++++ oca_search_engine/__init__.py | 2 + oca_search_engine/__manifest__.py | 36 ++ oca_search_engine/data/backend_data.xml | 12 + oca_search_engine/data/index_data.xml | 10 + oca_search_engine/models/__init__.py | 2 + oca_search_engine/models/se_index.py | 40 ++ .../models/vcp_odoo_module_version.py | 25 ++ oca_search_engine/pyproject.toml | 3 + oca_search_engine/readme/DESCRIPTION.md | 1 + oca_search_engine/schemas/__init__.py | 3 + .../schemas/vcp_odoo_module_version.py | 58 +++ oca_search_engine/schemas/vcp_repository.py | 24 + .../schemas/vcp_repository_category.py | 17 + .../static/description/index.html | 422 ++++++++++++++++++ oca_search_engine/tools/__init__.py | 1 + .../vcp_odoo_module_version_serializer.py | 17 + requirements.txt | 2 + 19 files changed, 752 insertions(+) create mode 100644 oca_search_engine/README.rst create mode 100644 oca_search_engine/__init__.py create mode 100644 oca_search_engine/__manifest__.py create mode 100644 oca_search_engine/data/backend_data.xml create mode 100644 oca_search_engine/data/index_data.xml create mode 100644 oca_search_engine/models/__init__.py create mode 100644 oca_search_engine/models/se_index.py create mode 100644 oca_search_engine/models/vcp_odoo_module_version.py create mode 100644 oca_search_engine/pyproject.toml create mode 100644 oca_search_engine/readme/DESCRIPTION.md create mode 100644 oca_search_engine/schemas/__init__.py create mode 100644 oca_search_engine/schemas/vcp_odoo_module_version.py create mode 100644 oca_search_engine/schemas/vcp_repository.py create mode 100644 oca_search_engine/schemas/vcp_repository_category.py create mode 100644 oca_search_engine/static/description/index.html create mode 100644 oca_search_engine/tools/__init__.py create mode 100644 oca_search_engine/tools/vcp_odoo_module_version_serializer.py diff --git a/oca_all/__manifest__.py b/oca_all/__manifest__.py index 3d256467..bb9d29aa 100644 --- a/oca_all/__manifest__.py +++ b/oca_all/__manifest__.py @@ -153,6 +153,7 @@ "partner_statement", "project_task_add_very_high", "oca_custom", + "oca_search_engine", "partner_contact_access_link", "pdf_xml_attachment", "project_role", diff --git a/oca_search_engine/README.rst b/oca_search_engine/README.rst new file mode 100644 index 00000000..143ed16f --- /dev/null +++ b/oca_search_engine/README.rst @@ -0,0 +1,76 @@ +================= +Oca Search Engine +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:178e2a122f4cfaa13da2f0e6d0ed10cac60f0554f99f35e48d71948f5416f423 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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%2Foca--custom-lightgray.png?logo=github + :target: https://github.com/OCA/oca-custom/tree/18.0/oca_search_engine + :alt: OCA/oca-custom +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/oca-custom-18-0/oca-custom-18-0-oca_search_engine + :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/oca-custom&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Custom OCA for exporting public data in typesense + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* Akretion + +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/oca-custom `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/oca_search_engine/__init__.py b/oca_search_engine/__init__.py new file mode 100644 index 00000000..738a2eec --- /dev/null +++ b/oca_search_engine/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import tools diff --git a/oca_search_engine/__manifest__.py b/oca_search_engine/__manifest__.py new file mode 100644 index 00000000..60b1875f --- /dev/null +++ b/oca_search_engine/__manifest__.py @@ -0,0 +1,36 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +{ + "name": "Oca Search Engine", + "summary": "Export Public OCA data to search engine", + "version": "18.0.1.0.0", + "development_status": "Alpha", + "category": "custom", + "website": "https://github.com/OCA/oca-custom", + "author": "Akretion, Odoo Community Association (OCA)", + "license": "AGPL-3", + "external_dependencies": { + "python": [ + "extendable_pydantic", + "pypandoc", + ], + "bin": [], + }, + "depends": [ + "oca_vcp", + "connector_typesense", + "search_engine_serializer_pydantic", + # following dependency are needed by uv to resolve the dep + # correctly as module are not merged + "vcp_management", + "vcp_git", + ], + "data": [ + "data/backend_data.xml", + "data/index_data.xml", + ], + "demo": [], +} diff --git a/oca_search_engine/data/backend_data.xml b/oca_search_engine/data/backend_data.xml new file mode 100644 index 00000000..6c0978d4 --- /dev/null +++ b/oca_search_engine/data/backend_data.xml @@ -0,0 +1,12 @@ + + + + OCA Typesense Backend + oca_typesense_backend + typesense + typesense + 8108 + http + xyz + + diff --git a/oca_search_engine/data/index_data.xml b/oca_search_engine/data/index_data.xml new file mode 100644 index 00000000..c78e296b --- /dev/null +++ b/oca_search_engine/data/index_data.xml @@ -0,0 +1,10 @@ + + + + + oca_typesense_backend + + + vcp_odoo_module_version_exports + + diff --git a/oca_search_engine/models/__init__.py b/oca_search_engine/models/__init__.py new file mode 100644 index 00000000..8b3985cd --- /dev/null +++ b/oca_search_engine/models/__init__.py @@ -0,0 +1,2 @@ +from . import vcp_odoo_module_version +from . import se_index diff --git a/oca_search_engine/models/se_index.py b/oca_search_engine/models/se_index.py new file mode 100644 index 00000000..a91d023c --- /dev/null +++ b/oca_search_engine/models/se_index.py @@ -0,0 +1,40 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from ..tools.vcp_odoo_module_version_serializer import VcpOdooModuleVersionSerializer + + +class SeIndex(models.Model): + _inherit = "se.index" + + serializer_type = fields.Selection( + selection_add=[ + ("vcp_odoo_module_version_exports", "Odoo Modules"), + ], + ondelete={ + "vcp_odoo_module_version_exports": "cascade", + }, + ) + + @api.constrains("model_id", "serializer_type") + def _check_model(self): + vcp_odoo_module_version_model = self.env["ir.model"].search( + [("model", "=", "vcp.odoo.module.version")], limit=1 + ) + for se_index in self: + if ( + se_index.serializer_type == "vcp_odoo_module_version_exports" + and se_index.model_id != vcp_odoo_module_version_model + ): + raise ValidationError(_("'Serializer Type' must match 'Model'")) + + def _get_serializer(self): + self.ensure_one() + if self.serializer_type == "vcp_odoo_module_version_exports": + return VcpOdooModuleVersionSerializer() + else: + return super()._get_serializer() diff --git a/oca_search_engine/models/vcp_odoo_module_version.py b/oca_search_engine/models/vcp_odoo_module_version.py new file mode 100644 index 00000000..6283c2de --- /dev/null +++ b/oca_search_engine/models/vcp_odoo_module_version.py @@ -0,0 +1,25 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import api, models + + +class VcpOdooModuleVersion(models.Model): + _name = "vcp.odoo.module.version" + _inherit = ["vcp.odoo.module.version", "se.indexable.record"] + + def _add_to_oca_search_engine(self): + self._add_to_index(self.env.ref("oca_search_engine.oca_typesense_index_module")) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._add_to_oca_search_engine() + return records + + def write(self, vals): + self._se_mark_to_update() + return super().write(vals) + diff --git a/oca_search_engine/pyproject.toml b/oca_search_engine/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/oca_search_engine/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/oca_search_engine/readme/DESCRIPTION.md b/oca_search_engine/readme/DESCRIPTION.md new file mode 100644 index 00000000..48aa0d79 --- /dev/null +++ b/oca_search_engine/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Custom OCA for exporting public data in typesense diff --git a/oca_search_engine/schemas/__init__.py b/oca_search_engine/schemas/__init__.py new file mode 100644 index 00000000..bf0025b9 --- /dev/null +++ b/oca_search_engine/schemas/__init__.py @@ -0,0 +1,3 @@ +from . import vcp_odoo_module_version +from . import vcp_repository +from . import vcp_repository_category diff --git a/oca_search_engine/schemas/vcp_odoo_module_version.py b/oca_search_engine/schemas/vcp_odoo_module_version.py new file mode 100644 index 00000000..5855791e --- /dev/null +++ b/oca_search_engine/schemas/vcp_odoo_module_version.py @@ -0,0 +1,58 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from extendable_pydantic import StrictExtendableBaseModel +from .vcp_repository import VcpRepository + +# Gestion des redirections ! + +class VcpOdooModuleVersion(StrictExtendableBaseModel): + id: int + name: str + techname: str + repo: VcpRepository + version: str + serie: str + dependencies: list # des url key + license: str + summary: str + maturity: str + # authors: list (Author) # TODO uniquement une liste de nom en V1 + github_url: str + runboat_url: str + readme_fragments: list + # contributors: list (liste de membre) [name, email, société) + # on va extraire le readme de Contributors pour le structurer + # maintainers: list (liste de membre) + icon_url: str + must_have: bool + + @classmethod + def _get_runboat_url(cls, odoo_rec): + return ( + "https://runboat.odoo-community.org/webui/builds.html?" + f"repo=OCA/{odoo_rec.repository_branch_id.repository_id.name}" + f"&target_branch={odoo_rec.repository_branch_id.branch_id.name}" + ) + + @classmethod + def from_record(cls, odoo_rec): + return cls.model_construct( + id=odoo_rec.id, + name=odoo_rec.name, + techname=odoo_rec.module_id.name, + repo=VcpRepository.from_record(odoo_rec.repository_branch_id.repository_id), + serie=odoo_rec.repository_branch_id.branch_id.name, + version=odoo_rec.version, + license=odoo_rec.license, + summary=odoo_rec.summary, + #maturity=odoo_rec.maturity, + github_url=odoo_rec.website, + runboat_url=cls._get_runboat_url(odoo_rec), + readme_fragments=odoo_rec.readme_fragments, + dependencies=odoo_rec.depends_on_module_ids.mapped("name"), + icon_url=odoo_rec.icon_url, + must_have=odoo_rec.module_id.must_have, + ) diff --git a/oca_search_engine/schemas/vcp_repository.py b/oca_search_engine/schemas/vcp_repository.py new file mode 100644 index 00000000..d20b2801 --- /dev/null +++ b/oca_search_engine/schemas/vcp_repository.py @@ -0,0 +1,24 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from extendable_pydantic import StrictExtendableBaseModel +from .vcp_repository_category import VcpRepositoryCategory + + +class VcpRepository(StrictExtendableBaseModel): + + name: str + url: str + description: str + category: VcpRepositoryCategory + + @classmethod + def from_record(cls, odoo_rec): + return cls.model_construct( + name=odoo_rec.name, + description=odoo_rec.description, + url=odoo_rec._get_repository_url(), + category=VcpRepositoryCategory.from_record(odoo_rec.category_id) + ) diff --git a/oca_search_engine/schemas/vcp_repository_category.py b/oca_search_engine/schemas/vcp_repository_category.py new file mode 100644 index 00000000..ba49ebb6 --- /dev/null +++ b/oca_search_engine/schemas/vcp_repository_category.py @@ -0,0 +1,17 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from extendable_pydantic import StrictExtendableBaseModel + + +class VcpRepositoryCategory(StrictExtendableBaseModel): + + name: str + + @classmethod + def from_record(cls, odoo_rec): + return cls.model_construct( + name=odoo_rec.name, + ) diff --git a/oca_search_engine/static/description/index.html b/oca_search_engine/static/description/index.html new file mode 100644 index 00000000..51864948 --- /dev/null +++ b/oca_search_engine/static/description/index.html @@ -0,0 +1,422 @@ + + + + + +Oca Search Engine + + + +
+

Oca Search Engine

+ + +

Alpha License: AGPL-3 OCA/oca-custom Translate me on Weblate Try me on Runboat

+

Custom OCA for exporting public data in typesense

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

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

+
    +
  • Akretion
  • +
+
+
+

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

+

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

+
+
+
+ + diff --git a/oca_search_engine/tools/__init__.py b/oca_search_engine/tools/__init__.py new file mode 100644 index 00000000..9c3d990c --- /dev/null +++ b/oca_search_engine/tools/__init__.py @@ -0,0 +1 @@ +from . import vcp_odoo_module_version_serializer diff --git a/oca_search_engine/tools/vcp_odoo_module_version_serializer.py b/oca_search_engine/tools/vcp_odoo_module_version_serializer.py new file mode 100644 index 00000000..6cc2a942 --- /dev/null +++ b/oca_search_engine/tools/vcp_odoo_module_version_serializer.py @@ -0,0 +1,17 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.search_engine_serializer_pydantic.tools.serializer import ( + PydanticModelSerializer, +) + +from ..schemas.vcp_odoo_module_version import VcpOdooModuleVersion + + +class VcpOdooModuleVersionSerializer(PydanticModelSerializer): + def get_model_class(self): + return VcpOdooModuleVersion + + def serialize(self, record): + return self.get_model_class().from_record(record).model_dump(mode="json") diff --git a/requirements.txt b/requirements.txt index 8f9d9a12..123a2bfd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ # generated from manifests external_dependencies +extendable_pydantic +pypandoc responses From c3d25b22c3687b44795da92170c73abcdda17fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Wed, 25 Mar 2026 16:23:09 +0100 Subject: [PATCH 03/36] Update pyproject --- pyproject.toml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9481459e..c01c05c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,6 @@ dependencies = [ "PyGithub<2.0.0", "click-odoo-contrib", "openupgradelib", - ] addons_dirs = ["./"] @@ -96,6 +95,7 @@ dev = [ "manifestoo>=1.0", "odoo-test-helper", "websocket-client", + "vcrpy-unittest", ] @@ -114,6 +114,20 @@ odoo-addon-account_statement_import_online_wise = { git = "https://github.com/Th odoo-addon-account_banking_pain_base = { git = "https://github.com/Therp/bank-payment", branch = "18.0-mig-account_banking_pain_base_sepa_hybrid_extended_for_09", subdirectory = "account_banking_pain_base" } odoo-addon-account_banking_sepa_credit_transfer = { git = "https://github.com/Therp/bank-payment", branch = "18.0-mig-account_banking_pain_base_sepa_hybrid_extended_for_09", subdirectory = "account_banking_sepa_credit_transfer" } +# VCP module +odoo-addon-vcp-management = { git = "https://github.com/dixmit/version-control-platform", branch="18.0-add-vcp", subdirectory="vcp_management" } +odoo-addon-vcp-github = { git = "https://github.com/dixmit/version-control-platform", branch="18.0-add-vcp", subdirectory="vcp_github" } +odoo-addon-vcp-odoo = { git = "https://github.com/dixmit/version-control-platform", branch="18.0-add-vcp", subdirectory="vcp_odoo" } +odoo-addon-vcp-git = { git = "https://github.com/dixmit/version-control-platform", branch="18.0-add-vcp", subdirectory="vcp_git" } + +# VCP module mode dev +#odoo-addon-vcp-management = { path = "/external-src/version-control-platform/vcp_management", editable = true } +#odoo-addon-vcp-github = { path = "/external-src/version-control-platform/vcp_github", editable = true } +#odoo-addon-vcp-git = { path = "/external-src/version-control-platform/vcp_git", editable = true } +#odoo-addon-vcp-odoo = { path = "/external-src/version-control-platform/vcp_odoo", editable = true } + + + # Example to develop module from another repository in editable mode # odoo-addon-membership-delegated-partner-line = { path = "src/vertical-association/setup/membership_delegated_partner_line", editable = true } From 239d6cd37fa6734feedce9733eca811dc96a0cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Thu, 26 Mar 2026 17:46:25 +0100 Subject: [PATCH 04/36] DO NOT MERGE: hack uv sync to avoid to commit uv.lock --- Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0a3c2993..f71f891f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,9 +50,8 @@ RUN set -e \ # but not the project ARG DISTRO RUN --mount=type=cache,target=/root/.cache/uv,id=uv-${DISTRO} \ - --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project + uv sync --no-install-project ############################################################### # In this runtime stage, we install the app in editable mode, @@ -69,6 +68,6 @@ WORKDIR /app RUN python -m compileall . ARG DISTRO RUN --mount=type=cache,target=/root/.cache/uv,id=uv-${DISTRO} \ - uv sync --locked --no-dev + uv sync --no-dev COPY entrypoints/* /odoo/start-entrypoint.d/ From 99d467727625a66e717b7d6d88640a69da4309b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 30 Mar 2026 10:46:37 +0200 Subject: [PATCH 05/36] oca_vcp: add category in repository tree view --- oca_vcp/views/vcp_repository_view.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/oca_vcp/views/vcp_repository_view.xml b/oca_vcp/views/vcp_repository_view.xml index effd1126..11101e3b 100644 --- a/oca_vcp/views/vcp_repository_view.xml +++ b/oca_vcp/views/vcp_repository_view.xml @@ -10,6 +10,16 @@ + + vcp.repository + + + + + + + + vcp.repository From 8a1f194ae0a5b90d688b4699bfbaff7ae640bdac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 30 Mar 2026 10:51:04 +0200 Subject: [PATCH 06/36] oca_search_engine: add author, maintainer, development_status --- .../models/vcp_odoo_module_version.py | 1 - oca_search_engine/schemas/__init__.py | 2 ++ oca_search_engine/schemas/vcp_odoo_author.py | 21 +++++++++++++++ .../schemas/vcp_odoo_maintainer.py | 25 ++++++++++++++++++ .../schemas/vcp_odoo_module_version.py | 26 +++++++++++++------ oca_search_engine/schemas/vcp_repository.py | 4 +-- .../schemas/vcp_repository_category.py | 4 +-- 7 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 oca_search_engine/schemas/vcp_odoo_author.py create mode 100644 oca_search_engine/schemas/vcp_odoo_maintainer.py diff --git a/oca_search_engine/models/vcp_odoo_module_version.py b/oca_search_engine/models/vcp_odoo_module_version.py index 6283c2de..ffd68d94 100644 --- a/oca_search_engine/models/vcp_odoo_module_version.py +++ b/oca_search_engine/models/vcp_odoo_module_version.py @@ -22,4 +22,3 @@ def create(self, vals_list): def write(self, vals): self._se_mark_to_update() return super().write(vals) - diff --git a/oca_search_engine/schemas/__init__.py b/oca_search_engine/schemas/__init__.py index bf0025b9..8f7a288b 100644 --- a/oca_search_engine/schemas/__init__.py +++ b/oca_search_engine/schemas/__init__.py @@ -1,3 +1,5 @@ from . import vcp_odoo_module_version +from . import vcp_odoo_maintainer +from . import vcp_odoo_author from . import vcp_repository from . import vcp_repository_category diff --git a/oca_search_engine/schemas/vcp_odoo_author.py b/oca_search_engine/schemas/vcp_odoo_author.py new file mode 100644 index 00000000..3c57c460 --- /dev/null +++ b/oca_search_engine/schemas/vcp_odoo_author.py @@ -0,0 +1,21 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from extendable_pydantic import StrictExtendableBaseModel + + +class VcpOdooAuthor(StrictExtendableBaseModel): + name: str + url_key: str + + @classmethod + def from_record(cls, odoo_rec): + return cls.model_construct( + name=odoo_rec.name, + url_key=( + odoo_rec.partner_id.website_published + and odoo_rec.env["ir.http"]._slugify(odoo_rec.partner_id.name) + ) + or "", + ) diff --git a/oca_search_engine/schemas/vcp_odoo_maintainer.py b/oca_search_engine/schemas/vcp_odoo_maintainer.py new file mode 100644 index 00000000..f9100684 --- /dev/null +++ b/oca_search_engine/schemas/vcp_odoo_maintainer.py @@ -0,0 +1,25 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from extendable_pydantic import StrictExtendableBaseModel + + +class VcpOdooMaintainer(StrictExtendableBaseModel): + name: str + github_user: str + avatar_url: str + url_key: str + + @classmethod + def from_record(cls, odoo_rec): + return cls.model_construct( + name=odoo_rec.name, + github_user=odoo_rec.external_id, + avatar_url=odoo_rec.avatar_url, + url_key=( + odoo_rec.partner_id.website_published + and odoo_rec.env["ir.http"]._slugify(odoo_rec.partner_id.name) + ) + or "", + ) diff --git a/oca_search_engine/schemas/vcp_odoo_module_version.py b/oca_search_engine/schemas/vcp_odoo_module_version.py index 5855791e..1be8a1ba 100644 --- a/oca_search_engine/schemas/vcp_odoo_module_version.py +++ b/oca_search_engine/schemas/vcp_odoo_module_version.py @@ -4,10 +4,14 @@ from extendable_pydantic import StrictExtendableBaseModel + +from .vcp_odoo_author import VcpOdooAuthor +from .vcp_odoo_maintainer import VcpOdooMaintainer from .vcp_repository import VcpRepository # Gestion des redirections ! + class VcpOdooModuleVersion(StrictExtendableBaseModel): id: int name: str @@ -15,17 +19,15 @@ class VcpOdooModuleVersion(StrictExtendableBaseModel): repo: VcpRepository version: str serie: str - dependencies: list # des url key + dependencies: list license: str summary: str - maturity: str - # authors: list (Author) # TODO uniquement une liste de nom en V1 + development_status: str + authors: list[VcpOdooAuthor] github_url: str runboat_url: str readme_fragments: list - # contributors: list (liste de membre) [name, email, société) - # on va extraire le readme de Contributors pour le structurer - # maintainers: list (liste de membre) + maintainers: list[VcpOdooMaintainer] icon_url: str must_have: bool @@ -35,7 +37,7 @@ def _get_runboat_url(cls, odoo_rec): "https://runboat.odoo-community.org/webui/builds.html?" f"repo=OCA/{odoo_rec.repository_branch_id.repository_id.name}" f"&target_branch={odoo_rec.repository_branch_id.branch_id.name}" - ) + ) @classmethod def from_record(cls, odoo_rec): @@ -48,10 +50,18 @@ def from_record(cls, odoo_rec): version=odoo_rec.version, license=odoo_rec.license, summary=odoo_rec.summary, - #maturity=odoo_rec.maturity, + development_status=odoo_rec.development_status, + authors=[ + VcpOdooAuthor.from_record(author) + for author in odoo_rec.author_ids + if author.name != "Odoo Community Association (OCA)" + ], github_url=odoo_rec.website, runboat_url=cls._get_runboat_url(odoo_rec), readme_fragments=odoo_rec.readme_fragments, + maintainers=[ + VcpOdooMaintainer.from_record(user) for user in odoo_rec.maintainer_ids + ], dependencies=odoo_rec.depends_on_module_ids.mapped("name"), icon_url=odoo_rec.icon_url, must_have=odoo_rec.module_id.must_have, diff --git a/oca_search_engine/schemas/vcp_repository.py b/oca_search_engine/schemas/vcp_repository.py index d20b2801..1a546971 100644 --- a/oca_search_engine/schemas/vcp_repository.py +++ b/oca_search_engine/schemas/vcp_repository.py @@ -4,11 +4,11 @@ from extendable_pydantic import StrictExtendableBaseModel + from .vcp_repository_category import VcpRepositoryCategory class VcpRepository(StrictExtendableBaseModel): - name: str url: str description: str @@ -20,5 +20,5 @@ def from_record(cls, odoo_rec): name=odoo_rec.name, description=odoo_rec.description, url=odoo_rec._get_repository_url(), - category=VcpRepositoryCategory.from_record(odoo_rec.category_id) + category=VcpRepositoryCategory.from_record(odoo_rec.category_id), ) diff --git a/oca_search_engine/schemas/vcp_repository_category.py b/oca_search_engine/schemas/vcp_repository_category.py index ba49ebb6..6d27d363 100644 --- a/oca_search_engine/schemas/vcp_repository_category.py +++ b/oca_search_engine/schemas/vcp_repository_category.py @@ -7,11 +7,11 @@ class VcpRepositoryCategory(StrictExtendableBaseModel): - name: str + url_key: str @classmethod def from_record(cls, odoo_rec): return cls.model_construct( - name=odoo_rec.name, + name=odoo_rec.name, url_key=odoo_rec.env["ir.http"]._slugify(odoo_rec.name) ) From cd53a1a6b36d8c69b12661118bc736f88fee3c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 30 Mar 2026 10:53:01 +0200 Subject: [PATCH 07/36] oca_all: add migration script --- oca_all/README.rst | 6 +---- oca_all/__manifest__.py | 2 +- .../18.0.1.0.0/post-migrate-github-user.py | 24 +++++++++++++++++++ oca_all/static/description/index.html | 22 +++++++---------- 4 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 oca_all/migrations/18.0.1.0.0/post-migrate-github-user.py diff --git a/oca_all/README.rst b/oca_all/README.rst index 89040ad0..e7a2a88e 100644 --- a/oca_all/README.rst +++ b/oca_all/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ======= OCA All ======= @@ -17,7 +13,7 @@ OCA All .. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png :target: https://odoo-community.org/page/development-status :alt: Alpha -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |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%2Foca--custom-lightgray.png?logo=github diff --git a/oca_all/__manifest__.py b/oca_all/__manifest__.py index bb9d29aa..eb2ef1de 100644 --- a/oca_all/__manifest__.py +++ b/oca_all/__manifest__.py @@ -3,7 +3,7 @@ { "name": "OCA All", "summary": "All oca modules dependencies", - "version": "18.0.0.0.2", + "version": "18.0.1.0.0", "development_status": "Alpha", "website": "https://github.com/OCA/oca-custom", "author": "Pierre Verkest , Odoo Community Association (OCA)", diff --git a/oca_all/migrations/18.0.1.0.0/post-migrate-github-user.py b/oca_all/migrations/18.0.1.0.0/post-migrate-github-user.py new file mode 100644 index 00000000..806daca6 --- /dev/null +++ b/oca_all/migrations/18.0.1.0.0/post-migrate-github-user.py @@ -0,0 +1,24 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +@openupgrade.migrate(use_env=True) +def migrate(env, version): + partners = env["res.partner"].search( + [ + ("github_name", "!=", False), + ] + ) + host = env.ref("vcp_github.vcp_github_host") + for partner in partners: + env["vcp.user"].create( + { + "name": partner.name, + "external_id": partner.github_name, + "partner_id": partner.id, + "host_id": host.id, + } + ) diff --git a/oca_all/static/description/index.html b/oca_all/static/description/index.html index 80c14f36..5819b2d2 100644 --- a/oca_all/static/description/index.html +++ b/oca_all/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +OCA All -
+
+

OCA All

- - -Odoo Community Association - -
-

OCA All

-

Alpha License: AGPL-3 OCA/oca-custom Translate me on Weblate Try me on Runboat

+

Alpha License: AGPL-3 OCA/oca-custom Translate me on Weblate Try me on Runboat

OCA’s Odoo instance’s dependencies.

Installing this module will create an instance likes the one used to manage the OCA association.

@@ -396,7 +391,7 @@

OCA All

-

Bug Tracker

+

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 @@ -404,15 +399,15 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -427,6 +422,5 @@

Maintainers

-
From 29985d24a382fdc211d8d1ac547b7666533a8b45 Mon Sep 17 00:00:00 2001 From: Arnaud LAYEC Date: Wed, 25 Mar 2026 17:37:53 +0100 Subject: [PATCH 08/36] [DRAFT] Cleaning of OCA database --- scripts/001_clean_db_akretion_2026_03.py | 75 ++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 scripts/001_clean_db_akretion_2026_03.py diff --git a/scripts/001_clean_db_akretion_2026_03.py b/scripts/001_clean_db_akretion_2026_03.py new file mode 100644 index 00000000..299216ea --- /dev/null +++ b/scripts/001_clean_db_akretion_2026_03.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +import click, click_odoo + +import logging +_logger = logging.getLogger(__name__) + +@click.command() +@click_odoo.env_options(default_log_level='debug') +def main(env): + _01_fix_orphans_views_manual(env) + _02_uninstall_modules(env) + _03_free_db_space(env) + _04_remove_duplicate_indexes(env) + + # clean DB space (takes some time) + _logger.warning("Start of 'VACUUM FULL;'") + env.execute(f""" + VACUUM FULL; + """) + _logger.warning("End of 'VACUUM FULL;'") + +def _01_fix_orphans_views_manual(env): + _logger.warning("_01_fix_orphans_views_manual") + + # Loyalty: remove orphans view blocking install of 'oca_custom' + env.execute(f""" + DELETE FROM ir_ui_view + WHERE id IN ( + SELECT res_id + FROM ir_model_data + WHERE + module LIKE '%loyalty%' + AND model = 'ir.ui.view' + ); + """) + + +def _02_uninstall_modules(env): + _logger.warning("_02_uninstall_modules") + + # Remove 'sql_request_abstract': 1 SQL report of 2018 throwing WARNING at Odoo start + env.execute(f""" + DROP TABLE IF EXISTS x_bi_sql_view_module_version_creation_date; + """) + env['ir.module.module'].search([('name', '=', 'sql_request_abstract')]).button_immediate_uninstall() + + +def _03_free_db_space(env): + _logger.warning("_03_free_db_space") + + # 1. Save 22GB of table website_track (80M indexed lines) + # oca=# SELECT DATE_PART('year', visit_datetime) AS year, COUNT(*) FROM website_track GROUP BY year ORDER BY year; + # year | count + # ------+---------- + # 2020 | 245950 + # 2021 | 3638629 + # 2022 | 5226488 + # 2023 | 7530616 + # 2024 | 17594311 + # 2025 | 41076026 + # 2026 | 4985794 + env.execute(f""" + DELETE FROM website_track WHERE visit_datetime < '2025-01-01'; -- 34235994 rows deleted + DELETE FROM website_track WHERE visit_datetime < '2026-01-01'; -- 41076026 rows deleted (only 2025) + """) + +def _04_remove_duplicate_indexes(env): + # TODO @arnaudlayec: verify indexes in duplicate + # look at "def check_indexes" + # +Odoo log from a "odoo -c oca -u base" + filter on "Keep unexpected index" + + # Remove unused index + env.execute(f""" + DROP INDEX IF EXISTS website_track__url_index; -- 3.6 GB + """) From e7b7b5c5b1185a6e125d45e8f89c59aad9d33619 Mon Sep 17 00:00:00 2001 From: Arnaud LAYEC Date: Wed, 25 Mar 2026 17:38:41 +0100 Subject: [PATCH 09/36] [18.0][ADD] oca_sponsor [18.0][MIG] Move sponsors mgt from 'website_oca_integrator' to 'oca_sponsor' [FIX] Request validation if blog post are updated by the sponsor itself --- oca_sponsor/__init__.py | 1 + oca_sponsor/__manifest__.py | 32 +++ oca_sponsor/data/mail_activity_data.xml | 12 + oca_sponsor/models/__init__.py | 5 + oca_sponsor/models/blog_post.py | 21 ++ oca_sponsor/models/mail_activity.py | 29 +++ oca_sponsor/models/res_partner.py | 229 ++++++++++++++++++ oca_sponsor/models/res_partner_grade.py | 24 ++ oca_sponsor/models/res_partner_industry.py | 13 + .../models/sponsorship_line.py | 13 +- oca_sponsor/readme/CONFIGURATION.rst | 6 + oca_sponsor/readme/CONTRIBUTORS.rst | 1 + oca_sponsor/readme/DESCRIPTION.rst | 8 + oca_sponsor/readme/HISTORY.rst | 6 + oca_sponsor/readme/ROADMAP.rst | 9 + oca_sponsor/readme/USAGE.rst | 15 ++ oca_sponsor/security/ir.model.access.csv | 11 + oca_sponsor/tests/__init__.py | 1 + oca_sponsor/tests/test_oca_sponsor.py | 127 ++++++++++ oca_sponsor/views/blog_post.xml | 17 ++ oca_sponsor/views/res_partner.xml | 143 +++++++++++ oca_sponsor/views/res_partner_industry.xml | 33 +++ oca_sponsor/views/sponsorship_line.xml | 21 ++ website_oca_integrator/__manifest__.py | 2 +- website_oca_integrator/controllers/main.py | 8 +- .../migrations/18.0.1.0.2/pre-migrate.py | 13 + website_oca_integrator/models/__init__.py | 1 - website_oca_integrator/models/res_partner.py | 6 - .../security/ir.model.access.csv | 3 - .../views/view_res_partner.xml | 21 -- .../website_oca_integrator_templates.xml | 4 +- 31 files changed, 791 insertions(+), 44 deletions(-) create mode 100644 oca_sponsor/__init__.py create mode 100644 oca_sponsor/__manifest__.py create mode 100644 oca_sponsor/data/mail_activity_data.xml create mode 100644 oca_sponsor/models/__init__.py create mode 100644 oca_sponsor/models/blog_post.py create mode 100644 oca_sponsor/models/mail_activity.py create mode 100644 oca_sponsor/models/res_partner.py create mode 100644 oca_sponsor/models/res_partner_grade.py create mode 100644 oca_sponsor/models/res_partner_industry.py rename website_oca_integrator/models/sponsorship.py => oca_sponsor/models/sponsorship_line.py (66%) create mode 100644 oca_sponsor/readme/CONFIGURATION.rst create mode 100644 oca_sponsor/readme/CONTRIBUTORS.rst create mode 100644 oca_sponsor/readme/DESCRIPTION.rst create mode 100644 oca_sponsor/readme/HISTORY.rst create mode 100644 oca_sponsor/readme/ROADMAP.rst create mode 100644 oca_sponsor/readme/USAGE.rst create mode 100644 oca_sponsor/security/ir.model.access.csv create mode 100644 oca_sponsor/tests/__init__.py create mode 100644 oca_sponsor/tests/test_oca_sponsor.py create mode 100644 oca_sponsor/views/blog_post.xml create mode 100644 oca_sponsor/views/res_partner.xml create mode 100644 oca_sponsor/views/res_partner_industry.xml create mode 100644 oca_sponsor/views/sponsorship_line.xml create mode 100644 website_oca_integrator/migrations/18.0.1.0.2/pre-migrate.py diff --git a/oca_sponsor/__init__.py b/oca_sponsor/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/oca_sponsor/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/oca_sponsor/__manifest__.py b/oca_sponsor/__manifest__.py new file mode 100644 index 00000000..5c3513e4 --- /dev/null +++ b/oca_sponsor/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Arnaud LAYEC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +{ + "name": "OCA Sponsors", + "description": """Add and manage sponsors data for OCA website""", + "version": "18.0.1.0.0", + "author": "Akretion", + "website": "https://github.com/oca/oca-custom", + "license": "AGPL-3", + "category": "Custom", + "depends": [ + "membership_extension", # for security group + "website_blog", + ], + "data": [ + # security + "security/ir.model.access.csv", + # data + "data/mail_activity_data.xml", + # views + "views/blog_post.xml", + "views/res_partner_industry.xml", + "views/res_partner.xml", + "views/sponsorship_line.xml", + ], + "installable": True, + "application": False, + "development_status": "Alpha", +} diff --git a/oca_sponsor/data/mail_activity_data.xml b/oca_sponsor/data/mail_activity_data.xml new file mode 100644 index 00000000..40debd3b --- /dev/null +++ b/oca_sponsor/data/mail_activity_data.xml @@ -0,0 +1,12 @@ + + + + + + Review sponsor website information + fa-check + res.partner + + + diff --git a/oca_sponsor/models/__init__.py b/oca_sponsor/models/__init__.py new file mode 100644 index 00000000..f5833bd9 --- /dev/null +++ b/oca_sponsor/models/__init__.py @@ -0,0 +1,5 @@ +from . import mail_activity +from . import res_partner +from . import res_partner_grade +from . import res_partner_industry +from . import sponsorship_line diff --git a/oca_sponsor/models/blog_post.py b/oca_sponsor/models/blog_post.py new file mode 100644 index 00000000..cd05b676 --- /dev/null +++ b/oca_sponsor/models/blog_post.py @@ -0,0 +1,21 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models, api + +class BlogPost(models.Model): + """Play review process at any update of a sponsor's blog, + except if the update is done by a reviewer""" + _inherit = ["blog.post"] + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + self.author_id._set_sponsor_to_review() + return res + + def write(self, vals): + res = super().write(vals) + self.author_id._set_sponsor_to_review() + return res diff --git a/oca_sponsor/models/mail_activity.py b/oca_sponsor/models/mail_activity.py new file mode 100644 index 00000000..53c0064b --- /dev/null +++ b/oca_sponsor/models/mail_activity.py @@ -0,0 +1,29 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + +class MailActivity(models.Model): + _inherit = ["mail.activity"] + + def action_done(self): + self._cancel_sibling_sponsor_reviewals() + return super().action_done() + + def action_cancel(self): + self._cancel_sibling_sponsor_reviewals() + return super().action_cancel() + + def _cancel_sibling_sponsor_reviewals(self): + """When 1 user review a sponsor, cancel sibling activities for the other reviewers""" + if self._context.get("skip_cancel_sibling_sponsor"): + return + + activities = self.filtered(lambda x: x.res_model == "res.partner") + if activities: + activity_type = self.env.ref("oca_sponsor.mail_activity_review_sponsor_oca") + partners = self.env["res.partner"].browse(activities.mapped("res_id")) + siblings = partners.sudo().activity_ids.filtered( # 'sudo' because activities of other users + lambda x: x.activity_type_id == activity_type + ) - self + siblings.with_context(skip_cancel_sibling_sponsor=True).action_cancel() diff --git a/oca_sponsor/models/res_partner.py b/oca_sponsor/models/res_partner.py new file mode 100644 index 00000000..a8b9276a --- /dev/null +++ b/oca_sponsor/models/res_partner.py @@ -0,0 +1,229 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models, exceptions, _ +from odoo.osv.expression import NOT_OPERATOR + +from hashlib import md5 + +SPONSOR_WEBSITE_FIELDS = { + # editable fields by the sponsor from the portal + "name", + "email", + "phone", + "website", + "country_id", "sponsor_country_ids", + "website_short_description", + "website_long_description", + "website_description_why_sponsoring", + "industry_id", "sponsor_industry_ids", + "avatar_1920", "avatar_1024", "avatar_512", "avatar_256", "avatar_128", +} + +class ResPartner(models.Model): + _inherit = ["res.partner"] + + grade_id = fields.Many2one( + comodel_name="res.partner.grade", + string="Sponsor Level", + ) + is_sponsor = fields.Boolean( + compute="_compute_is_sponsor", + search="_search_is_sponsor", + ) + sponsor_to_review = fields.Boolean( + string="To review", + default=False, + help="After the sponsor modifies its data from the web portal in autonomy, " + "the changes must be reviewed before being published on the website.", + ) + sponsorship_line_ids = fields.One2many( + string="Sponsorship history", + comodel_name="sponsorship.line", + inverse_name="partner_id", + ) + # Website fields + sponsor_country_ids = fields.Many2many( + comodel_name="res.country", + relation="res_partner_country_rel", + column1="partner_id", + column2="country_id", + string="Countries", + compute="_compute_sponsor_country_ids", + store=True, + readonly=False, + ) + industry_id = fields.Many2one( + compute="_compute_industry_id", + store=True, + readonly=False, + ) + sponsor_industry_ids = fields.Many2many( + comodel_name="res.partner.industry", + relation="res_partner_partner_industry_rel", + column1="partner_id", + column2="industry_id", + string="Industries", + compute="_compute_sponsor_industry_ids", + store=True, + readonly=False, + ) + website_long_description = fields.Text( + string="Sponsor long description", + translate=True, + ) + website_description_why_sponsoring = fields.Text( + string="Why sponsoring description", + translate=True, + ) + blog_post_ids = fields.One2many( + string="Blog posts", + comodel_name="blog.post", + inverse_name="author_id", + ) + blog_post_count = fields.Integer( + string="Blog posts count", + compute="_compute_blog_post_count", + ) + + #====== Compute ======# + @api.depends("grade_id") + def _compute_is_sponsor(self): + for partner in self: + partner.is_sponsor = bool(partner.grade_id) + @api.model + def _search_is_sponsor(self, operator, value): + if operator not in ["=", "!="] or not isinstance(value, bool): + raise NotImplementedError("Operation not supported.") + _not = [] + if operator == "!=" and value or operator == "=" and not value: + _not = [NOT_OPERATOR] + return _not + [("grade_id", "!=", False)] + + @api.depends("country_id", "grade_id") + def _compute_sponsor_country_ids(self): + """Put new `country_id` in `sponsor_country_ids`""" + self._compute_sponsor_field_ids("country_id") + + @api.depends("sponsor_industry_ids", "grade_id") + def _compute_industry_id(self): + """`industry_id`, if empty, is filled in by `sponsor_industry_ids`""" + for partner in self: + industries = partner.sponsor_industry_ids + if industries and partner.industry_id not in industries: + partner.industry_id = fields.first(industries) + + @api.depends("industry_id", "grade_id") + def _compute_sponsor_industry_ids(self): + """Put new `industry_id` in `sponsor_industry_ids`""" + self._compute_sponsor_field_ids("industry_id") + + def _compute_sponsor_field_ids(self, field): + """Called for both `sponsor_country_ids` and `sponsor_industry_ids`""" + for sponsor in self.filtered(lambda x: x.is_sponsor): + sponsor_field = "sponsor_" + field + "s" + old, new = sponsor._origin[field], sponsor[field] + if not new in sponsor[sponsor_field]: + sponsor[sponsor_field] |= new + if old != new and old in sponsor[sponsor_field]: + sponsor[sponsor_field] -= old + + @api.depends("blog_post_ids") + def _compute_blog_post_count(self): + for partner in self: + partner.blog_post_count = len(partner.blog_post_ids) + + #====== CRUD ======# + def write(self, vals): + """Set in review the sponsor whose relevant data changed""" + keys = set(vals) & SPONSOR_WEBSITE_FIELDS + + if keys: + before = self._get_hashes(keys) + + res = super().write(vals) + + if keys and (partners := self._compare_hashes(keys, before)): + partners._set_sponsor_to_review() + + return res + + def _get_hashes(self, keys, before=None): + return { + partner.id: md5(str(self.read(list(keys))).encode()).hexdigest() + for partner in self + } + def _compare_hashes(self, keys, before): + after = self._get_hashes(keys) + return self.browse([ + partner_id + for partner_id, after in after.items() + if before[partner_id] != after + ]) + + def search_fetch(self, domain, field_names, offset=0, limit=None, order=None): + """Order res.partner sponsor view in Kanban and List + with the ones to review as firsts""" + if self._context.get("membership_sponsor"): + delimiter = "" if not order else ", " + order = "sponsor_to_review DESC" + delimiter + (order or '') + return super().search_fetch(domain, field_names, offset, limit, order) + + #===== Business logics =====# + def button_sponsor_review_accept(self): + if not self.env.user.has_groups("membership_extension.group_membership_manager"): + raise exceptions.AccessError(_( + "Only a membership manager may publish sponsor information to the website." + )) + self._sponsor_review_accept() + + def action_open_blog_post(self): + return { + 'name': _("Blog posts"), + 'type': 'ir.actions.act_window', + 'res_model': "blog.post", + 'view_mode': 'list,form', + 'domain': [("author_id", "=", self.id)], + } + + def _sponsor_review_accept(self): + # Re-enable syncing + self.sudo().write({ # 'sudo' to bypass AccessError of 'website.published.multi.mixin' + "is_published": True, + "sponsor_to_review": False, + }) + + # Finish review + activity_type = self.env.ref("oca_sponsor.mail_activity_review_sponsor_oca") + self.activity_ids.filtered( + lambda x: x.activity_type_id == activity_type + ).sudo().action_done() # 'sudo' because activities of other users + + def _set_sponsor_to_review(self): + """Pause the syncing of new sponsors data until their review, + when their data are updated from the portal, + and notify reviewers with an activity""" + if ( + self._context.get("skip_sponsor_review") + or self.env.user.has_groups("membership_extension.group_membership_manager") + ): + return + self = self.with_context(skip_sponsor_review=True) # prevent infinite loop + + # Pause syncing + sponsors = self.filtered( + lambda x: x.is_sponsor and not x.sponsor_to_review + ) + if sponsors: + sponsors.sponsor_to_review = True + + # Notify reviewers + users = self.env.ref("membership_extension.group_membership_manager").users + for user in users: + # We use a specific activity template for custom done/cancel logic of mail.activity + sponsors.activity_schedule( + act_type_xmlid="oca_sponsor.mail_activity_review_sponsor_oca", + user_id=user.id, + note=_("The sponsor changes its information from its profile. " + "Please review the change to publish them on the website."), + ) diff --git a/oca_sponsor/models/res_partner_grade.py b/oca_sponsor/models/res_partner_grade.py new file mode 100644 index 00000000..1906b719 --- /dev/null +++ b/oca_sponsor/models/res_partner_grade.py @@ -0,0 +1,24 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +class ResPartnerGrade(models.Model): + """Reproduce original data model of 'website_crm_partner_assign', + but only for membership (grade) and without the CRM part, to free + this dependency (+ to `base_geolocalize`). + + We don't re-define the list/form/search ir.ui.view to avoid conflict + with native module, in case it is installed in parallel. + + *ALTERNATIVE*: create a `membership.sponsorship.category` with a migration + script copying data from `res.partner.grade` + """ + + _name = "res.partner.grade" + _order = "sequence" + _description = "Partner Grade" + + sequence = fields.Integer("Sequence") + active = fields.Boolean("Active", default=True) + name = fields.Char("Level Name", translate=True) diff --git a/oca_sponsor/models/res_partner_industry.py b/oca_sponsor/models/res_partner_industry.py new file mode 100644 index 00000000..fac211c9 --- /dev/null +++ b/oca_sponsor/models/res_partner_industry.py @@ -0,0 +1,13 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + +class ResPartnerIndustry(models.Model): + _inherit = ["res.partner.industry"] + + sequence = fields.Integer() + description = fields.Text( + string="Description", + help="The description is shared between all sponsors using this industry.", + ) diff --git a/website_oca_integrator/models/sponsorship.py b/oca_sponsor/models/sponsorship_line.py similarity index 66% rename from website_oca_integrator/models/sponsorship.py rename to oca_sponsor/models/sponsorship_line.py index 088cc5f5..afa6b099 100644 --- a/website_oca_integrator/models/sponsorship.py +++ b/oca_sponsor/models/sponsorship_line.py @@ -1,4 +1,6 @@ # Copyright 2018 Surekha Technologies (https://www.surekhatech.com) +# Copyright 2026 Akretion (https://akretion.com) +# > moved from `website_oca_integrator` in v18.0 (2026-03) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import fields, models @@ -6,14 +8,13 @@ class SponsorshipLine(models.Model): _name = "sponsorship.line" - _description = "Sponsorship Line" + _description = "Sponsorship history" + partner_id = fields.Many2one(comodel_name="res.partner", string="Partner") date_from = fields.Date(string="Join Date", required=True) date_end = fields.Date(string="End Date", required=True) - sponsorship_id = fields.Many2one( - comodel_name="product.product", string="Sponsorship Product" - ) - partner_id = fields.Many2one(comodel_name="res.partner", string="Partner") grade_id = fields.Many2one( - comodel_name="res.partner.grade", string="Level", required=True + comodel_name="res.partner.grade", + string="Sponsor Level", + required=True, ) diff --git a/oca_sponsor/readme/CONFIGURATION.rst b/oca_sponsor/readme/CONFIGURATION.rst new file mode 100644 index 00000000..4db0416f --- /dev/null +++ b/oca_sponsor/readme/CONFIGURATION.rst @@ -0,0 +1,6 @@ + +To add new Sponsor Level: +* if you use the module `website_crm_partner_assign`, which is not needed anymore: + browse to the menu *CRM / Configuration / § Resellers / Partners Level* +* if not, there is not entry menu but you may : enter any Contact, open *Sponsorship* tab, + click on *Sponsor Level* field, and create or edit new levels from here. diff --git a/oca_sponsor/readme/CONTRIBUTORS.rst b/oca_sponsor/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..509d04a0 --- /dev/null +++ b/oca_sponsor/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +- Arnaud LAYEC (arnaud.layec@akretion.com) \ No newline at end of file diff --git a/oca_sponsor/readme/DESCRIPTION.rst b/oca_sponsor/readme/DESCRIPTION.rst new file mode 100644 index 00000000..38ad77be --- /dev/null +++ b/oca_sponsor/readme/DESCRIPTION.rst @@ -0,0 +1,8 @@ + +This module adds website-publishable information to the partner form, +for website sponsorship pages. + +It also enable the sponsors to input their information by themselves +from the portal *[IN PROGRESS]* and helps the membership managers in +following up the review of those new informations. This is useful to +review the new information before publishing them online. diff --git a/oca_sponsor/readme/HISTORY.rst b/oca_sponsor/readme/HISTORY.rst new file mode 100644 index 00000000..54c2f1f4 --- /dev/null +++ b/oca_sponsor/readme/HISTORY.rst @@ -0,0 +1,6 @@ + +# 2026-03 (Akretion) + +- Move in of `sponsorship.line` from `website_oca_integrator` and free dependency + with `website_crm_partner_assign`, which were obliging other big dependencies like + `crm` and `base_geolocalize` diff --git a/oca_sponsor/readme/ROADMAP.rst b/oca_sponsor/readme/ROADMAP.rst new file mode 100644 index 00000000..1e78ed22 --- /dev/null +++ b/oca_sponsor/readme/ROADMAP.rst @@ -0,0 +1,9 @@ + +This module could join `vertical-association` if: +- reviewal process does not rely on Search Engine pausing-trick +- move-in logic of `is_publish` from `oca_search_engine` +- it adds route and template/view on native Odoo website to adds + basic sponsor pages (similar to v14.0 in `website_oca_integrator`) +- cleaning of other OCA customization: + - countries: to keep? + - industries: to remove? diff --git a/oca_sponsor/readme/USAGE.rst b/oca_sponsor/readme/USAGE.rst new file mode 100644 index 00000000..3465d711 --- /dev/null +++ b/oca_sponsor/readme/USAGE.rst @@ -0,0 +1,15 @@ + +1. On a standard partner, open the new *Sponsorship* tab +2. Create or choose a *Sponsor Level* +3. On the website portal, the sponsor may login and edit sponsorship fields + from its profile. Note if the sponsor is a company, the portal account related + to this company must be used at login. +4. On sponsor fields change, a review process is started: sponsor information are + *not* updated yet on the website and the new information must be reviewed from + the backend by a reviewer (membership or contact managers). +5. As a reminder of these review, activities are set on those sponsors for all reviewers. + Moreover, the new *Members / Sponsors* page is ordered so the first items are the ones + to be validated. A ribbon also reminds that on the Kanban card. +6. On the sponsor's partner form, review the new data on *Sponsorship* page and once done, + press the *Publish* button. It updates the information on the website, and clean the + activities of all reviewers for this sponsor. diff --git a/oca_sponsor/security/ir.model.access.csv b/oca_sponsor/security/ir.model.access.csv new file mode 100644 index 00000000..a5779509 --- /dev/null +++ b/oca_sponsor/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_sponsorship_line_user,sponsorship.line.user,model_sponsorship_line,base.group_user,1,0,0,0 +access_sponsorship_line_portal,sponsorship.line.portal,model_sponsorship_line,base.group_portal,1,0,0,0 +access_sponsorship_line_public,sponsorship.line.public,model_sponsorship_line,base.group_public,1,0,0,0 +access_sponsorship_line_manager_contact,sponsorship.line.manager.contact,model_sponsorship_line,base.group_partner_manager,1,1,1,1 +access_sponsorship_line_manager_membership,sponsorship.line.manager.membership,model_sponsorship_line,membership_extension.group_membership_manager,1,1,1,1 +access_res_partner_grade_user,res.partner.grade,model_res_partner_grade,base.group_user,1,0,0,0 +access_res_partner_grade_portal,res.partner.grade,model_res_partner_grade,base.group_portal,1,0,0,0 +access_res_partner_grade_public,res.partner.grade,model_res_partner_grade,base.group_public,1,0,0,0 +access_res_partner_grade_manager_contact,res.partner.grade.manager.contact,model_res_partner_grade,base.group_partner_manager,1,1,1,1 +access_res_partner_grade_manager_membership,res.partner.grade.manager.membership,model_res_partner_grade,membership_extension.group_membership_manager,1,1,1,1 diff --git a/oca_sponsor/tests/__init__.py b/oca_sponsor/tests/__init__.py new file mode 100644 index 00000000..e6280138 --- /dev/null +++ b/oca_sponsor/tests/__init__.py @@ -0,0 +1 @@ +from . import test_oca_sponsor diff --git a/oca_sponsor/tests/test_oca_sponsor.py b/oca_sponsor/tests/test_oca_sponsor.py new file mode 100644 index 00000000..62a91294 --- /dev/null +++ b/oca_sponsor/tests/test_oca_sponsor.py @@ -0,0 +1,127 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Arnaud LAYEC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import TransactionCase, new_test_user, users + + +class TestOcaSponsor(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Sponsor grade + cls.grade = cls.env["res.partner.grade"].create({"name": "Gold"}) + # Countries + cls.country_fr = cls.env.ref("base.fr") + cls.country_be = cls.env.ref("base.be") + cls.country_ch = cls.env.ref("base.ch") + # Industries + cls.industry_a, cls.industry_b = cls.env["res.partner.industry"].create([ + {"name": "ERP"}, {"name": "CRM"} + ]) + + # Users & partners + cls.group_manager = "membership_extension.group_membership_manager" + cls.manager = new_test_user(cls.env, "manager", groups="base.group_user," + cls.group_manager) + cls.manager2 = new_test_user(cls.env, "manager2", groups="base.group_user," + cls.group_manager) + cls.portal_user = new_test_user(cls.env, "sponsor", groups="base.group_portal") + cls.sponsor = cls.portal_user.partner_id + cls.sponsor.write({ + "grade_id": cls.grade.id, + "is_company": True, + }) + + + def test_is_sponsor(self): + self.assertTrue(self.sponsor.is_sponsor) + self.assertIn( + self.sponsor, + self.env["res.partner"].search([("is_sponsor", "=", True)]) + ) + + @users("sponsor") + def test_sponsor_country_ids(self): + """Ensure `country_id` is always in `sponsor_country_ids` + and that countries manually input stay in `sponsor_country_ids` + """ + self.sponsor.sponsor_country_ids = self.country_ch + self.sponsor.country_id = self.country_fr + self.sponsor.country_id = self.country_be + + countries = self.sponsor.sponsor_country_ids + self.assertIn(self.country_be, countries) + self.assertNotIn(self.country_fr, countries) # replaced by be + self.assertIn(self.country_ch, countries) # kept + + @users("sponsor") + def test_industry_id_to_ids(self): + """Ensure `industry_id` is synced in `industry_ids`""" + self.sponsor.sponsor_industry_ids = False + self.sponsor.industry_id = self.industry_a + self.assertEqual(self.sponsor.sponsor_industry_ids, self.industry_a) + + def test_industry_ids_to_id(self): + """Ensure `industry_id` is defined (if empty) from `industry_ids`""" + self.sponsor.industry_id = False + self.sponsor.sponsor_industry_ids = self.industry_a + self.assertEqual(self.sponsor.industry_id, self.industry_a) + + # Add another industry: no change + self.sponsor.sponsor_industry_ids |= self.industry_b + self.assertEqual(self.sponsor.industry_id, self.industry_a) + + @users("sponsor") + def test_sponsor_review_irrelevant_fields(self): + """Not 'to review' on irrelevant fields""" + self.assertFalse(self.sponsor.sponsor_to_review) + self.sponsor.comment = "Not a website field" + self.assertFalse(self.sponsor.sponsor_to_review) + + @users("manager") + def test_sponsor_review_membership_manager(self): + """Membership Managers do not trigger `sponsor_to_review`""" + self.assertFalse(self.sponsor.sponsor_to_review) + self.sponsor.website_long_description = "Changed by internal" + self.assertFalse(self.sponsor.sponsor_to_review) + + def test_sponsor_review_relevant(self): + """Mark to review when relevant (portal + fields) & create activities""" + # Marked as to review + self.sponsor.with_user(self.portal_user).sudo().website_long_description = "" + self.assertTrue(self.sponsor.sponsor_to_review) + + # Activity + def _get_activities(): + activity_type = self.env.ref("oca_sponsor.mail_activity_review_sponsor_oca") + activities = self.sponsor.activity_ids + return activities.filtered(lambda x: x.activity_type_id == activity_type) + + admins = self.env.ref(self.group_manager).users + self.assertEqual(_get_activities().mapped("user_id"), admins) + + # No duplicate activity on 2nd+ updates + self.website_short_description = "Quick update" + self.assertEqual(_get_activities().mapped("user_id"), admins) + + self.sponsor.with_user(self.manager).button_sponsor_review_accept() + self.assertEqual(self.sponsor.sponsor_to_review, False) + self.assertEqual(len(_get_activities()), 0) + + def test_search_fetch_partner_order_with_context(self): + """Sponsors to be reviewed are displayed first""" + ResPartner = self.env["res.partner"] + sponsor2 = ResPartner.create({ + "name": "Sponsor Corp 2", + "grade_id": self.grade.id, + "is_company": True, + }) + + def _get_first_sponsor(): + return ResPartner.with_context(membership_sponsor=True).search_fetch( + [("id", "in", (self.sponsor | sponsor2).ids)], + ["name", "sponsor_to_review"], + )[0] + self.assertEqual(_get_first_sponsor(), self.sponsor) + sponsor2.sponsor_to_review = True + self.assertEqual(_get_first_sponsor(), sponsor2) diff --git a/oca_sponsor/views/blog_post.xml b/oca_sponsor/views/blog_post.xml new file mode 100644 index 00000000..1fd025e9 --- /dev/null +++ b/oca_sponsor/views/blog_post.xml @@ -0,0 +1,17 @@ + + + + + + blog.post.view.form.add + blog.post + + + + + + + + + diff --git a/oca_sponsor/views/res_partner.xml b/oca_sponsor/views/res_partner.xml new file mode 100644 index 00000000..e881d53c --- /dev/null +++ b/oca_sponsor/views/res_partner.xml @@ -0,0 +1,143 @@ + + + + + + + res.partner.select.oca_sponsor + res.partner + + + + + + + + + + + + + + + + + + + res.partner.kanban.oca_sponsor + res.partner + + + + + + + + + + + + res.partner.form.oca_sponsor + res.partner + + + + + + + + +
+ +
+ + + + + + diff --git a/oca_vcp/models/vcp_rule.py b/oca_vcp/models/vcp_rule.py index 469a6a7c..ee7a2b70 100644 --- a/oca_vcp/models/vcp_rule.py +++ b/oca_vcp/models/vcp_rule.py @@ -9,7 +9,7 @@ import pypandoc -from odoo import models +from odoo import models, fields, _ _logger = logging.getLogger(__name__) @@ -20,6 +20,11 @@ class VcpRule(models.Model): _inherit = "vcp.rule" + rule_type = fields.Selection( + selection_add=[("oca_psc_update", "Update OCA PSC")], + ondelete={"oca_psc_update": "cascade"}, + ) + def _process_rule_odoo_module_prepare_vals( self, repository_branch, module_id, manifest_path ): @@ -60,3 +65,35 @@ def _process_rule_odoo_module_prepare_vals( f"{module.name}/static/description/icon.png" ) return vals + + def _process_rule_oca_psc_update(self, record): + """Read Github repo 'OCA/repo-maintainer-conf' and call `vcp.oca.psc` + to update the data in Odoo. + In this repo, 2 dirs are read: + - /conf/psc/*.yml: 1 yml per PSC team, listing the members + - /conf/repo/*.yml: 1 yml per repo, setting the responsible PSC team + """ + if record._name != "vcp.repository.branch": + return + record._download_code() + + mapped_pscs = {} + psc_files = self._cloc_get_matches(record.local_path) + for yml_path in psc_files: + dirname = os.path.basename(os.path.dirname(yml_path)) + file_path = Path(record.local_path + "/" + yml_path) + with open(file_path, 'r') as file: + yml_data = yaml.safe_load(file) + if not yml_data: + continue + + for name, item in yml_data.items(): + if dirname == "psc": + mapped_pscs.setdefault(name, {"repos": {}}).update(item) + elif dirname == "repo": + mapped_pscs.setdefault(item["psc"], {"repos": {}})["repos"][name] = item["name"] + mapped_pscs.setdefault(item["psc_rep"], {"repos": {}})["repos"][name] = item["name"] + else: + raise NotImplementedError(_("Operation not supported.")) + + record.env["vcp.oca.psc"]._update_from_source(record, mapped_pscs) diff --git a/pyproject.toml b/pyproject.toml index c01c05c9..559e9273 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,12 +120,14 @@ odoo-addon-vcp-github = { git = "https://github.com/dixmit/version-control-platf odoo-addon-vcp-odoo = { git = "https://github.com/dixmit/version-control-platform", branch="18.0-add-vcp", subdirectory="vcp_odoo" } odoo-addon-vcp-git = { git = "https://github.com/dixmit/version-control-platform", branch="18.0-add-vcp", subdirectory="vcp_git" } -# VCP module mode dev +odoo-addon-base-url = { git = "https://github.com/akretion/server-tools", branch="18.0-add-url-2", subdirectory="base_url" } + +# VCP + base_url module mode dev #odoo-addon-vcp-management = { path = "/external-src/version-control-platform/vcp_management", editable = true } #odoo-addon-vcp-github = { path = "/external-src/version-control-platform/vcp_github", editable = true } #odoo-addon-vcp-git = { path = "/external-src/version-control-platform/vcp_git", editable = true } #odoo-addon-vcp-odoo = { path = "/external-src/version-control-platform/vcp_odoo", editable = true } - +#odoo-addon-base-url = { path = "/external-src/server-tools/base_url", editable = true } # Example to develop module from another repository in editable mode From 92a64a9f88f3b96681965bcd93b0e82aeedc377c Mon Sep 17 00:00:00 2001 From: Arnaud LAYEC Date: Tue, 31 Mar 2026 14:36:22 +0200 Subject: [PATCH 12/36] Move PSC to oca_vcp and comment everything (as obsolete) --- oca_search_engine/__manifest__.py | 5 - oca_search_engine/data/index_data.xml | 4 +- oca_search_engine/hooks.py | 2 +- oca_search_engine/models/__init__.py | 8 -- oca_search_engine/models/se_index.py | 10 +- oca_search_engine/readme/CONFIGURATION.rst | 13 --- oca_search_engine/readme/DESCRIPTION.md | 1 - oca_search_engine/readme/DESCRIPTION.rst | 1 + .../schemas/res_partner_person.py | 8 +- oca_search_engine/tests/__init__.py | 2 +- oca_search_engine/tests/test_oca_se_psc.py | 102 +---------------- oca_search_engine/tools/__init__.py | 2 +- oca_vcp/__manifest__.py | 1 + .../data/vcp_oca_psc.xml | 4 +- oca_vcp/models/__init__.py | 2 + .../models/vcp_oca_psc.py | 28 ++--- oca_vcp/models/vcp_odoo_module_version.py | 1 - oca_vcp/models/vcp_repository_category.py | 1 + oca_vcp/models/vcp_rule.py | 66 +++++------ .../models/vcp_user.py | 0 oca_vcp/readme/CONFIGURATION.md | 10 ++ .../security/ir.model.access_psc.csv | 0 oca_vcp/tests/__init__.py | 1 + oca_vcp/tests/test_oca_vcp_psc.py | 104 ++++++++++++++++++ 24 files changed, 182 insertions(+), 194 deletions(-) delete mode 100644 oca_search_engine/readme/DESCRIPTION.md rename oca_search_engine/data/vcp_oca.xml => oca_vcp/data/vcp_oca_psc.xml (94%) rename {oca_search_engine => oca_vcp}/models/vcp_oca_psc.py (79%) rename {oca_search_engine => oca_vcp}/models/vcp_user.py (100%) create mode 100644 oca_vcp/readme/CONFIGURATION.md rename oca_search_engine/security/ir.model.access.csv => oca_vcp/security/ir.model.access_psc.csv (100%) create mode 100644 oca_vcp/tests/__init__.py create mode 100644 oca_vcp/tests/test_oca_vcp_psc.py diff --git a/oca_search_engine/__manifest__.py b/oca_search_engine/__manifest__.py index 86edf650..ce0b6c67 100644 --- a/oca_search_engine/__manifest__.py +++ b/oca_search_engine/__manifest__.py @@ -34,14 +34,9 @@ "shopinvader_base_url", # TODO: switch to 'base_url' @arnaudlayec @sebastienbeau ], "data": [ - # data "data/backend_data.xml", "data/index_data.xml", "data/membership_category_data.xml", - "data/vcp_oca.xml", - # security - "security/ir.model.access.csv", - # views "views/res_partner.xml", ], "demo": [], diff --git a/oca_search_engine/data/index_data.xml b/oca_search_engine/data/index_data.xml index ae48b45d..8a8df32e 100644 --- a/oca_search_engine/data/index_data.xml +++ b/oca_search_engine/data/index_data.xml @@ -15,13 +15,13 @@ persons_exports
- + diff --git a/oca_search_engine/hooks.py b/oca_search_engine/hooks.py index 2b71de30..bbb1361c 100644 --- a/oca_search_engine/hooks.py +++ b/oca_search_engine/hooks.py @@ -9,7 +9,7 @@ def post_init_hook(env): _init_indexing(env) def _init_indexing(env): - models = {"res.partner", "vcp.oca.psc"} + models = {"res.partner"} # "vcp.oca.psc" for model in models: records = env[model].search([]) records._add_to_oca_search_engine() diff --git a/oca_search_engine/models/__init__.py b/oca_search_engine/models/__init__.py index 4854c75f..aa045278 100644 --- a/oca_search_engine/models/__init__.py +++ b/oca_search_engine/models/__init__.py @@ -1,13 +1,5 @@ -# Sponsors & persons from . import blog_post from . import res_partner - -# PSC -from . import vcp_oca_psc - -# Modules from . import se_index from . import vcp_odoo_module_version -from . import vcp_rule -from . import vcp_user diff --git a/oca_search_engine/models/se_index.py b/oca_search_engine/models/se_index.py index 5f201715..4d037917 100644 --- a/oca_search_engine/models/se_index.py +++ b/oca_search_engine/models/se_index.py @@ -8,7 +8,7 @@ from ..tools import ( CompanySerializer, PersonSerializer, - PscSerializer, + # PscSerializer, VcpOdooModuleVersionSerializer, ) @@ -21,13 +21,13 @@ class SeIndex(models.Model): ("vcp_odoo_module_version_exports", "Odoo Modules"), ("companies_exports", "Companies (sponsors & integrators)"), ("persons_exports", "Persons (members & contributors)"), - ("pscs_exports", "PSCs (Project Steering Teams)"), + # ("pscs_exports", "PSCs (Project Steering Teams)"), ], ondelete={ "vcp_odoo_module_version_exports": "cascade", "companies_exports": "cascade", "persons_exports": "cascade", - "pscs_exports": "cascade", + # "pscs_exports": "cascade", }, ) @@ -36,7 +36,7 @@ def _check_model(self): mapped_models = { "companies_exports": "res.partner", "persons_exports": "res.partner", - "pscs_exports": "vcp.oca.psc", + # "pscs_exports": "vcp.oca.psc", "vcp_odoo_module_version_exports": "vcp.odoo.module.version", } for se_index in self: @@ -49,7 +49,7 @@ def _get_serializer(self): mapped_serializer = { "companies_exports": CompanySerializer(), "persons_exports": PersonSerializer(), - "pscs_exports": PscSerializer(), + # "pscs_exports": PscSerializer(), "vcp_odoo_module_version_exports": VcpOdooModuleVersionSerializer() } return ( diff --git a/oca_search_engine/readme/CONFIGURATION.rst b/oca_search_engine/readme/CONFIGURATION.rst index 676e258d..e69de29b 100644 --- a/oca_search_engine/readme/CONFIGURATION.rst +++ b/oca_search_engine/readme/CONFIGURATION.rst @@ -1,13 +0,0 @@ - -PSC synchronisation -------------------- - -In order to display accurate PSC information on the website, this module rely on the module `vpc/vpc_github`. -To keep models `vcp.oca.psc` and `oca.psc.member` updated, one should ensure in the the Virtual Control Platform app that: - -- there is a *Platform* named *"OCA"* (the name matters) -- a Github *Personal access token* is configured in the tab *API Keys*. **This step is manual and cannot be automatized** -- the repository "*repo-maintainer-conf*" is scheduled as *Scheduled Branch Update* -- on the branch "*master*" of this repo, the *Processing rules* named *Update PSC list & members (OCA)* is configured - -Those configuration comes at this module installation, except for the platform's *API Key*. diff --git a/oca_search_engine/readme/DESCRIPTION.md b/oca_search_engine/readme/DESCRIPTION.md deleted file mode 100644 index 48aa0d79..00000000 --- a/oca_search_engine/readme/DESCRIPTION.md +++ /dev/null @@ -1 +0,0 @@ -Custom OCA for exporting public data in typesense diff --git a/oca_search_engine/readme/DESCRIPTION.rst b/oca_search_engine/readme/DESCRIPTION.rst index e69de29b..48aa0d79 100644 --- a/oca_search_engine/readme/DESCRIPTION.rst +++ b/oca_search_engine/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Custom OCA for exporting public data in typesense diff --git a/oca_search_engine/schemas/res_partner_person.py b/oca_search_engine/schemas/res_partner_person.py index 00071ec3..ac927160 100644 --- a/oca_search_engine/schemas/res_partner_person.py +++ b/oca_search_engine/schemas/res_partner_person.py @@ -101,8 +101,8 @@ class Person(PersonBase): url_key: str # role & psc roles: list[Role] - psc: int - psc_list: list[Team] + # psc: int + # psc_list: list[Team] work_group_list: list[Team] # github indicators collaborator_index: int @@ -111,7 +111,7 @@ class Person(PersonBase): @classmethod def _model_construct_dict(cls, record): - psc = record.vcp_user_ids.vcp_oca_psc_ids + # psc = record.vcp_user_ids.vcp_oca_psc_ids return super()._model_construct_dict(record) | { # github indicators "translations": 0, @@ -120,7 +120,7 @@ def _model_construct_dict(cls, record): "module_contribution_ids": record.contributor_module_line_ids.ids or [], # role "roles": cls._get_roles(record), - # psc (obsolete ) + # psc (obsolete) # "psc": len(psc), # "psc_list": psc.read(["name", "description"]), "work_group_list": ( diff --git a/oca_search_engine/tests/__init__.py b/oca_search_engine/tests/__init__.py index ea878462..19a5dbe0 100644 --- a/oca_search_engine/tests/__init__.py +++ b/oca_search_engine/tests/__init__.py @@ -1,3 +1,3 @@ from . import test_oca_se_companies from . import test_oca_se_persons -from . import test_oca_se_psc +# from . import test_oca_se_psc diff --git a/oca_search_engine/tests/test_oca_se_psc.py b/oca_search_engine/tests/test_oca_se_psc.py index 2f9d5c42..0b0f163c 100644 --- a/oca_search_engine/tests/test_oca_se_psc.py +++ b/oca_search_engine/tests/test_oca_se_psc.py @@ -3,109 +3,15 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import os -from pathlib import Path +from odoo.addons.oca_vcp.tests.test_oca_vcp_psc import TestOcaPscsSearchEngine +from ..schemas.res_partner_person import Person +from ..schemas.vcp_oca_psc import Psc -import tempfile -from unittest.mock import patch, PropertyMock -import logging -_logger = logging.getLogger(__name__) - -from odoo.tests.common import TransactionCase -from ..schemas import Psc, Person - - -YML_CONTENT = { - "psc": """ -test-oca-psc: - members: - - user-github-login - name: Human name of the test OCA PSC -""", - "repo": """ -test-repo-name: - name: Human name of the test repo - psc: test-oca-psc - psc_rep: test-oca-psc -""" -} - - -class TestOcaPscsSearchEngine(TransactionCase): +class TestOcaPscsSearchEngine(TestOcaPscsSearchEngine): @classmethod def setUpClass(cls): super().setUpClass() - cls.member = cls.env["res.partner"].create([{ - "name": "Happy Member", - "is_company": False, - "country_id": cls.env.ref("base.fr").id, - "free_member": True, - "is_published": True, - }]) - cls.repository_branch = cls.env.ref("oca_search_engine.vcp_branch_repo_oca_maintainer_conf_master") - - def setUp(self): - self._setup_fake_repo() - - def _setup_fake_repo(self): - """Create fake and temporary YAML files without call to remote URL, - ensuring they are removed after tests""" - # Mock the "repository_branch.local_path" so we don't mess PROD data - tmp_dir = tempfile.TemporaryDirectory() - patcher = patch.object( - type(self.repository_branch), - "local_path", - new_callable=PropertyMock, - return_value=tmp_dir.name, - ) - patcher.start() - # Ensure file deletion after tests, whether they are successful or fail - self.addCleanup(patcher.stop) - self.addCleanup(tmp_dir.cleanup) - - # Create fake .yml files - base_dirs = ["psc", "repo"] - for base_dir in base_dirs: - full_dir = Path(tmp_dir.name) / "conf" / base_dir - file_path = full_dir / "test.yml" - os.makedirs(full_dir, exist_ok=True) - file_path.write_text(YML_CONTENT[base_dir]) - - - #==================== Tools =============== - - def _process_rule_oca_psc_update(self): - """Process all the rule without downloading code and return the created fake PSC - Instead, the file content in `_setup_fake_repo` will be used.""" - rule = self.env.ref("oca_search_engine.vcp_rule_oca_psc_update") - path_download_code = patch.object( - type(self.repository_branch), - "_download_code", - new=lambda self, *a, **kw: None, - ) - with path_download_code: - rule._process_rule_oca_psc_update(self.repository_branch) - return self.env["vcp.oca.psc"].search([]) - - - #==================== Tests =============== - - def test_psc_download(self): - """Test .yml reading""" - psc = self._process_rule_oca_psc_update() - user = self.env["vcp.user"].search([("name", "=", "user-github-login")]) - - self.assertEqual(user.name, "user-github-login") - self.assertEqual( - psc.read(["name", "description", "user_ids"]), - [{ - "id": psc.id, - "name": "test-oca-psc", - "description": "Human name of the test OCA PSC", - "user_ids": user.ids, - }] - ) def test_psc_json_output(self): """Test simple output for `Psc` index""" diff --git a/oca_search_engine/tools/__init__.py b/oca_search_engine/tools/__init__.py index b572ff97..ff4de2ad 100644 --- a/oca_search_engine/tools/__init__.py +++ b/oca_search_engine/tools/__init__.py @@ -1,3 +1,3 @@ from .vcp_odoo_module_version_serializer import VcpOdooModuleVersionSerializer from .res_partner_serializer import CompanySerializer, PersonSerializer -from .vcp_psc_team_serializer import PscSerializer +# from .vcp_psc_team_serializer import PscSerializer diff --git a/oca_vcp/__manifest__.py b/oca_vcp/__manifest__.py index 372bac28..11fd900b 100644 --- a/oca_vcp/__manifest__.py +++ b/oca_vcp/__manifest__.py @@ -21,6 +21,7 @@ "vcp_github", ], "data": [ + # "data/vcp_oca_psc.xml", "security/ir.model.access.csv", "views/vcp_odoo_module_view.xml", "views/vcp_repository_view.xml", diff --git a/oca_search_engine/data/vcp_oca.xml b/oca_vcp/data/vcp_oca_psc.xml similarity index 94% rename from oca_search_engine/data/vcp_oca.xml rename to oca_vcp/data/vcp_oca_psc.xml index 783c4a7d..81f0b201 100644 --- a/oca_search_engine/data/vcp_oca.xml +++ b/oca_vcp/data/vcp_oca_psc.xml @@ -2,7 +2,7 @@ - + diff --git a/oca_vcp/models/__init__.py b/oca_vcp/models/__init__.py index c3760154..39e07407 100644 --- a/oca_vcp/models/__init__.py +++ b/oca_vcp/models/__init__.py @@ -3,3 +3,5 @@ from . import vcp_repository from . import vcp_repository_category from . import vcp_rule +# from . import vcp_user +# from . import vcp_oca_psc diff --git a/oca_search_engine/models/vcp_oca_psc.py b/oca_vcp/models/vcp_oca_psc.py similarity index 79% rename from oca_search_engine/models/vcp_oca_psc.py rename to oca_vcp/models/vcp_oca_psc.py index f93a8cca..3e0c4110 100644 --- a/oca_search_engine/models/vcp_oca_psc.py +++ b/oca_vcp/models/vcp_oca_psc.py @@ -7,6 +7,7 @@ INDEX_PSCS = "oca_search_engine.oca_typesense_index_pscs" + class VcpOcaPsc(models.Model): _name = "vcp.oca.psc" _inherit = ["se.indexable.record"] @@ -50,7 +51,7 @@ def write(self, vals): self._add_to_oca_search_engine() return res - #===== VPC Logics =====# + #===== Logics =====# def _update_from_source(self, branch, mapped_pscs): """Update Odoo data from data source""" # Fetch data @@ -78,6 +79,9 @@ def _update_from_source(self, branch, mapped_pscs): def _prepare_team_vals(self, name, psc_dict, host_users, platform): + """Return `vals` for create + Also create any missing users, since one could be PSC with no contribution + For Repo: assumes they already exist (created by another rule)""" # Users psc_logins = set(psc_dict.get("members", []) + psc_dict.get("representatives", [])) psc_users = host_users.filtered(lambda x: x.name in psc_logins) @@ -86,24 +90,10 @@ def _prepare_team_vals(self, name, psc_dict, host_users, platform): psc_users |= self.env["vcp.user"].browse(created_ids) # Repositories - psc_repos_dict = psc_dict.get("repos", {}) - psc_repos = self.env["vcp.repository"] - if psc_repos_dict: - for repo in platform.repository_ids: - if repo.name in psc_repos_dict: - psc_repos |= repo - psc_repos_dict.pop(repo.name) - to_create = [ - { - "name": repo_name, - "description": description, - "platform_id": platform.id, - "from_date": fields.Datetime.now(), - } - for repo_name, description in psc_repos_dict.items() - ] - if to_create: - psc_repos |= self.env["vcp.repository"].create(to_create) + psc_repos_names = psc_dict.get("repos", {}).keys() + psc_repos = platform.repository_ids.filtered( + lambda x: x.name in psc_repos_names + ) return { "name": name, diff --git a/oca_vcp/models/vcp_odoo_module_version.py b/oca_vcp/models/vcp_odoo_module_version.py index fe5a8ab8..bb165055 100644 --- a/oca_vcp/models/vcp_odoo_module_version.py +++ b/oca_vcp/models/vcp_odoo_module_version.py @@ -8,7 +8,6 @@ class VcpOdooModuleVersion(models.Model): _inherit = "vcp.odoo.module.version" - _name = "vcp.odoo.module.version" readme_fragments = fields.Json() icon_url = fields.Char() diff --git a/oca_vcp/models/vcp_repository_category.py b/oca_vcp/models/vcp_repository_category.py index 08461878..f910aebc 100644 --- a/oca_vcp/models/vcp_repository_category.py +++ b/oca_vcp/models/vcp_repository_category.py @@ -8,5 +8,6 @@ class VcpRepositoryCategory(models.Model): _name = "vcp.repository.category" + _description = "Repository Category" name = fields.Char() diff --git a/oca_vcp/models/vcp_rule.py b/oca_vcp/models/vcp_rule.py index ee7a2b70..bca77acf 100644 --- a/oca_vcp/models/vcp_rule.py +++ b/oca_vcp/models/vcp_rule.py @@ -9,7 +9,7 @@ import pypandoc -from odoo import models, fields, _ +from odoo import models, fields _logger = logging.getLogger(__name__) @@ -20,10 +20,10 @@ class VcpRule(models.Model): _inherit = "vcp.rule" - rule_type = fields.Selection( - selection_add=[("oca_psc_update", "Update OCA PSC")], - ondelete={"oca_psc_update": "cascade"}, - ) + # rule_type = fields.Selection( + # selection_add=[("oca_psc_update", "Update OCA PSC")], + # ondelete={"oca_psc_update": "cascade"}, + # ) def _process_rule_odoo_module_prepare_vals( self, repository_branch, module_id, manifest_path @@ -66,34 +66,34 @@ def _process_rule_odoo_module_prepare_vals( ) return vals - def _process_rule_oca_psc_update(self, record): - """Read Github repo 'OCA/repo-maintainer-conf' and call `vcp.oca.psc` - to update the data in Odoo. - In this repo, 2 dirs are read: - - /conf/psc/*.yml: 1 yml per PSC team, listing the members - - /conf/repo/*.yml: 1 yml per repo, setting the responsible PSC team - """ - if record._name != "vcp.repository.branch": - return - record._download_code() + # def _process_rule_oca_psc_update(self, record): + # """Read Github repo 'OCA/repo-maintainer-conf' and call `vcp.oca.psc` + # to update the data in Odoo. + # In this repo, 2 dirs are read: + # - /conf/psc/*.yml: 1 yml per PSC team, listing the members + # - /conf/repo/*.yml: 1 yml per repo, setting the responsible PSC team + # """ + # if record._name != "vcp.repository.branch": + # return + # record._download_code() - mapped_pscs = {} - psc_files = self._cloc_get_matches(record.local_path) - for yml_path in psc_files: - dirname = os.path.basename(os.path.dirname(yml_path)) - file_path = Path(record.local_path + "/" + yml_path) - with open(file_path, 'r') as file: - yml_data = yaml.safe_load(file) - if not yml_data: - continue + # mapped_pscs = {} + # psc_files = self._cloc_get_matches(record.local_path) + # for yml_path in psc_files: + # dirname = os.path.basename(os.path.dirname(yml_path)) + # file_path = Path(record.local_path + "/" + yml_path) + # with open(file_path, 'r') as file: + # yml_data = yaml.safe_load(file) + # if not yml_data: + # continue - for name, item in yml_data.items(): - if dirname == "psc": - mapped_pscs.setdefault(name, {"repos": {}}).update(item) - elif dirname == "repo": - mapped_pscs.setdefault(item["psc"], {"repos": {}})["repos"][name] = item["name"] - mapped_pscs.setdefault(item["psc_rep"], {"repos": {}})["repos"][name] = item["name"] - else: - raise NotImplementedError(_("Operation not supported.")) + # for name, item in yml_data.items(): + # if dirname == "psc": + # mapped_pscs.setdefault(name, {"repos": {}}).update(item) + # elif dirname == "repo": + # mapped_pscs.setdefault(item["psc"], {"repos": {}})["repos"][name] = item["name"] + # mapped_pscs.setdefault(item["psc_rep"], {"repos": {}})["repos"][name] = item["name"] + # else: + # raise NotImplementedError(_("Operation not supported.")) - record.env["vcp.oca.psc"]._update_from_source(record, mapped_pscs) + # record.env["vcp.oca.psc"]._update_from_source(record, mapped_pscs) diff --git a/oca_search_engine/models/vcp_user.py b/oca_vcp/models/vcp_user.py similarity index 100% rename from oca_search_engine/models/vcp_user.py rename to oca_vcp/models/vcp_user.py diff --git a/oca_vcp/readme/CONFIGURATION.md b/oca_vcp/readme/CONFIGURATION.md new file mode 100644 index 00000000..3b3d98a8 --- /dev/null +++ b/oca_vcp/readme/CONFIGURATION.md @@ -0,0 +1,10 @@ + +In the Virtual Control Platform application: + +- Create a *Platform* named *"OCA"* (the name matters) +- Configure a Github *Personal access token* in the tab *API Keys*. **This step is manual and cannot be automatized** + +[OBSOLETE] +For PSC only: +- Configure the repository "*repo-maintainer-conf*" with *Scheduled Branch Update* +- Ensure on the branch "*master*" of this repo, the *Processing rules* named *Update PSC list & members (OCA)* is configured diff --git a/oca_search_engine/security/ir.model.access.csv b/oca_vcp/security/ir.model.access_psc.csv similarity index 100% rename from oca_search_engine/security/ir.model.access.csv rename to oca_vcp/security/ir.model.access_psc.csv diff --git a/oca_vcp/tests/__init__.py b/oca_vcp/tests/__init__.py new file mode 100644 index 00000000..676463d8 --- /dev/null +++ b/oca_vcp/tests/__init__.py @@ -0,0 +1 @@ +# from . import test_oca_vcp_psc diff --git a/oca_vcp/tests/test_oca_vcp_psc.py b/oca_vcp/tests/test_oca_vcp_psc.py new file mode 100644 index 00000000..d356b4ee --- /dev/null +++ b/oca_vcp/tests/test_oca_vcp_psc.py @@ -0,0 +1,104 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Arnaud LAYEC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +import os +from pathlib import Path + +import tempfile +from unittest.mock import patch, PropertyMock + +from odoo.tests.common import TransactionCase + + +YML_CONTENT = { + "psc": """ +test-oca-psc: + members: + - user-github-login + name: Human name of the test OCA PSC +""", + "repo": """ +test-repo-name: + name: Human name of the test repo + psc: test-oca-psc + psc_rep: test-oca-psc +""" +} + + +class TestOcaPscsSearchEngine(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.member = cls.env["res.partner"].create([{ + "name": "Happy Member", + "is_company": False, + "country_id": cls.env.ref("base.fr").id, + "free_member": True, + "is_published": True, + }]) + cls.repository_branch = cls.env.ref("oca_search_engine.vcp_branch_repo_oca_maintainer_conf_master") + + def setUp(self): + self._setup_fake_repo() + + def _setup_fake_repo(self): + """Create fake and temporary YAML files without call to remote URL, + ensuring they are removed after tests""" + # Mock the "repository_branch.local_path" so we don't mess PROD data + tmp_dir = tempfile.TemporaryDirectory() + patcher = patch.object( + type(self.repository_branch), + "local_path", + new_callable=PropertyMock, + return_value=tmp_dir.name, + ) + patcher.start() + # Ensure file deletion after tests, whether they are successful or fail + self.addCleanup(patcher.stop) + self.addCleanup(tmp_dir.cleanup) + + # Create fake .yml files + base_dirs = ["psc", "repo"] + for base_dir in base_dirs: + full_dir = Path(tmp_dir.name) / "conf" / base_dir + file_path = full_dir / "test.yml" + os.makedirs(full_dir, exist_ok=True) + file_path.write_text(YML_CONTENT[base_dir]) + + + #==================== Tools =============== + + def _process_rule_oca_psc_update(self): + """Process all the rule without downloading code and return the created fake PSC + Instead, the file content in `_setup_fake_repo` will be used.""" + rule = self.env.ref("oca_search_engine.vcp_rule_oca_psc_update") + path_download_code = patch.object( + type(self.repository_branch), + "_download_code", + new=lambda self, *a, **kw: None, + ) + with path_download_code: + rule._process_rule_oca_psc_update(self.repository_branch) + return self.env["vcp.oca.psc"].search([]) + + + #==================== Tests =============== + + def test_psc_download(self): + """Test .yml reading""" + psc = self._process_rule_oca_psc_update() + user = self.env["vcp.user"].search([("name", "=", "user-github-login")]) + + self.assertEqual(user.name, "user-github-login") + self.assertEqual( + psc.read(["name", "description", "user_ids"]), + [{ + "id": psc.id, + "name": "test-oca-psc", + "description": "Human name of the test OCA PSC", + "user_ids": user.ids, + }] + ) From 3417d593629df10fabd3cfe74aeaa3b265e6f62e Mon Sep 17 00:00:00 2001 From: Arnaud LAYEC Date: Tue, 31 Mar 2026 18:15:49 +0200 Subject: [PATCH 13/36] [oca_membership] Simplify Roles & Working Groups mgt: remove computation of industry_id from industry_ids Replace custom activity type with Teams activity (module 'mail_activity_team') + override the module to display the count of both Team & Personal activities in the Systray --- oca_membership/__manifest__.py | 2 - oca_membership/models/mail_group.py | 2 +- oca_membership/models/membership_category.py | 4 - oca_membership/models/res_partner.py | 42 +- oca_membership/readme/DESCRIPTION.rst | 9 +- oca_membership/security/ir.model.access.csv | 1 - oca_membership/tests/__init__.py | 2 +- oca_membership/views/mail_group.xml | 43 +- oca_membership/views/res_partner.xml | 10 +- oca_search_engine/__manifest__.py | 1 - .../data/membership_category_data.xml | 11 - oca_search_engine/models/res_partner.py | 12 +- .../schemas/res_partner_person.py | 6 +- oca_sponsor/__manifest__.py | 13 +- oca_sponsor/data/mail_activity_data.xml | 12 - oca_sponsor/data/mail_activity_team.xml | 11 + oca_sponsor/models/__init__.py | 2 +- oca_sponsor/models/mail_activity.py | 29 -- oca_sponsor/models/res_partner.py | 155 +++--- oca_sponsor/models/res_partner_grade.py | 6 +- oca_sponsor/models/res_users.py | 35 ++ .../activity_menu_view/activity_menu_view.xml | 11 + .../static/src/models/activity_menu_patch.js | 29 ++ oca_sponsor/tests/test_oca_sponsor.py | 46 +- oca_sponsor/views/mail_activity.xml | 21 + oca_sponsor/views/res_partner.xml | 21 +- uv.lock | 449 ++++++++++++++++++ 27 files changed, 732 insertions(+), 253 deletions(-) delete mode 100644 oca_membership/security/ir.model.access.csv delete mode 100644 oca_search_engine/data/membership_category_data.xml delete mode 100644 oca_sponsor/data/mail_activity_data.xml create mode 100644 oca_sponsor/data/mail_activity_team.xml delete mode 100644 oca_sponsor/models/mail_activity.py create mode 100644 oca_sponsor/models/res_users.py create mode 100644 oca_sponsor/static/src/components/activity_menu_view/activity_menu_view.xml create mode 100644 oca_sponsor/static/src/models/activity_menu_patch.js create mode 100644 oca_sponsor/views/mail_activity.xml diff --git a/oca_membership/__manifest__.py b/oca_membership/__manifest__.py index 82b5e0ea..554a508c 100644 --- a/oca_membership/__manifest__.py +++ b/oca_membership/__manifest__.py @@ -16,9 +16,7 @@ "membership_extension", # for membership.category ], "data": [ - # data "data/membership_category_data.xml", - # views "views/mail_group.xml", "views/membership_category.xml", "views/res_partner.xml", diff --git a/oca_membership/models/mail_group.py b/oca_membership/models/mail_group.py index eeb96d95..6f9a5a05 100644 --- a/oca_membership/models/mail_group.py +++ b/oca_membership/models/mail_group.py @@ -9,5 +9,5 @@ class MailGroup(models.Model): is_working_group = fields.Boolean( string="Is a Working Group", default=False, - help="Working Group are visible on the website page, on member profile.", + help="Working Group are visible on the website page, on members profile.", ) diff --git a/oca_membership/models/membership_category.py b/oca_membership/models/membership_category.py index fcb435b0..28c6fcd9 100644 --- a/oca_membership/models/membership_category.py +++ b/oca_membership/models/membership_category.py @@ -9,7 +9,6 @@ class MembershipCategory(models.Model): _order = "sequence" sequence = fields.Integer("Sequence") - active = fields.Boolean("Active", default=True) implied_ids = fields.Many2many( string="Implied roles", comodel_name="membership.membership_category", @@ -18,6 +17,3 @@ class MembershipCategory(models.Model): column2="implied_category_id", help="Implied roles by this one", ) - - def _get_with_implied(self): - return self + self.implied_ids diff --git a/oca_membership/models/res_partner.py b/oca_membership/models/res_partner.py index 799d4f66..6008c9d6 100644 --- a/oca_membership/models/res_partner.py +++ b/oca_membership/models/res_partner.py @@ -12,42 +12,28 @@ class ResPartner(models.Model): help="Role for next subscribed membership", default=lambda self: self._default_membership_category_id(), ) + membership_category_ids = fields.Many2many( + string="Active roles", + # `_compute_membership_state` is inherited too + ) mail_group_member_ids = fields.One2many( - string="Mailing list membership", comodel_name="mail.group.member", inverse_name="partner_id", - domain=[("mail_group_id.is_working_group", "=", True)], - ) - working_group_ids = fields.One2many( - # UI fields - string="Working Groups", - comodel_name="mail.group", - compute="_compute_working_group_ids", - inverse="_inverse_working_group_ids", ) def _default_membership_category_id(self): return self.env["membership.membership_category"].search([], limit=1).id - @api.depends("mail_group_member_ids.mail_group_id") - def _compute_working_group_ids(self): - for partner in self: - partner.working_group_ids = partner._get_working_groups() - - def _inverse_working_group_ids(self): - """Create or remove membership in mail_group""" + @api.depends("membership_category_ids.implied_ids") + def _compute_membership_state(self): + """Change `membership_category_ids` so it displays current role + plus implied roles, e.g. a 'Delegate' is also a 'Member' + (for the website, and the backend)""" + res = super()._compute_membership_state() for partner in self: - user_input = partner.working_group_ids - before = partner._get_working_groups() - added = user_input - before - removed = before - user_input - if added: - for mail_group in added: - mail_group.sudo()._join_group(partner.email, partner.id) - if removed: - partner.mail_group_member_ids.filtered( - lambda x: x.mail_group_id in removed - ).unlink() + partner.membership_category_ids |= partner.membership_category_ids.implied_ids + return res def _get_working_groups(self): - return self.mail_group_member_ids.mail_group_id + """For website""" + return self.mail_group_member_ids.mail_group_id.filtered("is_working_group") diff --git a/oca_membership/readme/DESCRIPTION.rst b/oca_membership/readme/DESCRIPTION.rst index ef31f4d0..1e277e36 100644 --- a/oca_membership/readme/DESCRIPTION.rst +++ b/oca_membership/readme/DESCRIPTION.rst @@ -7,7 +7,8 @@ This module adds several independant features. be updated by the association' secretary when members roles change, like on election, before the memberships are renewed. -- **Communities (Mailing List & Working Group)** - New menu "Communities" in *Membership* app to manage the Mailing List and - Working Group (underlying feature: Mail Groups). - Contacts may are added to Mail Groups through their Tags. +- **Working Group** + New menu "Working Group" in *Membership* app. They are native Odoo objects + *Mail Groups* `mail.group` with custom boolean *Is a Working Group* enabled. + When creating a *Mail Group*, create a *Partner Tag* with the same name. Then, + to add Members to a *Mail Group*, add the same tag to them. diff --git a/oca_membership/security/ir.model.access.csv b/oca_membership/security/ir.model.access.csv deleted file mode 100644 index 97dd8b91..00000000 --- a/oca_membership/security/ir.model.access.csv +++ /dev/null @@ -1 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/oca_membership/tests/__init__.py b/oca_membership/tests/__init__.py index e6280138..a90c9d54 100644 --- a/oca_membership/tests/__init__.py +++ b/oca_membership/tests/__init__.py @@ -1 +1 @@ -from . import test_oca_sponsor +from . import test_oca_membership diff --git a/oca_membership/views/mail_group.xml b/oca_membership/views/mail_group.xml index e1655beb..56e59d56 100644 --- a/oca_membership/views/mail_group.xml +++ b/oca_membership/views/mail_group.xml @@ -3,6 +3,20 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> + + + mail.group.view.kanban.oca_membership + mail.group + + + + +
+ Working Group +
+
+
+
mail.group.view.list.oca_membership @@ -15,7 +29,6 @@
- mail.group.view.form.oca_membership @@ -28,14 +41,34 @@ - - + + + mail.group.view.search.oca_membership + mail.group + + + + + + + + + + + + Working Groups + mail.group + kanban,list,form + { + 'default_is_working_group': True, + 'search_default_is_working_group': True, + } + diff --git a/oca_membership/views/res_partner.xml b/oca_membership/views/res_partner.xml index 2d3ed4a8..f0dd6ec7 100644 --- a/oca_membership/views/res_partner.xml +++ b/oca_membership/views/res_partner.xml @@ -10,18 +10,10 @@ - - - 1 - + - - - - - diff --git a/oca_search_engine/__manifest__.py b/oca_search_engine/__manifest__.py index ce0b6c67..93c0381c 100644 --- a/oca_search_engine/__manifest__.py +++ b/oca_search_engine/__manifest__.py @@ -36,7 +36,6 @@ "data": [ "data/backend_data.xml", "data/index_data.xml", - "data/membership_category_data.xml", "views/res_partner.xml", ], "demo": [], diff --git a/oca_search_engine/data/membership_category_data.xml b/oca_search_engine/data/membership_category_data.xml deleted file mode 100644 index a5f3230b..00000000 --- a/oca_search_engine/data/membership_category_data.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - Contributor - 15 - - - diff --git a/oca_search_engine/models/res_partner.py b/oca_search_engine/models/res_partner.py index c0604cef..ef5ff809 100644 --- a/oca_search_engine/models/res_partner.py +++ b/oca_search_engine/models/res_partner.py @@ -21,13 +21,9 @@ class ResPartner(models.Model): compute="_compute_can_be_published", search="_search_can_be_published", ) - mail_group_member_ids = fields.One2many( - comodel_name="mail.group.member", - inverse_name="partner_id", - ) #====== Search engine sync logics ======# - def _sync_with_oca_search_engine(self, vals={}): + def _add_to_oca_search_engine(self, vals={}): """Add, update or remove partners in index (persons & companies)""" def _add_or_remove(mode, partners): method = "_add_to_index" if mode == "add" else "_remove_from_index" @@ -92,17 +88,17 @@ def _search_can_be_published(self, operator, value): @api.model_create_multi def create(self, vals_list): records = super().create(vals_list) - records._sync_with_oca_search_engine() + records._add_to_oca_search_engine() return records def copy(self, default={}): records = super().copy(default) - records._sync_with_oca_search_engine() + records._add_to_oca_search_engine() return records def write(self, vals): res = super().write(vals) - self._sync_with_oca_search_engine(vals) + self._add_to_oca_search_engine(vals) return res #===== Business logics =====# diff --git a/oca_search_engine/schemas/res_partner_person.py b/oca_search_engine/schemas/res_partner_person.py index ac927160..2d8239d7 100644 --- a/oca_search_engine/schemas/res_partner_person.py +++ b/oca_search_engine/schemas/res_partner_person.py @@ -131,7 +131,7 @@ def _model_construct_dict(cls, record): @classmethod def _get_roles(cls, record): - categories = record.membership_category_id._get_with_implied() + roles = record.membership_category_ids.sorted("sequence", reverse=True).read(["name"]) if False and record.contributor_count: # TODO review with @sebastienbeau correct field name? - categories |= record.env.ref("oca_search_engine.membership_category_contributor_oca") - return categories.sorted("sequence", reverse=True).read(["name"]) + roles.append({"id": -1, "name": _("Contributor")}) + return roles diff --git a/oca_sponsor/__manifest__.py b/oca_sponsor/__manifest__.py index 5c3513e4..88c6faa4 100644 --- a/oca_sponsor/__manifest__.py +++ b/oca_sponsor/__manifest__.py @@ -12,20 +12,23 @@ "license": "AGPL-3", "category": "Custom", "depends": [ - "membership_extension", # for security group + "mail_activity_team", # for sponsor review process "website_blog", ], "data": [ - # security + "data/mail_activity_team.xml", "security/ir.model.access.csv", - # data - "data/mail_activity_data.xml", - # views "views/blog_post.xml", + "views/mail_activity.xml", "views/res_partner_industry.xml", "views/res_partner.xml", "views/sponsorship_line.xml", ], + 'assets': { + 'web.assets_backend': [ + 'oca_sponsor/static/src/**/*', + ] + }, "installable": True, "application": False, "development_status": "Alpha", diff --git a/oca_sponsor/data/mail_activity_data.xml b/oca_sponsor/data/mail_activity_data.xml deleted file mode 100644 index 40debd3b..00000000 --- a/oca_sponsor/data/mail_activity_data.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Review sponsor website information - fa-check - res.partner - - - diff --git a/oca_sponsor/data/mail_activity_team.xml b/oca_sponsor/data/mail_activity_team.xml new file mode 100644 index 00000000..9be95840 --- /dev/null +++ b/oca_sponsor/data/mail_activity_team.xml @@ -0,0 +1,11 @@ + + + + + + Sponsors Reviewers + + + + diff --git a/oca_sponsor/models/__init__.py b/oca_sponsor/models/__init__.py index f5833bd9..7e8be900 100644 --- a/oca_sponsor/models/__init__.py +++ b/oca_sponsor/models/__init__.py @@ -1,4 +1,4 @@ -from . import mail_activity +from . import res_users from . import res_partner from . import res_partner_grade from . import res_partner_industry diff --git a/oca_sponsor/models/mail_activity.py b/oca_sponsor/models/mail_activity.py deleted file mode 100644 index 53c0064b..00000000 --- a/oca_sponsor/models/mail_activity.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright 2026 AKRETION -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - -from odoo import models - -class MailActivity(models.Model): - _inherit = ["mail.activity"] - - def action_done(self): - self._cancel_sibling_sponsor_reviewals() - return super().action_done() - - def action_cancel(self): - self._cancel_sibling_sponsor_reviewals() - return super().action_cancel() - - def _cancel_sibling_sponsor_reviewals(self): - """When 1 user review a sponsor, cancel sibling activities for the other reviewers""" - if self._context.get("skip_cancel_sibling_sponsor"): - return - - activities = self.filtered(lambda x: x.res_model == "res.partner") - if activities: - activity_type = self.env.ref("oca_sponsor.mail_activity_review_sponsor_oca") - partners = self.env["res.partner"].browse(activities.mapped("res_id")) - siblings = partners.sudo().activity_ids.filtered( # 'sudo' because activities of other users - lambda x: x.activity_type_id == activity_type - ) - self - siblings.with_context(skip_cancel_sibling_sponsor=True).action_cancel() diff --git a/oca_sponsor/models/res_partner.py b/oca_sponsor/models/res_partner.py index a8b9276a..069bde59 100644 --- a/oca_sponsor/models/res_partner.py +++ b/oca_sponsor/models/res_partner.py @@ -1,8 +1,9 @@ # Copyright 2026 AKRETION # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import api, fields, models, exceptions, _ +from odoo import api, fields, models, Command, exceptions, _ from odoo.osv.expression import NOT_OPERATOR +from odoo.tools.safe_eval import safe_eval from hashlib import md5 @@ -31,6 +32,9 @@ class ResPartner(models.Model): compute="_compute_is_sponsor", search="_search_is_sponsor", ) + is_sponsor_reviewer = fields.Boolean( + compute="_compute_is_sponsor_reviewer" + ) sponsor_to_review = fields.Boolean( string="To review", default=False, @@ -53,11 +57,6 @@ class ResPartner(models.Model): store=True, readonly=False, ) - industry_id = fields.Many2one( - compute="_compute_industry_id", - store=True, - readonly=False, - ) sponsor_industry_ids = fields.Many2many( comodel_name="res.partner.industry", relation="res_partner_partner_industry_rel", @@ -67,6 +66,8 @@ class ResPartner(models.Model): compute="_compute_sponsor_industry_ids", store=True, readonly=False, + help="On the website, 1 partner may have several industries. " + "Their description is the same for all sponsors." ) website_long_description = fields.Text( string="Sponsor long description", @@ -100,49 +101,45 @@ def _search_is_sponsor(self, operator, value): _not = [NOT_OPERATOR] return _not + [("grade_id", "!=", False)] + @api.depends_context("uid") + def _compute_is_sponsor_reviewer(self): + self.is_sponsor_reviewer = self.env.user in self._get_sponsor_reviewer_team().member_ids + @api.model + def _get_sponsor_reviewer_team(self): + return self.env.ref("oca_sponsor.mail_activity_team_sponsor_reviewers") + @api.depends("country_id", "grade_id") def _compute_sponsor_country_ids(self): - """Put new `country_id` in `sponsor_country_ids`""" - self._compute_sponsor_field_ids("country_id") - - @api.depends("sponsor_industry_ids", "grade_id") - def _compute_industry_id(self): - """`industry_id`, if empty, is filled in by `sponsor_industry_ids`""" - for partner in self: - industries = partner.sponsor_industry_ids - if industries and partner.industry_id not in industries: - partner.industry_id = fields.first(industries) - + self._compute_sponsor_replace_in("country_id", "sponsor_country_ids") @api.depends("industry_id", "grade_id") def _compute_sponsor_industry_ids(self): - """Put new `industry_id` in `sponsor_industry_ids`""" - self._compute_sponsor_field_ids("industry_id") - - def _compute_sponsor_field_ids(self, field): - """Called for both `sponsor_country_ids` and `sponsor_industry_ids`""" - for sponsor in self.filtered(lambda x: x.is_sponsor): - sponsor_field = "sponsor_" + field + "s" - old, new = sponsor._origin[field], sponsor[field] - if not new in sponsor[sponsor_field]: - sponsor[sponsor_field] |= new - if old != new and old in sponsor[sponsor_field]: - sponsor[sponsor_field] -= old + self._compute_sponsor_replace_in("industry_id", "sponsor_industry_ids") + def _compute_sponsor_replace_in(self, origin_field, sponsor_field): + """Replace `sponsor._origin[field]` by `sponsor[field]` + in `sponsor[sponsor_field]`, or add it if no origin value""" + sponsors = self.filtered(lambda x: x.is_sponsor) + for sponsor in sponsors: + old, new = sponsor._origin[origin_field], sponsor[origin_field]._origin + current = sponsor[sponsor_field]._origin + if new and not new in current: + sponsor[sponsor_field] = [Command.link(new.id)] + if old and old != new and old in current: + sponsor[sponsor_field] = [Command.unlink(old.id)] @api.depends("blog_post_ids") def _compute_blog_post_count(self): for partner in self: partner.blog_post_count = len(partner.blog_post_ids) - #====== CRUD ======# + #====== CRUD & ORM ======# def write(self, vals): - """Set in review the sponsor whose relevant data changed""" + """Set the sponsor in review as soon as the sponsors fields are touched + by a non-authorized person""" keys = set(vals) & SPONSOR_WEBSITE_FIELDS if keys: before = self._get_hashes(keys) - res = super().write(vals) - if keys and (partners := self._compare_hashes(keys, before)): partners._set_sponsor_to_review() @@ -163,67 +160,59 @@ def _compare_hashes(self, keys, before): def search_fetch(self, domain, field_names, offset=0, limit=None, order=None): """Order res.partner sponsor view in Kanban and List - with the ones to review as firsts""" + with the ones to review at first""" if self._context.get("membership_sponsor"): delimiter = "" if not order else ", " order = "sponsor_to_review DESC" + delimiter + (order or '') return super().search_fetch(domain, field_names, offset, limit, order) - #===== Business logics =====# + #===== Actions & buttons =====# + def action_open_blog_post(self): + action = self.env.ref("website_blog.action_blog_post").sudo().read([])[0] + action.update({ + "domain": [("author_id", "=", self.id)], + "context": ( + safe_eval(action.get("context", "{}")) | + { + "default_author_id": self.id, + } + ) + }) + return action + def button_sponsor_review_accept(self): - if not self.env.user.has_groups("membership_extension.group_membership_manager"): - raise exceptions.AccessError(_( - "Only a membership manager may publish sponsor information to the website." - )) + if not self.is_sponsor_reviewer: + raise exceptions.AccessError(_("You are not a Sponsor Reviewer.")) self._sponsor_review_accept() - def action_open_blog_post(self): - return { - 'name': _("Blog posts"), - 'type': 'ir.actions.act_window', - 'res_model': "blog.post", - 'view_mode': 'list,form', - 'domain': [("author_id", "=", self.id)], - } + #===== Business logics =====# + def _set_sponsor_to_review(self): + """Pause the syncing of new sponsors data until their review, + when their data are updated from the portal, + and notify reviewers with an activity""" + if not self.is_sponsor_reviewer: + sponsors = self.filtered(lambda x: x.is_sponsor and not x.sponsor_to_review) + if sponsors: + sponsors.sponsor_to_review = True + sponsors._sponsor_reviewers_notify() + + def _sponsor_reviewers_notify(self, notify=True): + """`notify=True`: notify the reviewers when review starts + `notify=False`: remove the activity at review validation""" + reviewer_team = self._get_sponsor_reviewer_team() + if not notify: + self.activity_ids.filtered(lambda x: x.team_id == reviewer_team).unlink() + else: + self.activity_schedule( + team_id=reviewer_team.id, + note=_("The sponsor changed its information from its profile. " + "Please review those changes to publish them on the website." + ), + ) def _sponsor_review_accept(self): - # Re-enable syncing self.sudo().write({ # 'sudo' to bypass AccessError of 'website.published.multi.mixin' "is_published": True, "sponsor_to_review": False, }) - - # Finish review - activity_type = self.env.ref("oca_sponsor.mail_activity_review_sponsor_oca") - self.activity_ids.filtered( - lambda x: x.activity_type_id == activity_type - ).sudo().action_done() # 'sudo' because activities of other users - - def _set_sponsor_to_review(self): - """Pause the syncing of new sponsors data until their review, - when their data are updated from the portal, - and notify reviewers with an activity""" - if ( - self._context.get("skip_sponsor_review") - or self.env.user.has_groups("membership_extension.group_membership_manager") - ): - return - self = self.with_context(skip_sponsor_review=True) # prevent infinite loop - - # Pause syncing - sponsors = self.filtered( - lambda x: x.is_sponsor and not x.sponsor_to_review - ) - if sponsors: - sponsors.sponsor_to_review = True - - # Notify reviewers - users = self.env.ref("membership_extension.group_membership_manager").users - for user in users: - # We use a specific activity template for custom done/cancel logic of mail.activity - sponsors.activity_schedule( - act_type_xmlid="oca_sponsor.mail_activity_review_sponsor_oca", - user_id=user.id, - note=_("The sponsor changes its information from its profile. " - "Please review the change to publish them on the website."), - ) + self._sponsor_reviewers_notify(notify=False) diff --git a/oca_sponsor/models/res_partner_grade.py b/oca_sponsor/models/res_partner_grade.py index 1906b719..e9e38e8d 100644 --- a/oca_sponsor/models/res_partner_grade.py +++ b/oca_sponsor/models/res_partner_grade.py @@ -6,12 +6,12 @@ class ResPartnerGrade(models.Model): """Reproduce original data model of 'website_crm_partner_assign', but only for membership (grade) and without the CRM part, to free - this dependency (+ to `base_geolocalize`). + this weird dependency (e.g. it implies `base_geolocalize` & other non-wanted modules). We don't re-define the list/form/search ir.ui.view to avoid conflict with native module, in case it is installed in parallel. - *ALTERNATIVE*: create a `membership.sponsorship.category` with a migration + *ALTERNATIVE*: we could create a `membership.sponsorship.category` with a migration script copying data from `res.partner.grade` """ @@ -22,3 +22,5 @@ class ResPartnerGrade(models.Model): sequence = fields.Integer("Sequence") active = fields.Boolean("Active", default=True) name = fields.Char("Level Name", translate=True) + partner_weight = fields.Integer('Level Weight', default=1, + help="Gives the probability to assign a lead to this partner. (0 means no assignment.)") diff --git a/oca_sponsor/models/res_users.py b/oca_sponsor/models/res_users.py new file mode 100644 index 00000000..939ad217 --- /dev/null +++ b/oca_sponsor/models/res_users.py @@ -0,0 +1,35 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ResUsers(models.Model): + _inherit = ["res.users"] + + @api.model + def _get_activity_groups(self): + """Team activities are not counted in the Systray, unless user clicks on it + => Change it to notify the sponsors' reviewers of their team's activity, by + merging user & team activities so that UI `activityCounter` counts both + instead of only users. + Also see `static/src/activity_menu_patch.js` and `mail_activity.xml`""" + user_activities = super( + ResUsers, self.with_context(team_activities=False) + )._get_activity_groups() + team_activities = super( + ResUsers, self.with_context(team_activities=True) + )._get_activity_groups() + + states = ["total"] + [x[0] for x in self.env["mail.activity"]._fields["state"].selection] + merged_activities = {} + for activity in user_activities + team_activities: + model = activity["model"] + if not model in merged_activities: + merged_activities[model] = activity + else: + for state in states: + if state + "_count" in activity: + merged_activities[model][state + "_count"] += activity[state + "_count"] + + return list(merged_activities.values()) diff --git a/oca_sponsor/static/src/components/activity_menu_view/activity_menu_view.xml b/oca_sponsor/static/src/components/activity_menu_view/activity_menu_view.xml new file mode 100644 index 00000000..0ab88cf4 --- /dev/null +++ b/oca_sponsor/static/src/components/activity_menu_view/activity_menu_view.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/oca_sponsor/static/src/models/activity_menu_patch.js b/oca_sponsor/static/src/models/activity_menu_patch.js new file mode 100644 index 00000000..255b5d2e --- /dev/null +++ b/oca_sponsor/static/src/models/activity_menu_patch.js @@ -0,0 +1,29 @@ +/** @odoo-module */ + +import { ActivityMenu } from "@mail/core/web/activity_menu"; +import { Domain } from "@web/core/domain"; +import { patch } from "@web/core/utils/patch"; +import { user } from "@web/core/user"; + +patch(ActivityMenu.prototype, { + /** + * @override + * Since we display the count of both user' and team' activities in the Systray and + * the drop-down, we update the domain of action opening to show them both in the views + */ + async executeActivityAction(group, domain, views, context) { + const team_domain = [["activity_team_user_ids", "=", this.userId]] + domain = Domain.or([domain, team_domain]).toList(); + context["team_activites"] = true + return super.executeActivityAction(group, domain, views, context); + }, + updateTeamActivitiesContext() { + /* Needed so `_search_my_activity_date_deadline` renders team activities */ + super.updateTeamActivitiesContext() + user.updateContext({team_activities: true}); + }, + onBeforeOpen() { + super.onBeforeOpen(); + user.updateContext({team_activities: true}); + }, +}); diff --git a/oca_sponsor/tests/test_oca_sponsor.py b/oca_sponsor/tests/test_oca_sponsor.py index 62a91294..7888c699 100644 --- a/oca_sponsor/tests/test_oca_sponsor.py +++ b/oca_sponsor/tests/test_oca_sponsor.py @@ -22,9 +22,8 @@ def setUpClass(cls): ]) # Users & partners - cls.group_manager = "membership_extension.group_membership_manager" - cls.manager = new_test_user(cls.env, "manager", groups="base.group_user," + cls.group_manager) - cls.manager2 = new_test_user(cls.env, "manager2", groups="base.group_user," + cls.group_manager) + cls.manager = new_test_user(cls.env, "manager", groups="base.group_user") + cls.env.ref("oca_sponsor.mail_activity_team_sponsor_reviewers").member_ids |= cls.manager cls.portal_user = new_test_user(cls.env, "sponsor", groups="base.group_portal") cls.sponsor = cls.portal_user.partner_id cls.sponsor.write({ @@ -33,7 +32,7 @@ def setUpClass(cls): }) - def test_is_sponsor(self): + def test_is_sponsor_search(self): self.assertTrue(self.sponsor.is_sponsor) self.assertIn( self.sponsor, @@ -56,24 +55,14 @@ def test_sponsor_country_ids(self): @users("sponsor") def test_industry_id_to_ids(self): - """Ensure `industry_id` is synced in `industry_ids`""" + """Ensure `industry_id` is synced in `industry_ids` (same than country)""" self.sponsor.sponsor_industry_ids = False self.sponsor.industry_id = self.industry_a self.assertEqual(self.sponsor.sponsor_industry_ids, self.industry_a) - def test_industry_ids_to_id(self): - """Ensure `industry_id` is defined (if empty) from `industry_ids`""" - self.sponsor.industry_id = False - self.sponsor.sponsor_industry_ids = self.industry_a - self.assertEqual(self.sponsor.industry_id, self.industry_a) - - # Add another industry: no change - self.sponsor.sponsor_industry_ids |= self.industry_b - self.assertEqual(self.sponsor.industry_id, self.industry_a) - @users("sponsor") def test_sponsor_review_irrelevant_fields(self): - """Not 'to review' on irrelevant fields""" + """No 'review' mode when changing non-sponsor fields""" self.assertFalse(self.sponsor.sponsor_to_review) self.sponsor.comment = "Not a website field" self.assertFalse(self.sponsor.sponsor_to_review) @@ -88,34 +77,23 @@ def test_sponsor_review_membership_manager(self): def test_sponsor_review_relevant(self): """Mark to review when relevant (portal + fields) & create activities""" # Marked as to review - self.sponsor.with_user(self.portal_user).sudo().website_long_description = "" + self.sponsor.with_user(self.portal_user).sudo().website_long_description = "text to review" self.assertTrue(self.sponsor.sponsor_to_review) + self.assertIn(self.manager, self.sponsor.activity_ids.member_ids) - # Activity - def _get_activities(): - activity_type = self.env.ref("oca_sponsor.mail_activity_review_sponsor_oca") - activities = self.sponsor.activity_ids - return activities.filtered(lambda x: x.activity_type_id == activity_type) - - admins = self.env.ref(self.group_manager).users - self.assertEqual(_get_activities().mapped("user_id"), admins) - - # No duplicate activity on 2nd+ updates - self.website_short_description = "Quick update" - self.assertEqual(_get_activities().mapped("user_id"), admins) - + # Approval self.sponsor.with_user(self.manager).button_sponsor_review_accept() - self.assertEqual(self.sponsor.sponsor_to_review, False) - self.assertEqual(len(_get_activities()), 0) + self.assertFalse(self.sponsor.sponsor_to_review) + self.assertNotIn(self.manager, self.sponsor.activity_ids.member_ids) def test_search_fetch_partner_order_with_context(self): """Sponsors to be reviewed are displayed first""" ResPartner = self.env["res.partner"] - sponsor2 = ResPartner.create({ + sponsor2 = ResPartner.create([{ "name": "Sponsor Corp 2", "grade_id": self.grade.id, "is_company": True, - }) + }]) def _get_first_sponsor(): return ResPartner.with_context(membership_sponsor=True).search_fetch( diff --git a/oca_sponsor/views/mail_activity.xml b/oca_sponsor/views/mail_activity.xml new file mode 100644 index 00000000..91adb292 --- /dev/null +++ b/oca_sponsor/views/mail_activity.xml @@ -0,0 +1,21 @@ + + + + + + + mail.activity.view.search.oca_sponsor + mail.activity + + + + + [ + '|', ('user_id', '=', uid), ('team_id.member_ids', '=', uid) + ] + + + + + diff --git a/oca_sponsor/views/res_partner.xml b/oca_sponsor/views/res_partner.xml index 4eab1ad7..b985bd46 100644 --- a/oca_sponsor/views/res_partner.xml +++ b/oca_sponsor/views/res_partner.xml @@ -14,6 +14,7 @@ + @@ -40,7 +41,7 @@ - + res.partner.form.oca_sponsor @@ -61,6 +62,7 @@ class="oe_stat_button" name="action_open_blog_post" icon="fa-globe" + invisible="not is_sponsor" > @@ -69,13 +71,14 @@ - -