diff --git a/Dockerfile b/Dockerfile index 08639b6a..f71f891f 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/* @@ -49,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, @@ -68,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/ 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 3d256467..8264ecb9 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)", @@ -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", @@ -168,8 +169,14 @@ "web_refresher", "web_search_with_and", "web_widget_dropdown_dynamic", - "website_oca_integrator", "website_sale_hide_empty_category", + # Custom + "oca_custom", + "oca_membership", + "oca_search_engine", + "oca_sponsor", + "oca_website", + "website_oca_integrator", "website_sale_oca_apps", # OCA CUSTOM "oca_website", diff --git a/oca_all/migrations/18.0.1.0.0/post-migrate-avatar-reset.py b/oca_all/migrations/18.0.1.0.0/post-migrate-avatar-reset.py new file mode 100644 index 00000000..c62c4c62 --- /dev/null +++ b/oca_all/migrations/18.0.1.0.0/post-migrate-avatar-reset.py @@ -0,0 +1,65 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from openupgradelib import openupgrade + +from odoo.tools import SQL + +_logger = logging.getLogger(__file__) + +BATCH_SIZE = 1000 + + +@openupgrade.migrate(use_env=True) +def migrate(env, version): + """Lots of avatar are duplicated on `res.partner`, and stored 'in hard' in database + though they are default avatar""" + + # 1. Get partners with similar avatar (by checksum) + res = env.execute_query( + SQL(""" + SELECT res_id + FROM ir_attachment + WHERE checksum IN ( + SELECT checksum -- , COUNT(id), MAX(res_id) + FROM ir_attachment + WHERE res_model = 'res.partner' AND res_field = 'image_1920' + GROUP BY checksum + HAVING COUNT(id) >= 2 + ORDER BY COUNT(id) DESC + ) + AND res_model = 'res.partner' + """) + ) + partner_ids = [y for x in res for y in x] + partners = ( + env["res.partner"] + .browse(set(partner_ids)) + .exists() + .with_context(prefetch_fields=False) + ) + + # 2. Cleaning duplicated avatars, by batch + if not partners: + _logger.info("No partner with duplicate avatar => stop here.") + return + + partners_count = len(partners) + _logger.info("Start avatar cleaning: %d partners(s)", partners_count) + + for i in range(0, partners_count, BATCH_SIZE): + offset = i * BATCH_SIZE + _logger.info( + "Batch %d: partners %d to %d...", + i + 1, + offset + 1, + min(offset + BATCH_SIZE, partners_count), + ) + + batch = partners[i : i + BATCH_SIZE] + batch.image_1920 = False + + _logger.info("Avatar cleaning ended: %d partners cleaned.", partners_count) 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/migrations/18.0.1.0.0/post-migrate-membership-categories.py b/oca_all/migrations/18.0.1.0.0/post-migrate-membership-categories.py new file mode 100644 index 00000000..627ddc4f --- /dev/null +++ b/oca_all/migrations/18.0.1.0.0/post-migrate-membership-categories.py @@ -0,0 +1,47 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from click_odoo import odoo +from openupgradelib import openupgrade + +_logger = logging.getLogger(__file__) + + +@openupgrade.migrate(use_env=True) +def migrate(env, version): + """Set Membership Categories on Products, as per their name + We now need them to manage the Role in the association. + This scripts helps recomputing the history and define the last active + 'Category' in the association""" + + _logger.info("Membership Category init: start") + + # 1. Configure product: set `membership_category_id` based on products' name + mapped_categories = { + category.name.lower().split(" ")[0]: category + for category in env["membership.membership_category"].search([]) + } + products = ( + env["product.product"] + .with_context(active_test=False) + .search([("membership", "=", True)]) + ) + for product in products: + category = None + for category_name, category in mapped_categories.items(): + if category_name in product.name.lower(): + product.membership_category_id = category + break + + # 2. On members, set `membership_category_id` based on their last membership line + members = env["res.partner"].search([("member_lines", "!=", False)]) + for member in members: + last_line = odoo.fields.first( + member.member_lines.sorted("date_to", reverse=True) + ) + member.membership_category_id = last_line.category_id + + _logger.info("Membership Category init: done (%d members)", len(members)) 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

-
diff --git a/oca_custom/README.rst b/oca_custom/README.rst index 8d757c8b..b559059f 100644 --- a/oca_custom/README.rst +++ b/oca_custom/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 Custom Settings =================== @@ -17,7 +13,7 @@ OCA Custom Settings .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/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_custom/__manifest__.py b/oca_custom/__manifest__.py index 15f5ba2f..7773f933 100644 --- a/oca_custom/__manifest__.py +++ b/oca_custom/__manifest__.py @@ -9,19 +9,6 @@ "website": "https://github.com/OCA/oca-custom", "author": "GRAP, Akretion, Odoo Community Association (OCA)", "license": "AGPL-3", - "depends": [ - "base", - "contacts", - "github_connector", - "membership", - "mail_group", - ], - "data": [ - "data/ir_cron_data.xml", - "data/ir_action_server_data.xml", - "views/res_config_settings.xml", - "views/res_partner.xml", - "views/mail_group.xml", - ], + "depends": ["base"], "installable": True, } diff --git a/oca_custom/data/ir_action_server_data.xml b/oca_custom/data/ir_action_server_data.xml deleted file mode 100644 index 346b0f5a..00000000 --- a/oca_custom/data/ir_action_server_data.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - Sync Member Tag from Membership - - code - records.action_membership_sync() - - list,form - - diff --git a/oca_custom/data/ir_cron_data.xml b/oca_custom/data/ir_cron_data.xml deleted file mode 100644 index 37042501..00000000 --- a/oca_custom/data/ir_cron_data.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - Membership: sync Member tag from membership_state - - - code - model._cron_membership_tag_sync() - 15 - minutes - True - - diff --git a/oca_custom/models/__init__.py b/oca_custom/models/__init__.py index 28fdb42b..91fed54d 100644 --- a/oca_custom/models/__init__.py +++ b/oca_custom/models/__init__.py @@ -1,3 +1 @@ from . import res_partner -from . import res_config_settings -from . import mail_group diff --git a/oca_custom/models/mail_group.py b/oca_custom/models/mail_group.py deleted file mode 100644 index 789dd497..00000000 --- a/oca_custom/models/mail_group.py +++ /dev/null @@ -1,53 +0,0 @@ -from odoo import fields, models - - -class MailGroup(models.Model): - _inherit = "mail.group" - - partner_tag_id = fields.Many2one( - "res.partner.category", - string="Partner Tag", - help="If set, partners having this tag are automatically added/removed" - " as members of this mail group.", - ) - - def action_sync_members_from_tag(self): - Member = self.env["mail.group.member"].sudo() - Partner = self.env["res.partner"].sudo() - - for group in self: - tag = group.partner_tag_id - if not tag: - continue - - tagged_partners = Partner.search([("category_id", "in", [tag.id])]) - tagged_ids = set(tagged_partners.ids) - - existing_members = group.member_ids.sudo() - existing_partner_ids = set(existing_members.mapped("partner_id").ids) - - # Add missing members that match tag - to_add_ids = tagged_ids - existing_partner_ids - if to_add_ids: - Member.create( - [ - {"mail_group_id": group.id, "partner_id": pid} - for pid in to_add_ids - ] - ) - - # Remove members that no longer match tag (authoritative behavior!) - to_remove = existing_members.filtered_domain( - [("partner_id.category_id", "not in", [tag.id])] - ) - if to_remove: - to_remove.unlink() - - return True - - def write(self, vals): - res = super().write(vals) - if "partner_tag_id" in vals: - # Run after write so group.partner_tag_id is the new value - self.action_sync_members_from_tag() - return res diff --git a/oca_custom/models/res_config_settings.py b/oca_custom/models/res_config_settings.py deleted file mode 100644 index 00da96c3..00000000 --- a/oca_custom/models/res_config_settings.py +++ /dev/null @@ -1,32 +0,0 @@ -from odoo import api, fields, models - -PARAM_PREFIX = "oca_membership_channel_sync." - - -class ResConfigSettings(models.TransientModel): - _inherit = "res.config.settings" - - member_tag_id = fields.Many2one( - "res.partner.category", - string="Member tag", - help="Partner tag that marks Members.", - ) - - @api.model - def get_values(self): - res = super().get_values() - ICP = self.env["ir.config_parameter"].sudo() - val = ICP.get_param(PARAM_PREFIX + "member_tag_id") - res.update( - member_tag_id=int(val) if val and val.isdigit() else False, - ) - return res - - def set_values(self): - res = super().set_values() - ICP = self.env["ir.config_parameter"].sudo() - ICP.set_param( - PARAM_PREFIX + "member_tag_id", - str(self.member_tag_id.id) if self.member_tag_id else "", - ) - return res diff --git a/oca_custom/models/res_partner.py b/oca_custom/models/res_partner.py index f94b352f..3c8b3822 100644 --- a/oca_custom/models/res_partner.py +++ b/oca_custom/models/res_partner.py @@ -1,180 +1,11 @@ # Copyright (C) 2016-Today: Odoo Community Association (OCA) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import logging - -from odoo import api, fields, models - -_logger = logging.getLogger(__name__) - -PARAM_PREFIX = "oca_membership_channel_sync." -MEMBER_STATES = {"paid"} +from odoo import fields, models class ResPartner(models.Model): _inherit = "res.partner" + # Kept for migration only, to remove once module is uninstalled github_name = fields.Char(readonly=False, copy=False) - - def _cfg_id(self, key: str) -> int | None: - val = self.env["ir.config_parameter"].sudo().get_param(PARAM_PREFIX + key) - if val and str(val).isdigit(): - return int(val) - return None - - def _get_member_tag(self): - member_tag_id = self._cfg_id("member_tag_id") or 3 - tag = self.env["res.partner.category"].browse(member_tag_id).exists() - if not tag: - _logger.info( - "Member tag id=%s not found; skip membership_state tag sync", - member_tag_id, - ) - return tag - - def _sync_member_tag_from_membership_state(self): - member_tag = self._get_member_tag() - if not member_tag: - return - - tag_id = member_tag.id - - to_add = self.filtered( - lambda p, tag_id=tag_id: (p.membership_state in MEMBER_STATES) - and (tag_id not in p.category_id.ids) - ) - to_remove = self.filtered( - lambda p, tag_id=tag_id: (p.membership_state not in MEMBER_STATES) - and (tag_id in p.category_id.ids) - ) - - if to_add: - to_add.with_context(skip_membership_channel_sync=True).write( - {"category_id": [(4, tag_id)]} - ) - if to_remove: - to_remove.with_context(skip_membership_channel_sync=True).write( - {"category_id": [(3, tag_id)]} - ) - - def action_membership_sync(self): - self._sync_member_tag_from_membership_state() - return True - - @api.model - def _cron_membership_tag_sync(self, batch_size=500): - ICP = self.env["ir.config_parameter"].sudo() - - cursor_key = PARAM_PREFIX + "cron_last_partner_id" - last_id = int(ICP.get_param(cursor_key, "0") or 0) - - member_tag_id = int(ICP.get_param(PARAM_PREFIX + "member_tag_id", "3") or 3) - - partners = self.sudo().search( - [ - ("id", ">", last_id), - "|", - ("membership_state", "=", "paid"), - ("category_id", "in", [member_tag_id]), - ], - order="id asc", - limit=batch_size, - ) - - if not partners: - ICP.set_param(cursor_key, "0") - return True - - partners._sync_member_tag_from_membership_state() - ICP.set_param(cursor_key, str(partners[-1].id)) - return True - - def _sync_mail_groups_by_tag_delta(self, before_map): - """ - Sync mail.group membership based on changes - in partner tags (category_id). - """ - Group = self.env["mail.group"].sudo() - Member = self.env["mail.group.member"].sudo() - - groups = Group.search([("partner_tag_id", "!=", False)]) - if not groups: - return - - # tag_id -> recordset(mail.group) - tag_to_groups = {} - empty_groups = Group.browse() - for g in groups: - tag_to_groups.setdefault(g.partner_tag_id.id, empty_groups) - tag_to_groups[g.partner_tag_id.id] |= g - - for partner in self: - before = before_map.get(partner.id, set()) - after = set(partner.category_id.ids) - - added = after - before - removed = before - after - - # ADD memberships & avoid duplicates - if added: - add_groups = empty_groups - for tag_id in added: - add_groups |= tag_to_groups.get(tag_id, empty_groups) - - if add_groups: - existing = Member.search( - [ - ("partner_id", "=", partner.id), - ("mail_group_id", "in", add_groups.ids), - ] - ) - existing_group_ids = set(existing.mapped("mail_group_id").ids) - - to_create = [ - {"mail_group_id": gid, "partner_id": partner.id} - for gid in add_groups.ids - if gid not in existing_group_ids - ] - if to_create: - Member.create(to_create) - - # REMOVE memberships - if removed: - remove_groups = empty_groups - for tag_id in removed: - remove_groups |= tag_to_groups.get(tag_id, empty_groups) - - if remove_groups: - Member.search( - [ - ("partner_id", "=", partner.id), - ("mail_group_id", "in", remove_groups.ids), - ] - ).unlink() - - @api.model_create_multi - def create(self, vals_list): - partners = super().create(vals_list) - before_map = {p.id: set() for p in partners} - partners._sync_mail_groups_by_tag_delta(before_map) - return partners - - def write(self, vals): - before_map = ( - {p.id: set(p.category_id.ids) for p in self} - if "category_id" in vals - else {} - ) - - res = super().write(vals) - - if before_map: - self._sync_mail_groups_by_tag_delta(before_map) - - if ( - not self.env.context.get("skip_membership_channel_sync") - and "membership_state" in vals - ): - self._sync_member_tag_from_membership_state() - - return res diff --git a/oca_custom/static/description/index.html b/oca_custom/static/description/index.html index d0bd1d28..68a47979 100644 --- a/oca_custom/static/description/index.html +++ b/oca_custom/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +OCA Custom Settings -
+
+

OCA Custom Settings

- - -Odoo Community Association - -
-

OCA Custom Settings

-

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

+

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

Custom module for OCA Instance.

Table of contents

@@ -389,7 +384,7 @@

OCA Custom Settings

-

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 @@ -397,16 +392,16 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • GRAP
  • Akretion
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -440,6 +435,5 @@

Maintainers

-
diff --git a/oca_custom/tests/__init__.py b/oca_custom/tests/__init__.py deleted file mode 100644 index e5710de8..00000000 --- a/oca_custom/tests/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import test_membership_channel_sync -from . import test_mail_group_partner_tag_sync diff --git a/oca_custom/tests/test_mail_group_partner_tag_sync.py b/oca_custom/tests/test_mail_group_partner_tag_sync.py deleted file mode 100644 index d3ac6bed..00000000 --- a/oca_custom/tests/test_mail_group_partner_tag_sync.py +++ /dev/null @@ -1,207 +0,0 @@ -from odoo.tests import tagged -from odoo.tests.common import TransactionCase - - -@tagged("post_install", "-at_install") -class TestMailGroupPartnerTagSync(TransactionCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.Tag = cls.env["res.partner.category"].sudo() - cls.Partner = cls.env["res.partner"].sudo() - cls.Group = cls.env["mail.group"].sudo() - cls.Member = cls.env["mail.group.member"].sudo() - - cls.tag = cls.Tag.create({"name": "Board"}) - cls.other_tag = cls.Tag.create({"name": "Other"}) - - cls.partner_tagged = cls.Partner.create( - { - "name": "Alice", - "email": "alice@example.com", - "category_id": [(4, cls.tag.id)], - } - ) - cls.partner_untagged = cls.Partner.create( - {"name": "Partner 1", "email": "test@example.com"} - ) - - def _create_group(self, name, tag=None): - vals = { - "name": name, - "alias_name": name.lower().replace(" ", "_"), - } - if tag: - vals["partner_tag_id"] = tag.id - return self.Group.create(vals) - - def test_mail_group_action_sync_adds_and_removes(self): - """action_sync_members_from_tag must: - - add partners having the tag - - remove partners that do not have the tag - """ - group = self._create_group("Board Group", tag=self.tag) - - # Manually add an untagged member -> should be removed by sync - self.Member.create( - {"mail_group_id": group.id, "partner_id": self.partner_untagged.id} - ) - - group.action_sync_members_from_tag() - - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_tagged.id), - ] - ), - 1, - "Tagged partner must be a member after sync.", - ) - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_untagged.id), - ] - ), - 0, - "Untagged partner must be removed by sync.", - ) - - def test_mail_group_write_partner_tag_id_triggers_sync(self): - """Writing partner_tag_id on mail.group should trigger a sync after write().""" - group = self._create_group("Config Group", tag=None) - - # Add Partner 1 manually; when tag is set, sync should remove him - self.Member.create( - {"mail_group_id": group.id, "partner_id": self.partner_untagged.id} - ) - - group.write({"partner_tag_id": self.tag.id}) - - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_tagged.id), - ] - ), - 1, - "Tagged partner must be added when partner_tag_id is configured.", - ) - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_untagged.id), - ] - ), - 0, - "Non-tagged member must be removed when partner_tag_id is configured.", - ) - - def test_partner_tag_add_remove_updates_group_membership(self): - """Partner tag delta must add/remove mail.group.member rows.""" - group = self._create_group("Delta Group", tag=self.tag) - - # Initially Partner 1 is not tagged -> not a member - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_untagged.id), - ] - ), - 0, - ) - - # Add tag -> must become member (res.partner.write hook) - self.partner_untagged.write({"category_id": [(4, self.tag.id)]}) - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_untagged.id), - ] - ), - 1, - "Adding the tag to partner must add them to the configured group.", - ) - - # Remove tag -> must be removed from group - self.partner_untagged.write({"category_id": [(3, self.tag.id)]}) - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_untagged.id), - ] - ), - 0, - "Removing the tag from partner must remove them from the configured group.", - ) - - def test_partner_write_no_duplicate_memberships(self): - """Repeated writes that do not change tags must not create duplicate members.""" - group = self._create_group("No Dups Group", tag=self.tag) - - # Add tag once -> member created - self.partner_untagged.write({"category_id": [(4, self.tag.id)]}) - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_untagged.id), - ] - ), - 1, - ) - - # Write same tag again (no change) -> must still be exactly 1 membership - self.partner_untagged.write({"category_id": [(4, self.tag.id)]}) - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_untagged.id), - ] - ), - 1, - "Re-applying same tag must not duplicate mail.group.member.", - ) - - # Write unrelated field -> must still be 1 membership - self.partner_untagged.write({"name": "Partner 1 Updated"}) - self.assertEqual( - self.Member.search_count( - [ - ("mail_group_id", "=", group.id), - ("partner_id", "=", self.partner_untagged.id), - ] - ), - 1, - "Unrelated partner writes must not affect membership count.", - ) - - def test_partner_create_with_tag_creates_membership(self): - """Partner create override should add membership when created with the tag.""" - group = self._create_group("Create Group", tag=self.tag) - - new_partner = self.Partner.create( - { - "name": "Partner 2", - "email": "test_2@example.com", - "category_id": [(6, 0, [self.tag.id])], - } - ) - - self.assertEqual( - self.Member.search_count( - [("mail_group_id", "=", group.id), ("partner_id", "=", new_partner.id)] - ), - 1, - "Creating a tagged partner must create group membership.", - ) diff --git a/oca_custom/tests/test_membership_channel_sync.py b/oca_custom/tests/test_membership_channel_sync.py deleted file mode 100644 index 9778f157..00000000 --- a/oca_custom/tests/test_membership_channel_sync.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (C) 2016-Today: Odoo Community Association (OCA) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -from odoo.tests import tagged -from odoo.tests.common import TransactionCase - - -@tagged("post_install", "-at_install") -class TestMembershipTagSync(TransactionCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.ICP = cls.env["ir.config_parameter"].sudo() - - cls.tag_member = cls.env["res.partner.category"].create({"name": "Member"}) - cls.partner = cls.env["res.partner"].create({"name": "Partner A"}) - settings = cls.env["res.config.settings"].create({}) - settings.get_values() - settings.write({"member_tag_id": cls.tag_member.id}) - settings.set_values() - - def test_00_member_tag_setting_roundtrip(self): - settings = self.env["res.config.settings"].create({}) - vals = settings.get_values() - self.assertIn("member_tag_id", vals) - self.assertEqual(vals["member_tag_id"], self.tag_member.id) - - def _set_membership_state_sql(self, partner, state): - self.env.cr.execute( - "UPDATE res_partner SET membership_state=%s WHERE id=%s", - (state, partner.id), - ) - self.env.invalidate_all() - return self.env["res.partner"].browse(partner.id) - - def test_01_action_sync_adds_member_tag_when_paid(self): - partner = self._set_membership_state_sql(self.partner, "paid") - self.assertNotIn(self.tag_member, partner.category_id) - - partner.action_membership_sync() - partner.invalidate_recordset() - self.assertIn(self.tag_member, partner.category_id) - - def test_02_action_sync_removes_member_tag_when_not_paid(self): - partner = self._set_membership_state_sql(self.partner, "paid") - partner.action_membership_sync() - partner.invalidate_recordset() - self.assertIn(self.tag_member, partner.category_id) - - partner = self._set_membership_state_sql(self.partner, "none") - partner.action_membership_sync() - partner.invalidate_recordset() - self.assertNotIn(self.tag_member, partner.category_id) - - def test_03_cron_sync_reconciles_in_batches(self): - self.ICP.set_param("oca_membership_channel_sync.cron_last_partner_id", "0") - - partner = self._set_membership_state_sql(self.partner, "paid") - self.assertNotIn(self.tag_member, partner.category_id) - self.env["res.partner"]._cron_membership_tag_sync(batch_size=100) - - partner = self.env["res.partner"].browse(self.partner.id) - self.assertIn(self.tag_member, partner.category_id) - - def test_04_write_hook_syncs_tag_on_membership_state_change(self): - partner = self.env["res.partner"].browse(self.partner.id) - - # Ensure clean start - self.assertNotIn(self.tag_member, partner.category_id) - - # This should trigger write() override => add tag - partner.write({"membership_state": "paid"}) - partner.invalidate_recordset() - self.assertIn(self.tag_member, partner.category_id) - - # Flip back => should trigger write() override => remove tag - partner.write({"membership_state": "none"}) - partner.invalidate_recordset() - self.assertNotIn(self.tag_member, partner.category_id) - - def test_05_settings_get_values_handles_invalid_param(self): - """Cover: get_values() else branch when ICP value is not a digit.""" - self.ICP.set_param("oca_membership_channel_sync.member_tag_id", "abc") - - settings = self.env["res.config.settings"].create({}) - vals = settings.get_values() - - self.assertIn("member_tag_id", vals) - self.assertFalse(vals["member_tag_id"]) - - def test_06_settings_set_values_clears_param_when_empty(self): - """Cover: set_values() else branch when no member_tag_id is set.""" - # Put a value first, so we prove it gets cleared - self.ICP.set_param( - "oca_membership_channel_sync.member_tag_id", str(self.tag_member.id) - ) - - settings = self.env["res.config.settings"].create({}) - settings.write({"member_tag_id": False}) - settings.set_values() - - val = self.ICP.get_param("oca_membership_channel_sync.member_tag_id") - self.assertIn(val, ("", False)) - - def test_07_cron_resets_cursor_when_no_partners(self): - """Cover: cron 'no partners' branch (cursor reset to 0).""" - cursor_key = "oca_membership_channel_sync.cron_last_partner_id" - - # Set cursor past our only partner, so search returns nothing - self.ICP.set_param(cursor_key, str(self.partner.id)) - - # Ensure the only partner won't match the cron domain via paid OR tag - partner = self._set_membership_state_sql(self.partner, "none") - partner.write({"category_id": [(3, self.tag_member.id)]}) - partner.invalidate_recordset() - - self.env["res.partner"]._cron_membership_tag_sync(batch_size=100) - - self.assertEqual(self.ICP.get_param(cursor_key), "0") - - def test_08_sync_skips_when_configured_tag_missing(self): - """Cover: _get_member_tag() 'tag not found' -> early return.""" - self.ICP.set_param("oca_membership_channel_sync.member_tag_id", "999999") - - partner = self._set_membership_state_sql(self.partner, "paid") - partner.action_membership_sync() - partner.invalidate_recordset() - - # Should not crash, and should not add our real tag - self.assertNotIn(self.tag_member, partner.category_id) - - def test_09_cfg_id_returns_none_on_invalid_param(self): - """Cover: ResPartner._cfg_id() returns None when config is not numeric.""" - self.ICP.set_param("oca_membership_channel_sync.member_tag_id", "abc") - - partner = self.env["res.partner"].browse(self.partner.id) - self.assertIsNone(partner._cfg_id("member_tag_id")) diff --git a/oca_custom/views/mail_group.xml b/oca_custom/views/mail_group.xml deleted file mode 100644 index 8604d510..00000000 --- a/oca_custom/views/mail_group.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - OCA.mail.group.view.form - mail.group - - - - - - - - diff --git a/oca_custom/views/res_config_settings.xml b/oca_custom/views/res_config_settings.xml deleted file mode 100644 index 93529483..00000000 --- a/oca_custom/views/res_config_settings.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - res.config.settings.membership.tag.sync - res.config.settings - - - - - - - - - - - - diff --git a/oca_custom/views/res_partner.xml b/oca_custom/views/res_partner.xml deleted file mode 100644 index ec7ffcc9..00000000 --- a/oca_custom/views/res_partner.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - res.partner.github.visible.form - res.partner - - - - - 0 - - - - diff --git a/oca_membership/README.rst b/oca_membership/README.rst new file mode 100644 index 00000000..bb1bba6b --- /dev/null +++ b/oca_membership/README.rst @@ -0,0 +1,97 @@ +======================= +OCA Membership (custom) +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:40ae10af7740c71fe9b680e7e9e5f836bd55792b38878dfab0668fe9d01b6b0b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_membership + :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_membership + :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| + +This module adds several independant features. + +- **Target role [TO FINISH: logique de facturation]** New field *Target + role* on Members form: it defines the role the member will receive + when paying for its *next* membership. It should be updated by the + association' secretary when members roles change, like on election, + before the memberships are renewed. +- **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. + +.. 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: + +Usage +===== + + + +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 + +Contributors +------------ + +- Arnaud LAYEC (arnaud.layec@akretion.com) + +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_membership/__init__.py b/oca_membership/__init__.py new file mode 100644 index 00000000..91c5580f --- /dev/null +++ b/oca_membership/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/oca_membership/__manifest__.py b/oca_membership/__manifest__.py new file mode 100644 index 00000000..f7a91624 --- /dev/null +++ b/oca_membership/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Arnaud LAYEC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +{ + "name": "OCA Membership (custom)", + "version": "18.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/oca-custom", + "license": "AGPL-3", + "category": "Custom", + "depends": [ + "mail_group", # for Work Groups + "membership_extension", # for membership.category + "oca_vcp", + ], + "data": [ + "data/membership_category_data.xml", + "views/mail_group.xml", + "views/membership_category.xml", + "views/portal_templates.xml", + "views/res_partner.xml", + ], + "installable": True, + "application": False, + "development_status": "Alpha", +} diff --git a/oca_membership/controllers/__init__.py b/oca_membership/controllers/__init__.py new file mode 100644 index 00000000..8c3feb6f --- /dev/null +++ b/oca_membership/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/oca_membership/controllers/portal.py b/oca_membership/controllers/portal.py new file mode 100644 index 00000000..3a8f3495 --- /dev/null +++ b/oca_membership/controllers/portal.py @@ -0,0 +1,62 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 + +from odoo import _ +from odoo.http import request + +from odoo.addons.portal.controllers.portal import CustomerPortal + +AVATAR_MAX_SIZE = 2 * 1024 * 1024 # 2 Mo +AVATAR_ALLOWED_MIMETYPES = { + "image/jpeg", + "image/png", +} + + +class CustomerPortalPublish(CustomerPortal): + def _get_boolean_fields(self): + return [ + "is_published", + "is_published_email", + "is_published_phone", + "is_published_address", + "is_published_website", + "github_sync_avatar", + ] + + def details_form_validate(self, data, partner_creation=False): + error, error_message = super().details_form_validate(data, partner_creation) + + # Published fields + for field in self._get_boolean_fields(): + data[field] = field in data and data[field] in ("1", "on") + + # Avatar + avatar_update = False + image_file = request.httprequest.files.get("image_1920") + if image_file and image_file.filename: + mimetype = image_file.mimetype or "" + content = image_file.read() + if mimetype not in AVATAR_ALLOWED_MIMETYPES: + error["image_1920"] = _("Unauthorized format (JPG or PNG only).") + elif len(content) > AVATAR_MAX_SIZE: + error["image_1920"] = _( + "File is too big (%d Mo max).", AVATAR_MAX_SIZE / 1024 / 1024 + ) + elif content: + data["image_1920"] = base64.b64encode(content) + avatar_update = True + image_file.seek(0) + if not avatar_update: + data.pop("image_1920", None) + + return error, error_message + + def _get_optional_fields(self): + return ( + ["website", "github_main_login"] + + super()._get_optional_fields() + + self._get_boolean_fields() + ) diff --git a/oca_membership/data/membership_category_data.xml b/oca_membership/data/membership_category_data.xml new file mode 100644 index 00000000..e35ce267 --- /dev/null +++ b/oca_membership/data/membership_category_data.xml @@ -0,0 +1,33 @@ + + + + + 10 + + + Delegate + 20 + + + + Board member + 30 + + + diff --git a/oca_membership/models/__init__.py b/oca_membership/models/__init__.py new file mode 100644 index 00000000..aef4370f --- /dev/null +++ b/oca_membership/models/__init__.py @@ -0,0 +1,3 @@ +from . import membership_category +from . import mail_group +from . import res_partner diff --git a/oca_membership/models/mail_group.py b/oca_membership/models/mail_group.py new file mode 100644 index 00000000..275eeaa3 --- /dev/null +++ b/oca_membership/models/mail_group.py @@ -0,0 +1,14 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class MailGroup(models.Model): + _inherit = ["mail.group"] + + is_working_group = fields.Boolean( + string="Is a Working Group", + default=False, + 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 new file mode 100644 index 00000000..4faf477e --- /dev/null +++ b/oca_membership/models/membership_category.py @@ -0,0 +1,23 @@ +# Copyright 2026 AKRETION +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class MembershipCategory(models.Model): + _inherit = ["membership.membership_category"] + _description = "Membership role" + _order = "sequence" + + sequence = fields.Integer( + help="First category will the default one for new members.", + ) + implied_ids = fields.Many2many( + string="Implied roles", + comodel_name="membership.membership_category", + relation="membership_category_implied_rel", + column1="category_id", + column2="implied_category_id", + help="Implied roles by this one", + ) diff --git a/oca_membership/models/res_partner.py b/oca_membership/models/res_partner.py new file mode 100644 index 00000000..2069be8e --- /dev/null +++ b/oca_membership/models/res_partner.py @@ -0,0 +1,88 @@ +# Copyright 2026 AKRETION +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = ["res.partner"] + + is_member = fields.Boolean( + string="Member", + help="Is currently a member of the assocation", + compute="_compute_is_member", + store=True, + ) + is_integrator = fields.Boolean( + string="Integrator", + compute="_compute_is_integrator", + store=True, + ) + membership_category_id = fields.Many2one( + comodel_name="membership.membership_category", + string="Target role", + help="Role for next subscribed membership", + default=lambda self: self._default_membership_category_id(), + ) + membership_category_ids = fields.Many2many( + string="Active roles", + compute="_compute_membership_state", + ) + # Mail Groups: needed for for `oca_search_engine`, + # rest is in `oca_membership_groups` + mail_group_member_ids = fields.One2many( + comodel_name="mail.group.member", + inverse_name="partner_id", + ) + # website privacy + is_published = fields.Boolean( + tracking=True, + help="Whether this contact publicly appears on the website.\n" + "Automatically enabled for companies (sponsors and integrators).\n" + "To enable manually for individuals (members).", + ) + is_published_email = fields.Boolean(string="Publish email") + is_published_phone = fields.Boolean(string="Publish phone") + is_published_address = fields.Boolean(string="Publish address") + is_published_website = fields.Boolean(string="Publish website") + + def _default_membership_category_id(self): + return self.env["membership.membership_category"].search([], limit=1).id + + # ===== Compute =====# + @api.depends("membership_category_ids.implied_ids") + def _compute_membership_state(self): + """Add in `membership_category_ids` the current role and its implied roles + Example: a 'Delegate' is also a 'Member'""" + res = super()._compute_membership_state() + for partner in self: + partner.membership_category_ids |= ( + partner.membership_category_ids.implied_ids + ) + return res + + @api.depends("membership_state") + def _compute_is_member(self): + member_states = self._membership_member_states() + for partner in self: + partner.is_member = bool(partner.membership_state in member_states) + + @api.depends( + "child_ids", + "child_ids.is_member", + "child_ids.parent_id", + "child_ids.vcp_user_ids", + ) + def _compute_is_integrator(self): + """Integrators are companies having contributors or members""" + for partner in self: + partner.is_integrator = partner.is_company and any( + child._is_contributor() or child.is_member + for child in partner.child_ids + ) + + # ===== Logics =====# + def _is_contributor(self): + """Partner with any commit, pull request or message on Github""" + return bool(self.vcp_user_ids) diff --git a/oca_membership/pyproject.toml b/oca_membership/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/oca_membership/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/oca_membership/readme/CONFIGURATION.rst b/oca_membership/readme/CONFIGURATION.rst new file mode 100644 index 00000000..e69de29b diff --git a/oca_membership/readme/CONTRIBUTORS.md b/oca_membership/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..9f5c0692 --- /dev/null +++ b/oca_membership/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Arnaud LAYEC () diff --git a/oca_membership/readme/DESCRIPTION.md b/oca_membership/readme/DESCRIPTION.md new file mode 100644 index 00000000..edca250f --- /dev/null +++ b/oca_membership/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +This module adds several independant features. + +- **Target role \[TO FINISH: logique de facturation\]** New field + *Target role* on Members form: it defines the role the member will + receive when paying for its *next* membership. It should be updated by + the association' secretary when members roles change, like on + election, before the memberships are renewed. +- **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/readme/USAGE.md b/oca_membership/readme/USAGE.md new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/oca_membership/readme/USAGE.md @@ -0,0 +1 @@ + diff --git a/oca_membership/static/description/index.html b/oca_membership/static/description/index.html new file mode 100644 index 00000000..1f82d6ea --- /dev/null +++ b/oca_membership/static/description/index.html @@ -0,0 +1,445 @@ + + + + + +OCA Membership (custom) + + + +
+

OCA Membership (custom)

+ + +

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

+

This module adds several independant features.

+
    +
  • Target role [TO FINISH: logique de facturation] New field Target +role on Members form: it defines the role the member will receive +when paying for its next membership. It should be updated by the +association’ secretary when members roles change, like on election, +before the memberships are renewed.
  • +
  • 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.
  • +
+
+

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

+ +
+

Usage

+
+
+

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
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

This module is part of the OCA/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_membership/views/mail_group.xml b/oca_membership/views/mail_group.xml new file mode 100644 index 00000000..257489fe --- /dev/null +++ b/oca_membership/views/mail_group.xml @@ -0,0 +1,78 @@ + + + + + + mail.group.view.kanban.oca_membership + mail.group + + + + +
+ Working Group +
+
+
+
+ + + mail.group.view.list.oca_membership + mail.group + + + + + + + + + + + mail.group.view.form.oca_membership + mail.group + + + + + + + + + + + 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/membership_category.xml b/oca_membership/views/membership_category.xml new file mode 100644 index 00000000..b58d5102 --- /dev/null +++ b/oca_membership/views/membership_category.xml @@ -0,0 +1,23 @@ + + + + + membership.category.oca_membership + membership.membership_category + + + + + + + + + + + + + diff --git a/oca_membership/views/portal_templates.xml b/oca_membership/views/portal_templates.xml new file mode 100644 index 00000000..4ee2d9ae --- /dev/null +++ b/oca_membership/views/portal_templates.xml @@ -0,0 +1,167 @@ + + + + + + + + diff --git a/oca_membership/views/res_partner.xml b/oca_membership/views/res_partner.xml new file mode 100644 index 00000000..693f1388 --- /dev/null +++ b/oca_membership/views/res_partner.xml @@ -0,0 +1,80 @@ + + + + + + res.partner.select.oca_membership + res.partner + + + + + + + + + + + + + + res.partner.form.membership_extension.oca_membership + res.partner + + + + + + + + + + + + res.partner.form.oca_search_engine + res.partner + + + + + + + + + + + + + + + + + diff --git a/oca_search_engine/README.rst b/oca_search_engine/README.rst new file mode 100644 index 00000000..eff9fa27 --- /dev/null +++ b/oca_search_engine/README.rst @@ -0,0 +1,87 @@ +================= +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: + +Usage +===== + + + +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 + +Contributors +------------ + +- Sébastien BEAU (sebastien.beau@akretion.com) +- Arnaud LAYEC (arnaud.layec@akretion.com) + +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..7dcd5219 --- /dev/null +++ b/oca_search_engine/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import tools +from .hooks import post_init_hook diff --git a/oca_search_engine/__manifest__.py b/oca_search_engine/__manifest__.py new file mode 100644 index 00000000..68953ebc --- /dev/null +++ b/oca_search_engine/__manifest__.py @@ -0,0 +1,42 @@ +# 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", + # custom + "oca_sponsor", + "oca_membership", + "website_oca_integrator", + # following dependency are needed by uv to resolve the dep + # correctly as module are not merged + "vcp_management", + "vcp_git", + "base_url", + ], + "data": [ + "data/backend_data.xml", + "data/index_data.xml", + ], + "demo": [], + "post_init_hook": "post_init_hook", +} diff --git a/oca_search_engine/data/backend_data.xml b/oca_search_engine/data/backend_data.xml new file mode 100644 index 00000000..c59dab4c --- /dev/null +++ b/oca_search_engine/data/backend_data.xml @@ -0,0 +1,12 @@ + + + + OCA Typesense Backend + ocastore + typesense + typesense + 443 + https + 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..28c2ed0c --- /dev/null +++ b/oca_search_engine/data/index_data.xml @@ -0,0 +1,41 @@ + + + + + companies + + + companies_exports + + + + persons + + + persons_exports + + + + + + + modules + + + vcp_odoo_module_version_exports + + + + + categories + + + vcp_repository_category_exports + + diff --git a/oca_search_engine/hooks.py b/oca_search_engine/hooks.py new file mode 100644 index 00000000..84afc71d --- /dev/null +++ b/oca_search_engine/hooks.py @@ -0,0 +1,19 @@ +# Copyright 2026 AKRETION +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +_logger = logging.getLogger(__name__) + + +def post_init_hook(env): + _init_indexing(env) + + +def _init_indexing(env): + models = {"res.partner"} # "vcp.oca.psc" + for model in models: + records = env[model].search([]) + records._add_to_oca_search_engine() + _logger.info("Indexing of %s (%d records) done", model, len(records)) diff --git a/oca_search_engine/models/__init__.py b/oca_search_engine/models/__init__.py new file mode 100644 index 00000000..7afc4337 --- /dev/null +++ b/oca_search_engine/models/__init__.py @@ -0,0 +1,6 @@ +from . import blog_post +from . import res_partner +from . import se_index +from . import vcp_odoo_module_version +from . import vcp_odoo_module +from . import vcp_repository_category diff --git a/oca_search_engine/models/blog_post.py b/oca_search_engine/models/blog_post.py new file mode 100644 index 00000000..538523c7 --- /dev/null +++ b/oca_search_engine/models/blog_post.py @@ -0,0 +1,29 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class BlogPost(models.Model): + _inherit = ["blog.post"] + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records.author_id._se_mark_to_update() + return records + + def write(self, vals): + res = super().write(vals) + self.author_id._se_mark_to_update() + return res + + def _get_background_url(self): + """Strips the css and returns background's absolute URL""" + background_image = self._get_background() or "" + if not background_image or background_image == "none": + return None + elif background_image.startswith("url(/web/image/"): + return self.get_base_url() + background_image[4:-1] + else: + return background_image diff --git a/oca_search_engine/models/res_partner.py b/oca_search_engine/models/res_partner.py new file mode 100644 index 00000000..f98b0f40 --- /dev/null +++ b/oca_search_engine/models/res_partner.py @@ -0,0 +1,106 @@ +# 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 api, models + +INDEX_COMPANIES = "oca_search_engine.oca_typesense_index_companies" +INDEX_PERSONS = "oca_search_engine.oca_typesense_index_persons" + + +class ResPartner(models.Model): + _name = "res.partner" + _inherit = ["res.partner", "se.indexable.record", "abstract.url"] + + def _get_keyword_fields(self): + # The _get_keyword field is call in base_url in two case + # Called with a class in the @api.depends in order to flag + # the url as invalid "url_need_refresh" + # And on the record when recomputing the index + # In our case we want to use the sponsor_name as keyword for the url key + # if it's a company and the sponsor_name is filled + # if not we use the name + if not self: + # It's the case of the @api.depends, both field will invalid + # the "url_need_refresh" + return ["name", "sponsor_name"] + elif self.is_company and self.sponsor_name: + # For company if the sponsor name is fill we use it for the url key + return ["sponsor_name"] + else: + return ["name"] + + def _generate_url_key(self, referential, lang): + url_key = super()._generate_url_key(referential, lang) + # Add the right prefix for the url key dependendy if it's + # a company or a person + if self.is_company: + return f"integrators/{url_key}" + else: + return f"community/{url_key}" + + # ====== Search engine sync logics ======# + def _add_to_oca_search_engine(self, vals=None): + """Add, update or remove partners in 'Company' or 'Person' index""" + index_companies = self.env.ref(INDEX_COMPANIES) + index_persons = self.env.ref(INDEX_PERSONS) + + if vals and "is_published" in vals and not vals["is_published"]: + self._remove_from_index(index_companies | index_persons) + else: + self._autopublish_companies(vals) + + to_publish = self.filtered("is_published") + companies = to_publish.filtered("is_company") + persons = to_publish - companies + companies._add_to_index(index_companies) + persons._add_to_index(index_persons) + + def _autopublish_companies(self, vals): + """Auto-publish new integrators and new sponsors + (but not members: because of individual acceptance), + and remove partner manually set to "unpublished" + """ + # if called from write: prevent re-publishing a company already unpublished + if vals and not any( + x in vals and vals[x] for x in ["is_integrator", "grade_id"] + ): + return + + self.filtered( + lambda x: not x.is_published and (x.is_integrator or x.is_sponsor) + ).sudo().is_published = True + # 'sudo' to bypass AccessError of 'website.published.multi.mixin' + + # ====== CRUD ======# + @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): + res = super().write(vals) + self._add_to_oca_search_engine(vals) + self._se_mark_to_update() + return res + + # ===== Business logics =====# + def _get_working_groups(self): + return self.mail_group_member_ids.mail_group_id.filtered("is_working_group") + + def _get_company_members(self): + self.ensure_one() + if self.is_company: + return (self | self.sponsor_child_ids).child_ids.filtered("is_member") + else: + return self.browse() + + def _get_company_contributors(self): + self.ensure_one() + if self.is_company: + return (self | self.sponsor_child_ids).child_ids.filtered( + lambda s: s._is_contributor() + ) + else: + return self.browse() diff --git a/oca_search_engine/models/se_index.py b/oca_search_engine/models/se_index.py new file mode 100644 index 00000000..ff0dd1a8 --- /dev/null +++ b/oca_search_engine/models/se_index.py @@ -0,0 +1,60 @@ +# 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 import ( + CompanySerializer, + PersonSerializer, + # PscSerializer, + VcpOdooModuleVersionSerializer, + VcpRepositoryCategorySerializer, +) + + +class SeIndex(models.Model): + _inherit = "se.index" + + serializer_type = fields.Selection( + selection_add=[ + ("vcp_odoo_module_version_exports", "Odoo Modules"), + ("vcp_repository_category_exports", "Modules Category"), + ("companies_exports", "Companies (sponsors & integrators)"), + ("persons_exports", "Persons (members & contributors)"), + # ("pscs_exports", "PSCs (Project Steering Teams)"), + ], + ondelete={ + "vcp_odoo_module_version_exports": "cascade", + "vcp_repository_category_exports": "cascade", + "companies_exports": "cascade", + "persons_exports": "cascade", + # "pscs_exports": "cascade", + }, + ) + + @api.constrains("model_id", "serializer_type") + def _check_model(self): + mapped_models = { + "companies_exports": "res.partner", + "persons_exports": "res.partner", + # "pscs_exports": "vcp.oca.psc", + "vcp_odoo_module_version_exports": "vcp.odoo.module.version", + "vcp_repository_category_exports": "vcp.repository.category", + } + for se_index in self: + model = mapped_models.get(se_index.serializer_type) + if model and se_index.model_id != self.env["ir.model"]._get(model): + raise ValidationError(_("'Serializer Type' must match 'Model'")) + + def _get_serializer(self): + self.ensure_one() + mapped_serializer = { + "companies_exports": CompanySerializer(), + "persons_exports": PersonSerializer(), + # "pscs_exports": PscSerializer(), + "vcp_odoo_module_version_exports": VcpOdooModuleVersionSerializer(), + "vcp_repository_category_exports": VcpRepositoryCategorySerializer(), + } + return mapped_serializer.get(self.serializer_type) or super()._get_serializer() diff --git a/oca_search_engine/models/vcp_odoo_module.py b/oca_search_engine/models/vcp_odoo_module.py new file mode 100644 index 00000000..f81514ac --- /dev/null +++ b/oca_search_engine/models/vcp_odoo_module.py @@ -0,0 +1,15 @@ +# 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 models + + +class VcpOdooModule(models.Model): + _name = "vcp.odoo.module" + _inherit = ["vcp.odoo.module", "abstract.url"] + + def _generate_url_key(self, referential, lang): + url_key = super()._generate_url_key(referential, lang) + return f"modules/{url_key}" 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..ffd68d94 --- /dev/null +++ b/oca_search_engine/models/vcp_odoo_module_version.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 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/models/vcp_repository_category.py b/oca_search_engine/models/vcp_repository_category.py new file mode 100644 index 00000000..1a189dbe --- /dev/null +++ b/oca_search_engine/models/vcp_repository_category.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). + + +from odoo import api, models + + +class VcpRepositoryCategory(models.Model): + _inherit = ["vcp.repository.category", "abstract.url", "se.indexable.record"] + _name = "vcp.repository.category" + + def _add_to_oca_search_engine(self): + self._add_to_index( + self.env.ref("oca_search_engine.oca_typesense_index_category") + ) + + @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) + + def _generate_url_key(self, referential, lang): + url_key = super()._generate_url_key(referential, lang) + return f"categories/{url_key}" 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/CONFIGURATION.rst b/oca_search_engine/readme/CONFIGURATION.rst new file mode 100644 index 00000000..e69de29b diff --git a/oca_search_engine/readme/CONTRIBUTORS.md b/oca_search_engine/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..2aef2592 --- /dev/null +++ b/oca_search_engine/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Sébastien BEAU () +- Arnaud LAYEC () 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/readme/USAGE.md b/oca_search_engine/readme/USAGE.md new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/oca_search_engine/readme/USAGE.md @@ -0,0 +1 @@ + diff --git a/oca_search_engine/schemas/__init__.py b/oca_search_engine/schemas/__init__.py new file mode 100644 index 00000000..6f01b016 --- /dev/null +++ b/oca_search_engine/schemas/__init__.py @@ -0,0 +1,10 @@ +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 + +# from . import vcp_oca_psc +from . import res_partner_common +from . import res_partner_company +from . import res_partner_person diff --git a/oca_search_engine/schemas/res_partner_common.py b/oca_search_engine/schemas/res_partner_common.py new file mode 100644 index 00000000..d0c6c74f --- /dev/null +++ b/oca_search_engine/schemas/res_partner_common.py @@ -0,0 +1,44 @@ +# Copyright 2026 AKRETION +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from extendable_pydantic import StrictExtendableBaseModel + + +class Country(StrictExtendableBaseModel): + code: str + label: str + + @classmethod + def from_record(cls, record): + return cls.model_construct( + code=record.code, + label=record.name, + ) + + +class LogoUrls(StrictExtendableBaseModel): + alt: str + l: str # noqa: E741 + m: str + s: str + + @classmethod + def from_record(cls, record): + if cls._is_default_avatar(record): + return {} + else: + return cls.model_construct( + alt=record.name, + l=cls._get_full_url(record, size=1920), + m=cls._get_full_url(record, size=512), + s=cls._get_full_url(record, size=128), + ) + + @classmethod + def _is_default_avatar(cls, record): + return not record.image_1920 + + @classmethod + def _get_full_url(cls, record, size): + return f"{record.get_base_url()}/web/image/res.partner/{record.id}/image_{size}" diff --git a/oca_search_engine/schemas/res_partner_company.py b/oca_search_engine/schemas/res_partner_company.py new file mode 100644 index 00000000..2216fb30 --- /dev/null +++ b/oca_search_engine/schemas/res_partner_company.py @@ -0,0 +1,170 @@ +# Copyright 2026 AKRETION +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from extendable_pydantic import StrictExtendableBaseModel + +from odoo import exceptions + +from .res_partner_common import Country, LogoUrls + + +class SponsorshipLevel(StrictExtendableBaseModel): + id: int + rank: int + name: str + + @classmethod + def from_record(cls, record): + return cls.model_construct( + id=record.id, + name=record.name, + rank=record.sequence, + ) + + +class Industry(StrictExtendableBaseModel): + name: str + description: str + + @classmethod + def from_record(cls, record): + return cls.model_construct( + name=record.name, + description=record.description or "", + ) + + +class BlogPost(StrictExtendableBaseModel): + title: str + teaser: str + relative_url: str + cover_url: str + + @classmethod + def from_record(cls, record): + return cls.model_construct( + title=record.name, + teaser=record.teaser, + relative_url=record.website_url, + cover_url=record._get_background_url(), + ) + + +class Sponsorship(StrictExtendableBaseModel): + description_long: str + description_short: str + description_why_oca: str + level: SponsorshipLevel + industries: list[Industry] + stories: list[BlogPost] + + @classmethod + def from_record(cls, record): + return cls.model_construct( + description_long=record.website_long_description or "", + description_short=record.website_short_description or "", + description_why_oca=record.website_description_why_sponsoring or "", + level=SponsorshipLevel.from_record(record.grade_id), + industries=[ + Industry.from_record(industry) + for industry in record.sponsor_industry_ids + ], + stories=[ + BlogPost.from_record(blog_post) for blog_post in record.blog_post_ids + ], + ) + + +class Contact(StrictExtendableBaseModel): + name: str + street: str | None = None + street2: str | None = None + zip: str | None = None + city: str | None = None + state: str | None = None + country: Country + phone: str | None = None + email: str | None = None + + @classmethod + def from_record(cls, record): + return cls.model_construct( + name=record.name, + street=record.street or None, + street2=record.street2 or None, + zip=record.zip or None, + city=record.city or None, + state=record.state_id.name or None, + country=Country.from_record(record.country_id), + phone=record.phone or None, + email=record.email or None, + ) + + +class Company(StrictExtendableBaseModel): + id: int + name: str + email: str + phone: str + website: str + is_integrator: bool + countries: list[Country] + logo_urls: LogoUrls | dict + # github indicators + contributors_count: int + collaboration_index: int + members_count: int + modules_count: int + # technical website fields + url_key: str | None + redirect_url_key: list[str] + # sponsorship + sponsorship: Sponsorship | None + contacts: list[Contact] + + @classmethod + def from_record(cls, record): + if record.sponsor_to_review: + # This Exception is catched by `recompute_json` and set the bindings' + # `state` of the to-be-reviewed sponsors in error + raise exceptions.ValidationError( + record.env._( + "The information of this sponsor were updated and are pending a " + "review, thus this operation was blocked." + ) + ) + + # ensure url is up to date + record._update_url_key(lang=record.env.context.get("lang")) + members = record._get_company_members() + contributors = record._get_company_contributors() + return cls.model_construct( + id=record.id, + name=(record.sponsor_name or "").strip() or record.name.strip() or "", + email=record.email or "", + phone=record.phone or "", + website=record.website or None, + is_integrator=record.is_integrator, + countries=[ + Country.from_record(country) for country in record.sponsor_country_ids + ], + contacts=[ + Contact.from_record(contact) + for contact in record | record.sponsor_child_ids + ], + logo_urls=LogoUrls.from_record(record), + # github indicators + contributors_count=len(contributors), + collaboration_index=sum(members.mapped("oca_collaboration_index")), + members_count=len(members), + modules_count=record.modules_author_count, + # technical website fields + url_key=record.is_sponsor and record.url_key or None, + redirect_url_key=record.redirect_url_key, + # sponsorship + sponsorship=None + if not record.is_sponsor + else Sponsorship.from_record(record), + ) diff --git a/oca_search_engine/schemas/res_partner_person.py b/oca_search_engine/schemas/res_partner_person.py new file mode 100644 index 00000000..992adc69 --- /dev/null +++ b/oca_search_engine/schemas/res_partner_person.py @@ -0,0 +1,171 @@ +# Copyright 2026 AKRETION +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from extendable_pydantic import StrictExtendableBaseModel + +from odoo import _ + +from .res_partner_common import Country, LogoUrls + + +class Role(StrictExtendableBaseModel): + id: int + name: str + + @classmethod + def from_record(cls, record): + return cls.model_construct( + id=record["id"], + name=record["name"], + ) + + +class Team(StrictExtendableBaseModel): + """For Working Groups and PSC""" + + id: int + name: str + description: str + + @classmethod + def from_record(cls, record): + return cls.model_construct( + id=record.id, + name=record.name, + description=record.description or "", + ) + + +class ContactInfo(StrictExtendableBaseModel): + email: str + phone: str + website: str + city: str + address: str + + @classmethod + def from_record(cls, record): + return cls.model_construct( + email=record.is_published_email and record.email or "", + phone=record.is_published_phone and (record.phone or record.mobile) or "", + website=record.is_published_website and record.website or "", + city=( + f"{record.city} {record.state_id.code} {record.zip}" + if record.is_published_address + and any([record.city, record.state_id.code, record.zip]) + else "" + ), + address=( + f"{record.street}\n{record.street2}" + if record.is_published_address and (record.street or record.street2) + else "" + ), + ) + + +class ParentCompany(StrictExtendableBaseModel): + id: int + name: str + url_key: str + + @classmethod + def from_record(cls, record): + if not record.commercial_company_name: + return {} + else: + record.commercial_partner_id._update_url_key( + lang=record.env.context.get("lang") + ) + return cls.model_construct( + id=record.commercial_partner_id.id, + name=record.commercial_company_name.strip() or "", + url_key=record.commercial_partner_id.url_key, + ) + + +class PersonBase(StrictExtendableBaseModel): + """Intermediate 'Person' Class, used in PSC members""" + + id: int + name: str + company: ParentCompany | dict # allow {} + contact: ContactInfo + country: Country | dict + + # github + github_users: list[str] + logo_urls: LogoUrls | dict + + @classmethod + def from_record(cls, record): + # ensure url is up to date + record._update_url_key(lang=record.env.context.get("lang")) + return cls.model_construct(**cls._model_construct_dict(record)) + + @classmethod + def _model_construct_dict(cls, record): + """Dict to permit inheritance in `Person`""" + return { + "id": record.id, + "name": record.name, + "company": ParentCompany.from_record(record), + "contact": ContactInfo.from_record(record), + "country": ( + Country.from_record(record.country_id) if record.country_id else {} + ), + # github + "github_users": record.vcp_user_ids.mapped("external_id"), + "logo_urls": LogoUrls.from_record(record), + # technical website fields + "url_key": record.url_key, + } + + +class Person(PersonBase): + url_key: str + roles: list[Role] + # psc: int + # psc_list: list[Team] + work_group_list: list[Team] + + collaboration_index: int + modules_maintained: int + module_contribution_ids: list[int] + + @classmethod + def _model_construct_dict(cls, record): + # psc = record.vcp_user_ids.vcp_oca_psc_ids + return super()._model_construct_dict(record) | { + # github indicators + "translations": 0, + "collaboration_index": record.oca_collaboration_index, + "modules_maintained": record.modules_maintained_count, + # role + "roles": cls._get_roles(record), + # psc (obsolete) + # "psc": len(psc), + # "psc_list": psc.read(["name", "description"]), + "work_group_list": [ + Team.from_record(record) for record in record._get_working_groups() + ], + } + + @classmethod + def _get_roles(cls, record): + """Add fake role `Contributor` to display it on the website (only)""" + res = [ + Role.from_record(x) + for x in record.membership_category_ids.sorted("sequence", reverse=True) + ] + if record._is_contributor(): + res.append( + Role.from_record( + { + "id": -1, + "name": _("Contributor"), + } + ) + ) + return res diff --git a/oca_search_engine/schemas/vcp_oca_psc.py b/oca_search_engine/schemas/vcp_oca_psc.py new file mode 100644 index 00000000..1f906ca9 --- /dev/null +++ b/oca_search_engine/schemas/vcp_oca_psc.py @@ -0,0 +1,44 @@ +# Copyright 2026 AKRETION +# @author Arnaud LAYEC +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from extendable_pydantic import StrictExtendableBaseModel + +from .res_partner_person import PersonBase +from .vcp_odoo_module_version import VcpRepository + + +class Psc(StrictExtendableBaseModel): + id: int + name: str + description: str + repositories: list[VcpRepository] + members: list[PersonBase] + + @classmethod + def from_record(cls, record): + return cls.model_construct(**cls._model_construct_dict(record)) + + @classmethod + def _model_construct_dict(cls, record): + return { + "id": record.id, + "name": record.name, + "description": record.description, + "repositories": record.repository_ids.read(["name", "description"]), + "members": cls._flatten_list( + [ + PersonBase.from_record(partner) + for partner in record.user_ids.partner_id + if record.user_ids.partner_id + ] + ), + } + + @classmethod + def _flatten_list(cls, unflatten): + if any(not isinstance(x, list) for x in unflatten): + return unflatten + else: + return [y for x in unflatten for y in x] 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..54963f70 --- /dev/null +++ b/oca_search_engine/schemas/vcp_odoo_author.py @@ -0,0 +1,19 @@ +# 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 | None + + @classmethod + def from_record(cls, odoo_rec): + return cls.model_construct( + name=odoo_rec.name, + url_key=( + odoo_rec.partner_id.is_sponsor and odoo_rec.partner_id.url_key or None + ), + ) 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..8eb6fb6e --- /dev/null +++ b/oca_search_engine/schemas/vcp_odoo_maintainer.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 + + +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 or "", + url_key=( + odoo_rec.partner_id.website_published and odoo_rec.partner_id.url_key + ) + or "", + ) 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..d956e9c4 --- /dev/null +++ b/oca_search_engine/schemas/vcp_odoo_module_version.py @@ -0,0 +1,76 @@ +# 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_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 + techname: str + repo: VcpRepository + version: str + serie: str + dependencies: list + license: str + summary: str + development_status: str + authors: list[VcpOdooAuthor] + github_url: str + runboat_url: str + readme_fragments: dict + maintainers: list[VcpOdooMaintainer] + icon_url: str + must_have: bool + url_key: str + redirect_url_key: list[str] + + @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): + # Ensure url key is up to date + odoo_rec.module_id._update_url_key(lang=odoo_rec.env.context.get("lang")) + 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, + development_status=odoo_rec.development_status or "", + 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 or {}, + 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, + # Note all module version have the same url + # as version are just variants of the module + url_key=odoo_rec.module_id.url_key, + redirect_url_key=odoo_rec.module_id.redirect_url_key, + ) diff --git a/oca_search_engine/schemas/vcp_repository.py b/oca_search_engine/schemas/vcp_repository.py new file mode 100644 index 00000000..59d14173 --- /dev/null +++ b/oca_search_engine/schemas/vcp_repository.py @@ -0,0 +1,32 @@ +# 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 odoo.exceptions import UserError + +from .vcp_repository_category import VcpRepositoryCategoryLight + + +class VcpRepository(StrictExtendableBaseModel): + name: str + url: str + description: str + category: VcpRepositoryCategoryLight + + @classmethod + def from_record(cls, odoo_rec): + if not odoo_rec.category_id: + raise UserError( + odoo_rec.env._( + "The category on the repository is missing, please fill it" + ) + ) + return cls.model_construct( + name=odoo_rec.name, + description=odoo_rec.description, + url=odoo_rec._get_repository_url(), + category=VcpRepositoryCategoryLight.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..2b1d30d8 --- /dev/null +++ b/oca_search_engine/schemas/vcp_repository_category.py @@ -0,0 +1,33 @@ +# 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 VcpRepositoryCategoryLight(StrictExtendableBaseModel): + name: str + url_key: str + + @classmethod + def from_record(cls, record): + record._update_url_key(lang=record.env.context.get("lang")) + return cls.model_construct( + name=record.name, + url_key=record.url_key, + ) + + +class VcpRepositoryCategory(VcpRepositoryCategoryLight): + id: int + short_description: str | None + description: str | None + + @classmethod + def from_record(cls, record): + obj = super().from_record(record) + obj.id = record.id + obj.short_description = record.short_description or None + obj.description = record.description or None + return obj diff --git a/oca_search_engine/static/description/index.html b/oca_search_engine/static/description/index.html new file mode 100644 index 00000000..3488a3ae --- /dev/null +++ b/oca_search_engine/static/description/index.html @@ -0,0 +1,434 @@ + + + + + +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

+ +
+

Usage

+
+
+

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
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

This module is part of the OCA/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/tests/__init__.py b/oca_search_engine/tests/__init__.py new file mode 100644 index 00000000..19a5dbe0 --- /dev/null +++ b/oca_search_engine/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_oca_se_companies +from . import test_oca_se_persons +# from . import test_oca_se_psc diff --git a/oca_search_engine/tests/test_oca_se_companies.py b/oca_search_engine/tests/test_oca_se_companies.py new file mode 100644 index 00000000..f51e7084 --- /dev/null +++ b/oca_search_engine/tests/test_oca_se_companies.py @@ -0,0 +1,102 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Arnaud LAYEC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tools import mute_logger + +from odoo.addons.oca_sponsor.tests.test_oca_sponsor import TestOcaSponsorCommon + +from ..schemas.res_partner_company import Company + + +class TestOcaCompaniesSearchEngine(TestOcaSponsorCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + se_group_manager = cls.env.ref( + "connector_search_engine.group_connector_search_engine_manager" + ) + cls.manager.groups_id = [Command.link(se_group_manager.id)] + + def test_companies_json_output(self): + sponsor = self.env["res.partner"].create( + { + "name": "Full Sponsor", + "is_company": True, + "grade_id": self.grade.id, + "is_published": True, + "sponsor_to_review": False, + "website": "https://fullsponsor.com", + "email": "contact@fullsponsor.com", + "website_long_description": "We are a great sponsor.", + "website_description_why_sponsoring": "Because OCA rocks.", + "sponsor_industry_ids": [ + (6, 0, [self.industry_a.id, self.industry_b.id]) + ], + } + ) + data = Company.from_record(sponsor).model_dump(mode="json")["sponsorship"] + + # Test a few data + self.assertEqual(data["level"]["name"], self.grade.name) + industry_names = [i["name"] for i in data["industries"]] + self.assertIn("ERP", industry_names) + + def _in_index(self, partner, with_sync_active=False): + not_states = ["to_delete", "deleting"] + if with_sync_active: + not_states += ["recompute_error", "invalid_data"] + + bindings = partner._get_bindings() + return not bool(bindings.filtered(lambda x: x.state in not_states)) + + def test_becomes_integrator_autopublished(self): + """A sponsor or a partner becoming sponsor is auto-published""" + # create + partner = self.env["res.partner"].create( + { + "name": "Synced-as-light Sponsor", + "is_company": True, + "grade_id": self.grade.id, + } + ) + self.assertTrue(partner.is_published) + self.assertTrue(self._in_index(partner, with_sync_active=True)) + + # write + partner2 = self.env["res.partner"].create( + { + "name": "Future Sponsor", + "is_company": True, + } + ) + partner2.grade_id = self.grade + self.assertTrue(self._in_index(partner2, with_sync_active=True)) + + def test_partner_unpublished(self): + """Any published partner can be unpublished with `is_published`""" + partner = self.env["res.partner"].create( + { + "name": "Partner not to publish", + "is_company": True, + "grade_id": self.grade.id, + } + ) + self.assertTrue(self._in_index(partner, with_sync_active=True)) + partner.is_published = False # manually prevent publishing + self.assertFalse(self._in_index(partner)) + + @mute_logger("odoo.addons.connector_search_engine.models.se_binding") + def test_sponsor_to_review_not_in_index(self): + """Sponsor with pending review *is* in index, but its synchro is paused""" + self.assertTrue(self._in_index(self.sponsor, with_sync_active=True)) + self.sponsor.with_user( + self.portal_user + ).sudo().website_long_description = "Updated from portal" + self.assertTrue(self.sponsor.sponsor_to_review) + + self.sponsor._get_bindings().recompute_json() # logs an Exception + # transitory state + self.assertTrue(self._in_index(self.sponsor, with_sync_active=False)) + self.assertFalse(self._in_index(self.sponsor, with_sync_active=True)) diff --git a/oca_search_engine/tests/test_oca_se_persons.py b/oca_search_engine/tests/test_oca_se_persons.py new file mode 100644 index 00000000..12d758ed --- /dev/null +++ b/oca_search_engine/tests/test_oca_se_persons.py @@ -0,0 +1,30 @@ +# 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.common import TransactionCase + +from ..schemas.res_partner_person import Person + + +class TestOcaPersonsSearchEngine(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, + "is_published": True, + } + ] + ) + + def test_persons_json_output(self): + """Test output generation methods: very simple tests, + just to ensure the code does not throw errors""" + data = Person.from_record(self.member).model_dump(mode="json") + self.assertEqual(data["country"]["code"], "FR") diff --git a/oca_search_engine/tests/test_oca_se_psc.py b/oca_search_engine/tests/test_oca_se_psc.py new file mode 100644 index 00000000..65c58be7 --- /dev/null +++ b/oca_search_engine/tests/test_oca_se_psc.py @@ -0,0 +1,33 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# @author Arnaud LAYEC +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +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 + + +class TestOcaPscsSearchEngine(TestOcaPscsSearchEngine): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_psc_json_output(self): + """Test simple output for `Psc` index""" + # Test PSC output data sent to search engine + psc = self._process_rule_oca_psc_update() + data = Psc.from_record(psc).model_dump(mode="json") + self.assertEqual(data["description"], "Human name of the test OCA PSC") + + def test_person_output_psc(self): + """Test `res.partner` membership in PSC team""" + self._process_rule_oca_psc_update() + user = self.env["vcp.user"].search([("name", "=", "user-github-login")]) + pscs = self.env["vcp.oca.psc"].search([]) + + # 2. Test 'Person' output + self.member.vcp_user_ids = user + data = Person.from_record(self.member).model_dump(mode="json") + self.assertEqual(data["psc_list"], pscs.read(["name", "description"])) diff --git a/oca_search_engine/tools/__init__.py b/oca_search_engine/tools/__init__.py new file mode 100644 index 00000000..9fe85cc0 --- /dev/null +++ b/oca_search_engine/tools/__init__.py @@ -0,0 +1,4 @@ +from .vcp_odoo_module_version_serializer import VcpOdooModuleVersionSerializer +from .vcp_repository_category_serializer import VcpRepositoryCategorySerializer +from .res_partner_serializer import CompanySerializer, PersonSerializer +# from .vcp_psc_team_serializer import PscSerializer diff --git a/oca_search_engine/tools/res_partner_serializer.py b/oca_search_engine/tools/res_partner_serializer.py new file mode 100644 index 00000000..b8ad9154 --- /dev/null +++ b/oca_search_engine/tools/res_partner_serializer.py @@ -0,0 +1,26 @@ +# Copyright 2026 AKRETION +# @author Arnaud LAYEC +# # 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.res_partner_company import Company +from ..schemas.res_partner_person import Person + + +class CompanySerializer(PydanticModelSerializer): + def get_model_class(self): + return Company + + def serialize(self, record): + return self.get_model_class().from_record(record).model_dump(mode="json") + + +class PersonSerializer(PydanticModelSerializer): + def get_model_class(self): + return Person + + def serialize(self, record): + return self.get_model_class().from_record(record).model_dump(mode="json") 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/oca_search_engine/tools/vcp_psc_team_serializer.py b/oca_search_engine/tools/vcp_psc_team_serializer.py new file mode 100644 index 00000000..6a6093fb --- /dev/null +++ b/oca_search_engine/tools/vcp_psc_team_serializer.py @@ -0,0 +1,17 @@ +# Copyright 2026 AKRETION +# @author Arnaud LAYEC +# # 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_oca_psc import Psc + + +class PscSerializer(PydanticModelSerializer): + def get_model_class(self): + return Psc + + def serialize(self, record): + return self.get_model_class().from_record(record).model_dump(mode="json") diff --git a/oca_search_engine/tools/vcp_repository_category_serializer.py b/oca_search_engine/tools/vcp_repository_category_serializer.py new file mode 100644 index 00000000..2dc0bca8 --- /dev/null +++ b/oca_search_engine/tools/vcp_repository_category_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_repository_category import VcpRepositoryCategory + + +class VcpRepositoryCategorySerializer(PydanticModelSerializer): + def get_model_class(self): + return VcpRepositoryCategory + + def serialize(self, record): + return self.get_model_class().from_record(record).model_dump(mode="json") diff --git a/oca_sponsor/README.rst b/oca_sponsor/README.rst new file mode 100644 index 00000000..02b5b743 --- /dev/null +++ b/oca_sponsor/README.rst @@ -0,0 +1,116 @@ +============ +OCA Sponsors +============ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:568774ce6d7f5642d829bc6714eedbd7f3fa10d0e72b1455ebb6df8497cb8574 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_sponsor + :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_sponsor + :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| + +This module adds website-publishable information to res.partner for +website sponsorship pages, and implements a self-service update and a +review process of new information. + +.. 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: + +Usage +===== + +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, **using at login the account related to this + company**. +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 (member + of the Activity Group named *Sponsors Reviewers*). +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, and a ribbon + *To review* appear on the Kanban cards. +6. On the sponsor's partner form, review the data change. Use the action + *Sponsor Version History* to easily view the changes history and + differences. +7. Once done, press the *Review sponsor* button in the *Sponsorship* + tab. It re-starts the sponsor information synchronization with the + website, thus pushing the update online very soon. This button also + un-notify the other reviewers but removing the review activity. + +Changelog +========= + +# 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 + +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 + +Contributors +------------ + +- Arnaud LAYEC (arnaud.layec@akretion.com) + +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_sponsor/__init__.py b/oca_sponsor/__init__.py new file mode 100644 index 00000000..91c5580f --- /dev/null +++ b/oca_sponsor/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/oca_sponsor/__manifest__.py b/oca_sponsor/__manifest__.py new file mode 100644 index 00000000..f528de35 --- /dev/null +++ b/oca_sponsor/__manifest__.py @@ -0,0 +1,39 @@ +# 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", + "version": "18.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/oca-custom", + "license": "AGPL-3", + "category": "Custom", + "depends": [ + "html_editor", + "mail_activity_team", + "membership_extension", + "website_blog", + # Custom + "oca_membership", + ], + "data": [ + "data/mail_activity_team.xml", + "security/ir.model.access.csv", + "views/blog_post.xml", + "views/mail_activity.xml", + "views/portal_templates.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/controllers/__init__.py b/oca_sponsor/controllers/__init__.py new file mode 100644 index 00000000..8c3feb6f --- /dev/null +++ b/oca_sponsor/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/oca_sponsor/controllers/portal.py b/oca_sponsor/controllers/portal.py new file mode 100644 index 00000000..8c79db1b --- /dev/null +++ b/oca_sponsor/controllers/portal.py @@ -0,0 +1,38 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.http import request + +from odoo.addons.portal.controllers.portal import CustomerPortal + +from ..models.res_partner import SPONSOR_WEBSITE_FIELDS + + +class CustomerPortalSponsor(CustomerPortal): + def _prepare_portal_layout_values(self): + return super()._prepare_portal_layout_values() | { + "industries": request.env["res.partner.industry"].sudo().search([]) + } + + def details_form_validate(self, data, partner_creation=False): + # many2many fields + for field in ["sponsor_country_ids", "sponsor_industry_ids"]: + if data.get(field): + response = request.httprequest.form.getlist(field) + data[field] = [Command.set([int(id) for id in response])] + + return super().details_form_validate(data) + + def _get_optional_fields(self): + optional = super()._get_optional_fields() + mandatory = super()._get_mandatory_fields() + return ( + optional + + ["sponsor_country_ids", "sponsor_industry_ids"] + + [ + f + for f in SPONSOR_WEBSITE_FIELDS + if f not in optional and f not in mandatory + ] + ) diff --git a/oca_sponsor/data/mail_activity_team.xml b/oca_sponsor/data/mail_activity_team.xml new file mode 100644 index 00000000..b0b3bf47 --- /dev/null +++ b/oca_sponsor/data/mail_activity_team.xml @@ -0,0 +1,9 @@ + + + + + Sponsors Reviewers + + + diff --git a/oca_sponsor/models/__init__.py b/oca_sponsor/models/__init__.py new file mode 100644 index 00000000..7e8be900 --- /dev/null +++ b/oca_sponsor/models/__init__.py @@ -0,0 +1,5 @@ +from . import res_users +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..dc716f48 --- /dev/null +++ b/oca_sponsor/models/blog_post.py @@ -0,0 +1,23 @@ +# 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 api, models + + +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/res_partner.py b/oca_sponsor/models/res_partner.py new file mode 100644 index 00000000..2321cfcc --- /dev/null +++ b/oca_sponsor/models/res_partner.py @@ -0,0 +1,274 @@ +# Copyright 2026 AKRETION +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, _, api, exceptions, fields, models +from odoo.osv.expression import NOT_OPERATOR +from odoo.tools.safe_eval import safe_eval + +SPONSOR_WEBSITE_FIELDS = [ + # editable fields by the sponsor from the portal + "name", + "sponsor_name", + "email", + "phone", + "website", + "website_description_why_sponsoring", + "website_short_description", + "website_long_description", + "image_1920", +] + + +class ResPartner(models.Model): + _name = "res.partner" + _inherit = ["res.partner", "html.field.history.mixin"] + _html_field_history_size_limit = 20 + + grade_id = fields.Many2one( + comodel_name="res.partner.grade", + string="Sponsor Level", + ) + is_sponsor = fields.Boolean( + 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", + compute="_compute_sponsor_to_review", + store=True, + 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.", + tracking=True, + ) + sponsor_review_data = fields.Html( + # For history wizzard + compute="_compute_sponsor_review_data", + sanitize=True, + ) + sponsorship_line_ids = fields.One2many( + string="Sponsorship history", + comodel_name="sponsorship.line", + inverse_name="partner_id", + ) + # Website fields + sponsor_name = fields.Char( + string="Alternate name", help="If empty, the company name is displayed instead." + ) + sponsor_child_ids = fields.One2many( + comodel_name="res.partner", + inverse_name="sponsor_parent_id", + string="Sponsored companies", + domain=[("is_company", "=", True), ("is_sponsor", "=", False)], + help="Choose company who are included in the sponsorship, like branch, " + "subsidiaries or commercial partners.", + ) + sponsor_parent_id = fields.Many2one( + comodel_name="res.partner", + string="Sponsoring company", + ondelete="set null", + domain=[("is_sponsor", "=", True)], + ) + 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, + ) + 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, + 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", + 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): + self._compute_sponsor_replace_in("country_id", "sponsor_country_ids") + + @api.depends("industry_id", "grade_id") + def _compute_sponsor_industry_ids(self): + 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 new not 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) + + @api.depends_context("uid") + def _compute_is_sponsor_reviewer(self): + """Field needed in the view""" + self.is_sponsor_reviewer = self.env.user._is_sponsor_reviewer() + + @api.depends("html_field_history") + def _compute_sponsor_to_review(self): + """When `html.field.history.mixin` writes a new revision in `html_field_history` + this means fields have changed, and thus require a review""" + self._set_sponsor_to_review() + + @api.depends(*SPONSOR_WEBSITE_FIELDS) + def _compute_sponsor_review_data(self): + for partner in self: + partner.sponsor_review_data = partner._get_sponsor_review_data() + + def _get_sponsor_review_data(self): + return "\n\n".join( + [ + '

{name}

\n' "{content}".format( + name=self._fields[field].string, + content=self[field] or "", + ) + for field in SPONSOR_WEBSITE_FIELDS + ] + ) + + # ====== ORM ======# + @api.model_create_multi + def create(self, vals_list): + """The ORM recomputes stored field right after create + => Prevent it for `sponsor_to_review` to stick to default value (False)""" + records = super().create(vals_list) + self.env.remove_to_compute(self._fields["sponsor_to_review"], records) + return records + + def write(self, vals): + """Hack to trigger logics of `html.field.history.mixin` + without storing `sponsor_review_data`""" + if not fields.first(self).is_sponsor_reviewer and set(vals).intersection( + SPONSOR_WEBSITE_FIELDS + ): + return all( + super(ResPartner, partner).write( + vals | {"sponsor_review_data": partner.sponsor_review_data} + ) + for partner in self + ) + return super().write(vals) + + 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 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) + + # ===== 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._is_sponsor_reviewer(): + raise exceptions.AccessError(_("You are not a Sponsor Reviewer.")) + self._sponsor_review_accept() + + # ===== Business logics =====# + def _get_versioned_fields(self): + """For `html.field.history.mixin`""" + return ["sponsor_review_data"] + + 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.env.user._is_sponsor_reviewer(): + return + + 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.env["res.users"]._get_sponsor_reviewer_team() + if not notify: + self.activity_ids.filtered( + lambda x: x.team_id == reviewer_team + ).sudo().unlink() + else: + self.sudo().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." + ), + act_type_xmlid="mail.mail_activity_data_warning", + ) + + def _sponsor_review_accept(self): + self._sponsor_reviewers_notify(notify=False) + self.sudo().write( + { # 'sudo' to bypass AccessError of 'website.published.multi.mixin' + "is_published": True, + "sponsor_to_review": False, + } + ) diff --git a/oca_sponsor/models/res_partner_grade.py b/oca_sponsor/models/res_partner_grade.py new file mode 100644 index 00000000..9cba4f59 --- /dev/null +++ b/oca_sponsor/models/res_partner_grade.py @@ -0,0 +1,33 @@ +# 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 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*: we could 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() + active = fields.Boolean(default=True) + name = fields.Char(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_partner_industry.py b/oca_sponsor/models/res_partner_industry.py new file mode 100644 index 00000000..4ba3bbb5 --- /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( + help="The description is shared between all sponsors using this industry.", + ) diff --git a/oca_sponsor/models/res_users.py b/oca_sponsor/models/res_users.py new file mode 100644 index 00000000..320f9e9a --- /dev/null +++ b/oca_sponsor/models/res_users.py @@ -0,0 +1,50 @@ +# 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"] + + def _is_sponsor_reviewer(self): + return self in self._get_sponsor_reviewer_team().member_ids + + @api.model + def _get_sponsor_reviewer_team(self): + team_id = self.env["ir.model.data"]._xmlid_to_res_id( + "oca_sponsor.mail_activity_team_sponsor_reviewers", + raise_if_not_found=False, + ) + return self.env["mail.activity.team"].browse(team_id) + + @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 model not 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/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/pyproject.toml b/oca_sponsor/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/oca_sponsor/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" 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.md b/oca_sponsor/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..9f5c0692 --- /dev/null +++ b/oca_sponsor/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Arnaud LAYEC () diff --git a/oca_sponsor/readme/DESCRIPTION.md b/oca_sponsor/readme/DESCRIPTION.md new file mode 100644 index 00000000..43035d07 --- /dev/null +++ b/oca_sponsor/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module adds website-publishable information to res.partner for +website sponsorship pages, and implements a self-service update and a +review process of new information. diff --git a/oca_sponsor/readme/HISTORY.md b/oca_sponsor/readme/HISTORY.md new file mode 100644 index 00000000..623c0086 --- /dev/null +++ b/oca_sponsor/readme/HISTORY.md @@ -0,0 +1,5 @@ +\# 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/USAGE.md b/oca_sponsor/readme/USAGE.md new file mode 100644 index 00000000..8ddc6507 --- /dev/null +++ b/oca_sponsor/readme/USAGE.md @@ -0,0 +1,20 @@ +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, **using at login the account related to + this company**. +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 (member + of the Activity Group named *Sponsors Reviewers*). +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, and a + ribbon *To review* appear on the Kanban cards. +6. On the sponsor's partner form, review the data change. Use the + action *Sponsor Version History* to easily view the changes history + and differences. +7. Once done, press the *Review sponsor* button in the *Sponsorship* + tab. It re-starts the sponsor information synchronization with the + website, thus pushing the update online very soon. This button also + un-notify the other reviewers but removing the review activity. 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/static/description/index.html b/oca_sponsor/static/description/index.html new file mode 100644 index 00000000..2059ed85 --- /dev/null +++ b/oca_sponsor/static/description/index.html @@ -0,0 +1,467 @@ + + + + + +OCA Sponsors + + + +
+

OCA Sponsors

+ + +

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

+

This module adds website-publishable information to res.partner for +website sponsorship pages, and implements a self-service update and a +review process of new information.

+
+

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

+ +
+

Usage

+
    +
  1. On a standard partner, open the new Sponsorship tab
  2. +
  3. Create or choose a Sponsor Level
  4. +
  5. On the website portal, the sponsor may login and edit sponsorship +fields from its profile, using at login the account related to this +company.
  6. +
  7. 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 (member +of the Activity Group named Sponsors Reviewers).
  8. +
  9. 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, and a ribbon +To review appear on the Kanban cards.
  10. +
  11. On the sponsor’s partner form, review the data change. Use the action +Sponsor Version History to easily view the changes history and +differences.
  12. +
  13. Once done, press the Review sponsor button in the Sponsorship +tab. It re-starts the sponsor information synchronization with the +website, thus pushing the update online very soon. This button also +un-notify the other reviewers but removing the review activity.
  14. +
+
+
+

Changelog

+

# 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
  • +
+
+
+

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
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

This module is part of the OCA/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_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..47d3559f --- /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/components/history_dialog/history_dialog.xml b/oca_sponsor/static/src/components/history_dialog/history_dialog.xml new file mode 100644 index 00000000..2dd0b52d --- /dev/null +++ b/oca_sponsor/static/src/components/history_dialog/history_dialog.xml @@ -0,0 +1,10 @@ + + + +