diff --git a/bemade_fsm/views/task_views.xml b/bemade_fsm/views/task_views.xml index 6bce7cd..bd0d0e7 100644 --- a/bemade_fsm/views/task_views.xml +++ b/bemade_fsm/views/task_views.xml @@ -80,7 +80,7 @@ project_list - + @@ -92,6 +92,12 @@ hide + + hide + + + hide + diff --git a/caldav_sync/models/calendar_event.py b/caldav_sync/models/calendar_event.py index 620abb0..a3a5ae8 100644 --- a/caldav_sync/models/calendar_event.py +++ b/caldav_sync/models/calendar_event.py @@ -458,6 +458,7 @@ class CalendarEvent(models.Model): partner = self.env["res.partner"].search( [("email", "=", _extract_vcal_email(organizer))] ) + # TODO: prioritize partner with a user if there is one return partner[0] if partner else partner # partner[0] in case many matches else: return self.env["res.partner"] diff --git a/customer_applications/__init__.py b/customer_applications/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/customer_applications/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/customer_applications/__manifest__.py b/customer_applications/__manifest__.py new file mode 100644 index 0000000..f2971fb --- /dev/null +++ b/customer_applications/__manifest__.py @@ -0,0 +1,40 @@ +# +# Bemade Inc. +# +# Copyright (C) 2023-June Bemade Inc. (). +# Author: Marc Durepos (Contact : marc@bemade.org) +# +# This program is under the terms of the GNU Lesser General Public License, +# version 3. +# +# For full license details, see https://www.gnu.org/licenses/lgpl-3.0.en.html. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +{ + "name": "Customer Applications", + "version": "17.0.1.0.0", + "summary": "Adds the notion of applications to partners.", + "category": "Contacts", + "author": "Bemade Inc.", + "website": "http://www.bemade.org", + "license": "LGPL-3", + "depends": ["contacts", "incrementing_sequence_mixin"], + "data": [ + "security/groups.xml", + "security/ir.model.access.csv", + "data/menus_actions.xml", + "views/application_type_views.xml", + "views/res_partner_views.xml", + "views/application_views.xml", + ], + "assets": {}, + "installable": True, + "auto_install": False, +} diff --git a/customer_applications/data/menus_actions.xml b/customer_applications/data/menus_actions.xml new file mode 100644 index 0000000..c63bbd6 --- /dev/null +++ b/customer_applications/data/menus_actions.xml @@ -0,0 +1,43 @@ + + + + Application Types + partner.application.type + tree,form + +

+ No application types found. Create one? +

