Maintainers
+Maintainers
Current maintainers:
This module is part of the OpenSPP/OpenSPP2 project on GitHub.
diff --git a/spp_hazard/tests/__init__.py b/spp_hazard/tests/__init__.py index e0c78588..9956de3b 100644 --- a/spp_hazard/tests/__init__.py +++ b/spp_hazard/tests/__init__.py @@ -5,3 +5,4 @@ from . import test_hazard_impact from . import test_hazard_impact_type from . import test_geofence +from . import test_registrant diff --git a/spp_hazard/tests/common.py b/spp_hazard/tests/common.py index 16ac4342..baef332d 100644 --- a/spp_hazard/tests/common.py +++ b/spp_hazard/tests/common.py @@ -1,5 +1,6 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from odoo import Command from odoo.tests.common import TransactionCase @@ -68,3 +69,36 @@ def setUpClass(cls): "category": "physical", } ) + + # Create test users for security tests + base_group = Command.link(cls.env.ref("base.group_user").id) + cls.hazard_viewer = cls.env["res.users"].create( + { + "name": "Hazard Viewer", + "login": "hazard_viewer_test", + "group_ids": [ + base_group, + Command.link(cls.env.ref("spp_hazard.group_hazard_viewer").id), + ], + } + ) + cls.hazard_officer = cls.env["res.users"].create( + { + "name": "Hazard Officer", + "login": "hazard_officer_test", + "group_ids": [ + base_group, + Command.link(cls.env.ref("spp_hazard.group_hazard_officer").id), + ], + } + ) + cls.hazard_manager = cls.env["res.users"].create( + { + "name": "Hazard Manager", + "login": "hazard_manager_test", + "group_ids": [ + base_group, + Command.link(cls.env.ref("spp_hazard.group_hazard_manager").id), + ], + } + ) diff --git a/spp_hazard/tests/test_geofence.py b/spp_hazard/tests/test_geofence.py index 00866cca..13a935f2 100644 --- a/spp_hazard/tests/test_geofence.py +++ b/spp_hazard/tests/test_geofence.py @@ -2,14 +2,11 @@ """Tests for geofence extensions in spp_hazard.""" import json -import logging from odoo.tests import tagged from .common import HazardTestCase -_logger = logging.getLogger(__name__) - @tagged("post_install", "-at_install") class TestHazardGeofence(HazardTestCase): diff --git a/spp_hazard/tests/test_hazard_category.py b/spp_hazard/tests/test_hazard_category.py index 7a118b5f..45f96a51 100644 --- a/spp_hazard/tests/test_hazard_category.py +++ b/spp_hazard/tests/test_hazard_category.py @@ -1,15 +1,11 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -import logging - from psycopg2 import IntegrityError from odoo.tests import mute_logger from .common import HazardTestCase -_logger = logging.getLogger(__name__) - class TestHazardCategory(HazardTestCase): """Test cases for spp.hazard.category model.""" diff --git a/spp_hazard/tests/test_hazard_impact.py b/spp_hazard/tests/test_hazard_impact.py index e0e7e28b..f69bce94 100644 --- a/spp_hazard/tests/test_hazard_impact.py +++ b/spp_hazard/tests/test_hazard_impact.py @@ -1,7 +1,5 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -import logging - from psycopg2 import IntegrityError from odoo.exceptions import ValidationError @@ -9,8 +7,6 @@ from .common import HazardTestCase -_logger = logging.getLogger(__name__) - class TestHazardImpact(HazardTestCase): """Test cases for spp.hazard.impact model.""" diff --git a/spp_hazard/tests/test_hazard_impact_type.py b/spp_hazard/tests/test_hazard_impact_type.py index 5f228c82..8d68e03a 100644 --- a/spp_hazard/tests/test_hazard_impact_type.py +++ b/spp_hazard/tests/test_hazard_impact_type.py @@ -1,15 +1,11 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -import logging - from psycopg2 import IntegrityError from odoo.tests import mute_logger from .common import HazardTestCase -_logger = logging.getLogger(__name__) - class TestHazardImpactType(HazardTestCase): """Test cases for spp.hazard.impact.type model.""" diff --git a/spp_hazard/tests/test_hazard_incident.py b/spp_hazard/tests/test_hazard_incident.py index ea42c208..c92f4e43 100644 --- a/spp_hazard/tests/test_hazard_incident.py +++ b/spp_hazard/tests/test_hazard_incident.py @@ -1,16 +1,13 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -import logging - from psycopg2 import IntegrityError +from odoo import Command from odoo.exceptions import ValidationError from odoo.tests import mute_logger from .common import HazardTestCase -_logger = logging.getLogger(__name__) - class TestHazardIncident(HazardTestCase): """Test cases for spp.hazard.incident model.""" @@ -99,7 +96,7 @@ def test_06_area_linking(self): # Link area self.incident.write( { - "area_ids": [(4, self.area.id)], + "area_ids": [Command.link(self.area.id)], } ) self.assertEqual(self.incident.area_count, 1) @@ -124,7 +121,7 @@ def test_08_identify_potentially_affected(self): # Link area to incident self.incident.write( { - "area_ids": [(4, self.area.id)], + "area_ids": [Command.link(self.area.id)], } ) @@ -156,3 +153,190 @@ def test_10_action_view_impacts(self): self.assertEqual(action["type"], "ir.actions.act_window") self.assertEqual(action["res_model"], "spp.hazard.impact") self.assertEqual(action["domain"], [("incident_id", "=", self.incident.id)]) + + def test_11_action_view_areas(self): + """Test the action to view areas.""" + self.incident.write({"area_ids": [Command.link(self.area.id)]}) + action = self.incident.action_view_areas() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.area") + self.assertEqual(action["domain"], [("id", "in", self.incident.area_ids.ids)]) + + def test_12_incident_area_display_name(self): + """Test _compute_display_name on HazardIncidentArea.""" + incident_area = self.env["spp.hazard.incident.area"].create( + { + "incident_id": self.incident.id, + "area_id": self.area.id, + } + ) + expected = f"{self.incident.name} - {self.area.name}" + self.assertEqual(incident_area.display_name, expected) + + def test_13_incident_area_unique_constraint(self): + """Test duplicate (incident, area) raises IntegrityError.""" + self.env["spp.hazard.incident.area"].create( + { + "incident_id": self.incident.id, + "area_id": self.area.id, + } + ) + with self.assertRaises(IntegrityError), mute_logger("odoo.sql_db"): + self.env["spp.hazard.incident.area"].create( + { + "incident_id": self.incident.id, + "area_id": self.area.id, + } + ) + + def test_14_identify_no_areas(self): + """Test identify returns empty when no areas linked.""" + affected = self.incident.identify_potentially_affected_registrants() + self.assertFalse(affected) + + def test_15_close_sets_end_date(self): + """Test closing without end_date auto-sets today.""" + incident = self.env["spp.hazard.incident"].create( + { + "name": "Close Date Test", + "code": "CLOSE-DATE-TEST", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-01", + } + ) + self.assertFalse(incident.end_date) + incident.action_close() + self.assertTrue(incident.end_date) + self.assertEqual(incident.status, "closed") + + def test_16_close_preserves_existing_end_date(self): + """Test closing with existing end_date does not overwrite it.""" + incident = self.env["spp.hazard.incident"].create( + { + "name": "Preserve End Date Test", + "code": "PRESERVE-END-TEST", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-01", + "end_date": "2024-02-01", + } + ) + incident.action_close() + self.assertEqual(str(incident.end_date), "2024-02-01") + self.assertEqual(incident.status, "closed") + + def test_17_is_ongoing_alert_and_recovery(self): + """Test is_ongoing for alert and recovery statuses.""" + incident = self.env["spp.hazard.incident"].create( + { + "name": "Alert Ongoing Test", + "code": "ALERT-ONGOING", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-01", + "status": "alert", + } + ) + # Alert with no end_date is ongoing + self.assertTrue(incident.is_ongoing) + + # Recovery with no end_date is ongoing + incident.write({"status": "recovery"}) + self.assertTrue(incident.is_ongoing) + + # Closed with no end_date is NOT ongoing + incident.write({"status": "closed"}) + self.assertFalse(incident.is_ongoing) + + def test_18_date_validation_on_update(self): + """Test date constraint fires on update, not just creation.""" + incident = self.env["spp.hazard.incident"].create( + { + "name": "Update Date Test", + "code": "UPDATE-DATE-TEST", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-15", + } + ) + with self.assertRaises(ValidationError): + incident.write({"end_date": "2024-01-01"}) + + def test_19_affected_registrant_count_distinct(self): + """Test affected_registrant_count counts distinct registrants.""" + registrant2 = self.env["res.partner"].create( + { + "name": "Second Registrant", + "is_registrant": True, + "is_group": False, + } + ) + incident = self.env["spp.hazard.incident"].create( + { + "name": "Multi Registrant Test", + "code": "MULTI-REG-TEST", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-01", + } + ) + Impact = self.env["spp.hazard.impact"] + # Two impacts for same registrant, one for different + Impact.create( + { + "incident_id": incident.id, + "registrant_id": self.registrant.id, + "impact_type_id": self.impact_type_displacement.id, + "damage_level": "moderate", + "impact_date": "2024-01-02", + } + ) + Impact.create( + { + "incident_id": incident.id, + "registrant_id": self.registrant.id, + "impact_type_id": self.impact_type_property.id, + "damage_level": "severe", + "impact_date": "2024-01-02", + } + ) + Impact.create( + { + "incident_id": incident.id, + "registrant_id": registrant2.id, + "impact_type_id": self.impact_type_displacement.id, + "damage_level": "minimal", + "impact_date": "2024-01-02", + } + ) + # 3 impacts but only 2 distinct registrants + self.assertEqual(incident.impact_count, 3) + self.assertEqual(incident.affected_registrant_count, 2) + + def test_20_affected_registrant_count_empty(self): + """Test affected_registrant_count on new/empty recordset.""" + empty = self.env["spp.hazard.incident"].browse() + empty._compute_affected_registrant_count() + # Should not raise; field set to 0 + + def test_21_multi_record_close(self): + """Test action_close works on multiple records.""" + inc1 = self.env["spp.hazard.incident"].create( + { + "name": "Multi Close 1", + "code": "MULTI-CLOSE-1", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-01", + } + ) + inc2 = self.env["spp.hazard.incident"].create( + { + "name": "Multi Close 2", + "code": "MULTI-CLOSE-2", + "category_id": self.category_typhoon.id, + "start_date": "2024-03-01", + "end_date": "2024-04-01", + } + ) + (inc1 | inc2).action_close() + self.assertEqual(inc1.status, "closed") + self.assertEqual(inc2.status, "closed") + # inc1 gets auto end_date, inc2 preserves its own + self.assertTrue(inc1.end_date) + self.assertEqual(str(inc2.end_date), "2024-04-01") diff --git a/spp_hazard/tests/test_registrant.py b/spp_hazard/tests/test_registrant.py new file mode 100644 index 00000000..e343295b --- /dev/null +++ b/spp_hazard/tests/test_registrant.py @@ -0,0 +1,238 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.exceptions import AccessError + +from .common import HazardTestCase + + +class TestRegistrant(HazardTestCase): + """Test cases for res.partner hazard extension.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.incident = cls.env["spp.hazard.incident"].create( + { + "name": "Registrant Test Incident", + "code": "REG-TEST-INC-001", + "category_id": cls.category_typhoon.id, + "start_date": "2024-01-01", + "severity": "3", + } + ) + cls.impact = cls.env["spp.hazard.impact"].create( + { + "incident_id": cls.incident.id, + "registrant_id": cls.registrant.id, + "impact_type_id": cls.impact_type_displacement.id, + "damage_level": "moderate", + "impact_date": "2024-01-02", + } + ) + + def test_hazard_impact_count(self): + """Test that impact count updates when impacts are created/deleted.""" + self.assertEqual(self.registrant.hazard_impact_count, 1) + + # Create another impact + impact2 = self.env["spp.hazard.impact"].create( + { + "incident_id": self.incident.id, + "registrant_id": self.registrant.id, + "impact_type_id": self.impact_type_property.id, + "damage_level": "severe", + "impact_date": "2024-01-02", + } + ) + self.assertEqual(self.registrant.hazard_impact_count, 2) + + # Delete impact + impact2.unlink() + self.assertEqual(self.registrant.hazard_impact_count, 1) + + def test_has_active_impact_with_active_incident(self): + """Test flag is True when incident is active.""" + self.assertEqual(self.incident.status, "active") + self.assertTrue(self.registrant.has_active_impact) + + def test_has_active_impact_with_closed_incident(self): + """Test flag is False when incident is closed.""" + self.incident.write({"status": "closed", "end_date": "2024-01-15"}) + self.assertFalse(self.registrant.has_active_impact) + + def test_has_active_impact_transitions(self): + """Test flag updates when incident status changes.""" + self.incident.write({"status": "active", "end_date": False}) + self.assertTrue(self.registrant.has_active_impact) + + # Move to recovery - should still be active + self.incident.action_set_recovery() + self.assertTrue(self.registrant.has_active_impact) + + # Close - should no longer be active + self.incident.action_close() + self.assertFalse(self.registrant.has_active_impact) + + def test_action_view_hazard_impacts(self): + """Test the action returns correct domain and model.""" + action = self.registrant.action_view_hazard_impacts() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.hazard.impact") + self.assertEqual(action["domain"], [("registrant_id", "=", self.registrant.id)]) + + def test_get_impact_history(self): + """Test impact history returns impacts sorted by date desc.""" + # Create a second impact with an earlier date + self.env["spp.hazard.impact"].create( + { + "incident_id": self.incident.id, + "registrant_id": self.registrant.id, + "impact_type_id": self.impact_type_property.id, + "damage_level": "minimal", + "impact_date": "2024-01-01", + } + ) + history = self.registrant.get_impact_history() + self.assertEqual(len(history), 2) + # Most recent date first + self.assertGreaterEqual(history[0].impact_date, history[1].impact_date) + + def test_get_active_incident_impacts(self): + """Test filtering to only active/alert/recovery incidents.""" + self.incident.write({"status": "active", "end_date": False}) + active_impacts = self.registrant.get_active_incident_impacts() + self.assertEqual(len(active_impacts), 1) + + # Close the incident + self.incident.write({"status": "closed", "end_date": "2024-01-15"}) + active_impacts = self.registrant.get_active_incident_impacts() + self.assertEqual(len(active_impacts), 0) + + def test_no_impacts(self): + """Test fresh registrant has no impacts.""" + fresh = self.env["res.partner"].create( + { + "name": "Fresh Registrant", + "is_registrant": True, + "is_group": False, + } + ) + self.assertEqual(fresh.hazard_impact_count, 0) + self.assertFalse(fresh.has_active_impact) + self.assertEqual(len(fresh.get_impact_history()), 0) + self.assertEqual(len(fresh.get_active_incident_impacts()), 0) + + def test_group_registrant_hazard_fields(self): + """Test that group registrants also support hazard fields.""" + group = self.env["res.partner"].create( + { + "name": "Test Group", + "is_registrant": True, + "is_group": True, + } + ) + self.assertEqual(group.hazard_impact_count, 0) + self.assertFalse(group.has_active_impact) + + +class TestRegistrantSecurity(HazardTestCase): + """Test security access for hazard impacts on registrants.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.incident = cls.env["spp.hazard.incident"].create( + { + "name": "Security Test Incident", + "code": "SEC-TEST-INC-001", + "category_id": cls.category_typhoon.id, + "start_date": "2024-01-01", + } + ) + + def test_viewer_can_read_impacts(self): + """Test that viewers can read impact records (validates 1.3 fix).""" + impact = self.env["spp.hazard.impact"].create( + { + "incident_id": self.incident.id, + "registrant_id": self.registrant.id, + "impact_type_id": self.impact_type_displacement.id, + "damage_level": "moderate", + "impact_date": "2024-01-02", + } + ) + # Viewer should be able to read + impact.with_user(self.hazard_viewer).read(["damage_level"]) + + def test_viewer_cannot_create_incidents(self): + """Test that viewers cannot create incidents.""" + with self.assertRaises(AccessError): + self.env["spp.hazard.incident"].with_user(self.hazard_viewer).create( + { + "name": "Viewer Incident", + "code": "VIEWER-INC", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-01", + } + ) + + def test_officer_can_create_incidents(self): + """Test that officers can create incidents.""" + incident = ( + self.env["spp.hazard.incident"] + .with_user(self.hazard_officer) + .create( + { + "name": "Officer Incident", + "code": "OFFICER-INC", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-01", + } + ) + ) + self.assertTrue(incident) + + def test_officer_cannot_delete_incidents(self): + """Test that officers cannot delete incidents.""" + incident = self.env["spp.hazard.incident"].create( + { + "name": "Delete Test", + "code": "DEL-TEST-INC", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-01", + } + ) + with self.assertRaises(AccessError): + incident.with_user(self.hazard_officer).unlink() + + def test_officer_cannot_write_categories(self): + """Test that officers cannot modify categories (validates 1.4 fix).""" + with self.assertRaises(AccessError): + self.category_typhoon.with_user(self.hazard_officer).write({"name": "Hacked"}) + + def test_officer_cannot_create_categories(self): + """Test that officers cannot create categories (validates 1.4 fix).""" + with self.assertRaises(AccessError): + self.env["spp.hazard.category"].with_user(self.hazard_officer).create( + { + "name": "Unauthorized", + "code": "UNAUTH_CAT", + } + ) + + def test_manager_has_full_access(self): + """Test that managers can create and delete records.""" + incident = ( + self.env["spp.hazard.incident"] + .with_user(self.hazard_manager) + .create( + { + "name": "Manager Incident", + "code": "MGR-INC", + "category_id": self.category_typhoon.id, + "start_date": "2024-01-01", + } + ) + ) + self.assertTrue(incident) + incident.with_user(self.hazard_manager).unlink() diff --git a/spp_hazard/views/hazard_category_views.xml b/spp_hazard/views/hazard_category_views.xml index 2ae034ff..52d60c51 100644 --- a/spp_hazard/views/hazard_category_views.xml +++ b/spp_hazard/views/hazard_category_views.xml @@ -10,7 +10,7 @@





