diff --git a/spp_gis_report_programs/README.rst b/spp_gis_report_programs/README.rst index 5b02590f..cf07635f 100644 --- a/spp_gis_report_programs/README.rst +++ b/spp_gis_report_programs/README.rst @@ -40,15 +40,10 @@ Key Capabilities Key Models ~~~~~~~~~~ -+---------------------------+------------------------------------------+ -| Model | Extension | -+===========================+==========================================+ -| ``spp.gis.report`` | Adds ``program_id`` field and filters | -| | domain by program members | -+---------------------------+------------------------------------------+ -| ``spp.gis.report.wizard`` | Adds ``program_id`` selection with | -| | validation and code suffix | -+---------------------------+------------------------------------------+ +- ``spp.gis.report`` — Adds ``program_id`` field and filters domain by + program members +- ``spp.gis.report.wizard`` — Adds ``program_id`` selection with + validation and code suffix Configuration ~~~~~~~~~~~~~ @@ -67,18 +62,12 @@ UI Location Security ~~~~~~~~ -+------------------------------------------+----------------------------------+ -| Group | Access | -+==========================================+==================================+ -| ``spp_programs.group_programs_officer`` | Read GIS reports, data, | -| | templates, etc. | -+------------------------------------------+----------------------------------+ -| ``spp_programs.group_programs_manager`` | Read/Write (no create/unlink) | -| | all models | -+------------------------------------------+----------------------------------+ -| ``spp_gis_report.group_gis_report_user`` | Implied for program managers via | -| | XML | -+------------------------------------------+----------------------------------+ +- ``spp_programs.group_programs_officer`` — Read GIS reports, data, + templates, etc. +- ``spp_programs.group_programs_manager`` — Read/Write (no + create/unlink) all models +- ``spp_gis_report.group_gis_report_user`` — Implied for program + managers via XML Models with access: ``spp.gis.report``, ``spp.gis.report.data``, ``spp.gis.report.threshold``, ``spp.gis.report.template``, diff --git a/spp_gis_report_programs/__manifest__.py b/spp_gis_report_programs/__manifest__.py index b55ad1df..afb25790 100644 --- a/spp_gis_report_programs/__manifest__.py +++ b/spp_gis_report_programs/__manifest__.py @@ -16,6 +16,7 @@ "views/gis_report_views.xml", "views/gis_report_wizard_views.xml", ], + "development_status": "Beta", "installable": True, "application": False, "auto_install": True, diff --git a/spp_gis_report_programs/readme/DESCRIPTION.md b/spp_gis_report_programs/readme/DESCRIPTION.md index 832ec6e8..3852c981 100644 --- a/spp_gis_report_programs/readme/DESCRIPTION.md +++ b/spp_gis_report_programs/readme/DESCRIPTION.md @@ -10,10 +10,8 @@ Auto-install glue module that adds program context filtering to GIS reports. Ext ### Key Models -| Model | Extension | -| ----------------------- | -------------------------------------------------------------- | -| `spp.gis.report` | Adds `program_id` field and filters domain by program members | -| `spp.gis.report.wizard` | Adds `program_id` selection with validation and code suffix | +- `spp.gis.report` — Adds `program_id` field and filters domain by program members +- `spp.gis.report.wizard` — Adds `program_id` selection with validation and code suffix ### Configuration @@ -26,11 +24,9 @@ No configuration required. Auto-installs when both `spp_gis_report` and `spp_pro ### Security -| Group | Access | -| ------------------------------------- | ----------------------------------------- | -| `spp_programs.group_programs_officer` | Read GIS reports, data, templates, etc. | -| `spp_programs.group_programs_manager` | Read/Write (no create/unlink) all models | -| `spp_gis_report.group_gis_report_user` | Implied for program managers via XML | +- `spp_programs.group_programs_officer` — Read GIS reports, data, templates, etc. +- `spp_programs.group_programs_manager` — Read/Write (no create/unlink) all models +- `spp_gis_report.group_gis_report_user` — Implied for program managers via XML Models with access: `spp.gis.report`, `spp.gis.report.data`, `spp.gis.report.threshold`, `spp.gis.report.template`, `spp.gis.report.category` diff --git a/spp_gis_report_programs/static/description/index.html b/spp_gis_report_programs/static/description/index.html index afb7257e..0f688297 100644 --- a/spp_gis_report_programs/static/description/index.html +++ b/spp_gis_report_programs/static/description/index.html @@ -387,27 +387,12 @@
| Model | -Extension | -
|---|---|
| spp.gis.report | -Adds program_id field and filters -domain by program members | -
| spp.gis.report.wizard | -Adds program_id selection with -validation and code suffix | -
| Group | -Access | -
|---|---|
| spp_programs.group_programs_officer | -Read GIS reports, data, -templates, etc. | -
| spp_programs.group_programs_manager | -Read/Write (no create/unlink) -all models | -
| spp_gis_report.group_gis_report_user | -Implied for program managers via -XML | -
Models with access: spp.gis.report, spp.gis.report.data, spp.gis.report.threshold, spp.gis.report.template, spp.gis.report.category
diff --git a/spp_gis_report_programs/tests/__init__.py b/spp_gis_report_programs/tests/__init__.py new file mode 100644 index 00000000..f06bdf3b --- /dev/null +++ b/spp_gis_report_programs/tests/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import test_gis_report_programs diff --git a/spp_gis_report_programs/tests/test_gis_report_programs.py b/spp_gis_report_programs/tests/test_gis_report_programs.py new file mode 100644 index 00000000..77dc74a7 --- /dev/null +++ b/spp_gis_report_programs/tests/test_gis_report_programs.py @@ -0,0 +1,197 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + + +from odoo.exceptions import ValidationError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestGISReportProgramsModel(TransactionCase): + """Test GIS Report extension for program context filtering.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Report = cls.env["spp.gis.report"] + cls.Program = cls.env["spp.program"] + + cls.program = cls.Program.create( + { + "name": "Test Program for GIS", + "target_type": "group", + } + ) + + # Get source model for res.partner + cls.partner_model = cls.env["ir.model"].search([("model", "=", "res.partner")], limit=1) + + def _make_report_vals(self, **overrides): + """Build minimal vals dict for a spp.gis.report record.""" + vals = { + "name": "Test Report", + "code": "TEST_RPT_001", + "source_model_id": self.partner_model.id, + "area_field_path": "area_id", + "filter_mode": "domain", + "aggregation_method": "count", + "base_area_level": 2, + "normalization_method": "raw", + "threshold_mode": "auto_quartile", + "refresh_mode": "manual", + "geometry_type": "polygon", + } + vals.update(overrides) + return vals + + def test_program_id_field_exists(self): + """Test that program_id field is added to spp.gis.report.""" + field_info = self.Report.fields_get(["program_id"]) + self.assertIn("program_id", field_info) + self.assertEqual(field_info["program_id"]["relation"], "spp.program") + + def test_report_create_with_program(self): + """Test creating a report with program_id set.""" + report = self.Report.create(self._make_report_vals(program_id=self.program.id)) + self.assertEqual(report.program_id, self.program) + + def test_report_create_without_program(self): + """Test creating a report without program_id.""" + report = self.Report.create(self._make_report_vals()) + self.assertFalse(report.program_id) + + def test_apply_context_filters_with_program(self): + """Test _apply_context_filters adds program enrollment domain.""" + report = self.Report.new( + { + "program_id": self.program.id, + "source_model": "res.partner", + } + ) + domain = [("is_registrant", "=", True)] + result = report._apply_context_filters(domain) + + # Should contain the original domain + program membership filter + flat = str(result) + self.assertIn("program_membership_ids.program_id", flat) + + def test_apply_context_filters_without_program(self): + """Test _apply_context_filters is a no-op without program_id.""" + report = self.Report.new( + { + "source_model": "res.partner", + } + ) + domain = [("is_registrant", "=", True)] + result = report._apply_context_filters(domain) + self.assertEqual(result, domain) + + def test_apply_context_filters_non_partner_model(self): + """Test _apply_context_filters skips filter for non-partner models.""" + report = self.Report.new( + { + "program_id": self.program.id, + "source_model": "spp.area", + } + ) + domain = [("active", "=", True)] + result = report._apply_context_filters(domain) + # Should NOT add program filter since source_model is not res.partner + self.assertEqual(result, domain) + + +@tagged("post_install", "-at_install") +class TestGISReportWizardPrograms(TransactionCase): + """Test GIS Report Wizard extension for program context.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Wizard = cls.env["spp.gis.report.wizard"] + cls.Program = cls.env["spp.program"] + + cls.program = cls.Program.create( + { + "name": "Wizard Test Program", + "target_type": "group", + } + ) + + def test_wizard_program_id_field(self): + """Test that program_id field exists on the wizard.""" + field_info = self.Wizard.fields_get(["program_id"]) + self.assertIn("program_id", field_info) + self.assertEqual(field_info["program_id"]["relation"], "spp.program") + + def test_validate_context_requirements_program_required(self): + """Test validation when template requires program but none set.""" + wizard = self.Wizard.new( + { + "template_requires_program": True, + "program_id": False, + } + ) + with self.assertRaises(ValidationError): + wizard._validate_context_requirements() + + def test_validate_context_requirements_program_provided(self): + """Test validation passes when required program is provided.""" + wizard = self.Wizard.new( + { + "template_requires_program": True, + "program_id": self.program.id, + } + ) + # Should not raise + wizard._validate_context_requirements() + + def test_validate_context_requirements_not_required(self): + """Test validation passes when program is not required.""" + wizard = self.Wizard.new( + { + "template_requires_program": False, + "program_id": False, + } + ) + # Should not raise + wizard._validate_context_requirements() + + def test_get_context_filter_vals_with_program(self): + """Test _get_context_filter_vals includes program_id.""" + wizard = self.Wizard.new( + { + "program_id": self.program.id, + } + ) + vals = wizard._get_context_filter_vals() + self.assertEqual(vals.get("program_id"), self.program.id) + + def test_get_context_filter_vals_without_program(self): + """Test _get_context_filter_vals without program.""" + wizard = self.Wizard.new( + { + "program_id": False, + } + ) + vals = wizard._get_context_filter_vals() + self.assertNotIn("program_id", vals) + + def test_get_context_code_suffix_with_program(self): + """Test _get_context_code_suffix includes program ID.""" + wizard = self.Wizard.new( + { + "program_id": self.program.id, + } + ) + suffix = wizard._get_context_code_suffix() + self.assertIn(str(self.program.id), suffix) + + def test_get_context_code_suffix_without_program(self): + """Test _get_context_code_suffix returns empty without program.""" + wizard = self.Wizard.new( + { + "program_id": False, + } + ) + suffix = wizard._get_context_code_suffix() + self.assertEqual(suffix, "") diff --git a/spp_studio_api_v2/README.rst b/spp_studio_api_v2/README.rst index 4a1709f6..808eb92a 100644 --- a/spp_studio_api_v2/README.rst +++ b/spp_studio_api_v2/README.rst @@ -47,21 +47,14 @@ Key Capabilities Key Models ~~~~~~~~~~ -+-------------------------------------+----------------------------------+ -| Model | Description | -+=====================================+==================================+ -| ``spp.studio.api.individual.mixin`` | Service methods for Individual | -| | API extension and variable data | -+-------------------------------------+----------------------------------+ -| ``spp.studio.api.group.mixin`` | Service methods for Group API | -| | extension and variable data | -+-------------------------------------+----------------------------------+ -| ``spp.studio.field`` (extended) | Adds ``api_exposed`` flag and | -| | auto-registration hooks | -+-------------------------------------+----------------------------------+ -| ``fastapi.endpoint`` (extended) | Mounts Studio router on API v2 | -| | endpoint | -+-------------------------------------+----------------------------------+ +- ``spp.studio.api.individual.mixin`` — Service methods for Individual + API extension and variable data +- ``spp.studio.api.group.mixin`` — Service methods for Group API + extension and variable data +- ``spp.studio.field`` (extended) — Adds ``api_exposed`` flag and + auto-registration hooks +- ``fastapi.endpoint`` (extended) — Mounts Studio router on API v2 + endpoint API Endpoints ~~~~~~~~~~~~~ @@ -97,16 +90,10 @@ fields are activated. Security ~~~~~~~~ -+------------------------------------+----------------------------------+ -| Group | Access | -+====================================+==================================+ -| ``spp_api_v2.group_api_v2_read`` | Read on service mixins | -+------------------------------------+----------------------------------+ -| ``spp_api_v2.group_api_v2_write`` | Read/Write on service mixins | -+------------------------------------+----------------------------------+ -| ``spp_api_v2.group_api_v2_create`` | Read/Write/Create on service | -| | mixins (no delete) | -+------------------------------------+----------------------------------+ +- ``spp_api_v2.group_api_v2_read`` — Read on service mixins +- ``spp_api_v2.group_api_v2_write`` — Read/Write on service mixins +- ``spp_api_v2.group_api_v2_create`` — Read/Write/Create on service + mixins (no delete) API authorization uses scope-based authentication (``studio:read`` scope), not Odoo group checks. diff --git a/spp_studio_api_v2/__manifest__.py b/spp_studio_api_v2/__manifest__.py index 4e80c58f..0c50870b 100644 --- a/spp_studio_api_v2/__manifest__.py +++ b/spp_studio_api_v2/__manifest__.py @@ -16,6 +16,7 @@ "security/ir.model.access.csv", "data/api_extension_data.xml", ], + "development_status": "Beta", "installable": True, # Bridge module: auto-install when both spp_api_v2 and spp_studio are present "auto_install": ["spp_api_v2", "spp_studio"], diff --git a/spp_studio_api_v2/readme/DESCRIPTION.md b/spp_studio_api_v2/readme/DESCRIPTION.md index a5efd2e6..15763bd7 100644 --- a/spp_studio_api_v2/readme/DESCRIPTION.md +++ b/spp_studio_api_v2/readme/DESCRIPTION.md @@ -11,12 +11,10 @@ Bridge module that exposes Studio custom fields and CEL variables through API v2 ### Key Models -| Model | Description | -| ------------------------------------- | -------------------------------------------------------------- | -| `spp.studio.api.individual.mixin` | Service methods for Individual API extension and variable data | -| `spp.studio.api.group.mixin` | Service methods for Group API extension and variable data | -| `spp.studio.field` (extended) | Adds `api_exposed` flag and auto-registration hooks | -| `fastapi.endpoint` (extended) | Mounts Studio router on API v2 endpoint | +- `spp.studio.api.individual.mixin` — Service methods for Individual API extension and variable data +- `spp.studio.api.group.mixin` — Service methods for Group API extension and variable data +- `spp.studio.field` (extended) — Adds `api_exposed` flag and auto-registration hooks +- `fastapi.endpoint` (extended) — Mounts Studio router on API v2 endpoint ### API Endpoints @@ -41,11 +39,9 @@ No dedicated UI. Field registration happens automatically when Studio fields are ### Security -| Group | Access | -| ------------------------------------ | -------------------------------------------------------- | -| `spp_api_v2.group_api_v2_read` | Read on service mixins | -| `spp_api_v2.group_api_v2_write` | Read/Write on service mixins | -| `spp_api_v2.group_api_v2_create` | Read/Write/Create on service mixins (no delete) | +- `spp_api_v2.group_api_v2_read` — Read on service mixins +- `spp_api_v2.group_api_v2_write` — Read/Write on service mixins +- `spp_api_v2.group_api_v2_create` — Read/Write/Create on service mixins (no delete) API authorization uses scope-based authentication (`studio:read` scope), not Odoo group checks. diff --git a/spp_studio_api_v2/routers/studio.py b/spp_studio_api_v2/routers/studio.py index 34f446de..8f97697c 100644 --- a/spp_studio_api_v2/routers/studio.py +++ b/spp_studio_api_v2/routers/studio.py @@ -397,12 +397,12 @@ async def list_variables( for var in variables: # Skip variables with missing required fields to avoid validation errors # All required fields must be non-empty strings - cel_accessor = var.cel_accessor - value_type = var.value_type - source_type = var.source_type - applies_to = var.applies_to + var_cel_accessor = var.cel_accessor + var_value_type = var.value_type + var_source_type = var.source_type + var_applies_to = var.applies_to - if not cel_accessor or not value_type or not source_type or not applies_to: + if not var_cel_accessor or not var_value_type or not var_source_type or not var_applies_to: _logger.warning("Skipping variable ID %s with missing required fields", var.id) continue @@ -410,14 +410,14 @@ async def list_variables( try: items.append( VariableInfo( - name=str(cel_accessor), - label=str(getattr(var, "label", None) or var.name or cel_accessor), + name=str(var_cel_accessor), + label=str(getattr(var, "label", None) or var.name or var_cel_accessor), description=str(getattr(var, "description", None) or "") if getattr(var, "description", None) else None, - valueType=str(value_type), - sourceType=str(source_type), - appliesTo=str(applies_to), + valueType=str(var_value_type), + sourceType=str(var_source_type), + appliesTo=str(var_applies_to), periodGranularity=str(var.period_granularity) if var.period_granularity else "current", supportsHistorical=bool(var.supports_historical), unit=str(getattr(var, "unit", None)) if getattr(var, "unit", None) else None, diff --git a/spp_studio_api_v2/static/description/index.html b/spp_studio_api_v2/static/description/index.html index 387f42b1..662b815e 100644 --- a/spp_studio_api_v2/static/description/index.html +++ b/spp_studio_api_v2/static/description/index.html @@ -394,35 +394,16 @@| Model | -Description | -
|---|---|
| spp.studio.api.individual.mixin | -Service methods for Individual -API extension and variable data | -
| spp.studio.api.group.mixin | -Service methods for Group API -extension and variable data | -
| spp.studio.field (extended) | -Adds api_exposed flag and -auto-registration hooks | -
| fastapi.endpoint (extended) | -Mounts Studio router on API v2 -endpoint | -
| Group | -Access | -
|---|---|
| spp_api_v2.group_api_v2_read | -Read on service mixins | -
| spp_api_v2.group_api_v2_write | -Read/Write on service mixins | -
| spp_api_v2.group_api_v2_create | -Read/Write/Create on service -mixins (no delete) | -
API authorization uses scope-based authentication (studio:read scope), not Odoo group checks.
html
") + self.assertEqual(result, "html
") + + def test_convert_to_odoo_date_non_string(self): + """Test _convert_to_odoo for date with non-string returns None.""" + service = self._get_service() + + class MockField: + ttype = "date" + + result = service._convert_to_odoo(MockField(), 12345) + self.assertIsNone(result) + + def test_convert_to_odoo_bool_from_bool(self): + """Test _convert_to_odoo integer from bool.""" + service = self._get_service() + + class MockField: + ttype = "integer" + + result = service._convert_to_odoo(MockField(), True) + self.assertEqual(result, 1) + result = service._convert_to_odoo(MockField(), False) + self.assertEqual(result, 0) + + def test_convert_to_odoo_float_from_bool(self): + """Test _convert_to_odoo float from bool.""" + service = self._get_service() + + class MockField: + ttype = "float" + + result = service._convert_to_odoo(MockField(), True) + self.assertEqual(result, 1.0) + result = service._convert_to_odoo(MockField(), False) + self.assertEqual(result, 0.0) + + def test_convert_to_odoo_unknown_type(self): + """Test _convert_to_odoo with unknown ttype passes through.""" + service = self._get_service() + + class MockField: + ttype = "binary" + + result = service._convert_to_odoo(MockField(), b"data") + self.assertEqual(result, b"data") + + def test_parse_extension_fields_with_real_extension(self): + """Test _parse_extension_fields with a configured extension.""" + service = self._get_service() + + # Create a test extension with a char field + module = self.env["ir.module.module"].search([("name", "=", "spp_studio_api_v2")], limit=1) + if not module: + module = self.env["ir.module.module"].search([("state", "=", "installed")], limit=1) + + # Find an x_ field on res.partner + char_field = self.env["ir.model.fields"].search( + [ + ("model", "=", "res.partner"), + ("name", "like", "x_%"), + ("ttype", "=", "char"), + ], + limit=1, + ) + if not char_field: + self.skipTest("No x_ char field on res.partner") + + extension = self.env["spp.api.extension"].create( + { + "name": "Parse Test Extension", + "url": "urn:test:parse-ext", + "module_id": module.id, + "applies_to": "individual", + "active": True, + "field_ids": [Command.set([char_field.id])], + } + ) + + api_name = service._odoo_to_api_name(char_field.name) + ext_values = {api_name: "test_value"} + + result = service._parse_extension_fields(extension, ext_values) + self.assertIn(char_field.name, result) + self.assertEqual(result[char_field.name], "test_value") + + def test_parse_extension_fields_null_value(self): + """Test _parse_extension_fields with explicit null sets False.""" + service = self._get_service() + + module = self.env["ir.module.module"].search([("name", "=", "spp_studio_api_v2")], limit=1) + if not module: + module = self.env["ir.module.module"].search([("state", "=", "installed")], limit=1) + + char_field = self.env["ir.model.fields"].search( + [ + ("model", "=", "res.partner"), + ("name", "like", "x_%"), + ("ttype", "=", "char"), + ], + limit=1, + ) + if not char_field: + self.skipTest("No x_ char field on res.partner") + + extension = self.env["spp.api.extension"].create( + { + "name": "Null Value Test Extension", + "url": "urn:test:null-ext", + "module_id": module.id, + "applies_to": "individual", + "active": True, + "field_ids": [Command.set([char_field.id])], + } + ) + + api_name = service._odoo_to_api_name(char_field.name) + ext_values = {api_name: None} + + result = service._parse_extension_fields(extension, ext_values) + self.assertIn(char_field.name, result) + self.assertFalse(result[char_field.name]) + + def test_parse_extension_data_invalid_values(self): + """Test parse_extension_data with non-dict extension values.""" + service = self._get_service() + result = service.parse_extension_data({"ext": "not_a_dict"}, "individual") + self.assertEqual(result, {}) + + def test_find_extension_by_name(self): + """Test _find_extension by name match.""" + service = self._get_service() + + ext = service._find_extension("Studio Individual Fields", "individual") + self.assertTrue(ext) + + def test_find_by_display_unsafe_model(self): + """Test _find_by_display rejects non-safe non-blocked model.""" + service = self._get_service() + result = service._find_by_display("res.partner", "Some Name") + self.assertIsNone(result) + + def test_find_by_display_case_insensitive(self): + """Test _find_by_display falls back to ilike.""" + service = self._get_service() + # Search with wrong case - should still find via ilike + country = self.env["res.country"].search([], limit=1) + if not country: + self.skipTest("No countries available") + + result = service._find_by_display("res.country", country.name.upper()) + if result: + self.assertEqual(result.id, country.id) + + def test_find_vocabulary_code_model_missing(self): + """Test _find_vocabulary_code when model is not in env.""" + service = self._get_service() + # Patch env to not have the model + with patch.object(type(service.env), "__contains__", return_value=False): + result = service._find_vocabulary_code("urn:test", "code") + self.assertIsNone(result) + + def test_convert_to_odoo_float_non_finite(self): + """Test _convert_to_odoo float with non-finite value (lines 241-243).""" + service = self._get_service() + + class MockField: + ttype = "float" + + result = service._convert_to_odoo(MockField(), "not_a_number") + self.assertIsNone(result) + + def test_convert_many2one_string_not_found(self): + """Test _convert_many2one returns False when no match (line 312).""" + service = self._get_service() + + class MockField: + ttype = "many2one" + relation = "res.country" + + result = service._convert_many2one(MockField(), "Nonexistent Country XYZ999") + self.assertFalse(result) + + def test_find_by_display_exception(self): + """Test _find_by_display handles exception (lines 416-418).""" + service = self._get_service() + + with patch.object( + type(self.env["res.country"]), + "search", + side_effect=Exception("DB error"), + ): + result = service._find_by_display("res.country", "Test") + self.assertIsNone(result) + + def test_odoo_to_api_name_empty(self): + """Test _odoo_to_api_name with empty string (line 183).""" + service = self._get_service() + result = service._odoo_to_api_name("") + self.assertEqual(result, "") + + def test_parse_extension_fields_field_not_in_values(self): + """Test _parse_extension_fields skips fields not in values (line 147).""" + service = self._get_service() + + module = self.env["ir.module.module"].search([("state", "=", "installed")], limit=1) + + char_field = self.env["ir.model.fields"].search( + [ + ("model", "=", "res.partner"), + ("name", "like", "x_%"), + ("ttype", "=", "char"), + ], + limit=1, + ) + if not char_field: + self.skipTest("No x_ char field on res.partner") + + extension = self.env["spp.api.extension"].create( + { + "name": "Skip Field Test Extension", + "url": "urn:test:skip-field-ext", + "module_id": module.id, + "applies_to": "individual", + "active": True, + "field_ids": [Command.set([char_field.id])], + } + ) + + # Pass empty values - field should be skipped + result = service._parse_extension_fields(extension, {}) + self.assertEqual(result, {}) + + +@tagged("post_install", "-at_install") +class TestVariableValueServiceGaps(TransactionCase): + """Test variable value service coverage gaps.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + { + "name": "VVS Gap Test Partner", + "is_registrant": True, + "is_group": False, + } + ) + + def _get_service(self): + from ..services.variable_value_service import VariableValueService + + return VariableValueService(self.env) + + def test_get_values_for_subjects_truncation(self): + """Test get_values_for_subjects truncates large batches.""" + service = self._get_service() + + # Create batch exceeding MAX_BATCH_SIZE (1000) + large_batch = list(range(1, 1100)) + result = service.get_values_for_subjects(large_batch) + # Should only have 1000 entries (truncated) + self.assertEqual(len(result), 1000) + + def test_get_available_variables_individual_filter(self): + """Test get_available_variables with individual resource_type filter.""" + service = self._get_service() + + # Create variables for individual and group + uid = int(time.time() * 1000) + self.env["spp.cel.variable"].create( + { + "name": f"ind_only_{uid}", + "cel_accessor": f"ind_only_{uid}", + "source_type": "field", + "value_type": "number", + "state": "active", + "applies_to": "individual", + } + ) + + result = service.get_available_variables(resource_type="individual") + # Should include individual and both variables, not group-only + applies_set = {v["appliesTo"] for v in result} + self.assertNotIn("group", applies_set) + + def test_get_available_variables_include_inactive(self): + """Test get_available_variables with include_inactive=True.""" + service = self._get_service() + + uid = int(time.time() * 1000) + self.env["spp.cel.variable"].create( + { + "name": f"inactive_{uid}", + "cel_accessor": f"inactive_{uid}", + "source_type": "field", + "value_type": "number", + "state": "inactive", + "applies_to": "both", + } + ) + + result = service.get_available_variables(include_inactive=True) + names = [v["name"] for v in result] + self.assertIn(f"inactive_{uid}", names) + + def test_get_available_variables_with_unit(self): + """Test get_available_variables includes unit when available.""" + service = self._get_service() + + result = service.get_available_variables(resource_type="both") + # Just verify no errors - unit is optional + self.assertIsInstance(result, list) + + def test_compute_variable_value_field_type_success(self): + """Test compute_variable_value successfully computes field value.""" + service = self._get_service() + + uid = int(time.time() * 1000) + self.env["spp.cel.variable"].create( + { + "name": f"field_var_{uid}", + "cel_accessor": f"field_var_{uid}", + "source_type": "field", + "value_type": "string", + "state": "active", + "applies_to": "both", + "source_model": "res.partner", + "source_field": "name", + } + ) + + result = service.compute_variable_value( + partner_id=self.partner.id, + variable_name=f"field_var_{uid}", + ) + self.assertEqual(result, "VVS Gap Test Partner") + + def test_compute_variable_value_unsafe_model(self): + """Test compute_variable_value rejects unsafe source models.""" + service = self._get_service() + + uid = int(time.time() * 1000) + self.env["spp.cel.variable"].create( + { + "name": f"unsafe_var_{uid}", + "cel_accessor": f"unsafe_var_{uid}", + "source_type": "field", + "value_type": "string", + "state": "active", + "applies_to": "both", + "source_model": "ir.cron", + "source_field": "name", + } + ) + + result = service.compute_variable_value( + partner_id=self.partner.id, + variable_name=f"unsafe_var_{uid}", + ) + self.assertIsNone(result) + + def test_compute_variable_value_nonexistent_record(self): + """Test compute_variable_value with non-existent partner.""" + service = self._get_service() + + uid = int(time.time() * 1000) + self.env["spp.cel.variable"].create( + { + "name": f"norecord_var_{uid}", + "cel_accessor": f"norecord_var_{uid}", + "source_type": "field", + "value_type": "string", + "state": "active", + "applies_to": "both", + "source_model": "res.partner", + "source_field": "name", + } + ) + + result = service.compute_variable_value( + partner_id=999999999, + variable_name=f"norecord_var_{uid}", + ) + # browse returns empty recordset for non-existent ID, so field access returns False + self.assertFalse(result) + + def test_get_values_model_missing(self): + """Test get_values_for_subject when spp.data.value not in env.""" + service = self._get_service() + with patch.object(type(service.env), "__contains__", return_value=False): + result = service.get_values_for_subject(self.partner.id) + self.assertEqual(result, {}) + + def test_get_values_for_subjects_model_missing(self): + """Test get_values_for_subjects when spp.data.value not in env.""" + service = self._get_service() + with patch.object(type(service.env), "__contains__", return_value=False): + result = service.get_values_for_subjects([self.partner.id]) + self.assertEqual(result, {}) + + def test_compute_variable_value_field_exception(self): + """Test compute_variable_value handles field access exception (lines 276-277).""" + service = self._get_service() + + uid = int(time.time() * 1000) + 1 + self.env["spp.cel.variable"].create( + { + "name": f"exc_var_{uid}", + "cel_accessor": f"exc_var_{uid}", + "source_type": "field", + "value_type": "string", + "state": "active", + "applies_to": "both", + "source_model": "res.partner", + "source_field": "nonexistent_field_xyz", + } + ) + + result = service.compute_variable_value( + partner_id=self.partner.id, + variable_name=f"exc_var_{uid}", + ) + self.assertIsNone(result) + + +@tagged("post_install", "-at_install") +class TestFastapiEndpointStudio(TransactionCase): + """Test FastAPI endpoint studio extension.""" + + def test_get_fastapi_routers_includes_studio(self): + """Test that studio router is added for api_v2 app.""" + endpoint = self.env["fastapi.endpoint"].search([("app", "=", "api_v2")], limit=1) + if not endpoint: + self.skipTest("No api_v2 endpoint configured") + + routers = endpoint._get_fastapi_routers() + # Should include at least one router + self.assertTrue(routers) + + def test_get_fastapi_routers_non_api_v2(self): + """Test that studio router is NOT added for non-api_v2 apps.""" + endpoint = self.env["fastapi.endpoint"].search([("app", "!=", "api_v2")], limit=1) + if not endpoint: + self.skipTest("No non-api_v2 endpoint available") + + # Call should work but not include studio router + routers = endpoint._get_fastapi_routers() + self.assertIsInstance(routers, list)