From 29998277ab8fa7e4507852a3f429a81392c4c02e Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sun, 4 Jan 2026 16:06:55 +0000 Subject: [PATCH 1/2] [4579][ADD] product_odoo_module --- product_odoo_module/README.md | 29 ++++ product_odoo_module/__init__.py | 1 + product_odoo_module/__manifest__.py | 34 ++++ .../data/module_business_impact_data.xml | 18 ++ .../data/module_complexity_data.xml | 18 ++ .../data/module_license_data.xml | 22 +++ .../data/module_popularity_data.xml | 28 +++ .../data/module_source_type_data.xml | 23 +++ .../data/product_attribute_data.xml | 48 ++++++ product_odoo_module/models/__init__.py | 11 ++ .../models/customer_module_info.py | 160 ++++++++++++++++++ .../models/module_business_impact.py | 15 ++ .../models/module_complexity.py | 15 ++ product_odoo_module/models/module_license.py | 15 ++ .../models/module_popularity.py | 15 ++ .../models/module_source_type.py | 15 ++ .../models/product_attribute.py | 12 ++ product_odoo_module/models/product_product.py | 83 +++++++++ .../models/product_template.py | 52 ++++++ product_odoo_module/models/res_company.py | 19 +++ product_odoo_module/models/res_partner.py | 34 ++++ .../security/ir.model.access.csv | 19 +++ product_odoo_module/security/security.xml | 18 ++ product_odoo_module/tests/__init__.py | 1 + .../tests/test_customer_module.py | 48 ++++++ .../views/customer_module_info_views.xml | 131 ++++++++++++++ .../views/module_business_impact_views.xml | 56 ++++++ .../views/module_complexity_views.xml | 41 +++++ .../views/module_license_views.xml | 56 ++++++ .../views/module_popularity_views.xml | 41 +++++ .../views/module_source_type_views.xml | 57 +++++++ .../views/product_attribute_views.xml | 15 ++ .../views/product_odoo_module_menuitems.xml | 43 +++++ .../views/product_product_views.xml | 21 +++ .../views/product_template_views.xml | 45 +++++ .../views/res_company_views.xml | 26 +++ .../views/res_partner_views.xml | 24 +++ .../odoo/addons/product_odoo_module | 1 + setup/product_odoo_module/setup.py | 6 + 39 files changed, 1316 insertions(+) create mode 100644 product_odoo_module/README.md create mode 100644 product_odoo_module/__init__.py create mode 100644 product_odoo_module/__manifest__.py create mode 100644 product_odoo_module/data/module_business_impact_data.xml create mode 100644 product_odoo_module/data/module_complexity_data.xml create mode 100644 product_odoo_module/data/module_license_data.xml create mode 100644 product_odoo_module/data/module_popularity_data.xml create mode 100644 product_odoo_module/data/module_source_type_data.xml create mode 100644 product_odoo_module/data/product_attribute_data.xml create mode 100644 product_odoo_module/models/__init__.py create mode 100644 product_odoo_module/models/customer_module_info.py create mode 100644 product_odoo_module/models/module_business_impact.py create mode 100644 product_odoo_module/models/module_complexity.py create mode 100644 product_odoo_module/models/module_license.py create mode 100644 product_odoo_module/models/module_popularity.py create mode 100644 product_odoo_module/models/module_source_type.py create mode 100644 product_odoo_module/models/product_attribute.py create mode 100644 product_odoo_module/models/product_product.py create mode 100644 product_odoo_module/models/product_template.py create mode 100644 product_odoo_module/models/res_company.py create mode 100644 product_odoo_module/models/res_partner.py create mode 100644 product_odoo_module/security/ir.model.access.csv create mode 100644 product_odoo_module/security/security.xml create mode 100644 product_odoo_module/tests/__init__.py create mode 100644 product_odoo_module/tests/test_customer_module.py create mode 100644 product_odoo_module/views/customer_module_info_views.xml create mode 100644 product_odoo_module/views/module_business_impact_views.xml create mode 100644 product_odoo_module/views/module_complexity_views.xml create mode 100644 product_odoo_module/views/module_license_views.xml create mode 100644 product_odoo_module/views/module_popularity_views.xml create mode 100644 product_odoo_module/views/module_source_type_views.xml create mode 100644 product_odoo_module/views/product_attribute_views.xml create mode 100644 product_odoo_module/views/product_odoo_module_menuitems.xml create mode 100644 product_odoo_module/views/product_product_views.xml create mode 100644 product_odoo_module/views/product_template_views.xml create mode 100644 product_odoo_module/views/res_company_views.xml create mode 100644 product_odoo_module/views/res_partner_views.xml create mode 120000 setup/product_odoo_module/odoo/addons/product_odoo_module create mode 100644 setup/product_odoo_module/setup.py diff --git a/product_odoo_module/README.md b/product_odoo_module/README.md new file mode 100644 index 0000000..7779ace --- /dev/null +++ b/product_odoo_module/README.md @@ -0,0 +1,29 @@ +# Product Odoo Module + +This addon tracks Odoo modules you maintain as products and registers which customers +subscribe to each module variant with a monthly maintenance fee. + +## Usage + +1. Create a module product template: + + - Go to Sales > Products > Odoo Modules. + - Create a product and enable **Is Odoo Module**. + - Fill in the technical name and repository URL as needed. + - Tip: module products are typically **Service** products. + +2. Create variants by Odoo Series (when needed): + + - On the product template, add an attribute line for **Odoo Series**. + - Select the required values (14, 15, 16, 17) to generate variants. + - Variants are created manually by adding the attribute line; the addon does not + auto-assign it. + +3. Register customer subscriptions: + - Go to Sales > Products > Customer Modules. + - Create a line with Customer, Module variant, monthly fee, and date range. + +Smart buttons: + +- **Modules** on a customer shows active module lines for that customer. +- **Customers** on a module template shows active customer lines for its variants. diff --git a/product_odoo_module/__init__.py b/product_odoo_module/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/product_odoo_module/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_odoo_module/__manifest__.py b/product_odoo_module/__manifest__.py new file mode 100644 index 0000000..c15e27b --- /dev/null +++ b/product_odoo_module/__manifest__.py @@ -0,0 +1,34 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Product Odoo Module", + "category": "Productivity", + "license": "AGPL-3", + "author": "Quartile", + "website": "https://www.quartile.co", + "version": "16.0.1.0.0", + "depends": ["product"], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "data/module_business_impact_data.xml", + "data/module_complexity_data.xml", + "data/module_license_data.xml", + "data/module_popularity_data.xml", + "data/module_source_type_data.xml", + "data/product_attribute_data.xml", + "views/customer_module_info_views.xml", + "views/module_business_impact_views.xml", + "views/module_complexity_views.xml", + "views/module_license_views.xml", + "views/module_popularity_views.xml", + "views/module_source_type_views.xml", + "views/product_attribute_views.xml", + "views/product_odoo_module_menuitems.xml", + "views/product_product_views.xml", + "views/product_template_views.xml", + "views/res_company_views.xml", + "views/res_partner_views.xml", + ], + "installable": True, +} diff --git a/product_odoo_module/data/module_business_impact_data.xml b/product_odoo_module/data/module_business_impact_data.xml new file mode 100644 index 0000000..3e39602 --- /dev/null +++ b/product_odoo_module/data/module_business_impact_data.xml @@ -0,0 +1,18 @@ + + + + Tier-A + Mission Critical + 10 + + + Tier-B + Major + 20 + + + Tier-C + Minor + 30 + + diff --git a/product_odoo_module/data/module_complexity_data.xml b/product_odoo_module/data/module_complexity_data.xml new file mode 100644 index 0000000..d3e22f2 --- /dev/null +++ b/product_odoo_module/data/module_complexity_data.xml @@ -0,0 +1,18 @@ + + + + High + 1 + 1.2 + + + Medium + 2 + 1.0 + + + Low + 3 + 0.8 + + diff --git a/product_odoo_module/data/module_license_data.xml b/product_odoo_module/data/module_license_data.xml new file mode 100644 index 0000000..0cc3191 --- /dev/null +++ b/product_odoo_module/data/module_license_data.xml @@ -0,0 +1,22 @@ + + + + AGPL-3 + + 10 + + + LGPL-3 + + 20 + + + GPL-3 + + 30 + + + Other Proprietary + 40 + + diff --git a/product_odoo_module/data/module_popularity_data.xml b/product_odoo_module/data/module_popularity_data.xml new file mode 100644 index 0000000..379efd0 --- /dev/null +++ b/product_odoo_module/data/module_popularity_data.xml @@ -0,0 +1,28 @@ + + + + Very High + 1 + 0.2 + + + High + 2 + 0.4 + + + Medium + 3 + 0.6 + + + Low + 4 + 0.8 + + + Very Low + 5 + 1.0 + + diff --git a/product_odoo_module/data/module_source_type_data.xml b/product_odoo_module/data/module_source_type_data.xml new file mode 100644 index 0000000..fdf6622 --- /dev/null +++ b/product_odoo_module/data/module_source_type_data.xml @@ -0,0 +1,23 @@ + + + + Odoo Standard + odoo_standard + 10 + + + OCA + oca + 20 + + + Our Team + our_team + 30 + + + User In-house + user_in_house + 40 + + diff --git a/product_odoo_module/data/product_attribute_data.xml b/product_odoo_module/data/product_attribute_data.xml new file mode 100644 index 0000000..7bf8735 --- /dev/null +++ b/product_odoo_module/data/product_attribute_data.xml @@ -0,0 +1,48 @@ + + + + Series + always + + + + 10.0 + + + + 11.0 + + + + 12.0 + + + + 13.0 + + + + 14.0 + + + + 15.0 + + + + 16.0 + + + + 17.0 + + + + 18.0 + + + + 19.0 + + + diff --git a/product_odoo_module/models/__init__.py b/product_odoo_module/models/__init__.py new file mode 100644 index 0000000..075b662 --- /dev/null +++ b/product_odoo_module/models/__init__.py @@ -0,0 +1,11 @@ +from . import customer_module_info +from . import module_business_impact +from . import module_complexity +from . import module_license +from . import module_popularity +from . import module_source_type +from . import product_attribute +from . import product_product +from . import product_template +from . import res_company +from . import res_partner diff --git a/product_odoo_module/models/customer_module_info.py b/product_odoo_module/models/customer_module_info.py new file mode 100644 index 0000000..97516fb --- /dev/null +++ b/product_odoo_module/models/customer_module_info.py @@ -0,0 +1,160 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class CustomerModuleInfo(models.Model): + _name = "customer.module.info" + _description = "Customer Module Information" + _order = "partner_id, product_id, date_start desc" + + name = fields.Char(compute="_compute_name") + partner_id = fields.Many2one( + "res.partner", + required=True, + ondelete="cascade", + ) + product_id = fields.Many2one( + "product.product", + domain=[("is_odoo_module", "=", True)], + required=True, + ondelete="restrict", + ) + product_tmpl_id = fields.Many2one( + related="product_id.product_tmpl_id", + store=True, + ) + source_type_id = fields.Many2one( + related="product_id.source_type_id", + store=True, + ) + module_license_id = fields.Many2one( + related="product_id.module_license_id", + store=True, + ) + module_complexity_id = fields.Many2one( + related="product_id.module_complexity_id", + store=True, + ) + is_open_source = fields.Boolean( + related="module_license_id.is_open_source", + store=True, + ) + customer_count = fields.Integer( + related="product_tmpl_id.customer_module_count", + string="Total Customers", + ) + fee_factor = fields.Float( + default=1.0, + help="Multiplier applied to the product's suggested fee.", + ) + suggested_fee = fields.Monetary( + compute="_compute_suggested_fee", + currency_field="currency_id", + ) + loc = fields.Integer( + string="Lines of Code", + related="product_id.loc", + store=True, + readonly=True, + ) + company_id = fields.Many2one( + "res.company", + required=True, + default=lambda self: self.env.company, + ) + currency_id = fields.Many2one( + related="company_id.currency_id", + store=True, + readonly=True, + ) + monthly_fee = fields.Monetary(required=True) + date_start = fields.Date(default=fields.Date.context_today) + date_end = fields.Date() + active = fields.Boolean(default=True) + note = fields.Text() + business_impact_id = fields.Many2one( + "module.business.impact", + compute="_compute_business_impact_id", + store=True, + ) + series_value_id = fields.Many2one( + "product.attribute.value", + compute="_compute_series_value_id", + store=True, + ) + + @api.depends("product_id", "partner_id") + def _compute_name(self): + for record in self: + record.name = f"{record.product_id.name} - {record.partner_id.ref}" + + @api.depends("product_id", "product_id.business_impact_id") + def _compute_business_impact_id(self): + for record in self: + record.business_impact_id = record.product_id.business_impact_id + + @api.depends("product_id.suggested_fee", "fee_factor") + def _compute_suggested_fee(self): + for record in self: + record.suggested_fee = record.product_id.suggested_fee * record.fee_factor + + @api.depends( + "product_id", + "product_id.product_template_attribute_value_ids", + "product_id.product_template_attribute_value_ids.attribute_id", + ) + def _compute_series_value_id(self): + for rec in self: + rec.series_value_id = False + if rec.product_id: + ptavs = rec.product_id.product_template_attribute_value_ids.filtered( + lambda v: v.attribute_id.is_odoo_series + ) + if ptavs: + rec.series_value_id = ptavs[0].product_attribute_value_id + + @api.constrains( + "partner_id", + "product_id", + "date_start", + "date_end", + "active", + ) + def _check_date_overlap(self): + for record in self.filtered("active"): + if ( + record.date_start + and record.date_end + and record.date_end < record.date_start + ): + raise ValidationError( + _("End date cannot be earlier than the start date.") + ) + if not record.partner_id or not record.product_id or not record.date_start: + continue + domain = [ + ("id", "!=", record.id), + ("active", "=", True), + ("partner_id", "=", record.partner_id.id), + ("product_id", "=", record.product_id.id), + "|", + ("date_end", "=", False), + ("date_end", ">=", record.date_start), + ] + if record.date_end: + domain += [ + "|", + ("date_start", "=", False), + ("date_start", "<=", record.date_end), + ] + overlap = self.search_count(domain) + if overlap: + raise ValidationError( + _( + "An active customer module already exists for this customer " + "and module in the selected period." + ) + ) diff --git a/product_odoo_module/models/module_business_impact.py b/product_odoo_module/models/module_business_impact.py new file mode 100644 index 0000000..c01feb4 --- /dev/null +++ b/product_odoo_module/models/module_business_impact.py @@ -0,0 +1,15 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ModuleBusinessImpact(models.Model): + _name = "module.business.impact" + _description = "Module Business Impact" + _order = "sequence, name" + + name = fields.Char(required=True) + description = fields.Char() + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) diff --git a/product_odoo_module/models/module_complexity.py b/product_odoo_module/models/module_complexity.py new file mode 100644 index 0000000..315ae5e --- /dev/null +++ b/product_odoo_module/models/module_complexity.py @@ -0,0 +1,15 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ModuleComplexity(models.Model): + _name = "module.complexity" + _description = "Module Complexity" + _order = "sequence, id" + + name = fields.Char(required=True, translate=True) + sequence = fields.Integer(default=10) + factor = fields.Float(default=1.0, help="Multiplier for fee calculation.") + active = fields.Boolean(default=True) diff --git a/product_odoo_module/models/module_license.py b/product_odoo_module/models/module_license.py new file mode 100644 index 0000000..b7be5e2 --- /dev/null +++ b/product_odoo_module/models/module_license.py @@ -0,0 +1,15 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ModuleLicense(models.Model): + _name = "module.license" + _description = "Module License" + _order = "sequence, name" + + name = fields.Char(required=True) + sequence = fields.Integer(default=10) + is_open_source = fields.Boolean() + active = fields.Boolean(default=True) diff --git a/product_odoo_module/models/module_popularity.py b/product_odoo_module/models/module_popularity.py new file mode 100644 index 0000000..3ffe238 --- /dev/null +++ b/product_odoo_module/models/module_popularity.py @@ -0,0 +1,15 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ModulePopularity(models.Model): + _name = "module.popularity" + _description = "Module Popularity" + _order = "sequence, id" + + name = fields.Char(required=True, translate=True) + sequence = fields.Integer(default=10) + factor = fields.Float(default=1.0, help="Multiplier for fee calculation.") + active = fields.Boolean(default=True) diff --git a/product_odoo_module/models/module_source_type.py b/product_odoo_module/models/module_source_type.py new file mode 100644 index 0000000..2fe62b3 --- /dev/null +++ b/product_odoo_module/models/module_source_type.py @@ -0,0 +1,15 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ModuleSourceType(models.Model): + _name = "module.source.type" + _description = "Module Source Type" + _order = "sequence, name" + + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) diff --git a/product_odoo_module/models/product_attribute.py b/product_odoo_module/models/product_attribute.py new file mode 100644 index 0000000..9b9046d --- /dev/null +++ b/product_odoo_module/models/product_attribute.py @@ -0,0 +1,12 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ProductAttribute(models.Model): + _inherit = "product.attribute" + + is_odoo_series = fields.Boolean( + help="Indicates whether this attribute represents the Odoo version series.", + ) diff --git a/product_odoo_module/models/product_product.py b/product_odoo_module/models/product_product.py new file mode 100644 index 0000000..ea14746 --- /dev/null +++ b/product_odoo_module/models/product_product.py @@ -0,0 +1,83 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.tools.safe_eval import safe_eval + + +class ProductProduct(models.Model): + _inherit = "product.product" + + is_odoo_module = fields.Boolean(related="product_tmpl_id.is_odoo_module") + source_type_id = fields.Many2one("module.source.type", string="Source Type") + module_complexity_id = fields.Many2one("module.complexity", string="Complexity") + module_license_id = fields.Many2one("module.license", string="License") + loc = fields.Integer(string="Lines of Code") + suggested_fee = fields.Float( + compute="_compute_suggested_fee", + help="Suggested monthly fee computed from company formula.", + ) + + @api.model_create_multi + def create(self, vals_list): + products = super().create(vals_list) + for product in products: + vals = {} + if not product.source_type_id and product.product_tmpl_id.source_type_id: + vals["source_type_id"] = product.product_tmpl_id.source_type_id.id + if ( + not product.module_complexity_id + and product.product_tmpl_id.module_complexity_id + ): + vals[ + "module_complexity_id" + ] = product.product_tmpl_id.module_complexity_id.id + if ( + not product.module_license_id + and product.product_tmpl_id.module_license_id + ): + vals["module_license_id"] = product.product_tmpl_id.module_license_id.id + if vals: + product.write(vals) + return products + + @api.depends( + "lst_price", + "loc", + "source_type_id", + "product_tmpl_id.customer_module_count", + "module_license_id", + "module_complexity_id", + ) + def _compute_suggested_fee(self): + company = self.env.company + formula = company.module_fee_formula + standard_rate = company.module_standard_rate or 0.0 + for record in self: + if not formula: + record.suggested_fee = record.lst_price + continue + try: + local_vars = { + "base_price": record.lst_price or 0.0, + "source_type_code": record.source_type_id.code or "", + "customer_count": record.product_tmpl_id.customer_module_count or 1, + "license_name": record.module_license_id.name or "", + "loc": record.loc or 0, + "standard_rate": standard_rate, + "complexity_factor": record.module_complexity_id.factor or 1.0, + } + record.suggested_fee = safe_eval(formula, local_vars) + except Exception: + record.suggested_fee = record.lst_price + + def action_view_customer_modules(self): + self.ensure_one() + action = self.env.ref("product_odoo_module.action_customer_module").read()[0] + action["domain"] = [("product_tmpl_id", "=", self.product_tmpl_id.id)] + action["context"] = dict( + self.env.context, + default_product_tmpl_id=self.product_tmpl_id.id, + search_default_active=1, + ) + return action diff --git a/product_odoo_module/models/product_template.py b/product_odoo_module/models/product_template.py new file mode 100644 index 0000000..8cf4f10 --- /dev/null +++ b/product_odoo_module/models/product_template.py @@ -0,0 +1,52 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + is_odoo_module = fields.Boolean( + default=False, + help="Enable this for products that represent maintained Odoo modules. " + "Module products are typically Service products (no stock).", + ) + source_type_id = fields.Many2one("module.source.type", string="Source Type") + module_technical_name = fields.Char(string="Technical Name") + module_repo_url = fields.Char(string="Repository URL") + module_notes = fields.Text(string="Notes") + business_impact_id = fields.Many2one("module.business.impact") + module_license_id = fields.Many2one("module.license", string="License") + module_complexity_id = fields.Many2one( + "module.complexity", + string="Complexity", + ) + customer_module_count = fields.Integer(compute="_compute_customer_module_count") + + @api.depends("product_variant_ids") + def _compute_customer_module_count(self): + counts = {} + if self.ids: + data = self.env["customer.module.info"].read_group( + [("product_tmpl_id", "in", self.ids), ("active", "=", True)], + ["product_tmpl_id"], + ["product_tmpl_id"], + ) + counts = { + item["product_tmpl_id"][0]: item["product_tmpl_id_count"] + for item in data + } + for record in self: + record.customer_module_count = counts.get(record.id, 0) + + def action_view_customer_modules(self): + self.ensure_one() + action = self.env.ref("product_odoo_module.action_customer_module").read()[0] + action["domain"] = [("product_tmpl_id", "=", self.id)] + action["context"] = dict( + self.env.context, + default_product_tmpl_id=self.id, + search_default_active=1, + ) + return action diff --git a/product_odoo_module/models/res_company.py b/product_odoo_module/models/res_company.py new file mode 100644 index 0000000..fc48226 --- /dev/null +++ b/product_odoo_module/models/res_company.py @@ -0,0 +1,19 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + module_fee_formula = fields.Text( + help="Python expression to compute suggested monthly fee. " + "Available variables: base_price, source_type_code, customer_count, " + "license_name, loc, standard_rate. " + "Example: base_price * (0.8 if source_type_code == 'oca' else 1.0)", + ) + module_standard_rate = fields.Monetary( + string="Standard Rate per 100 LOC", + help="Standard price per 100 lines of code for module fee calculation.", + ) diff --git a/product_odoo_module/models/res_partner.py b/product_odoo_module/models/res_partner.py new file mode 100644 index 0000000..67ffb92 --- /dev/null +++ b/product_odoo_module/models/res_partner.py @@ -0,0 +1,34 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# 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" + + customer_module_count = fields.Integer(compute="_compute_customer_module_count") + + @api.depends("child_ids") + def _compute_customer_module_count(self): + counts = {} + if self.ids: + data = self.env["customer.module.info"].read_group( + [("partner_id", "in", self.ids), ("active", "=", True)], + ["partner_id"], + ["partner_id"], + ) + counts = {item["partner_id"][0]: item["partner_id_count"] for item in data} + for partner in self: + partner.customer_module_count = counts.get(partner.id, 0) + + def action_view_customer_modules(self): + self.ensure_one() + action = self.env.ref("product_odoo_module.action_customer_module").read()[0] + action["domain"] = [("partner_id", "=", self.id)] + action["context"] = dict( + self.env.context, + default_partner_id=self.id, + search_default_active=1, + ) + return action diff --git a/product_odoo_module/security/ir.model.access.csv b/product_odoo_module/security/ir.model.access.csv new file mode 100644 index 0000000..c4eb7df --- /dev/null +++ b/product_odoo_module/security/ir.model.access.csv @@ -0,0 +1,19 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_customer_module_info_user,customer.module.info user,model_customer_module_info,base.group_user,1,0,0,0 +access_customer_module_info_portal,customer.module.info portal,model_customer_module_info,base.group_portal,1,0,0,0 +access_customer_module_info_manager,customer.module.info manager,model_customer_module_info,product_odoo_module.group_product_odoo_module_manager,1,1,1,1 +access_module_complexity_user,module.complexity user,model_module_complexity,base.group_user,1,0,0,0 +access_module_complexity_portal,module.complexity portal,model_module_complexity,base.group_portal,1,0,0,0 +access_module_complexity_manager,module.complexity manager,model_module_complexity,product_odoo_module.group_product_odoo_module_manager,1,1,1,1 +access_module_license_user,module.license user,model_module_license,base.group_user,1,0,0,0 +access_module_license_portal,module.license portal,model_module_license,base.group_portal,1,0,0,0 +access_module_license_manager,module.license manager,model_module_license,product_odoo_module.group_product_odoo_module_manager,1,1,1,1 +access_module_popularity_user,module.popularity user,model_module_popularity,base.group_user,1,0,0,0 +access_module_popularity_portal,module.popularity portal,model_module_popularity,base.group_portal,1,0,0,0 +access_module_popularity_manager,module.popularity manager,model_module_popularity,product_odoo_module.group_product_odoo_module_manager,1,1,1,1 +access_module_business_impact_user,module.business.impact user,model_module_business_impact,base.group_user,1,0,0,0 +access_module_business_impact_portal,module.business.impact portal,model_module_business_impact,base.group_portal,1,0,0,0 +access_module_business_impact_manager,module.business.impact manager,model_module_business_impact,product_odoo_module.group_product_odoo_module_manager,1,1,1,1 +access_module_source_type_user,module.source.type user,model_module_source_type,base.group_user,1,0,0,0 +access_module_source_type_portal,module.source.type portal,model_module_source_type,base.group_portal,1,0,0,0 +access_module_source_type_manager,module.source.type manager,model_module_source_type,product_odoo_module.group_product_odoo_module_manager,1,1,1,1 diff --git a/product_odoo_module/security/security.xml b/product_odoo_module/security/security.xml new file mode 100644 index 0000000..921cdfb --- /dev/null +++ b/product_odoo_module/security/security.xml @@ -0,0 +1,18 @@ + + + + Odoo Module Registry + 30 + + + Odoo Module Manager + + + + + Customer Module Info: multi-company + + + [('company_id', 'in', company_ids + [False])] + + diff --git a/product_odoo_module/tests/__init__.py b/product_odoo_module/tests/__init__.py new file mode 100644 index 0000000..0cbde8f --- /dev/null +++ b/product_odoo_module/tests/__init__.py @@ -0,0 +1 @@ +from . import test_customer_module diff --git a/product_odoo_module/tests/test_customer_module.py b/product_odoo_module/tests/test_customer_module.py new file mode 100644 index 0000000..9108f54 --- /dev/null +++ b/product_odoo_module/tests/test_customer_module.py @@ -0,0 +1,48 @@ +from datetime import date + +from odoo.exceptions import ValidationError +from odoo.tests import common + + +class TestCustomerModule(common.TransactionCase): + def setUp(self): + super().setUp() + self.partner = self.env["res.partner"].create({"name": "Test Partner"}) + self.product_template = self.env["product.template"].create( + { + "name": "Module A", + "detailed_type": "service", + } + ) + self.product_variant = self.product_template.product_variant_id + + def test_overlap_constraint(self): + model = self.env["customer.module.info"] + model.create( + { + "partner_id": self.partner.id, + "product_id": self.product_variant.id, + "monthly_fee": 100.0, + "date_start": date(2026, 1, 1), + "date_end": date(2026, 6, 30), + } + ) + with self.assertRaises(ValidationError): + model.create( + { + "partner_id": self.partner.id, + "product_id": self.product_variant.id, + "monthly_fee": 120.0, + "date_start": date(2026, 6, 1), + "date_end": date(2026, 12, 31), + } + ) + model.create( + { + "partner_id": self.partner.id, + "product_id": self.product_variant.id, + "monthly_fee": 120.0, + "date_start": date(2026, 7, 1), + "date_end": date(2026, 12, 31), + } + ) diff --git a/product_odoo_module/views/customer_module_info_views.xml b/product_odoo_module/views/customer_module_info_views.xml new file mode 100644 index 0000000..eb4802b --- /dev/null +++ b/product_odoo_module/views/customer_module_info_views.xml @@ -0,0 +1,131 @@ + + + + customer.module.info.tree + customer.module.info + + + + + + + + + + + + + + + + + + + + + + + customer.module.info.form + customer.module.info + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + customer.module.info.search + customer.module.info + + + + + + + + + + + + + + + + + + + Customer Modules + customer.module.info + tree,form + + {'search_default_active': 1} + + + Odoo Module Templates + product.template + tree,form + [("is_odoo_module","=",True)] + {'search_default_is_odoo_module': 1} + + + Odoo Modules + product.product + tree,form + [("is_odoo_module","=",True)] + +
diff --git a/product_odoo_module/views/module_business_impact_views.xml b/product_odoo_module/views/module_business_impact_views.xml new file mode 100644 index 0000000..2f3beff --- /dev/null +++ b/product_odoo_module/views/module_business_impact_views.xml @@ -0,0 +1,56 @@ + + + + module.business.impact.view.tree + module.business.impact + + + + + + + + + + module.business.impact.view.form + module.business.impact + +
+ + + + + + + + + + +
+
+ + module.business.impact.view.search + module.business.impact + + + + + + + + + Business Impacts + module.business.impact + tree,form + + +
diff --git a/product_odoo_module/views/module_complexity_views.xml b/product_odoo_module/views/module_complexity_views.xml new file mode 100644 index 0000000..b497530 --- /dev/null +++ b/product_odoo_module/views/module_complexity_views.xml @@ -0,0 +1,41 @@ + + + + module.complexity.tree + module.complexity + + + + + + + + + + module.complexity.form + module.complexity + +
+ + + + + + + + + + +
+
+ + Module Complexity + module.complexity + tree,form + +
diff --git a/product_odoo_module/views/module_license_views.xml b/product_odoo_module/views/module_license_views.xml new file mode 100644 index 0000000..f309cb6 --- /dev/null +++ b/product_odoo_module/views/module_license_views.xml @@ -0,0 +1,56 @@ + + + + module.license.view.tree + module.license + + + + + + + + + + module.license.view.form + module.license + +
+ + + + + + + + + + +
+
+ + module.license.view.search + module.license + + + + + + + + + Licenses + module.license + tree,form + + +
diff --git a/product_odoo_module/views/module_popularity_views.xml b/product_odoo_module/views/module_popularity_views.xml new file mode 100644 index 0000000..90c7bb6 --- /dev/null +++ b/product_odoo_module/views/module_popularity_views.xml @@ -0,0 +1,41 @@ + + + + module.popularity.tree + module.popularity + + + + + + + + + + module.popularity.form + module.popularity + +
+ + + + + + + + + + +
+
+ + Module Popularity + module.popularity + tree,form + +
diff --git a/product_odoo_module/views/module_source_type_views.xml b/product_odoo_module/views/module_source_type_views.xml new file mode 100644 index 0000000..2252186 --- /dev/null +++ b/product_odoo_module/views/module_source_type_views.xml @@ -0,0 +1,57 @@ + + + + module.source.type.view.tree + module.source.type + + + + + + + + + + module.source.type.view.form + module.source.type + +
+ + + + + + + + + + +
+
+ + module.source.type.view.search + module.source.type + + + + + + + + + + Source Types + module.source.type + tree,form + + +
diff --git a/product_odoo_module/views/product_attribute_views.xml b/product_odoo_module/views/product_attribute_views.xml new file mode 100644 index 0000000..cf1bdb9 --- /dev/null +++ b/product_odoo_module/views/product_attribute_views.xml @@ -0,0 +1,15 @@ + + + + product.attribute.view.form.inherit.product_odoo_module + product.attribute + + + + + + + + diff --git a/product_odoo_module/views/product_odoo_module_menuitems.xml b/product_odoo_module/views/product_odoo_module_menuitems.xml new file mode 100644 index 0000000..09b6cb0 --- /dev/null +++ b/product_odoo_module/views/product_odoo_module_menuitems.xml @@ -0,0 +1,43 @@ + + + + + + + + + + diff --git a/product_odoo_module/views/product_product_views.xml b/product_odoo_module/views/product_product_views.xml new file mode 100644 index 0000000..88fd79a --- /dev/null +++ b/product_odoo_module/views/product_product_views.xml @@ -0,0 +1,21 @@ + + + + product.product.view.form + product.product + + + + + + + + + + diff --git a/product_odoo_module/views/product_template_views.xml b/product_odoo_module/views/product_template_views.xml new file mode 100644 index 0000000..8758582 --- /dev/null +++ b/product_odoo_module/views/product_template_views.xml @@ -0,0 +1,45 @@ + + + + product.template.form.view + product.template + + + + + + + + + + + + + + + + + + + + + diff --git a/product_odoo_module/views/res_company_views.xml b/product_odoo_module/views/res_company_views.xml new file mode 100644 index 0000000..38af7f9 --- /dev/null +++ b/product_odoo_module/views/res_company_views.xml @@ -0,0 +1,26 @@ + + + + res.company.form.odoo.module + res.company + + + + + + + + + + +
+ Available variables: base_price, source_type_code, customer_count, license_name, loc, standard_rate, complexity_factor +
+
+
+
+
+
diff --git a/product_odoo_module/views/res_partner_views.xml b/product_odoo_module/views/res_partner_views.xml new file mode 100644 index 0000000..aaeb7a6 --- /dev/null +++ b/product_odoo_module/views/res_partner_views.xml @@ -0,0 +1,24 @@ + + + + res.partner.view.form + res.partner + + + + + + + + diff --git a/setup/product_odoo_module/odoo/addons/product_odoo_module b/setup/product_odoo_module/odoo/addons/product_odoo_module new file mode 120000 index 0000000..13a790c --- /dev/null +++ b/setup/product_odoo_module/odoo/addons/product_odoo_module @@ -0,0 +1 @@ +../../../../product_odoo_module \ No newline at end of file diff --git a/setup/product_odoo_module/setup.py b/setup/product_odoo_module/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/product_odoo_module/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From b7172089a33aa1e387ea95fee32ccade480ea38f Mon Sep 17 00:00:00 2001 From: yostashiro Date: Sun, 4 Jan 2026 16:57:53 +0000 Subject: [PATCH 2/2] fixup! --- .../data/module_license_data.xml | 7 ++-- .../data/module_source_type_data.xml | 20 +++++----- .../models/customer_module_info.py | 4 -- product_odoo_module/models/module_license.py | 2 +- .../models/module_source_type.py | 2 +- product_odoo_module/models/product_product.py | 40 +++++++++---------- .../models/product_template.py | 1 + .../views/customer_module_info_views.xml | 2 - .../views/module_license_views.xml | 4 +- .../views/module_source_type_views.xml | 5 +-- .../views/product_odoo_module_menuitems.xml | 12 ++++++ .../views/product_template_views.xml | 1 + .../views/res_company_views.xml | 2 +- 13 files changed, 54 insertions(+), 48 deletions(-) diff --git a/product_odoo_module/data/module_license_data.xml b/product_odoo_module/data/module_license_data.xml index 0cc3191..207fa58 100644 --- a/product_odoo_module/data/module_license_data.xml +++ b/product_odoo_module/data/module_license_data.xml @@ -2,21 +2,22 @@ AGPL-3 - 10 + 0.8 LGPL-3 - 20 + 0.8 GPL-3 - 30 + 0.8 Other Proprietary 40 + 1.0 diff --git a/product_odoo_module/data/module_source_type_data.xml b/product_odoo_module/data/module_source_type_data.xml index fdf6622..9752c29 100644 --- a/product_odoo_module/data/module_source_type_data.xml +++ b/product_odoo_module/data/module_source_type_data.xml @@ -1,23 +1,23 @@ - - Odoo Standard - odoo_standard - 10 - OCA - oca - 20 + 10 + 0.3 Our Team - our_team - 30 + 20 + 0.5 User In-house - user_in_house + 30 + 0.5 + + + Third Party 40 + 1.0 diff --git a/product_odoo_module/models/customer_module_info.py b/product_odoo_module/models/customer_module_info.py index 97516fb..2220600 100644 --- a/product_odoo_module/models/customer_module_info.py +++ b/product_odoo_module/models/customer_module_info.py @@ -38,10 +38,6 @@ class CustomerModuleInfo(models.Model): related="product_id.module_complexity_id", store=True, ) - is_open_source = fields.Boolean( - related="module_license_id.is_open_source", - store=True, - ) customer_count = fields.Integer( related="product_tmpl_id.customer_module_count", string="Total Customers", diff --git a/product_odoo_module/models/module_license.py b/product_odoo_module/models/module_license.py index b7be5e2..70307af 100644 --- a/product_odoo_module/models/module_license.py +++ b/product_odoo_module/models/module_license.py @@ -11,5 +11,5 @@ class ModuleLicense(models.Model): name = fields.Char(required=True) sequence = fields.Integer(default=10) - is_open_source = fields.Boolean() + factor = fields.Float(default=1.0, help="Multiplier for fee calculation.") active = fields.Boolean(default=True) diff --git a/product_odoo_module/models/module_source_type.py b/product_odoo_module/models/module_source_type.py index 2fe62b3..2d132c0 100644 --- a/product_odoo_module/models/module_source_type.py +++ b/product_odoo_module/models/module_source_type.py @@ -10,6 +10,6 @@ class ModuleSourceType(models.Model): _order = "sequence, name" name = fields.Char(required=True, translate=True) - code = fields.Char(required=True) sequence = fields.Integer(default=10) + factor = fields.Float(default=1.0, help="Multiplier for fee calculation.") active = fields.Boolean(default=True) diff --git a/product_odoo_module/models/product_product.py b/product_odoo_module/models/product_product.py index ea14746..c4f18a7 100644 --- a/product_odoo_module/models/product_product.py +++ b/product_odoo_module/models/product_product.py @@ -2,7 +2,6 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import api, fields, models -from odoo.tools.safe_eval import safe_eval class ProductProduct(models.Model): @@ -42,34 +41,33 @@ def create(self, vals_list): return products @api.depends( - "lst_price", "loc", "source_type_id", - "product_tmpl_id.customer_module_count", "module_license_id", "module_complexity_id", + "module_popularity_id", + "customer_module_count", ) def _compute_suggested_fee(self): company = self.env.company - formula = company.module_fee_formula standard_rate = company.module_standard_rate or 0.0 - for record in self: - if not formula: - record.suggested_fee = record.lst_price - continue - try: - local_vars = { - "base_price": record.lst_price or 0.0, - "source_type_code": record.source_type_id.code or "", - "customer_count": record.product_tmpl_id.customer_module_count or 1, - "license_name": record.module_license_id.name or "", - "loc": record.loc or 0, - "standard_rate": standard_rate, - "complexity_factor": record.module_complexity_id.factor or 1.0, - } - record.suggested_fee = safe_eval(formula, local_vars) - except Exception: - record.suggested_fee = record.lst_price + for rec in self: + source_type_factor = rec.source_type_id.factor or 1.0 + license_factor = rec.module_license_id.factor or 1.0 + complexity_factor = rec.module_complexity_id.factor or 1.0 + popularity_factor = rec.module_popularity_id.factor or 1.0 + customer_count = rec.customer_module_count or 1 + rec.suggested_fee = ( + 100 + + rec.loc + / 100 + * standard_rate + * source_type_factor + * complexity_factor + * license_factor + * popularity_factor + / (customer_count * 0.7) + ) def action_view_customer_modules(self): self.ensure_one() diff --git a/product_odoo_module/models/product_template.py b/product_odoo_module/models/product_template.py index 8cf4f10..85151ce 100644 --- a/product_odoo_module/models/product_template.py +++ b/product_odoo_module/models/product_template.py @@ -22,6 +22,7 @@ class ProductTemplate(models.Model): "module.complexity", string="Complexity", ) + module_popularity_id = fields.Many2one("module.popularity", string="Popularity") customer_module_count = fields.Integer(compute="_compute_customer_module_count") @api.depends("product_variant_ids") diff --git a/product_odoo_module/views/customer_module_info_views.xml b/product_odoo_module/views/customer_module_info_views.xml index eb4802b..8b87357 100644 --- a/product_odoo_module/views/customer_module_info_views.xml +++ b/product_odoo_module/views/customer_module_info_views.xml @@ -12,7 +12,6 @@ - @@ -49,7 +48,6 @@ - diff --git a/product_odoo_module/views/module_license_views.xml b/product_odoo_module/views/module_license_views.xml index f309cb6..65b0c01 100644 --- a/product_odoo_module/views/module_license_views.xml +++ b/product_odoo_module/views/module_license_views.xml @@ -7,7 +7,7 @@ - + @@ -26,8 +26,8 @@ - + diff --git a/product_odoo_module/views/module_source_type_views.xml b/product_odoo_module/views/module_source_type_views.xml index 2252186..5fe51d5 100644 --- a/product_odoo_module/views/module_source_type_views.xml +++ b/product_odoo_module/views/module_source_type_views.xml @@ -7,7 +7,7 @@ - + @@ -26,8 +26,8 @@ - + @@ -39,7 +39,6 @@ - + + diff --git a/product_odoo_module/views/product_template_views.xml b/product_odoo_module/views/product_template_views.xml index 8758582..06c94c6 100644 --- a/product_odoo_module/views/product_template_views.xml +++ b/product_odoo_module/views/product_template_views.xml @@ -32,6 +32,7 @@ +
- Available variables: base_price, source_type_code, customer_count, license_name, loc, standard_rate, complexity_factor + Available variables: source_type_factor, complexity_factor, license_factor, customer_count, loc, standard_rate