diff --git a/spp_custom_field/__init__.py b/spp_custom_field/__init__.py index c4ccea794..2f015d4e1 100644 --- a/spp_custom_field/__init__.py +++ b/spp_custom_field/__init__.py @@ -1,3 +1,4 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. from . import models +from . import tests diff --git a/spp_custom_field/__manifest__.py b/spp_custom_field/__manifest__.py index 6e13eb48c..db03e240d 100644 --- a/spp_custom_field/__manifest__.py +++ b/spp_custom_field/__manifest__.py @@ -11,7 +11,10 @@ "development_status": "Production/Stable", "maintainers": ["jeremi", "gonzalesedwin1123"], "depends": ["base", "g2p_registry_base"], - "data": [], + "data": [ + "security/ir.model.access.csv", + "views/field_group_views.xml", + ], "assets": {}, "demo": [], "images": [], diff --git a/spp_custom_field/models/__init__.py b/spp_custom_field/models/__init__.py index 5813e2082..d6e170e7c 100644 --- a/spp_custom_field/models/__init__.py +++ b/spp_custom_field/models/__init__.py @@ -1,3 +1,5 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import field_group from . import cus_partner +from . import ir_model_fields diff --git a/spp_custom_field/models/cus_partner.py b/spp_custom_field/models/cus_partner.py index 8e20904d1..d1145587a 100644 --- a/spp_custom_field/models/cus_partner.py +++ b/spp_custom_field/models/cus_partner.py @@ -55,7 +55,55 @@ def create_field_element(self, div_element, model_field_id, is_ind=False): modifiers = {"readonly": True} new_field.set("modifiers", json.dumps(modifiers)) - def _get_view(self, view_id=None, view_type="form", **options): + def _group_fields_by_group(self, fields_list): + """ + Group fields by their field_group_id and sort by sequence. + Returns a list of tuples (group_record, fields_in_group). + Fields without a group are returned with group_record=None. + """ + from collections import defaultdict + + grouped = defaultdict(list) + + # Check if field_group_id exists on the model + has_group_field = hasattr(fields_list[0], "field_group_id") if fields_list else False + has_sequence_field = hasattr(fields_list[0], "sequence") if fields_list else False + + for field in fields_list: + group_id = None + if has_group_field and field.field_group_id: + group_id = field.field_group_id.id + grouped[group_id].append(field) + + # Sort fields within each group by sequence + for group_id in grouped: + if has_sequence_field: + grouped[group_id] = sorted(grouped[group_id], key=lambda f: (f.sequence, f.field_description)) + else: + grouped[group_id] = sorted(grouped[group_id], key=lambda f: f.field_description) + + # Get group records and sort groups by sequence + result = [] + group_ids = [gid for gid in grouped.keys() if gid is not None] + if group_ids and "spp.custom.field.group" in self.env: + try: + group_records = self.env["spp.custom.field.group"].browse(group_ids) + group_records = group_records.sorted(key=lambda g: g.sequence) + for group in group_records: + result.append((group, grouped[group.id])) + except KeyError: + # Model doesn't exist, treat all fields as ungrouped + _logger.warning("spp.custom.field.group model not found, ignoring field groups") + if None in grouped: + result.append((None, grouped[None])) + + # Add fields without a group at the end + if None in grouped: + result.append((None, grouped[None])) + + return result + + def _get_view(self, view_id=None, view_type="form", **options): # noqa: C901 arch, view = super()._get_view(view_id, view_type, **options) if view_type == "form": @@ -69,7 +117,7 @@ def _get_view(self, view_id=None, view_type="form", **options): model_fields_id = self.env["ir.model.fields"].search( [("model_id", "=", "res.partner")], - order="ttype, field_description", + order="sequence, ttype, field_description", ) if basic_info_page: if action_id.context: @@ -79,22 +127,109 @@ def _get_view(self, view_id=None, view_type="form", **options): custom_page = etree.Element("page", {"string": "Additional Details", "name": "additional_details"}) indicators_page = etree.Element("page", {"string": "Indicators", "name": "indicators"}) - custom_div = etree.SubElement(custom_page, "div", {"class": "row mt16 o_settings_container"}) - indicators_div = etree.SubElement(indicators_page, "div", {"class": "row mt16 o_settings_container"}) + # Separate custom and indicator fields + custom_fields = [] + indicator_fields = [] + for rec in model_fields_id: els = rec.name.split("_") if len(els) >= 3 and (els[2] == "grp" and not is_group or els[2] == "indv" and is_group): continue if len(els) >= 2 and els[1] == "cst": - self.create_field_element(custom_div, rec) - + custom_fields.append(rec) elif len(els) >= 2 and els[1] == "ind": - self.create_field_element(indicators_div, rec, is_ind=True) - - if custom_div.getchildren(): + indicator_fields.append(rec) + + # Process custom fields with grouping + if custom_fields: + grouped_custom_fields = self._group_fields_by_group(custom_fields) + + # Create main container row for side-by-side layout + main_row = etree.SubElement(custom_page, "div", {"class": "row"}) + + for group_record, fields_in_group in grouped_custom_fields: + if group_record: + # Create a half-width column for each group + group_col = etree.SubElement( + main_row, + "div", + {"class": "col-12 col-lg-6"}, + ) + # Add group label/title + group_label_div = etree.SubElement( + group_col, + "div", + {"class": "o_horizontal_separator mt-2 mb-3"}, + ) + group_label = etree.SubElement( + group_label_div, + "strong", + ) + group_label.text = group_record.name + # Create fields container + group_div = etree.SubElement(group_col, "div", {"class": "row mt16 o_settings_container"}) + for field in fields_in_group: + self.create_field_element(group_div, field) + else: + # Fields without a group go in full width at the bottom + if not custom_page.xpath(".//div[@class='row mt16 o_settings_container o_no_group']"): + custom_div = etree.SubElement( + custom_page, "div", {"class": "row mt16 o_settings_container o_no_group"} + ) + else: + custom_div = custom_page.xpath( + ".//div[@class='row mt16 o_settings_container o_no_group']" + )[0] + for field in fields_in_group: + self.create_field_element(custom_div, field) + + # Process indicator fields with grouping + if indicator_fields: + grouped_indicator_fields = self._group_fields_by_group(indicator_fields) + + # Create main container row for side-by-side layout + main_row = etree.SubElement(indicators_page, "div", {"class": "row"}) + + for group_record, fields_in_group in grouped_indicator_fields: + if group_record: + # Create a half-width column for each group + group_col = etree.SubElement( + main_row, + "div", + {"class": "col-12 col-lg-6"}, + ) + # Add group label/title + group_label_div = etree.SubElement( + group_col, + "div", + {"class": "o_horizontal_separator mt-2 mb-3"}, + ) + group_label = etree.SubElement( + group_label_div, + "strong", + ) + group_label.text = group_record.name + # Create fields container + group_div = etree.SubElement(group_col, "div", {"class": "row mt16 o_settings_container"}) + for field in fields_in_group: + self.create_field_element(group_div, field, is_ind=True) + else: + # Fields without a group go in full width at the bottom + if not indicators_page.xpath(".//div[@class='row mt16 o_settings_container o_no_group']"): + indicators_div = etree.SubElement( + indicators_page, "div", {"class": "row mt16 o_settings_container o_no_group"} + ) + else: + indicators_div = indicators_page.xpath( + ".//div[@class='row mt16 o_settings_container o_no_group']" + )[0] + for field in fields_in_group: + self.create_field_element(indicators_div, field, is_ind=True) + + if custom_page.getchildren(): basic_info_page[0].addnext(custom_page) - if indicators_div.getchildren(): + if indicators_page.getchildren(): basic_info_page[0].addnext(indicators_page) arch = doc diff --git a/spp_custom_field/models/field_group.py b/spp_custom_field/models/field_group.py new file mode 100644 index 000000000..4b1cb32ba --- /dev/null +++ b/spp_custom_field/models/field_group.py @@ -0,0 +1,21 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class CustomFieldGroup(models.Model): + _name = "spp.custom.field.group" + _description = "Custom Field Group" + _order = "sequence, name" + + name = fields.Char(string="Group Name", required=True, translate=True) + target_type = fields.Selection( + selection=[("grp", "Group"), ("indv", "Individual")], + string="Target Type", + required=True, + default="grp", + help="Specify whether this group is for Group or Individual fields", + ) + sequence = fields.Integer(string="Sequence", default=10) + description = fields.Text(string="Description", translate=True) + active = fields.Boolean(string="Active", default=True) diff --git a/spp_custom_field/models/ir_model_fields.py b/spp_custom_field/models/ir_model_fields.py new file mode 100644 index 000000000..aa7437ef8 --- /dev/null +++ b/spp_custom_field/models/ir_model_fields.py @@ -0,0 +1,25 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class IrModelFields(models.Model): + _inherit = "ir.model.fields" + + field_group_id = fields.Many2one( + "spp.custom.field.group", + string="Field Group", + help="Group this field belongs to for UI organization", + domain="[('target_type', '=', target_type)]", + ) + sequence = fields.Integer( + string="Sequence", + default=10, + help="Order of the field within its group or section", + ) + + @api.onchange("target_type") + def _onchange_target_type(self): + """Clear field_group_id if target_type changes and doesn't match""" + if self.field_group_id and self.field_group_id.target_type != self.target_type: + self.field_group_id = False diff --git a/spp_custom_field/security/ir.model.access.csv b/spp_custom_field/security/ir.model.access.csv new file mode 100644 index 000000000..ce80e287e --- /dev/null +++ b/spp_custom_field/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_custom_field_group_user,spp.custom.field.group.user,model_spp_custom_field_group,base.group_user,1,0,0,0 +access_spp_custom_field_group_admin,spp.custom.field.group.admin,model_spp_custom_field_group,base.group_system,1,1,1,1 diff --git a/spp_custom_field/tests/__init__.py b/spp_custom_field/tests/__init__.py new file mode 100644 index 000000000..b87998fe3 --- /dev/null +++ b/spp_custom_field/tests/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import test_field_group +from . import test_view_generation diff --git a/spp_custom_field/tests/test_field_group.py b/spp_custom_field/tests/test_field_group.py new file mode 100644 index 000000000..6c45f09f6 --- /dev/null +++ b/spp_custom_field/tests/test_field_group.py @@ -0,0 +1,138 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase + + +class TestFieldGroup(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.field_group_model = cls.env["spp.custom.field.group"] + cls.field_model = cls.env["ir.model.fields"] + cls.partner_model = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + + # Create field groups + cls.group_field_group = cls.field_group_model.create( + { + "name": "Household Information", + "target_type": "grp", + "sequence": 10, + "description": "Fields related to household data", + } + ) + + cls.individual_field_group = cls.field_group_model.create( + { + "name": "Personal Information", + "target_type": "indv", + "sequence": 20, + "description": "Fields related to individual data", + } + ) + + def test_01_create_field_group(self): + """Test field group creation with proper attributes""" + self.assertEqual(self.group_field_group.name, "Household Information") + self.assertEqual(self.group_field_group.target_type, "grp") + self.assertEqual(self.group_field_group.sequence, 10) + self.assertTrue(self.group_field_group.active) + + def test_02_field_group_ordering(self): + """Test field groups are ordered by sequence""" + groups = self.field_group_model.search([]) + self.assertGreater(len(groups), 0) + # Verify default ordering by sequence + for i in range(len(groups) - 1): + self.assertLessEqual(groups[i].sequence, groups[i + 1].sequence) + + def test_03_field_group_assignment_to_group_field(self): + """Test assigning field group to a group-type field""" + field = self.field_model.create( + { + "name": "x_cst_grp_household_size", + "model_id": self.partner_model.id, + "field_description": "Household Size", + "ttype": "integer", + "state": "manual", + "field_group_id": self.group_field_group.id, + "sequence": 5, + } + ) + + self.assertEqual(field.field_group_id, self.group_field_group) + self.assertEqual(field.sequence, 5) + + def test_04_field_group_assignment_to_individual_field(self): + """Test assigning field group to an individual-type field""" + field = self.field_model.create( + { + "name": "x_cst_indv_education_level", + "model_id": self.partner_model.id, + "field_description": "Education Level", + "ttype": "char", + "state": "manual", + "field_group_id": self.individual_field_group.id, + "sequence": 15, + } + ) + + self.assertEqual(field.field_group_id, self.individual_field_group) + + def test_05_field_sequence_ordering(self): + """Test fields can be ordered by sequence""" + field1 = self.field_model.create( + { + "name": "x_cst_grp_test_field_1", + "model_id": self.partner_model.id, + "field_description": "Test Field 1", + "ttype": "char", + "state": "manual", + "field_group_id": self.group_field_group.id, + "sequence": 5, + } + ) + field2 = self.field_model.create( + { + "name": "x_cst_grp_test_field_2", + "model_id": self.partner_model.id, + "field_description": "Test Field 2", + "ttype": "char", + "state": "manual", + "field_group_id": self.group_field_group.id, + "sequence": 10, + } + ) + + self.assertLess(field1.sequence, field2.sequence) + + def test_06_field_group_domain_filtering_by_target_type(self): + """Test field groups can be filtered by target_type""" + # Get group-type field groups + group_type_groups = self.field_group_model.search([("target_type", "=", "grp")]) + + # Should only include group-type field groups + self.assertIn(self.group_field_group, group_type_groups) + self.assertNotIn(self.individual_field_group, group_type_groups) + + # Get individual-type field groups + indv_type_groups = self.field_group_model.search([("target_type", "=", "indv")]) + + # Should only include individual-type field groups + self.assertIn(self.individual_field_group, indv_type_groups) + self.assertNotIn(self.group_field_group, indv_type_groups) + + def test_07_inactive_field_group(self): + """Test inactive field groups""" + inactive_group = self.field_group_model.create( + { + "name": "Inactive Group", + "target_type": "grp", + "active": False, + } + ) + + self.assertFalse(inactive_group.active) + + # Search with default domain should not include inactive + active_groups = self.field_group_model.search([("target_type", "=", "grp"), ("active", "=", True)]) + self.assertNotIn(inactive_group, active_groups) diff --git a/spp_custom_field/tests/test_view_generation.py b/spp_custom_field/tests/test_view_generation.py new file mode 100644 index 000000000..e3f5d4477 --- /dev/null +++ b/spp_custom_field/tests/test_view_generation.py @@ -0,0 +1,201 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from lxml import etree + +from odoo.tests.common import TransactionCase + + +class TestViewGeneration(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner_model = cls.env["res.partner"] + cls.field_model = cls.env["ir.model.fields"] + cls.field_group_model = cls.env["spp.custom.field.group"] + cls.res_partner_model = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + + # Create field groups + cls.household_group = cls.field_group_model.create( + { + "name": "Household Info", + "target_type": "grp", + "sequence": 10, + } + ) + + cls.demographics_group = cls.field_group_model.create( + { + "name": "Demographics", + "target_type": "grp", + "sequence": 20, + } + ) + + # Create custom fields with groups + cls.field_model.create( + { + "name": "x_cst_grp_household_size", + "model_id": cls.res_partner_model.id, + "field_description": "Household Size", + "ttype": "integer", + "state": "manual", + "field_group_id": cls.household_group.id, + "sequence": 5, + } + ) + + cls.field_model.create( + { + "name": "x_cst_grp_location", + "model_id": cls.res_partner_model.id, + "field_description": "Location", + "ttype": "char", + "state": "manual", + "field_group_id": cls.demographics_group.id, + "sequence": 15, + } + ) + + # Create indicator field with group + cls.field_model.create( + { + "name": "x_ind_grp_member_count", + "model_id": cls.res_partner_model.id, + "field_description": "Member Count", + "ttype": "integer", + "state": "manual", + "field_group_id": cls.household_group.id, + "sequence": 25, + } + ) + + def test_01_group_fields_by_group(self): + """Test grouping fields by field_group_id""" + fields = self.field_model.search( + [ + ("model_id", "=", self.res_partner_model.id), + ("name", "in", ["x_cst_grp_household_size", "x_cst_grp_location"]), + ] + ) + + partner = self.partner_model.create({"name": "Test", "is_group": True}) + grouped = partner._group_fields_by_group(fields) + + # Should have 2 groups + self.assertEqual(len(grouped), 2) + + # Check groups are present + group_ids = [g[0].id if g[0] else None for g in grouped] + self.assertIn(self.household_group.id, group_ids) + self.assertIn(self.demographics_group.id, group_ids) + + def test_02_fields_ordered_by_sequence(self): + """Test fields within groups are ordered by sequence""" + # Create multiple fields in same group with different sequences + self.field_model.create( + { + "name": "x_cst_grp_field_a", + "model_id": self.res_partner_model.id, + "field_description": "Field A", + "ttype": "char", + "state": "manual", + "field_group_id": self.household_group.id, + "sequence": 30, + } + ) + self.field_model.create( + { + "name": "x_cst_grp_field_b", + "model_id": self.res_partner_model.id, + "field_description": "Field B", + "ttype": "char", + "state": "manual", + "field_group_id": self.household_group.id, + "sequence": 10, + } + ) + + fields = self.field_model.search( + [ + ("model_id", "=", self.res_partner_model.id), + ("field_group_id", "=", self.household_group.id), + ("name", "in", ["x_cst_grp_field_a", "x_cst_grp_field_b"]), + ] + ) + + partner = self.partner_model.create({"name": "Test", "is_group": True}) + grouped = partner._group_fields_by_group(fields) + + # Find the household group + for group_record, fields_in_group in grouped: + if group_record and group_record.id == self.household_group.id: + # Check fields are ordered by sequence + sequences = [f.sequence for f in fields_in_group] + self.assertEqual(sequences, sorted(sequences)) + break + + def test_03_ungrouped_fields_placed_separately(self): + """Test fields without group are placed separately""" + # Create field without group + self.field_model.create( + { + "name": "x_cst_grp_no_group", + "model_id": self.res_partner_model.id, + "field_description": "No Group Field", + "ttype": "char", + "state": "manual", + "sequence": 100, + } + ) + + fields = self.field_model.search( + [ + ("model_id", "=", self.res_partner_model.id), + ("name", "=", "x_cst_grp_no_group"), + ] + ) + + partner = self.partner_model.create({"name": "Test", "is_group": True}) + grouped = partner._group_fields_by_group(fields) + + # Should have ungrouped section (group_record=None) + has_ungrouped = any(g[0] is None for g in grouped) + self.assertTrue(has_ungrouped) + + def test_04_view_generation_creates_elements(self): + """Test that view generation creates proper XML elements""" + partner = self.partner_model.create({"name": "Test Group", "is_group": True}) + + # Get the form view (simplified test - just check it doesn't error) + try: + # This will trigger _get_view which processes custom fields + view = self.env["ir.ui.view"].search([("model", "=", "res.partner"), ("type", "=", "form")], limit=1) + if view: + arch, _ = partner._get_view(view_id=view.id, view_type="form") + # Basic check that arch is valid XML + self.assertIsInstance(arch, etree._Element) + except Exception as e: + self.fail(f"View generation failed: {str(e)}") + + def test_05_group_ordering_by_sequence(self): + """Test that field groups are ordered by sequence""" + partner = self.partner_model.create({"name": "Test", "is_group": True}) + + # Get all fields + fields = self.field_model.search( + [ + ("model_id", "=", self.res_partner_model.id), + ("field_group_id", "!=", False), + ] + ) + + grouped = partner._group_fields_by_group(fields) + + # Extract group sequences (excluding None) + group_sequences = [] + for group_record, _ in grouped: + if group_record: + group_sequences.append(group_record.sequence) + + # Check groups are ordered by sequence + self.assertEqual(group_sequences, sorted(group_sequences)) diff --git a/spp_custom_field/views/field_group_views.xml b/spp_custom_field/views/field_group_views.xml new file mode 100644 index 000000000..7d07dfb76 --- /dev/null +++ b/spp_custom_field/views/field_group_views.xml @@ -0,0 +1,99 @@ + + + + spp.custom.field.group.tree + spp.custom.field.group + + + + + + + + + + + + + + spp.custom.field.group.form + spp.custom.field.group + +
+ + + + + + + + + + + + + + + +
+
+
+ + + + spp.custom.field.group.search + spp.custom.field.group + + + + + + + + + + + + + + + + + + + + + Field Groups + spp.custom.field.group + tree,form + + {'search_default_filter_active': 1} + +