+
+
+ + + Partner Applications + partner.application + tree,form + [('partner_id', '=', context.get('partner_id'))] + {'default_partner_id': context.get('partner_id')} + + + + Partner Applications by Type + partner.application + tree,form + [ + ('application_type_id', '=', context.get('application_type_id')) + ] + + { + 'default_application_type_id': context.get('application_type_id') + } + + + + + + +
\ No newline at end of file diff --git a/customer_applications/models/__init__.py b/customer_applications/models/__init__.py new file mode 100644 index 0000000..dd86493 --- /dev/null +++ b/customer_applications/models/__init__.py @@ -0,0 +1,5 @@ +from . import application +from . import application_specification +from . import application_type +from . import res_partner +from . import application_specification_key diff --git a/customer_applications/models/application.py b/customer_applications/models/application.py new file mode 100644 index 0000000..389fe6b --- /dev/null +++ b/customer_applications/models/application.py @@ -0,0 +1,36 @@ +from odoo import models, fields, Command + + +class Application(models.Model): + _name = "partner.application" + _description = "Partner Application" + _inherit = ["mail.thread", "mail.activity.mixin"] + + name = fields.Char(tracking=1) + description = fields.Text(tracking=2) + partner_id = fields.Many2one( + comodel_name="res.partner", + required=True, + tracking=3, + copy=False, + ) + application_type_id = fields.Many2one( + comodel_name="partner.application.type", + required=True, + tracking=4, + ondelete="restrict", + ) + specification_ids = fields.One2many( + comodel_name="partner.application.specification", + inverse_name="application_id", + tracking=5, + ) + + def copy(self, default=None): + self.ensure_one() # This logic won't work for batches, and it doesn't need to + default = default or {} + if "specification_ids" not in default: + default["specification_ids"] = [ + Command.create(line.copy_data()[0]) for line in self.specification_ids + ] + return super().copy(default) diff --git a/customer_applications/models/application_specification.py b/customer_applications/models/application_specification.py new file mode 100644 index 0000000..e694e01 --- /dev/null +++ b/customer_applications/models/application_specification.py @@ -0,0 +1,42 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + + +class PartnerApplicationSpecification(models.Model): + _name = "partner.application.specification" + _description = "Partner Application Specification" + _inherit = ["mail.thread", "mail.activity.mixin", "incrementing.sequence.mixin"] + _sequence_group = "application_id" + + key_id = fields.Many2one( + comodel_name="partner.application.specification.key", + tracking=1, + ondelete="restrict", + domain="[('id', 'in', allowed_specification_keys)]", + string="Specification Name", + required=True, + ) + name = fields.Char( + related="key_id.name", + ) + value = fields.Text( + tracking=2, + ) + application_id = fields.Many2one( + comodel_name="partner.application", + tracking=1, + ondelete="cascade", + ) + allowed_specification_keys = fields.Many2many( + related="application_id.application_type_id.allowed_specification_keys", + ) + + @api.constrains("key_id") + def _constrain_key_id(self): + for rec in self: + if rec.key_id not in rec.allowed_specification_keys: + raise ValidationError( + _( + f"Key '{rec.key_id.name}' is not allowed for this application type." + ) + ) diff --git a/customer_applications/models/application_specification_key.py b/customer_applications/models/application_specification_key.py new file mode 100644 index 0000000..41ef8c4 --- /dev/null +++ b/customer_applications/models/application_specification_key.py @@ -0,0 +1,11 @@ +from odoo import models, fields, api + + +class ApplicationSpecificationKey(models.Model): + _name = "partner.application.specification.key" + _description = "Application Specification Key" + + name = fields.Char(required=True, index="trigram") + _sql_constraints = [ + ("name_uniq", "unique (name)", "Specification key name must be unique."), + ] diff --git a/customer_applications/models/application_type.py b/customer_applications/models/application_type.py new file mode 100644 index 0000000..a34f94e --- /dev/null +++ b/customer_applications/models/application_type.py @@ -0,0 +1,54 @@ +from odoo import models, fields, api + + +class PartnerApplicationType(models.Model): + _name = "partner.application.type" + _description = "Partner Application Type" + + _inherit = ["mail.thread", "mail.activity.mixin"] + + color = fields.Integer() + name = fields.Char( + required=True, + tracking=1, + ) + description = fields.Text(tracking=2) + application_ids = fields.One2many( + comodel_name="partner.application", + inverse_name="application_type_id", + tracking=3, + ) + + applications_count = fields.Integer( + string="Applications Count", + compute="_compute_applications_count", + ) + + partner_ids = fields.One2many( + comodel_name="res.partner", + compute="_compute_partner_ids", + search="_search_partner_ids", + string="Partners", + readonly=True, + ) + allowed_specification_keys = fields.Many2many( + comodel_name="partner.application.specification.key", + relation="application_specification_key_application_type_rel", + column1="application_type_id", + column2="application_specification_key_id", + ) + + @api.depends("application_ids", "application_ids.partner_id") + def _compute_partner_ids(self): + for application_type in self: + application_type.partner_ids = application_type.application_ids.mapped( + "partner_id" + ) + + def _search_partner_ids(self, operator, value): + return [("application_ids.partner_id", operator, value)] + + @api.depends("application_ids") + def _compute_applications_count(self): + for record in self: + record.applications_count = len(record.application_ids) diff --git a/customer_applications/models/res_partner.py b/customer_applications/models/res_partner.py new file mode 100644 index 0000000..53cdf2c --- /dev/null +++ b/customer_applications/models/res_partner.py @@ -0,0 +1,42 @@ +from odoo import models, fields, api + + +class ResPartner(models.Model): + _inherit = "res.partner" + + application_ids = fields.One2many( + "partner.application", + "partner_id", + string="Applications", + ) + applications_count = fields.Integer( + string="Applications Count", + compute="_compute_applications_count", + ) + application_type_ids = fields.One2many( + comodel_name="partner.application.type", + compute="_compute_application_type_ids", + string="Application Types", + readonly=True, + search="_search_application_type_ids", + ) + + @api.depends("application_ids.application_type_id") + def _compute_application_type_ids(self): + for partner in self: + partner.application_type_ids = partner.application_ids.mapped( + "application_type_id" + ) + + def _search_application_type_ids(self, operator, value): + return [("application_ids.application_type_id", operator, value)] + + @api.depends("application_ids") + def _compute_applications_count(self): + for partner in self: + partner.applications_count = len(partner.application_ids) + + @api.depends("application_ids") + def _compute_applications_count(self): + for rec in self: + rec.applications_count = len(rec.application_ids) diff --git a/customer_applications/security/groups.xml b/customer_applications/security/groups.xml new file mode 100644 index 0000000..23e3fbc --- /dev/null +++ b/customer_applications/security/groups.xml @@ -0,0 +1,14 @@ + + + + + Applications User + + + Applications Admin + + + + + + \ No newline at end of file diff --git a/customer_applications/security/ir.model.access.csv b/customer_applications/security/ir.model.access.csv new file mode 100644 index 0000000..b7ce421 --- /dev/null +++ b/customer_applications/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_partner_application_user,access.partner.application.user,model_partner_application,group_applications_user,1,1,1,1 +access_partner_application_type_user,access.partner.application.type.user,model_partner_application_type,group_applications_user,1,0,0,0 +access_partner_application_type_manager,access.partner.application.type.manager,model_partner_application_type,group_applications_admin,1,1,1,1 +access_partner_application_specification_user,access.partner.application.specification.user,model_partner_application_specification,group_applications_user,1,1,1,1 +access_partner_application_specification_key_user,access.partner.application.specification.key.user,model_partner_application_specification_key,group_applications_user,1,0,0,0 +access_partner_application_specification_key_admin,access.partner.application.specification.key.admin,model_partner_application_specification_key,group_applications_admin,1,1,1,1 \ No newline at end of file diff --git a/customer_applications/tests/__init__.py b/customer_applications/tests/__init__.py new file mode 100644 index 0000000..7abb9fe --- /dev/null +++ b/customer_applications/tests/__init__.py @@ -0,0 +1 @@ +from . import test_application diff --git a/customer_applications/tests/test_application.py b/customer_applications/tests/test_application.py new file mode 100644 index 0000000..8c3f77b --- /dev/null +++ b/customer_applications/tests/test_application.py @@ -0,0 +1,59 @@ +from odoo.tests import TransactionCase + + +class TestApplication(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_1, cls.partner_2 = cls.env["res.partner"].create( + [ + { + "name": "Test Partner", + }, + { + "name": "Test Partner 2", + }, + ] + ) + cls.application_type = cls.env["partner.application.type"].create( + { + "name": "application type", + } + ) + + def test_copy_correctly_creates_specification_lines(self): + application = self.env["partner.application"].create( + { + "partner_id": self.partner_1.id, + "application_type_id": self.application_type.id, + } + ) + specifications = self.env["partner.application.specification"].create( + [ + { + "name": "Spec 1", + "value": "Spec 1 value", + "application_id": application.id, + }, + { + "name": "Spec 2", + "value": "Spec 2 value", + "application_id": application.id, + }, + ] + ) + application_copy = application.copy( + default={ + "partner_id": self.partner_2.id, + } + ) + self.assertEqual(len(application_copy.specification_ids), 2) + self.assertEqual(len(application.specification_ids), 2) + self.assertNotEqual( + application.specification_ids, application_copy.specification_ids + ) + + # TODO: move this to a test in the mixin module once we figure out how + # to dynamically create and load models + self.assertEqual(specifications[0].sequence, 1) + self.assertEqual(specifications[1].sequence, 2) diff --git a/customer_applications/views/application_type_views.xml b/customer_applications/views/application_type_views.xml new file mode 100644 index 0000000..6b1c6a3 --- /dev/null +++ b/customer_applications/views/application_type_views.xml @@ -0,0 +1,74 @@ + + + + partner.application.type.list + partner.application.type + + + + + + + + + + + partner.application.type.form + partner.application.type + +
+
+ +
+ +
+
+
+
+ Application Type +
+

