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