+ Create a new Field Group +

+

+ Field groups help organize custom fields in the user interface. + Fields can be assigned to groups for better visual organization. +

+
+
+ + + +
diff --git a/spp_custom_fields_ui/__manifest__.py b/spp_custom_fields_ui/__manifest__.py index 05556b860..0a361286b 100644 --- a/spp_custom_fields_ui/__manifest__.py +++ b/spp_custom_fields_ui/__manifest__.py @@ -8,7 +8,7 @@ "license": "LGPL-3", "development_status": "Production/Stable", "maintainers": ["jeremi", "gonzalesedwin1123"], - "depends": ["base", "g2p_registry_base", "g2p_registry_membership"], + "depends": ["base", "g2p_registry_base", "g2p_registry_membership", "spp_custom_field"], "data": ["views/custom_fields_ui.xml"], "assets": {}, "demo": [], diff --git a/spp_custom_fields_ui/models/custom_fields_ui.py b/spp_custom_fields_ui/models/custom_fields_ui.py index 208ae3309..2b9353a0f 100644 --- a/spp_custom_fields_ui/models/custom_fields_ui.py +++ b/spp_custom_fields_ui/models/custom_fields_ui.py @@ -1,6 +1,6 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -from odoo import _, api, fields, models +from odoo import api, fields, models FIELD_TYPES = [(key, key) for key in sorted(fields.Field.by_type)] @@ -21,39 +21,6 @@ class OpenSPPCustomFieldsUI(models.Model): kinds = fields.Many2many("g2p.group.membership.kind", string="Kind") has_presence = fields.Boolean("Presence", default=False) - def open_custom_fields_tree(self): - """ - This method is used to open custom field UI Tree. - :param model_id: The Model ID. - :param model: The Model. - :return: This will return the action based on the params. - """ - res_model = self.env["ir.model"].search([("model", "=", "res.partner")]) - action = { - "name": _("Custom Fields"), - "type": "ir.actions.act_window", - "res_model": "ir.model.fields", - "context": { - "default_model_id": res_model.id, - "default_model": res_model.model, - # "search_default_enrolled_state": 1, - }, - "view_mode": "tree, form", - "views": [ - ( - self.env.ref("spp_custom_fields_ui.view_custom_fields_ui_tree").id, - "tree", - ), - ( - self.env.ref("spp_custom_fields_ui.view_custom_fields_ui_form").id, - "form", - ), - ], - # "view_id": self.env.ref("spp_custom_fields_ui.view_custom_fields_ui_tree").id, - "domain": [("model_id", "=", res_model.id), ("state", "=", "manual")], - } - return action - @api.depends("field_category", "target_type") def _compute_prefix(self): """ diff --git a/spp_custom_fields_ui/tests/test_custom_fields_ui.py b/spp_custom_fields_ui/tests/test_custom_fields_ui.py index 88a163bbb..bf4c941b2 100644 --- a/spp_custom_fields_ui/tests/test_custom_fields_ui.py +++ b/spp_custom_fields_ui/tests/test_custom_fields_ui.py @@ -2,87 +2,197 @@ from odoo.tests.common import TransactionCase -class CustomFieldsTest(TransactionCase): +class TestCustomFieldsUI(TransactionCase): @classmethod def setUpClass(cls): - """ - Setup and create necessary records for this test - """ super().setUpClass() cls.model_id = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1) - cls.kind_id = cls.env.ref("g2p_registry_membership.group_membership_kind_head") - cls.model_field_id = cls.env["ir.model.fields"].create( + cls.field_model = cls.env["ir.model.fields"] + cls.kind_head = cls.env.ref("g2p_registry_membership.group_membership_kind_head") + + # Create field group for testing + cls.field_group = cls.env["spp.custom.field.group"].create( { - "name": "x_test_field", - "model_id": cls.model_id.id, + "name": "Test Group", + "target_type": "grp", + "sequence": 10, + } + ) + + def test_01_compute_prefix_custom_group(self): + """Test prefix computation for custom group field""" + field = self.field_model.create( + { + "name": "x_temp", + "model_id": self.model_id.id, "field_description": "Test Field", - "draft_name": "test_field", "ttype": "char", "state": "manual", - "kinds": [(6, 0, [cls.kind_id.id])], + "target_type": "grp", + "field_category": "cst", } ) + field._compute_prefix() + self.assertEqual(field.prefix, "x_cst_grp") - def test_open_custom_fields_tree(self): - action = self.model_field_id.open_custom_fields_tree() - - self.assertEqual(action["name"], "Custom Fields") - self.assertEqual(action["type"], "ir.actions.act_window") - self.assertEqual(action["res_model"], "ir.model.fields") - self.assertEqual(action["context"]["default_model_id"], self.model_id.id) - self.assertEqual(action["context"]["default_model"], self.model_id.model) - self.assertEqual(action["view_mode"], "tree, form") - self.assertEqual(action["views"][0][0], self.env.ref("spp_custom_fields_ui.view_custom_fields_ui_tree").id) - self.assertEqual(action["views"][0][1], "tree") - self.assertEqual(action["views"][1][0], self.env.ref("spp_custom_fields_ui.view_custom_fields_ui_form").id) - self.assertEqual(action["views"][1][1], "form") - self.assertEqual(action["domain"], [("model_id", "=", self.model_id.id), ("state", "=", "manual")]) + def test_02_compute_prefix_indicator_individual(self): + """Test prefix computation for indicator individual field""" + field = self.field_model.create( + { + "name": "x_temp", + "model_id": self.model_id.id, + "field_description": "Test Indicator", + "ttype": "integer", + "state": "manual", + "target_type": "indv", + "field_category": "ind", + } + ) + field._compute_prefix() + self.assertEqual(field.prefix, "x_ind_indv") - def test_compute_prefix(self): - self.model_field_id._compute_prefix() - self.assertEqual(self.model_field_id.prefix, "x_cst_grp") + def test_03_onchange_draft_name_generates_field_name(self): + """Test that draft name generates proper field name""" + field = self.field_model.create( + { + "name": "x_temp", + "model_id": self.model_id.id, + "field_description": "Test Field", + "ttype": "char", + "state": "manual", + "target_type": "grp", + "field_category": "cst", + "draft_name": "household_size", + } + ) + field._onchange_draft_name() + self.assertEqual(field.name, "x_cst_grp_household_size") - def test_onchange_draft_name(self): - self.model_field_id._onchange_draft_name() + def test_04_indicator_field_with_kinds(self): + """Test indicator field with membership kinds""" + field = self.field_model.create( + { + "name": "x_temp", + "model_id": self.model_id.id, + "field_description": "Number of Heads", + "draft_name": "num_heads", + "ttype": "integer", + "state": "manual", + "target_type": "grp", + "field_category": "ind", + "kinds": [(6, 0, [self.kind_head.id])], + } + ) + field._onchange_draft_name() - self.assertEqual(self.model_field_id.name, "x_cst_grp_test_field") + self.assertEqual(field.name, "x_ind_grp_num_heads") + self.assertTrue(field.compute) + self.assertIn(self.kind_head.name, field.compute) + self.assertIn("compute_count_and_set_indicator", field.compute) - def test_onchange_field_category(self): - self.model_field_id._onchange_field_category() + def test_05_presence_field_creates_boolean(self): + """Test that presence field creates boolean type""" + field = self.field_model.create( + { + "name": "x_ind_indv_has_disability", + "model_id": self.model_id.id, + "field_description": "Has Disability", + "draft_name": "has_disability", + "ttype": "boolean", + "state": "manual", + "target_type": "indv", + "field_category": "ind", + "has_presence": True, + } + ) + field.set_compute() - self.assertEqual(self.model_field_id.name, "x_cst_grp_test_field") - self.assertEqual(self.model_field_id.ttype, "char") - self.assertFalse(self.model_field_id.compute) + self.assertEqual(field.ttype, "boolean") + self.assertTrue(field.compute) + self.assertIn("presence_only=True", field.compute) - def test_onchange_kinds(self): - self.model_field_id._onchange_kinds() + def test_06_calculated_field_without_presence_creates_integer(self): + """Test that calculated field without presence is integer""" + field = self.field_model.create( + { + "name": "x_ind_grp_member_count", + "model_id": self.model_id.id, + "field_description": "Member Count", + "draft_name": "member_count", + "ttype": "integer", + "state": "manual", + "target_type": "grp", + "field_category": "ind", + "has_presence": False, + } + ) + field.set_compute() - self.assertEqual(self.model_field_id.name, "x_cst_grp_test_field") - self.assertEqual(self.model_field_id.ttype, "char") - self.assertFalse(self.model_field_id.compute) + self.assertEqual(field.ttype, "integer") + self.assertTrue(field.compute) + self.assertIn("compute_count_and_set_indicator", field.compute) - def test_onchange_target_type(self): - self.model_field_id._onchange_target_type() + def test_07_field_group_assignment(self): + """Test field can be assigned to field group""" + field = self.field_model.create( + { + "name": "x_cst_grp_test_grouped", + "model_id": self.model_id.id, + "field_description": "Grouped Field", + "ttype": "char", + "state": "manual", + "target_type": "grp", + "field_category": "cst", + "field_group_id": self.field_group.id, + } + ) - self.assertEqual(self.model_field_id.name, "x_cst_grp_test_field") - self.assertEqual(self.model_field_id.ttype, "char") - self.assertFalse(self.model_field_id.compute) + self.assertEqual(field.field_group_id, self.field_group) - def test_onchange_has_presence(self): - self.model_field_id._onchange_has_presence() + def test_08_sequence_field(self): + """Test sequence field for ordering""" + field1 = self.field_model.create( + { + "name": "x_cst_grp_field1", + "model_id": self.model_id.id, + "field_description": "Field 1", + "ttype": "char", + "state": "manual", + "sequence": 5, + } + ) + field2 = self.field_model.create( + { + "name": "x_cst_grp_field2", + "model_id": self.model_id.id, + "field_description": "Field 2", + "ttype": "char", + "state": "manual", + "sequence": 10, + } + ) - self.assertEqual(self.model_field_id.name, "x_cst_grp_test_field") - self.assertEqual(self.model_field_id.ttype, "char") + self.assertEqual(field1.sequence, 5) + self.assertEqual(field2.sequence, 10) + self.assertLess(field1.sequence, field2.sequence) - def test_set_compute(self): - self.model_field_id.field_category = "ind" - with self.assertRaisesRegex( - UserError, "Changing the type of a field is not yet supported. Please drop it and create it again!" - ): - self.model_field_id.set_compute() + def test_09_set_compute_error_on_type_change(self): + """Test error when trying to change field type""" + field = self.field_model.create( + { + "name": "x_cst_grp_test_change", + "model_id": self.model_id.id, + "field_description": "Test Change", + "ttype": "char", + "state": "manual", + "field_category": "cst", + } + ) - self.model_field_id.has_presence = True + # Try to change to indicator (different type) + field.field_category = "ind" with self.assertRaisesRegex( - UserError, "Changing the type of a field is not yet supported. Please drop it and create it again!" + UserError, + "Changing the type of a field is not yet supported", ): - self.model_field_id.set_compute() + field.set_compute() diff --git a/spp_custom_fields_ui/views/custom_fields_ui.xml b/spp_custom_fields_ui/views/custom_fields_ui.xml index 75246b14e..09aef5364 100644 --- a/spp_custom_fields_ui/views/custom_fields_ui.xml +++ b/spp_custom_fields_ui/views/custom_fields_ui.xml @@ -1,23 +1,88 @@ - - view_custom_fields_ui_tree + + + view_custom_fields_ui_tree_group ir.model.fields 1 - + + - - - - - + + + + + + + + + view_custom_fields_ui_tree_individual + ir.model.fields + 1 + + + + + + + + + + + + + + + + + + + + view_custom_fields_ui_search + ir.model.fields + + + + + + + + + + + + + + + + + + + + view_custom_fields_ui_form ir.model.fields @@ -32,12 +97,14 @@ + - + + @@ -180,35 +247,114 @@ - - Custom Fields - ir.actions.server - - code - action = model.open_custom_fields_tree() + + + Group Custom Fields + ir.model.fields + tree,form + + + + + +

+ Create a new Custom Field for Groups +

+

+ Define custom fields that will appear on group registrant profiles. + You can organize fields into groups for better UI organization. +

+
- + + Individual Custom Fields + ir.model.fields + tree,form + + + + + +

+ Create a new Custom Field for Individuals +

+

+ Define custom fields that will appear on individual registrant profiles. + You can organize fields into groups for better UI organization. +

+
- - - form - - - --> - + + + + + + + + + +