diff --git a/spp_registry_search/__init__.py b/spp_registry_search/__init__.py new file mode 100644 index 000000000..c4ccea794 --- /dev/null +++ b/spp_registry_search/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/spp_registry_search/__manifest__.py b/spp_registry_search/__manifest__.py new file mode 100644 index 000000000..6a3865ba2 --- /dev/null +++ b/spp_registry_search/__manifest__.py @@ -0,0 +1,45 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + + +{ + "name": "OpenSPP Registry Search", + "category": "OpenSPP/OpenSPP", + "version": "17.0.1.0.0", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/openspp-modules", + "license": "LGPL-3", + "development_status": "Beta", + "maintainers": ["jeremi", "gonzalesedwin1123", "emjay0921"], + "depends": [ + "base", + "g2p_registry_base", + "spp_base_common", + "g2p_registry_individual", + "g2p_registry_group", + ], + "excludes": [], + "external_dependencies": {}, + "data": [ + "security/ir.model.access.csv", + "data/partner_search_field_data.xml", + "data/partner_search_filter_data.xml", + "views/partner_search_field_view.xml", + "views/partner_search_filter_view.xml", + "views/partner_custom_search_view.xml", + ], + "assets": { + "web.assets_backend": [ + "spp_registry_search/static/src/js/partner_search_view.js", + "spp_registry_search/static/src/js/partner_search_widget.js", + "spp_registry_search/static/src/xml/partner_search_view.xml", + "spp_registry_search/static/src/xml/partner_search_widget.xml", + ], + }, + "demo": [], + "images": [], + "application": False, + "installable": True, + "auto_install": False, + "summary": "Provides advanced search capabilities for the OpenSPP Registry. Features include configurable search fields, dynamic field filtering by registrant type (Individual/Group), and an intuitive search interface with real-time results.", +} diff --git a/spp_registry_search/data/partner_search_field_data.xml b/spp_registry_search/data/partner_search_field_data.xml new file mode 100644 index 000000000..b69df364b --- /dev/null +++ b/spp_registry_search/data/partner_search_field_data.xml @@ -0,0 +1,30 @@ + + + + + + Name + + both + 10 + + + + + Email + + both + 20 + + + + + Phone + + both + 30 + + + diff --git a/spp_registry_search/data/partner_search_filter_data.xml b/spp_registry_search/data/partner_search_filter_data.xml new file mode 100644 index 000000000..9f5c64a52 --- /dev/null +++ b/spp_registry_search/data/partner_search_filter_data.xml @@ -0,0 +1,30 @@ + + + + + Female Registrants + 10 + individual + [('gender', '=', 'Female')] + Show only female individuals + + + + + Male Registrants + 20 + individual + [('gender', '=', 'Male')] + Show only male individuals + + + + + Archived Registrants + 90 + both + [('active', '=', False)] + Show only archived registrants + + + diff --git a/spp_registry_search/models/__init__.py b/spp_registry_search/models/__init__.py new file mode 100644 index 000000000..d32fa5249 --- /dev/null +++ b/spp_registry_search/models/__init__.py @@ -0,0 +1,5 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import partner_search_field +from . import partner_search_filter +from . import res_partner diff --git a/spp_registry_search/models/partner_search_field.py b/spp_registry_search/models/partner_search_field.py new file mode 100644 index 000000000..70c0ac715 --- /dev/null +++ b/spp_registry_search/models/partner_search_field.py @@ -0,0 +1,99 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class PartnerSearchField(models.Model): + """Configuration model for managing searchable partner fields""" + + _name = "spp.partner.search.field" + _description = "Partner Search Field Configuration" + _order = "sequence, name" + + name = fields.Char( + string="Field Label", + required=True, + help="Display name for the field in the dropdown", + ) + field_id = fields.Many2one( + "ir.model.fields", + string="Field", + domain="[('model', '=', 'res.partner')]", + help="Partner field to be searchable", + ) + field_name = fields.Char( + string="Field Name", + related="field_id.name", + store=True, + readonly=True, + ) + field_type = fields.Selection( + string="Field Type", + related="field_id.ttype", + store=True, + readonly=True, + ) + target_type = fields.Selection( + [ + ("individual", "Individual"), + ("group", "Group"), + ("both", "Both"), + ], + string="Target Type", + default="both", + required=True, + help="Specify if this field is for Individuals, Groups, or Both", + ) + active = fields.Boolean( + default=True, + help="If unchecked, this field will not appear in the search dropdown", + ) + sequence = fields.Integer( + default=10, + help="Order in which fields appear in the dropdown", + ) + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.company, + required=False, + ) + + _sql_constraints = [ + ( + "unique_field_per_company", + "unique(field_id, company_id)", + "This field is already configured for this company!", + ), + ] + + @api.constrains("field_id") + def _check_field_type(self): + """Ensure only searchable field types are selected""" + searchable_types = [ + "char", + "text", + "selection", + "many2one", + "many2many", + "integer", + "float", + "date", + "datetime", + "boolean", + ] + for record in self: + if record.field_type not in searchable_types: + raise models.ValidationError(f"Field type '{record.field_type}' is not supported for searching.") + + def name_get(self): + """Custom name display""" + result = [] + for record in self: + name = f"{record.name} ({record.field_name})" + result.append((record.id, name)) + return result diff --git a/spp_registry_search/models/partner_search_filter.py b/spp_registry_search/models/partner_search_filter.py new file mode 100644 index 000000000..7d28601a4 --- /dev/null +++ b/spp_registry_search/models/partner_search_filter.py @@ -0,0 +1,64 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import api, fields, models +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + + +class SPPPartnerSearchFilter(models.Model): + _name = "spp.partner.search.filter" + _description = "Partner Search Filter Configuration" + _order = "sequence, name" + + name = fields.Char(string="Filter Name", required=True, translate=True) + sequence = fields.Integer(default=10, help="Order of filter in dropdown") + active = fields.Boolean(default=True) + domain = fields.Text( + string="Domain", + required=True, + default="[]", + help="Domain filter in Python format, e.g., [('gender', '=', 'Female')]", + ) + target_type = fields.Selection( + [ + ("individual", "Individual"), + ("group", "Group"), + ("both", "Both"), + ], + string="Target Type", + required=True, + default="both", + help="Specify whether this filter applies to individuals, groups, or both", + ) + description = fields.Text(string="Description", translate=True) + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.company, + ) + + @api.constrains("domain") + def _check_domain(self): + """Validate that the domain is a valid Python expression""" + for record in self: + try: + domain = safe_eval(record.domain or "[]") + if not isinstance(domain, list): + raise ValueError("Domain must be a list") + except Exception as e: + from odoo.exceptions import ValidationError + + raise ValidationError(f"Invalid domain: {e}") from e + + def name_get(self): + """Custom name display""" + result = [] + for record in self: + name = record.name + if record.description: + name = f"{name} - {record.description}" + result.append((record.id, name)) + return result diff --git a/spp_registry_search/models/res_partner.py b/spp_registry_search/models/res_partner.py new file mode 100644 index 000000000..9be2eea1b --- /dev/null +++ b/spp_registry_search/models/res_partner.py @@ -0,0 +1,216 @@ +import json +import logging + +from odoo import api, models +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + + +class SPPResPartner(models.Model): + _inherit = "res.partner" + + @api.model + def search_by_field(self, field_name, search_value, is_group=False, filter_domain="[]"): # noqa: C901 + """ + Search partners by a specific field + :param field_name: The field name to search on + :param search_value: The value to search for (empty string for "search all") + :param is_group: Whether to search for groups (True) or individuals (False) + :param filter_domain: Additional filter domain in string format (e.g., "[('gender', '=', 'Female')]") + :return: List of matching partner IDs + """ + if not field_name: + return [] + + # Start with base domain + domain = [] + + # If search_value is provided, add field-specific search + if search_value: + # Get the field configuration + field_config = self.env["spp.partner.search.field"].search( + [("field_name", "=", field_name), ("active", "=", True)], limit=1 + ) + + if not field_config: + _logger.warning(f"Field {field_name} is not configured for searching") + return [] + + # Build the search domain based on field type + field_type = field_config.field_type + + if field_type in ["char", "text"]: + domain = [(field_name, "ilike", search_value)] + elif field_type in ["integer", "float"]: + try: + numeric_value = float(search_value) + domain = [(field_name, "=", numeric_value)] + except ValueError: + _logger.warning(f"Invalid numeric value: {search_value}") + return [] + elif field_type == "boolean": + bool_value = search_value.lower() in ["true", "1", "yes"] + domain = [(field_name, "=", bool_value)] + elif field_type == "selection": + domain = [(field_name, "=", search_value)] + elif field_type == "many2one": + # search_value should be the ID of the related record + try: + record_id = int(search_value) + domain = [(field_name, "=", record_id)] + except ValueError: + # Fallback to name search + domain = [(field_name + ".name", "ilike", search_value)] + elif field_type in ["date", "datetime"]: + domain = [(field_name, "=", search_value)] + else: + domain = [(field_name, "ilike", search_value)] + # If search_value is empty, we're doing a "search all" - no field-specific filter + + # Add partner type filter (is_group) + domain.append(("is_group", "=", is_group)) + + # Always filter by is_registrant = True + domain.append(("is_registrant", "=", True)) + + # Apply additional filter domain + include_archived = False + try: + # Parse JSON domain from frontend (comes as JSON string with lowercase true/false) + additional_domain = json.loads(filter_domain or "[]") + + if additional_domain and isinstance(additional_domain, list): + # Convert list format to tuple format for Odoo + # JSON: ["|", ["field", "=", value], ...] -> Odoo: ["|", ("field", "=", value), ...] + for condition in additional_domain: + if isinstance(condition, str): + # Domain operators like '|', '&', '!' + domain.append(condition) + elif isinstance(condition, list) and len(condition) >= 3: + # Regular domain condition + domain.append(tuple(condition)) + # Check if we're searching for archived records + if condition[0] == "active" and not condition[2]: + include_archived = True + else: + # If no filter domain, only show active records by default + domain.append(("active", "=", True)) + except Exception as e: + _logger.warning(f"Error applying filter domain: {e}") + # Default to active records on error + domain.append(("active", "=", True)) + + # Use with_context to include archived records if needed + if include_archived: + return self.with_context(active_test=False).search(domain).ids + return self.search(domain).ids + + @api.model + def get_searchable_fields(self, partner_type=None): + """ + Get list of searchable fields configured for partner search + :param partner_type: 'individual', 'group', or None for all + :return: List of dictionaries with field information + """ + domain = [("active", "=", True)] + + # Filter by target_type based on partner_type + if partner_type == "individual": + domain.append(("target_type", "in", ["individual", "both"])) + elif partner_type == "group": + domain.append(("target_type", "in", ["group", "both"])) + # If partner_type is None, return all active fields + + search_fields = self.env["spp.partner.search.field"].search(domain, order="sequence, name") + + result = [] + for field in search_fields: + field_info = { + "id": field.id, + "name": field.name, + "field_name": field.field_name, + "field_type": field.field_type, + "target_type": field.target_type, + } + + # Add relational field information + if field.field_type == "many2one": + # Get the related model + odoo_field = field.field_id + if odoo_field.relation: + field_info["relation"] = odoo_field.relation + field_info["relation_field"] = "name" # Default display field + + elif field.field_type == "selection": + # Get selection options + odoo_field = field.field_id + model = self.env[odoo_field.model] + field_obj = model._fields.get(odoo_field.name) + if field_obj and hasattr(field_obj, "selection"): + if callable(field_obj.selection): + selection_options = field_obj.selection(model) + else: + selection_options = field_obj.selection + field_info["selection"] = selection_options + + result.append(field_info) + + return result + + @api.model + def get_field_options(self, relation_model): + """ + Get options for a many2one field + :param relation_model: The model name to get records from + :return: List of tuples (id, name) + """ + try: + records = self.env[relation_model].search([], limit=200) + # Use name_get() which is more universal and returns [(id, name), ...] + return records.name_get() + except Exception as e: + _logger.error(f"Error loading options for {relation_model}: {e}") + return [] + + @api.model + def get_search_filters(self, partner_type=None): + """ + Get list of search filters configured for partner search + :param partner_type: 'individual', 'group', or None for all + :return: List of dictionaries with filter information + """ + + domain = [("active", "=", True)] + + # Filter by target_type based on partner_type + if partner_type == "individual": + domain.append(("target_type", "in", ["individual", "both"])) + elif partner_type == "group": + domain.append(("target_type", "in", ["group", "both"])) + # If partner_type is None, return all active filters + + filters = self.env["spp.partner.search.filter"].search(domain, order="sequence, name") + + result = [] + for f in filters: + # Convert Python domain to JSON-compatible format + try: + python_domain = safe_eval(f.domain or "[]") + # Convert tuples to lists for JSON compatibility + json_domain = json.dumps(python_domain) + except Exception as e: + _logger.warning(f"Error converting domain for filter {f.name}: {e}") + json_domain = "[]" + + result.append( + { + "id": f.id, + "name": f.name, + "domain": json_domain, + "description": f.description, + "target_type": f.target_type, + } + ) + + return result diff --git a/spp_registry_search/pyproject.toml b/spp_registry_search/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/spp_registry_search/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_registry_search/readme/README.rst b/spp_registry_search/readme/README.rst new file mode 100644 index 000000000..90413d0de --- /dev/null +++ b/spp_registry_search/readme/README.rst @@ -0,0 +1,111 @@ +====================== +OpenSPP Registry Search +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:TODO + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2Fopenspp--modules-lightgray.png?logo=github + :target: https://github.com/OpenSPP/openspp-modules/tree/17.0/spp_registry_search + :alt: OpenSPP/openspp-modules + +|badge1| |badge2| |badge3| + +This module provides advanced search capabilities for the OpenSPP Registry system. + +**Features:** + +* **Configurable Search Fields**: Administrators can configure which partner fields are searchable +* **Dynamic Field Filtering**: Search fields automatically filter based on registrant type (Individual/Group) +* **Target Type Support**: Fields can be configured for Individuals, Groups, or Both +* **Intuitive Search Interface**: User-friendly search form with real-time results +* **Type-Aware Search**: Different search strategies for different field types (text, numbers, dates, etc.) +* **Always Filters Registrants**: Automatically filters to only show registrants (is_registrant=True) + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure searchable fields: + +1. Go to **Settings → Administration → Partner Search Fields** +2. Click **Create** to add a new searchable field +3. Fill in: + + * **Field Label**: Display name for the field + * **Field**: Select the partner field + * **Target Type**: Individual, Group, or Both + * **Sequence**: Display order + * **Active**: Enable/disable the field + +4. Save the configuration + +Usage +===== + +To search for registrants: + +1. Navigate to **Registry → Registry Search** +2. Select **Registrant Type** (Individual or Group) +3. Select **Search By** field from the dropdown +4. Enter your **Search Value** +5. Press **Enter** or click **Search** +6. View results and click **Open** to access full records + +The search interface dynamically updates available fields based on the selected Registrant Type. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* OpenSPP.org + +Contributors +~~~~~~~~~~~~ + +* Jeremi Joslin +* Edwin Gonzales +* Emjay Rolusta + +Maintainers +~~~~~~~~~~~ + +This module is maintained by OpenSPP.org. + +.. image:: https://openspp.org/logo.png + :alt: OpenSPP + :target: https://openspp.org + +OpenSPP is a digital platform for social protection programs. + +This module is part of the `OpenSPP/openspp-modules `_ project on GitHub. + +You are welcome to contribute. + diff --git a/spp_registry_search/security/ir.model.access.csv b/spp_registry_search/security/ir.model.access.csv new file mode 100644 index 000000000..254e7e6db --- /dev/null +++ b/spp_registry_search/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink + +spp_partner_search_field_read_access,Partner Search Field Read Access,model_spp_partner_search_field,spp_base_common.read_registry,1,0,0,0 +spp_partner_search_field_write_access,Partner Search Field Write Access,model_spp_partner_search_field,spp_base_common.write_registry,1,1,0,0 +spp_partner_search_field_create_access,Partner Search Field Create Access,model_spp_partner_search_field,spp_base_common.create_registry,1,1,1,0 +spp_partner_search_field_admin_access,Partner Search Field Admin Access,model_spp_partner_search_field,base.group_system,1,1,1,1 + +spp_partner_search_filter_read_access,Partner Search Filter Read Access,model_spp_partner_search_filter,spp_base_common.read_registry,1,0,0,0 +spp_partner_search_filter_write_access,Partner Search Filter Write Access,model_spp_partner_search_filter,spp_base_common.write_registry,1,1,0,0 +spp_partner_search_filter_create_access,Partner Search Filter Create Access,model_spp_partner_search_filter,spp_base_common.create_registry,1,1,1,0 +spp_partner_search_filter_admin_access,Partner Search Filter Admin Access,model_spp_partner_search_filter,base.group_system,1,1,1,1 diff --git a/spp_registry_search/static/description/icon.png b/spp_registry_search/static/description/icon.png new file mode 100644 index 000000000..c7dbdaaf1 Binary files /dev/null and b/spp_registry_search/static/description/icon.png differ diff --git a/spp_registry_search/static/src/js/partner_search_view.js b/spp_registry_search/static/src/js/partner_search_view.js new file mode 100644 index 000000000..0c765ad6e --- /dev/null +++ b/spp_registry_search/static/src/js/partner_search_view.js @@ -0,0 +1,577 @@ +/** @odoo-module **/ + +import {registry} from "@web/core/registry"; +import {Component, onWillStart, useState} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; + +export class PartnerSearchAction extends Component { + static template = "spp_registry_search.PartnerSearchAction"; + static props = ["*"]; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.notification = useService("notification"); + + // Get context from action props + const context = this.props.action?.context || {}; + const defaultPartnerType = context.default_partner_type || "individual"; + this.hidePartnerType = context.hide_partner_type || false; + this.formViewRef = context.form_view_ref || null; + + this.state = useState({ + searchFields: [], + selectedField: "", + selectedFieldInfo: null, + searchValue: "", + fieldOptions: [], + partnerType: defaultPartnerType, + searching: false, + results: [], + showResults: false, + selectedIds: new Set(), + searchFilters: [], + selectedFilterIds: new Set(), + }); + + onWillStart(async () => { + await this.loadSearchFields(this.state.partnerType); + await this.loadSearchFilters(this.state.partnerType); + // Try to restore previous search state + await this.restoreSearchState(); + }); + } + + getSearchStateKey() { + // Create unique key per partner type + return `partner_search_state_${this.state.partnerType}`; + } + + saveSearchState() { + const searchState = { + selectedField: this.state.selectedField, + searchValue: this.state.searchValue, + partnerType: this.state.partnerType, + results: this.state.results, + showResults: this.state.showResults, + selectedIds: Array.from(this.state.selectedIds), + selectedFilterIds: Array.from(this.state.selectedFilterIds), + }; + sessionStorage.setItem(this.getSearchStateKey(), JSON.stringify(searchState)); + } + + async restoreSearchState() { + const savedState = sessionStorage.getItem(this.getSearchStateKey()); + if (savedState) { + try { + const state = JSON.parse(savedState); + // Only restore if the partner type matches + if (state.partnerType === this.state.partnerType) { + this.state.selectedField = state.selectedField || this.state.selectedField; + this.state.searchValue = state.searchValue || ""; + this.state.results = state.results || []; + this.state.showResults = state.showResults || false; + this.state.selectedIds = new Set(state.selectedIds || []); + this.state.selectedFilterIds = new Set(state.selectedFilterIds || []); + + // Restore field info and options for relational fields + if (this.state.selectedField) { + const fieldInfo = this.state.searchFields.find( + (f) => f.field_name === this.state.selectedField + ); + this.state.selectedFieldInfo = fieldInfo; + + if (fieldInfo) { + if (fieldInfo.field_type === "selection" && fieldInfo.selection) { + this.state.fieldOptions = fieldInfo.selection; + } else if (fieldInfo.field_type === "many2one" && fieldInfo.relation) { + try { + const options = await this.orm.call("res.partner", "get_field_options", [ + fieldInfo.relation, + ]); + this.state.fieldOptions = options; + } catch (error) { + console.error("Error loading field options during restore:", error); + } + } + } + } + } + } catch (error) { + console.error("Error restoring search state:", error); + } + } + } + + async loadSearchFields(partnerType = null) { + try { + const fields = await this.orm.call("res.partner", "get_searchable_fields", [partnerType]); + this.state.searchFields = fields; + if (fields.length > 0) { + this.state.selectedField = fields[0].field_name; + } else { + this.state.selectedField = ""; + } + } catch (error) { + console.error("Error loading searchable fields:", error); + } + } + + async loadSearchFilters(partnerType = null) { + try { + const filters = await this.orm.call("res.partner", "get_search_filters", [partnerType]); + this.state.searchFilters = filters; + // Don't auto-select any filters by default + this.state.selectedFilterIds.clear(); + } catch (error) { + console.error("Error loading search filters:", error); + } + } + + async onFieldChange(event) { + this.state.selectedField = event.target.value; + this.state.searchValue = ""; + this.state.fieldOptions = []; + + // Find the selected field info + const fieldInfo = this.state.searchFields.find((f) => f.field_name === this.state.selectedField); + this.state.selectedFieldInfo = fieldInfo; + + // Load options for relational fields + if (fieldInfo) { + if (fieldInfo.field_type === "selection" && fieldInfo.selection) { + // Selection field - options are already loaded + this.state.fieldOptions = fieldInfo.selection; + } else if (fieldInfo.field_type === "many2one" && fieldInfo.relation) { + // Many2one field - load options from related model + try { + const options = await this.orm.call("res.partner", "get_field_options", [ + fieldInfo.relation, + ]); + this.state.fieldOptions = options; + } catch (error) { + console.error("Error loading field options:", error); + } + } + } + } + + onSearchValueChange(event) { + this.state.searchValue = event.target.value; + } + + get isArchivedFilterSelected() { + // Check if any selected filter is for archived records + const selectedFilters = this.state.searchFilters.filter((f) => + this.state.selectedFilterIds.has(f.id) + ); + + return selectedFilters.some((filter) => { + try { + const domain = JSON.parse(filter.domain || "[]"); + // Check if domain contains active = False in any format + return domain.some((condition) => { + if (Array.isArray(condition) && condition.length === 3) { + return condition[0] === "active" && condition[1] === "=" && condition[2] === false; + } + return false; + }); + } catch (error) { + console.error("Error parsing filter domain for archive check:", error); + return false; + } + }); + } + + get combinedFilterDomain() { + // Combine all selected filter domains + const selectedFilters = this.state.searchFilters.filter((f) => + this.state.selectedFilterIds.has(f.id) + ); + + if (selectedFilters.length === 0) { + return "[]"; + } + + // Parse all filter domains + const parsedDomains = []; + for (const filter of selectedFilters) { + try { + const domain = JSON.parse(filter.domain || "[]"); + if (domain && domain.length > 0) { + parsedDomains.push(domain); + } + } catch (error) { + console.error("Error parsing filter domain:", error); + } + } + + if (parsedDomains.length === 0) { + return "[]"; + } + + // Single filter - just return it + if (parsedDomains.length === 1) { + return JSON.stringify(parsedDomains[0]); + } + + // Multiple filters - combine with OR logic + // Odoo domain syntax: ['|', cond1, cond2] for OR + // For n conditions, we need (n-1) '|' operators at the beginning + const combinedDomain = []; + + // Add (n-1) OR operators at the beginning + for (let i = 0; i < parsedDomains.length - 1; i++) { + combinedDomain.push("|"); + } + + // Add all conditions (flattened) + for (const domain of parsedDomains) { + for (const condition of domain) { + combinedDomain.push(condition); + } + } + + return JSON.stringify(combinedDomain); + } + + onFilterChange(event) { + // Handle multiple select + const selectedOptions = Array.from(event.target.selectedOptions); + this.state.selectedFilterIds.clear(); + + selectedOptions.forEach((option) => { + this.state.selectedFilterIds.add(parseInt(option.value)); + }); + } + + removeFilter(filterId) { + // Remove a specific filter from selection + this.state.selectedFilterIds.delete(filterId); + } + + async onPartnerTypeChange(event) { + this.state.partnerType = event.target.value; + // Reload fields and filters based on selected partner type + await this.loadSearchFields(this.state.partnerType); + await this.loadSearchFilters(this.state.partnerType); + // Clear search value when partner type changes + this.state.searchValue = ""; + this.state.showResults = false; + } + + async onSearch() { + // Only require selected field, allow empty search value + if (!this.state.selectedField) { + return; + } + + this.state.searching = true; + this.state.showResults = false; + try { + // Determine is_group value based on partner type + const isGroup = this.state.partnerType === "group"; + + let results = []; + + // Always use backend method for consistency + results = await this.orm.call("res.partner", "search_by_field", [ + this.state.selectedField, + this.state.searchValue || "", // Pass empty string for "search all" + isGroup, + this.combinedFilterDomain, + ]); + + if (results && results.length > 0) { + // Load partner details with proper context for archived records + const searchReadDomain = [["id", "in", results]]; + const searchReadFields = [ + "name", + "address", + "phone", + "tags_ids", + "birthdate", + "registration_date", + "is_group", + "active", + ]; + + if (this.isArchivedFilterSelected) { + // Use webSearchRead with context for archived records + this.state.results = await this.orm.call("res.partner", "search_read", [], { + domain: searchReadDomain, + fields: searchReadFields, + context: {active_test: false}, + }); + } else { + this.state.results = await this.orm.searchRead( + "res.partner", + searchReadDomain, + searchReadFields + ); + } + this.state.showResults = true; + // Clear previous selections + this.state.selectedIds.clear(); + + // Save search state to session storage + this.saveSearchState(); + } else { + this.state.results = []; + this.state.showResults = true; + this.state.selectedIds.clear(); + } + } catch (error) { + console.error("Error searching partners:", error); + } finally { + this.state.searching = false; + } + } + + onKeyPress(event) { + if (event.key === "Enter") { + this.onSearch(); + } + } + + async openPartner(partnerId, isGroup) { + // Save current search state before navigating + this.saveSearchState(); + + // Determine the correct form view based on partner type + let viewId = false; + + if (this.formViewRef) { + // Use the view reference from context + try { + const viewRef = await this.orm.call("ir.model.data", "xmlid_to_res_id", [this.formViewRef]); + viewId = viewRef || false; + } catch (error) { + console.error("Error resolving view reference:", error); + } + } else { + // Auto-detect based on partner type + const viewRefString = isGroup + ? "g2p_registry_group.view_groups_form" + : "g2p_registry_individual.view_individuals_form"; + + try { + viewId = await this.orm.call("ir.model.data", "xmlid_to_res_id", [viewRefString]); + } catch (error) { + console.error("Error resolving view reference:", error); + } + } + + await this.action.doAction({ + type: "ir.actions.act_window", + res_model: "res.partner", + res_id: partnerId, + views: [[viewId, "form"]], + target: "current", + context: { + form_view_ref: this.formViewRef, + }, + }); + } + + onClearSearch() { + this.state.searchValue = ""; + this.state.results = []; + this.state.showResults = false; + this.state.selectedIds.clear(); + this.state.selectedFilterIds.clear(); + // Clear saved state for current partner type + sessionStorage.removeItem(this.getSearchStateKey()); + } + + async onArchiveSelected() { + // Archive selected records + if (this.state.selectedIds.size === 0) { + return; + } + + const ids = Array.from(this.state.selectedIds); + + try { + // Call Odoo's action_archive method + await this.orm.call("res.partner", "action_archive", [ids]); + + // Refresh the search + await this.onSearch(); + + // Show success notification + this.notification.add(`${ids.length} record(s) archived successfully`, { + type: "success", + }); + } catch (error) { + console.error("Error archiving records:", error); + this.notification.add("Failed to archive records", { + type: "danger", + }); + } + } + + async onUnarchiveSelected() { + // Unarchive selected records + if (this.state.selectedIds.size === 0) { + return; + } + + const ids = Array.from(this.state.selectedIds); + + try { + // Call Odoo's action_unarchive method + await this.orm.call("res.partner", "action_unarchive", [ids]); + + // Refresh the search + await this.onSearch(); + + // Show success notification + this.notification.add(`${ids.length} record(s) unarchived successfully`, { + type: "success", + }); + } catch (error) { + console.error("Error unarchiving records:", error); + this.notification.add("Failed to unarchive records", { + type: "danger", + }); + } + } + + onRowClick(event, partnerId, isGroup) { + // Open the partner record + this.openPartner(partnerId, isGroup); + } + + onSelectRecord(recordId) { + if (this.state.selectedIds.has(recordId)) { + this.state.selectedIds.delete(recordId); + } else { + this.state.selectedIds.add(recordId); + } + } + + onSelectAll() { + this.state.selectedIds.clear(); + this.state.results.forEach((record) => { + this.state.selectedIds.add(record.id); + }); + } + + onDeselectAll() { + this.state.selectedIds.clear(); + } + + isSelected(recordId) { + return this.state.selectedIds.has(recordId); + } + + get hasSelections() { + return this.state.selectedIds.size > 0; + } + + get allSelected() { + return this.state.results.length > 0 && this.state.selectedIds.size === this.state.results.length; + } + + async onCreate() { + // Determine is_group value based on partner type + const isGroup = this.state.partnerType === "group"; + + // Determine the correct form view + let viewId = false; + + if (this.formViewRef) { + try { + viewId = await this.orm.call("ir.model.data", "xmlid_to_res_id", [this.formViewRef]); + } catch (error) { + console.error("Error resolving view reference:", error); + } + } else { + const viewRefString = isGroup + ? "g2p_registry_group.view_groups_form" + : "g2p_registry_individual.view_individuals_form"; + + try { + viewId = await this.orm.call("ir.model.data", "xmlid_to_res_id", [viewRefString]); + } catch (error) { + console.error("Error resolving view reference:", error); + } + } + + await this.action.doAction({ + type: "ir.actions.act_window", + res_model: "res.partner", + views: [[viewId, "form"]], + target: "current", + context: { + default_is_group: isGroup, + default_is_registrant: true, + form_view_ref: this.formViewRef, + }, + }); + } + + async onImport() { + // Determine is_group value based on partner type + const isGroup = this.state.partnerType === "group"; + + // Call Odoo's built-in import action + await this.action.doAction({ + type: "ir.actions.client", + tag: "import", + params: { + model: "res.partner", + context: { + default_is_group: isGroup, + default_is_registrant: true, + }, + }, + }); + } + + async onExport() { + // Check if there are selected records + if (this.state.selectedIds.size === 0) { + return; + } + + // Get the IDs of selected records + const ids = Array.from(this.state.selectedIds); + + // Save search state before export + this.saveSearchState(); + + // Determine is_group value and correct tree view + const isGroup = this.state.partnerType === "group"; + let treeViewId = false; + + const treeViewRefString = isGroup + ? "g2p_registry_group.view_groups_list_tree" + : "g2p_registry_individual.view_individuals_list_tree"; + + try { + treeViewId = await this.orm.call("ir.model.data", "xmlid_to_res_id", [treeViewRefString]); + } catch (error) { + console.error("Error resolving tree view reference:", error); + } + + // Open list view with filtered records + // User can then use Actions > Export from the list view + await this.action.doAction({ + type: "ir.actions.act_window", + name: isGroup ? "Export Groups" : "Export Individuals", + res_model: "res.partner", + views: [[treeViewId, "list"]], + view_mode: "list", + target: "current", + domain: [["id", "in", ids]], + context: { + default_is_group: isGroup, + default_is_registrant: true, + }, + }); + } +} + +registry.category("actions").add("partner_search_action", PartnerSearchAction); diff --git a/spp_registry_search/static/src/js/partner_search_widget.js b/spp_registry_search/static/src/js/partner_search_widget.js new file mode 100644 index 000000000..42294c2ba --- /dev/null +++ b/spp_registry_search/static/src/js/partner_search_widget.js @@ -0,0 +1,83 @@ +/** @odoo-module **/ + +import {Component, onWillStart, useState} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +export class PartnerSearchWidget extends Component { + static template = "spp_registry_search.PartnerSearchWidget"; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.state = useState({ + searchFields: [], + selectedField: "", + searchValue: "", + searching: false, + }); + + onWillStart(async () => { + await this.loadSearchFields(); + }); + } + + async loadSearchFields() { + try { + const fields = await this.orm.call("res.partner", "get_searchable_fields", []); + this.state.searchFields = fields; + if (fields.length > 0) { + this.state.selectedField = fields[0].field_name; + } + } catch (error) { + console.error("Error loading searchable fields:", error); + } + } + + onFieldChange(event) { + this.state.selectedField = event.target.value; + } + + onSearchValueChange(event) { + this.state.searchValue = event.target.value; + } + + async onSearch() { + if (!this.state.selectedField || !this.state.searchValue) { + return; + } + + this.state.searching = true; + try { + const results = await this.orm.call("res.partner", "search_by_field", [ + this.state.selectedField, + this.state.searchValue, + ]); + + // Open the partner list with the search results + await this.action.doAction({ + type: "ir.actions.act_window", + name: "Search Results", + res_model: "res.partner", + views: [ + [false, "list"], + [false, "form"], + ], + domain: [["id", "in", results]], + target: "current", + }); + } catch (error) { + console.error("Error searching partners:", error); + } finally { + this.state.searching = false; + } + } + + onKeyPress(event) { + if (event.key === "Enter") { + this.onSearch(); + } + } +} + +registry.category("actions").add("partner_search_widget", PartnerSearchWidget); diff --git a/spp_registry_search/static/src/xml/partner_search_view.xml b/spp_registry_search/static/src/xml/partner_search_view.xml new file mode 100644 index 000000000..7533fb538 --- /dev/null +++ b/spp_registry_search/static/src/xml/partner_search_view.xml @@ -0,0 +1,416 @@ + + + +
+
+ +
+
+
+
+

+ + Individual Search + + + Group Search + + + Registry Search + +

+
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+
+ + + Active Filters: + + + + + + + + + + + +
+
+
+ +
+
+
+ + +
+
+
+
+ + + Select filters (Ctrl+Click for multiple - uses OR logic), choose a search field, and enter a value. Leave search value empty to list all. No filters = active registrants only. + +
+
+
+
+
+ + +
+
+
+
+
+ Search Results + + + + + + selected + + +
+
+
+ +
+
+ + + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + NameAddressPhoneTagsBirthdateRegistration Date
+ + + + + + + + Archived + + + + + + + + + + + + + + + - + + + + +
+
+
+ +
+ +
No registrants found
+

Try adjusting your search criteria

+
+
+
+
+
+
+
+
+
+
diff --git a/spp_registry_search/static/src/xml/partner_search_widget.xml b/spp_registry_search/static/src/xml/partner_search_widget.xml new file mode 100644 index 000000000..73d1389b8 --- /dev/null +++ b/spp_registry_search/static/src/xml/partner_search_widget.xml @@ -0,0 +1,72 @@ + + + +
+
+
+
+
+
+

+ Registry Search +

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + + Select a field from the dropdown and enter a search value to find matching partners. + +
+
+
+
+
+
+
+
+
diff --git a/spp_registry_search/tests/__init__.py b/spp_registry_search/tests/__init__.py new file mode 100644 index 000000000..2dd364258 --- /dev/null +++ b/spp_registry_search/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import test_partner_search diff --git a/spp_registry_search/tests/test_partner_search.py b/spp_registry_search/tests/test_partner_search.py new file mode 100644 index 000000000..c7686e45b --- /dev/null +++ b/spp_registry_search/tests/test_partner_search.py @@ -0,0 +1,577 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +import logging + +from psycopg2 import IntegrityError + +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + +_logger = logging.getLogger(__name__) + + +# Tests for spp_registry_search module + + +class TestPartnerSearch(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + test_queue_job_no_delay=True, + ) + ) + + # Create test partners (individuals) + cls.partner_1 = cls.env["res.partner"].create( + { + "name": "Test Partner Alpha", + "email": "alpha@test.com", + "phone": "+1234567890", + "mobile": "+9876543210", + "ref": "REF001", + "is_registrant": True, + "is_group": False, + } + ) + + cls.partner_2 = cls.env["res.partner"].create( + { + "name": "Test Partner Beta", + "email": "beta@test.com", + "phone": "+1111111111", + "mobile": "+2222222222", + "ref": "REF002", + "is_registrant": True, + "is_group": False, + } + ) + + cls.partner_3 = cls.env["res.partner"].create( + { + "name": "Another Partner", + "email": "another@test.com", + "phone": "+3333333333", + "ref": "REF003", + "is_registrant": True, + "is_group": False, + } + ) + + # Create test group + cls.group_1 = cls.env["res.partner"].create( + { + "name": "Test Group Alpha", + "email": "group@test.com", + "phone": "+5555555555", + "ref": "GRP001", + "is_registrant": True, + "is_group": True, + } + ) + + # Get predefined search field configurations + cls.search_field_name = cls.env.ref("spp_registry_search.registry_search_field_name") + cls.search_field_email = cls.env.ref("spp_registry_search.registry_search_field_email") + cls.search_field_phone = cls.env.ref("spp_registry_search.registry_search_field_phone") + + # Get model fields for reference + cls.name_field = cls.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "name")], limit=1 + ) + cls.email_field = cls.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "email")], limit=1 + ) + cls.phone_field = cls.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "phone")], limit=1 + ) + + def test_01_search_field_configuration(self): + """Test predefined search field configuration""" + # Use predefined name search field + search_field = self.search_field_name + + self.assertTrue(search_field.exists()) + self.assertEqual(search_field.field_name, "name") + self.assertEqual(search_field.field_type, "char") + self.assertEqual(search_field.target_type, "both") + self.assertTrue(search_field.active) + + def test_02_get_searchable_fields(self): + """Test retrieving searchable fields""" + # Predefined fields use "both" target type + # Temporarily change email field to "individual" for testing + original_target = self.search_field_email.target_type + self.search_field_email.write({"target_type": "individual"}) + + try: + # Test getting all fields + fields = self.env["res.partner"].get_searchable_fields() + self.assertIsInstance(fields, list) + self.assertTrue(len(fields) >= 2) + + # Test getting individual fields only + individual_fields = self.env["res.partner"].get_searchable_fields("individual") + self.assertTrue(len(individual_fields) >= 2) # both + individual + + # Test getting group fields only + group_fields = self.env["res.partner"].get_searchable_fields("group") + self.assertTrue(len(group_fields) >= 1) # only "both" fields + + # Check field structure + if fields: + field = fields[0] + self.assertIn("id", field) + self.assertIn("name", field) + self.assertIn("field_name", field) + self.assertIn("field_type", field) + self.assertIn("target_type", field) + finally: + # Restore original target type + self.search_field_email.write({"target_type": original_target}) + + def test_03_search_by_name(self): + """Test searching partners by name""" + # Use predefined name search field (already active) + # Search for partial name (individuals) + partner_ids = self.env["res.partner"].search_by_field("name", "Test Partner", is_group=False) + + self.assertIn(self.partner_1.id, partner_ids) + self.assertIn(self.partner_2.id, partner_ids) + self.assertNotIn(self.partner_3.id, partner_ids) + self.assertNotIn(self.group_1.id, partner_ids) + + # Search for groups + group_ids = self.env["res.partner"].search_by_field("name", "Test Group", is_group=True) + self.assertIn(self.group_1.id, group_ids) + self.assertNotIn(self.partner_1.id, group_ids) + + def test_04_search_by_email(self): + """Test searching partners by email""" + # Use predefined email search field (already active) + # Search for specific email (individuals) + partner_ids = self.env["res.partner"].search_by_field("email", "alpha@test.com", is_group=False) + + self.assertIn(self.partner_1.id, partner_ids) + self.assertNotIn(self.partner_2.id, partner_ids) + self.assertNotIn(self.partner_3.id, partner_ids) + self.assertNotIn(self.group_1.id, partner_ids) + + def test_05_search_by_phone(self): + """Test searching partners by phone""" + # Use predefined phone search field (already active) + # Search for specific phone (individuals) + partner_ids = self.env["res.partner"].search_by_field("phone", "+1234567890", is_group=False) + + self.assertIn(self.partner_1.id, partner_ids) + self.assertNotIn(self.partner_2.id, partner_ids) + self.assertNotIn(self.group_1.id, partner_ids) + + def test_06_search_empty_value(self): + """Test searching with empty value - should return all matching records""" + # Use predefined name search field (already active) + # Search with empty value returns all active registrant individuals (search all) + results = self.env["res.partner"].search_by_field("name", "", is_group=False) + # Should return all 3 individual registrants (partner_1, partner_2, partner_3) + self.assertEqual(len(results), 3) + self.assertIn(self.partner_1.id, results) + self.assertIn(self.partner_2.id, results) + self.assertIn(self.partner_3.id, results) + + def test_07_search_nonexistent_field(self): + """Test searching with non-configured field""" + # Search with a field that is not configured should return empty recordset + results = self.env["res.partner"].search_by_field("nonexistent_field", "value") + self.assertEqual(len(results), 0) + + def test_08_search_inactive_field(self): + """Test searching with inactive field configuration""" + # Temporarily deactivate predefined name field + original_active = self.search_field_name.active + self.search_field_name.write({"active": False}) + + try: + # Search should not work for inactive field + results = self.env["res.partner"].search_by_field("name", "Test Partner", is_group=False) + self.assertEqual(len(results), 0) + finally: + # Restore original active state + self.search_field_name.write({"active": original_active}) + + @mute_logger("odoo.sql_db") + def test_09_unique_field_constraint(self): + """Test unique field per company constraint""" + # Predefined name field already exists + # Try to create duplicate of the same field - should fail with IntegrityError + with self.assertRaises(IntegrityError), self.cr.savepoint(): + self.env["spp.partner.search.field"].create( + { + "name": "Name Duplicate", + "field_id": self.name_field.id, + "target_type": "both", + "sequence": 20, + "active": True, + } + ) + + def test_10_name_get(self): + """Test custom name_get method""" + # Use predefined name search field + search_field = self.search_field_name + + name_get_result = search_field.name_get() + self.assertEqual(len(name_get_result), 1) + self.assertIn("Name", name_get_result[0][1]) + self.assertIn("(name)", name_get_result[0][1]) + + def test_11_is_registrant_filter(self): + """Test that is_registrant filter is always applied""" + # Create a partner that is not a registrant + non_registrant = self.env["res.partner"].create( + { + "name": "Non Registrant Partner", + "email": "nonreg@test.com", + "is_registrant": False, + "is_group": False, + } + ) + + # Use predefined name search field (already active) + # Search should not return non-registrant partners + partner_ids = self.env["res.partner"].search_by_field("name", "Non Registrant", is_group=False) + self.assertNotIn(non_registrant.id, partner_ids) + + # But registrants should be found + partner_ids = self.env["res.partner"].search_by_field("name", "Test Partner", is_group=False) + self.assertIn(self.partner_1.id, partner_ids) + + def test_12_search_by_integer_field(self): + """Test searching by integer field""" + # Get an integer field (e.g., color) + color_field = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "color")], limit=1 + ) + + # Create search configuration for integer field + int_search_field = self.env["spp.partner.search.field"].create( + { + "name": "Color", + "field_id": color_field.id, + "target_type": "both", + "sequence": 100, + "active": True, + } + ) + + # Set color on partner + self.partner_1.write({"color": 5}) + + try: + # Search by integer value + partner_ids = self.env["res.partner"].search_by_field("color", "5", is_group=False) + self.assertIn(self.partner_1.id, partner_ids) + + # Search with invalid integer value + partner_ids = self.env["res.partner"].search_by_field("color", "invalid", is_group=False) + self.assertEqual(len(partner_ids), 0) + finally: + int_search_field.unlink() + + def test_13_search_by_selection_field(self): + """Test searching by selection field""" + # Get a selection field (e.g., type) + type_field = self.env["ir.model.fields"].search([("model", "=", "res.partner"), ("name", "=", "type")], limit=1) + + if not type_field: + self.skipTest("Type field not available") + + # Create search configuration for selection field + sel_search_field = self.env["spp.partner.search.field"].create( + { + "name": "Type", + "field_id": type_field.id, + "target_type": "both", + "sequence": 101, + "active": True, + } + ) + + # Set type on partner + self.partner_1.write({"type": "contact"}) + + try: + # Search by selection value + partner_ids = self.env["res.partner"].search_by_field("type", "contact", is_group=False) + self.assertIn(self.partner_1.id, partner_ids) + finally: + sel_search_field.unlink() + + def test_14_search_by_many2one_field(self): + """Test searching by many2one field""" + # Create a country for testing + test_country = self.env["res.country"].search([("code", "=", "US")], limit=1) + if not test_country: + test_country = self.env["res.country"].create({"name": "United States", "code": "US"}) + + # Get country_id field + country_field = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "country_id")], limit=1 + ) + + # Create search configuration for many2one field + m2o_search_field = self.env["spp.partner.search.field"].create( + { + "name": "Country", + "field_id": country_field.id, + "target_type": "both", + "sequence": 102, + "active": True, + } + ) + + # Set country on partner + self.partner_1.write({"country_id": test_country.id}) + + try: + # Search by ID + partner_ids = self.env["res.partner"].search_by_field("country_id", str(test_country.id), is_group=False) + self.assertIn(self.partner_1.id, partner_ids) + + # Search by name (fallback) + partner_ids = self.env["res.partner"].search_by_field("country_id", "United States", is_group=False) + self.assertIn(self.partner_1.id, partner_ids) + finally: + m2o_search_field.unlink() + + def test_15_search_by_boolean_field(self): + """Test searching by boolean field""" + # Get a boolean field (e.g., active) + active_field = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "active")], limit=1 + ) + + # Create search configuration for boolean field + bool_search_field = self.env["spp.partner.search.field"].create( + { + "name": "Active", + "field_id": active_field.id, + "target_type": "both", + "sequence": 103, + "active": True, + } + ) + + try: + # Search for active partners (true) + partner_ids = self.env["res.partner"].search_by_field("active", "true", is_group=False) + self.assertIn(self.partner_1.id, partner_ids) + + # Search with different boolean representations + partner_ids = self.env["res.partner"].search_by_field("active", "1", is_group=False) + self.assertIn(self.partner_1.id, partner_ids) + + partner_ids = self.env["res.partner"].search_by_field("active", "yes", is_group=False) + self.assertIn(self.partner_1.id, partner_ids) + finally: + bool_search_field.unlink() + + def test_16_search_with_filter_domain(self): + """Test searching with additional filter domain""" + # Test with email filter domain (always available) + # Search with filter domain that filters by email + filter_domain = '[["email", "=", "alpha@test.com"]]' + partner_ids = self.env["res.partner"].search_by_field("name", "", is_group=False, filter_domain=filter_domain) + + # Should find only partner_1 with alpha@test.com + self.assertIn(self.partner_1.id, partner_ids) + self.assertNotIn(self.partner_2.id, partner_ids) + self.assertNotIn(self.partner_3.id, partner_ids) + + def test_17_search_with_or_filter_domain(self): + """Test searching with OR operator in filter domain""" + # Search with complex domain using OR operator + filter_domain = '["|", ["email", "=", "alpha@test.com"], ["email", "=", "beta@test.com"]]' + partner_ids = self.env["res.partner"].search_by_field("name", "", is_group=False, filter_domain=filter_domain) + + self.assertIn(self.partner_1.id, partner_ids) + self.assertIn(self.partner_2.id, partner_ids) + + def test_18_search_archived_records(self): + """Test searching archived records with filter domain""" + # Archive a partner + self.partner_3.write({"active": False}) + + # Search without archived filter - should not find archived + partner_ids = self.env["res.partner"].search_by_field("name", "Another Partner", is_group=False) + self.assertNotIn(self.partner_3.id, partner_ids) + + # Search with archived filter - should find archived + filter_domain = '[["active", "=", false]]' + partner_ids = self.env["res.partner"].search_by_field( + "name", "Another Partner", is_group=False, filter_domain=filter_domain + ) + self.assertIn(self.partner_3.id, partner_ids) + + # Restore partner + self.partner_3.write({"active": True}) + + def test_19_search_with_invalid_filter_domain(self): + """Test searching with invalid filter domain""" + # Search with invalid JSON domain - should default to active records + invalid_domain = "not valid json" + partner_ids = self.env["res.partner"].search_by_field( + "name", "Test Partner", is_group=False, filter_domain=invalid_domain + ) + + # Should still return results (with default active filter) + self.assertIn(self.partner_1.id, partner_ids) + + def test_20_get_searchable_fields_with_selection(self): + """Test get_searchable_fields includes selection options""" + # Get a selection field + type_field = self.env["ir.model.fields"].search([("model", "=", "res.partner"), ("name", "=", "type")], limit=1) + + if not type_field: + self.skipTest("Type field not available") + + # Create search configuration for selection field + sel_search_field = self.env["spp.partner.search.field"].create( + { + "name": "Type", + "field_id": type_field.id, + "target_type": "both", + "sequence": 200, + "active": True, + } + ) + + try: + # Get searchable fields + fields = self.env["res.partner"].get_searchable_fields() + + # Find the selection field + type_field_info = next((f for f in fields if f["field_name"] == "type"), None) + self.assertIsNotNone(type_field_info) + + # Check that selection options are included + self.assertIn("selection", type_field_info) + self.assertIsInstance(type_field_info["selection"], (list, tuple)) + finally: + sel_search_field.unlink() + + def test_21_get_searchable_fields_with_many2one(self): + """Test get_searchable_fields includes many2one relation info""" + # Get country_id field + country_field = self.env["ir.model.fields"].search( + [("model", "=", "res.partner"), ("name", "=", "country_id")], limit=1 + ) + + # Create search configuration for many2one field + m2o_search_field = self.env["spp.partner.search.field"].create( + { + "name": "Country", + "field_id": country_field.id, + "target_type": "both", + "sequence": 201, + "active": True, + } + ) + + try: + # Get searchable fields + fields = self.env["res.partner"].get_searchable_fields() + + # Find the many2one field + country_field_info = next((f for f in fields if f["field_name"] == "country_id"), None) + self.assertIsNotNone(country_field_info) + + # Check that relation info is included + self.assertIn("relation", country_field_info) + self.assertEqual(country_field_info["relation"], "res.country") + self.assertIn("relation_field", country_field_info) + finally: + m2o_search_field.unlink() + + def test_22_get_searchable_fields_by_type(self): + """Test get_searchable_fields filtered by partner type""" + # Get all fields + all_fields = self.env["res.partner"].get_searchable_fields() + self.assertTrue(len(all_fields) >= 3) + + # Get individual fields + individual_fields = self.env["res.partner"].get_searchable_fields("individual") + self.assertTrue(len(individual_fields) >= 3) + + # Get group fields + group_fields = self.env["res.partner"].get_searchable_fields("group") + self.assertTrue(len(group_fields) >= 3) + + def test_23_get_field_options(self): + """Test get_field_options method""" + # Get options for res.country + options = self.env["res.partner"].get_field_options("res.country") + + # Should return list of tuples + self.assertIsInstance(options, list) + if options: + self.assertIsInstance(options[0], tuple) + self.assertEqual(len(options[0]), 2) # (id, name) + + def test_24_get_field_options_invalid_model(self): + """Test get_field_options with invalid model""" + # Should handle error gracefully + options = self.env["res.partner"].get_field_options("invalid.model") + self.assertEqual(options, []) + + def test_25_get_search_filters(self): + """Test get_search_filters method""" + # Get all filters + filters = self.env["res.partner"].get_search_filters() + + # Should return list of dictionaries + self.assertIsInstance(filters, list) + + # Check filter structure if any exist + if filters: + filter_info = filters[0] + self.assertIn("id", filter_info) + self.assertIn("name", filter_info) + self.assertIn("domain", filter_info) + self.assertIn("description", filter_info) + self.assertIn("target_type", filter_info) + + # Domain should be valid JSON + import json + + domain = json.loads(filter_info["domain"]) + self.assertIsInstance(domain, list) + + def test_26_get_search_filters_by_type(self): + """Test get_search_filters filtered by partner type""" + # Get all filters + all_filters = self.env["res.partner"].get_search_filters() + + # Get individual filters + individual_filters = self.env["res.partner"].get_search_filters("individual") + + # Get group filters + group_filters = self.env["res.partner"].get_search_filters("group") + + # All should return lists + self.assertIsInstance(all_filters, list) + self.assertIsInstance(individual_filters, list) + self.assertIsInstance(group_filters, list) + + def test_27_search_with_empty_field_name(self): + """Test search_by_field with empty field name""" + # Should return empty list + results = self.env["res.partner"].search_by_field("", "value", is_group=False) + self.assertEqual(results, []) + + def test_28_search_with_none_field_name(self): + """Test search_by_field with None field name""" + # Should return empty list + results = self.env["res.partner"].search_by_field(None, "value", is_group=False) + self.assertEqual(results, []) diff --git a/spp_registry_search/views/partner_custom_search_view.xml b/spp_registry_search/views/partner_custom_search_view.xml new file mode 100644 index 000000000..e4636c3c5 --- /dev/null +++ b/spp_registry_search/views/partner_custom_search_view.xml @@ -0,0 +1,81 @@ + + + + + + partner.custom.search.form + res.partner + 1000 + +
+ + + + + + + +
+
+
+ + + + partner.custom.search.tree + res.partner + 1000 + + + + + + + + + + + + + + + + Individuals + partner_search_action + current + {'default_partner_type': 'individual', 'hide_partner_type': True, 'form_view_ref': 'g2p_registry_individual.view_individuals_form'} + + + + + Groups + partner_search_action + current + {'default_partner_type': 'group', 'hide_partner_type': True, 'form_view_ref': 'g2p_registry_group.view_groups_form'} + + + + + + + +
diff --git a/spp_registry_search/views/partner_search_field_view.xml b/spp_registry_search/views/partner_search_field_view.xml new file mode 100644 index 000000000..e84bd3b22 --- /dev/null +++ b/spp_registry_search/views/partner_search_field_view.xml @@ -0,0 +1,105 @@ + + + + + + spp.partner.search.field.tree + spp.partner.search.field + + + + + + + + + + + + + + + spp.partner.search.field.form + spp.partner.search.field + +
+ +
+ +
+ + + + + + + + + + + + + + +
+
+
+
+ + + + spp.partner.search.field.search + spp.partner.search.field + + + + + + + + + + + + + + + + + Registry Search Fields + spp.partner.search.field + tree,form + {'search_default_active': 1} + +

+ Create your first searchable registry field! +

+

+ Configure which registry fields should be available for searching in the registry search interface. +

+
+
+ + + + + + +
diff --git a/spp_registry_search/views/partner_search_filter_view.xml b/spp_registry_search/views/partner_search_filter_view.xml new file mode 100644 index 000000000..88290469e --- /dev/null +++ b/spp_registry_search/views/partner_search_filter_view.xml @@ -0,0 +1,104 @@ + + + + + spp.partner.search.filter.form + spp.partner.search.filter + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + + spp.partner.search.filter.tree + spp.partner.search.filter + + + + + + + + + + + + + + + spp.partner.search.filter.search + spp.partner.search.filter + + + + + + + + + + + + + + + + + Search Filters + spp.partner.search.filter + tree,form + {'search_default_active': 1} + +

+ Create your first search filter +

+

+ Search filters allow you to define predefined domain filters + for searching partners. For example, you can create a filter + to show only female registrants, or registrants from a specific region. +

+
+
+ + + +