+ +

+
+
+ + + + + + + + + +
+
+ + + +
+ + + + + + partner.application.type.search + partner.application.type + + + + + + + + + \ No newline at end of file diff --git a/customer_applications/views/application_views.xml b/customer_applications/views/application_views.xml new file mode 100644 index 0000000..36c2ab4 --- /dev/null +++ b/customer_applications/views/application_views.xml @@ -0,0 +1,86 @@ + + + + partner.application.form + partner.application + +
+
+ +
+
+
+
+ Application +
+

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + partner.application.tree + partner.application + + + + + + + + + + + partner.application.search + partner.application + + + + + + + + + + + Partner Applications + partner.application + tree,form + + + + + \ No newline at end of file diff --git a/customer_applications/views/res_partner_views.xml b/customer_applications/views/res_partner_views.xml new file mode 100644 index 0000000..7ec37ec --- /dev/null +++ b/customer_applications/views/res_partner_views.xml @@ -0,0 +1,45 @@ + + + + view.partner.form + res.partner + + +
+ +
+
+
+ + + view.partner.tree + res.partner + + + + + + + + + + view.partner.search + res.partner + + + + + + + + +
\ No newline at end of file diff --git a/fsm_equipment/__manifest__.py b/fsm_equipment/__manifest__.py index 69fbb42..2ea15b1 100644 --- a/fsm_equipment/__manifest__.py +++ b/fsm_equipment/__manifest__.py @@ -19,17 +19,18 @@ # { "name": "FSM Equipment", - "version": "17.0.0.1.2", + "version": "17.0.0.2.0", "summary": "Add the notion of client equipment for Field Service", "category": "Services/Field Service", "author": "Bemade Inc.", "website": "http://www.bemade.org", "license": "LGPL-3", - "depends": ["industry_fsm", "account"], + "depends": ["industry_fsm", "account", "contacts", "incrementing_sequence_mixin"], "data": [ "security/ir.model.access.csv", "views/equipment_views.xml", "views/res_partner_views.xml", + "views/project_task_views.xml", ], "assets": {}, "installable": True, diff --git a/fsm_equipment/migrations/17.0.0.2.0/post-migrate.py b/fsm_equipment/migrations/17.0.0.2.0/post-migrate.py new file mode 100644 index 0000000..b62c2d1 --- /dev/null +++ b/fsm_equipment/migrations/17.0.0.2.0/post-migrate.py @@ -0,0 +1,35 @@ +from odoo import SUPERUSER_ID, api, Command +from odoo.tools.sql import SQL + + +def migrate(cr, version): + sql = "select * from fsm_equipment_component" + cr.execute(SQL(sql)) + components = cr.dictfetchall() + sql = "select * from fsm_equipment_component_purpose" + cr.execute(SQL(sql)) + purposes = cr.dictfetchall() + + env = api.Environment(cr, SUPERUSER_ID, {}) + tags = env["fsm.equipment.tag"].create( + [{"name": purpose["name"]} for purpose in purposes] + ) + + purpose_dict = { + purpose["id"]: tags.filtered(lambda tag: tag.name == purpose["name"]).id + for purpose in purposes + } + + env["fsm.equipment"].create( + [ + { + "name": component["name"], + "sequence": component["sequence"], + "tag_ids": [Command.link(purpose_dict[component["purpose_id"]])], + "parent_id": component["equipment_id"], + "description": component["note"], + "product_id": component["product_id"], + } + for component in components + ] + ) diff --git a/fsm_equipment/models/__init__.py b/fsm_equipment/models/__init__.py index 79c07ad..0df2c04 100644 --- a/fsm_equipment/models/__init__.py +++ b/fsm_equipment/models/__init__.py @@ -1,5 +1,4 @@ from . import equipment_tag from . import equipment from . import res_partner -from . import equipment_component from . import task diff --git a/fsm_equipment/models/equipment.py b/fsm_equipment/models/equipment.py index de15f5c..18cd5c9 100644 --- a/fsm_equipment/models/equipment.py +++ b/fsm_equipment/models/equipment.py @@ -1,11 +1,13 @@ from odoo import models, fields, api, _ from odoo.osv import expression +from odoo.exceptions import ValidationError class Equipment(models.Model): _name = "fsm.equipment" _description = "Partner-Owned Equipment" - _inherit = ["mail.thread", "mail.activity.mixin"] + _inherit = ["mail.thread", "mail.activity.mixin", "incrementing.sequence.mixin"] + _sequence_group = "parent_id" code = fields.Char( tracking=True, @@ -26,7 +28,8 @@ class Equipment(models.Model): comodel_name="res.partner", string="Physical Address", tracking=True, - ondelete="cascade", + ondelete="restrict", + required=False, ) location_notes = fields.Text( @@ -49,11 +52,52 @@ class Equipment(models.Model): tracking=True, ) - equipment_component_ids = fields.One2many( - "fsm.equipment.component", - inverse_name="equipment_id", + parent_id = fields.Many2one( + "fsm.equipment", tracking=True, ) + inherited_partner_id = fields.Many2one( + comodel_name="res.partner", + string="Main Equipment Location", + readonly=True, + compute="_compute_inherited_partner_id", + recursive=True, + ) + + child_ids = fields.One2many( + "fsm.equipment", + inverse_name="parent_id", + string="Components", + tracking=True, + ) + + product_id = fields.Many2one( + "product.product", + ondelete="restrict", + help="The product that represents this equipment, if any.", + ) + + @api.depends("parent_id", "parent_id.partner_id") + def _compute_inherited_partner_id(self): + for rec in self: + if not rec.parent_id: + rec.inherited_partner_id = False + continue + rec.inherited_partner_id = ( + rec.parent_id.partner_id or rec.parent_id.inherited_partner_id + ) + + @api.constrains("product_id", "partner_id") + def _constrain_only_root_has_partner(self): + for rec in self: + if rec.partner_id and rec.parent_id: + raise ValidationError( + _("Only top-level (root) equipments can be linked to a partner.") + ) + if not rec.parent_id and not rec.partner_id: + raise ValidationError( + _("Top-level (root) equipments must be linked to a partner.") + ) @api.model def name_search(self, name="", args=None, operator="ilike", limit=100): diff --git a/fsm_equipment/models/equipment_component.py b/fsm_equipment/models/equipment_component.py deleted file mode 100644 index adae313..0000000 --- a/fsm_equipment/models/equipment_component.py +++ /dev/null @@ -1,34 +0,0 @@ -from odoo import models, fields, api - - -class EquipmentComponent(models.Model): - _name = "fsm.equipment.component" - _description = "Equipment Component" - - sequence = fields.Integer() - name = fields.Char() - product_id = fields.Many2one( - "product.product", - ondelete="cascade", - ) - purpose_id = fields.Many2one( - "fsm.equipment.component.purpose", - ondelete="restrict", - ) - equipment_id = fields.Many2one( - "fsm.equipment", - ondelete="cascade", - ) - note = fields.Text() - - @api.onchange("product_id") - def onchange_product_id(self): - for rec in self: - rec.name = rec.product_id.display_name - - -class EquipmentComponentPurpose(models.Model): - _name = "fsm.equipment.component.purpose" - _description = "Component Purpose" - - name = fields.Char(translate=True) diff --git a/fsm_equipment/models/res_partner.py b/fsm_equipment/models/res_partner.py index cd16a33..8cc0f30 100644 --- a/fsm_equipment/models/res_partner.py +++ b/fsm_equipment/models/res_partner.py @@ -41,7 +41,7 @@ class Partner(models.Model): def _compute_equipment_count(self): for rec in self: all_equipment_ids = self.env["fsm.equipment"].search( - [("partner_id", "=", rec.id)] + [("partner_id", "=", rec.id), ("parent_id", "=", False)] ) rec.equipment_count = len(all_equipment_ids) diff --git a/fsm_equipment/security/ir.model.access.csv b/fsm_equipment/security/ir.model.access.csv index 1e8c92e..1a784e3 100644 --- a/fsm_equipment/security/ir.model.access.csv +++ b/fsm_equipment/security/ir.model.access.csv @@ -1,6 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_fsm_equipment,fsm_equipment,model_fsm_equipment,base.group_user,1,1,1,1 access_fsm_equipment_tag,fsm_equipment_tag,model_fsm_equipment_tag,base.group_user,1,1,1,1 -access_fsm_equipment_component,fsm_equipment_component,model_fsm_equipment_component,industry_fsm.group_fsm_user,1,1,1,1 -access_fsm_equipment_component_purpose_user,access_fsm_equipment_component_purpose_user,model_fsm_equipment_component_purpose,industry_fsm.group_fsm_user,1,0,0,0 -access_fsm_equipment_component_purpose_manager,access_fsm_equipment_component_purpose_manager,model_fsm_equipment_component_purpose,industry_fsm.group_fsm_manager,1,1,1,1 diff --git a/fsm_equipment/views/equipment_views.xml b/fsm_equipment/views/equipment_views.xml index e181a63..c3528be 100644 --- a/fsm_equipment/views/equipment_views.xml +++ b/fsm_equipment/views/equipment_views.xml @@ -9,68 +9,88 @@ - - - - + + + + + + options='{"always_reload": True}' + invisible="parent_id" + required="not parent_id" + /> + name="inherited_partner_id" + context="{ + 'default_type': 'delivery', + 'show_address': 1 + }" + options='{"always_reload": True}' + invisible="partner_id or parent_id" + /> + - - - - - - - + + + + + + + - + + + + + + + + + +
- - - + + +
- + fsm.equipment.tree fsm.equipment - - - + + + - + name="tag_ids" + widget="many2many_tags" + options="{'no_open': False}" + /> + @@ -80,21 +100,21 @@ fsm.equipment - - - + + + - + name="tag_ids" + widget="many2many_tags" + options="{'no_open': False}" + /> + + +
+ + + + \ No newline at end of file diff --git a/partner_equipment_applications/views/equipment_views.xml b/partner_equipment_applications/views/equipment_views.xml new file mode 100644 index 0000000..2ecd9ce --- /dev/null +++ b/partner_equipment_applications/views/equipment_views.xml @@ -0,0 +1,42 @@ + + + + fsm.equipment.view.search + fsm.equipment + + + + + + + + + + + + + fsm.equipment.view.tree + fsm.equipment + + + + + + + + + + + fsm.equipment.view.form + fsm.equipment + + + + + + + + \ No newline at end of file