From 959374f75f5aba116100e229fb2af6a8c56f42e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Mon, 15 Jun 2026 15:46:46 +0200 Subject: [PATCH 1/6] [FIX] maintenance_service_http_monitoring: add missing hr_maintenance dependency --- maintenance_service_http_monitoring/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maintenance_service_http_monitoring/__manifest__.py b/maintenance_service_http_monitoring/__manifest__.py index d0fe809..7d092b8 100644 --- a/maintenance_service_http_monitoring/__manifest__.py +++ b/maintenance_service_http_monitoring/__manifest__.py @@ -5,7 +5,7 @@ "license": "AGPL-3", "category": "Tools", "summary": "Monitor HTTP availability of services", - "depends": ["base", "maintenance", "maintenance_server_data"], + "depends": ["base", "maintenance", "hr_maintenance", "maintenance_server_data"], "external_dependencies": {"python": ["requests"]}, "data": [ "data/ir_config_parameter.xml", -- 2.49.1 From cb3ed485b8b595d902a52169714d341272c62355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Mon, 15 Jun 2026 15:47:15 +0200 Subject: [PATCH 2/6] [IMP] maintenance_service_http_monitoring: add tests --- .../tests/__init__.py | 1 + .../tests/test_http_monitoring.py | 220 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 maintenance_service_http_monitoring/tests/__init__.py create mode 100644 maintenance_service_http_monitoring/tests/test_http_monitoring.py diff --git a/maintenance_service_http_monitoring/tests/__init__.py b/maintenance_service_http_monitoring/tests/__init__.py new file mode 100644 index 0000000..ab5c71a --- /dev/null +++ b/maintenance_service_http_monitoring/tests/__init__.py @@ -0,0 +1 @@ +from . import test_http_monitoring diff --git a/maintenance_service_http_monitoring/tests/test_http_monitoring.py b/maintenance_service_http_monitoring/tests/test_http_monitoring.py new file mode 100644 index 0000000..0a9098f --- /dev/null +++ b/maintenance_service_http_monitoring/tests/test_http_monitoring.py @@ -0,0 +1,220 @@ +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from odoo import fields +from odoo.tests.common import TransactionCase + +SERVICE_INSTANCE_REQUESTS = ( + "odoo.addons.maintenance_service_http_monitoring.models.service_instance.requests" +) +EQUIPMENT_HTTP_REQUESTS = ( + "odoo.addons.maintenance_service_http_monitoring" + ".models.maintenance_equipment.http_requests" +) + + +def _mock_response(status_code): + response = MagicMock() + response.status_code = status_code + return response + + +class TestHttpMonitoring(TransactionCase): + def setUp(self): + super().setUp() + team = self.env["maintenance.team"].search([], limit=1) + self.equipment = self.env["maintenance.equipment"].create( + { + "name": "Test Server", + "maintenance_team_id": team.id if team else False, + } + ) + self.service = self.env["service"].create({"name": "Test Service"}) + self.service_instance = self.env["service.instance"].create( + { + "equipment_id": self.equipment.id, + "service_id": self.service.id, + "service_url": "https://example.com", + } + ) + + # ------------------------------------------------------------------ + # Test 1 — HTTP 200 → service marked OK + # ------------------------------------------------------------------ + def test_http_200_sets_status_ok(self): + with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + mock_requests.get.return_value = _mock_response(200) + mock_requests.exceptions.RequestException = Exception + self.service_instance.check_http_status() + + self.assertTrue(self.service_instance.http_status_ok) + self.assertEqual(self.service_instance.last_http_status_code, 200) + self.assertIsNotNone(self.service_instance.last_http_check_date) + + # ------------------------------------------------------------------ + # Test 2 — HTTP 500 → service KO + maintenance.request created + # ------------------------------------------------------------------ + def test_http_500_creates_maintenance_request(self): + with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + mock_requests.get.return_value = _mock_response(500) + mock_requests.exceptions.RequestException = Exception + self.service_instance.check_http_status() + + self.assertFalse(self.service_instance.http_status_ok) + self.assertEqual(self.service_instance.last_http_status_code, 500) + + request = self.equipment.http_maintenance_request + self.assertTrue(request) + self.assertEqual(request.name, f"[HTTP KO] {self.equipment.name}") + self.assertEqual(request.priority, "2") + self.assertEqual(request.maintenance_type, "corrective") + + # ------------------------------------------------------------------ + # Test 3 — Network error → KO with code -1 + # ------------------------------------------------------------------ + def test_network_error_sets_status_ko_and_minus_one(self): + with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + mock_requests.get.side_effect = Exception("connection refused") + mock_requests.exceptions.RequestException = Exception + self.service_instance.check_http_status() + + self.assertFalse(self.service_instance.http_status_ok) + self.assertEqual(self.service_instance.last_http_status_code, -1) + + # ------------------------------------------------------------------ + # Test 4 — Two consecutive failures → no duplicate maintenance.request + # ------------------------------------------------------------------ + def test_no_duplicate_request_on_repeated_failure(self): + with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + mock_requests.get.return_value = _mock_response(500) + mock_requests.exceptions.RequestException = Exception + self.service_instance.check_http_status() + + request_1 = self.equipment.http_maintenance_request + self.assertTrue(request_1) + + with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + mock_requests.get.return_value = _mock_response(500) + mock_requests.exceptions.RequestException = Exception + self.service_instance.check_http_status() + + self.assertEqual(self.equipment.http_maintenance_request, request_1) + self.assertEqual( + self.env["maintenance.request"].search_count( + [("equipment_id", "=", self.equipment.id)] + ), + 1, + ) + + # ------------------------------------------------------------------ + # Test 5 — Equipment in maintenance mode → cron skips it + # ------------------------------------------------------------------ + def test_maintenance_mode_skips_http_check(self): + self.equipment.write({"maintenance_mode": True}) + + with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + mock_requests.get.return_value = _mock_response(500) + mock_requests.exceptions.RequestException = Exception + self.env["service.instance"].cron_check_http_services() + + mock_requests.get.assert_not_called() + # last_http_check_date must remain unset (never checked) + self.assertFalse(self.service_instance.last_http_check_date) + + # ------------------------------------------------------------------ + # Test 6 — Expired maintenance mode → cron deactivates it + # ------------------------------------------------------------------ + def test_maintenance_mode_auto_expiry(self): + past = fields.Datetime.now() - timedelta(hours=1) + self.equipment.write( + { + "maintenance_mode": True, + "maintenance_mode_start": past - timedelta(hours=4), + "maintenance_mode_end": past, + } + ) + self.assertTrue(self.equipment.maintenance_mode) + + self.env["maintenance.equipment"].cron_deactivate_expired_maintenance_mode() + + self.assertFalse(self.equipment.maintenance_mode) + self.assertFalse(self.equipment.maintenance_mode_start) + self.assertFalse(self.equipment.maintenance_mode_end) + + # ------------------------------------------------------------------ + # Test 7 — Service without URL → ignored by cron + # ------------------------------------------------------------------ + def test_service_without_url_is_ignored(self): + self.service_instance.write({"service_url": False}) + + with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + mock_requests.get.return_value = _mock_response(200) + mock_requests.exceptions.RequestException = Exception + self.env["service.instance"].cron_check_http_services() + + mock_requests.get.assert_not_called() + self.assertFalse(self.service_instance.last_http_check_date) + + # ------------------------------------------------------------------ + # Test 8 — HTTP 404 (non-exception) → KO with correct code + # ------------------------------------------------------------------ + def test_http_non_200_non_exception(self): + with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + mock_requests.get.return_value = _mock_response(404) + mock_requests.exceptions.RequestException = Exception + self.service_instance.check_http_status() + + self.assertFalse(self.service_instance.http_status_ok) + self.assertEqual(self.service_instance.last_http_status_code, 404) + + # ------------------------------------------------------------------ + # Test 9 — Webhook called when a new maintenance.request is created + # ------------------------------------------------------------------ + def test_webhook_called_on_new_request(self): + self.env["ir.config_parameter"].sudo().set_param( + "maintenance_service_http_monitoring.webhook_url", + "https://webhook.example.com/hook", + ) + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(EQUIPMENT_HTTP_REQUESTS) as mock_http, + ): + mock_requests.get.return_value = _mock_response(500) + mock_requests.exceptions.RequestException = Exception + self.service_instance.check_http_status() + + mock_http.post.assert_called_once() + call_kwargs = mock_http.post.call_args + payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + self.assertEqual(payload["equipment"], self.equipment.name) + + # ------------------------------------------------------------------ + # Test 10 — Webhook skipped when no URL configured + # ------------------------------------------------------------------ + def test_webhook_skipped_when_no_url(self): + self.env["ir.config_parameter"].sudo().set_param( + "maintenance_service_http_monitoring.webhook_url", "" + ) + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(EQUIPMENT_HTTP_REQUESTS) as mock_http, + ): + mock_requests.get.return_value = _mock_response(500) + mock_requests.exceptions.RequestException = Exception + self.service_instance.check_http_status() + + mock_http.post.assert_not_called() + + # ------------------------------------------------------------------ + # Test 11 — Service without equipment → check_http_status skips it + # ------------------------------------------------------------------ + def test_service_without_equipment_is_ignored(self): + self.service_instance.write({"equipment_id": False}) + + with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + mock_requests.get.return_value = _mock_response(200) + mock_requests.exceptions.RequestException = Exception + self.service_instance.check_http_status() + + mock_requests.get.assert_not_called() + self.assertFalse(self.service_instance.last_http_check_date) -- 2.49.1 From b9b8662bad161b49b6da2b883807e1c53d2ca789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Mon, 15 Jun 2026 16:10:09 +0200 Subject: [PATCH 3/6] [IMP] maintenance_service_http_monitoring: add double check on HTTP errors to reduce "noise" from transient HTTP errors --- maintenance_service_http_monitoring/README.md | 3 + .../models/service_instance.py | 47 ++++++-- .../tests/test_http_monitoring.py | 102 ++++++++++++++---- 3 files changed, 121 insertions(+), 31 deletions(-) diff --git a/maintenance_service_http_monitoring/README.md b/maintenance_service_http_monitoring/README.md index 63afdf4..603b247 100644 --- a/maintenance_service_http_monitoring/README.md +++ b/maintenance_service_http_monitoring/README.md @@ -102,6 +102,9 @@ When a service fails HTTP checks: - The request is linked to the equipment - Only one request per equipment per day is created - The request description lists all failing services +- A **double-check** is performed before creating the request: the service is retested + after 2 seconds. A maintenance request is only created if the service fails **both** + checks, reducing noise from transient HTTP errors ## Webhook notifications diff --git a/maintenance_service_http_monitoring/models/service_instance.py b/maintenance_service_http_monitoring/models/service_instance.py index 3024801..72ecd5c 100644 --- a/maintenance_service_http_monitoring/models/service_instance.py +++ b/maintenance_service_http_monitoring/models/service_instance.py @@ -1,4 +1,5 @@ import logging +import time from odoo import api, fields, models @@ -10,6 +11,7 @@ except ImportError: _logger = logging.getLogger(__name__) HTTP_CHECK_TIMEOUT = 10 # seconds +HTTP_RETRY_DELAY = 2 # seconds between pass 1 and pass 2 class ServiceInstance(models.Model): @@ -31,11 +33,18 @@ class ServiceInstance(models.Model): ) def check_http_status(self): + """ + Perform HTTP check for each record and return the KO recordset. + + Writes last_http_status_code, last_http_check_date and http_status_ok on every + checked record. Does NOT create maintenance.request — that decision belongs to + the caller (cron) after optional retry logic. + """ + ko_records = self.browse() for rec in self: if not rec.service_url or not rec.equipment_id: continue - equipment = rec.equipment_id - if getattr(equipment, "maintenance_mode", False): + if rec.equipment_id.maintenance_mode: continue status_ok = False status_code = -1 @@ -57,20 +66,36 @@ class ServiceInstance(models.Model): } ) if not status_ok: - # Delegate maintenance.request creation to equipment - if hasattr(equipment, "create_http_maintenance_request"): - equipment.create_http_maintenance_request([rec]) + ko_records |= rec + return ko_records @api.model def cron_check_http_services(self): + """ + Check all active services with a URL, with one retry on failure. + + Pass 1: test every eligible service. + If any fail, wait HTTP_RETRY_DELAY seconds then retest only the KO ones. + maintenance.request is created only for services that fail both passes, + reducing noise from transient HTTP errors. + """ domain = [ ("active", "=", True), ("service_url", "!=", False), ("equipment_id", "!=", False), ] - services = self.search(domain) - for service in services: - equipment = service.equipment_id - if getattr(equipment, "maintenance_mode", False): - continue - service.check_http_status() + services = self.search(domain).filtered( + lambda s: not s.equipment_id.maintenance_mode + ) + + ko_after_pass1 = services.check_http_status() + + if not ko_after_pass1: + return + + time.sleep(HTTP_RETRY_DELAY) + + ko_confirmed = ko_after_pass1.check_http_status() + + for service in ko_confirmed: + service.equipment_id.create_http_maintenance_request([service]) diff --git a/maintenance_service_http_monitoring/tests/test_http_monitoring.py b/maintenance_service_http_monitoring/tests/test_http_monitoring.py index 0a9098f..2040f56 100644 --- a/maintenance_service_http_monitoring/tests/test_http_monitoring.py +++ b/maintenance_service_http_monitoring/tests/test_http_monitoring.py @@ -11,6 +11,9 @@ EQUIPMENT_HTTP_REQUESTS = ( "odoo.addons.maintenance_service_http_monitoring" ".models.maintenance_equipment.http_requests" ) +SERVICE_INSTANCE_SLEEP = ( + "odoo.addons.maintenance_service_http_monitoring.models.service_instance.time.sleep" +) def _mock_response(status_code): @@ -39,7 +42,7 @@ class TestHttpMonitoring(TransactionCase): ) # ------------------------------------------------------------------ - # Test 1 — HTTP 200 → service marked OK + # Test 1 -- HTTP 200 -> service marked OK # ------------------------------------------------------------------ def test_http_200_sets_status_ok(self): with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: @@ -52,13 +55,16 @@ class TestHttpMonitoring(TransactionCase): self.assertIsNotNone(self.service_instance.last_http_check_date) # ------------------------------------------------------------------ - # Test 2 — HTTP 500 → service KO + maintenance.request created + # Test 2 -- Two KO passes -> maintenance.request created # ------------------------------------------------------------------ def test_http_500_creates_maintenance_request(self): - with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP), + ): mock_requests.get.return_value = _mock_response(500) mock_requests.exceptions.RequestException = Exception - self.service_instance.check_http_status() + self.env["service.instance"].cron_check_http_services() self.assertFalse(self.service_instance.http_status_ok) self.assertEqual(self.service_instance.last_http_status_code, 500) @@ -70,7 +76,7 @@ class TestHttpMonitoring(TransactionCase): self.assertEqual(request.maintenance_type, "corrective") # ------------------------------------------------------------------ - # Test 3 — Network error → KO with code -1 + # Test 3 -- Network error -> KO with code -1 # ------------------------------------------------------------------ def test_network_error_sets_status_ko_and_minus_one(self): with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: @@ -82,21 +88,27 @@ class TestHttpMonitoring(TransactionCase): self.assertEqual(self.service_instance.last_http_status_code, -1) # ------------------------------------------------------------------ - # Test 4 — Two consecutive failures → no duplicate maintenance.request + # Test 4 -- Two consecutive cron runs KO -> no duplicate request # ------------------------------------------------------------------ def test_no_duplicate_request_on_repeated_failure(self): - with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP), + ): mock_requests.get.return_value = _mock_response(500) mock_requests.exceptions.RequestException = Exception - self.service_instance.check_http_status() + self.env["service.instance"].cron_check_http_services() request_1 = self.equipment.http_maintenance_request self.assertTrue(request_1) - with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP), + ): mock_requests.get.return_value = _mock_response(500) mock_requests.exceptions.RequestException = Exception - self.service_instance.check_http_status() + self.env["service.instance"].cron_check_http_services() self.assertEqual(self.equipment.http_maintenance_request, request_1) self.assertEqual( @@ -107,7 +119,7 @@ class TestHttpMonitoring(TransactionCase): ) # ------------------------------------------------------------------ - # Test 5 — Equipment in maintenance mode → cron skips it + # Test 5 -- Equipment in maintenance mode -> cron skips it # ------------------------------------------------------------------ def test_maintenance_mode_skips_http_check(self): self.equipment.write({"maintenance_mode": True}) @@ -118,11 +130,10 @@ class TestHttpMonitoring(TransactionCase): self.env["service.instance"].cron_check_http_services() mock_requests.get.assert_not_called() - # last_http_check_date must remain unset (never checked) self.assertFalse(self.service_instance.last_http_check_date) # ------------------------------------------------------------------ - # Test 6 — Expired maintenance mode → cron deactivates it + # Test 6 -- Expired maintenance mode -> cron deactivates it # ------------------------------------------------------------------ def test_maintenance_mode_auto_expiry(self): past = fields.Datetime.now() - timedelta(hours=1) @@ -142,7 +153,7 @@ class TestHttpMonitoring(TransactionCase): self.assertFalse(self.equipment.maintenance_mode_end) # ------------------------------------------------------------------ - # Test 7 — Service without URL → ignored by cron + # Test 7 -- Service without URL -> ignored by cron # ------------------------------------------------------------------ def test_service_without_url_is_ignored(self): self.service_instance.write({"service_url": False}) @@ -156,7 +167,7 @@ class TestHttpMonitoring(TransactionCase): self.assertFalse(self.service_instance.last_http_check_date) # ------------------------------------------------------------------ - # Test 8 — HTTP 404 (non-exception) → KO with correct code + # Test 8 -- HTTP 404 (non-exception) -> KO with correct code # ------------------------------------------------------------------ def test_http_non_200_non_exception(self): with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests: @@ -168,7 +179,7 @@ class TestHttpMonitoring(TransactionCase): self.assertEqual(self.service_instance.last_http_status_code, 404) # ------------------------------------------------------------------ - # Test 9 — Webhook called when a new maintenance.request is created + # Test 9 -- Webhook called when a new maintenance.request is created # ------------------------------------------------------------------ def test_webhook_called_on_new_request(self): self.env["ir.config_parameter"].sudo().set_param( @@ -177,11 +188,12 @@ class TestHttpMonitoring(TransactionCase): ) with ( patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP), patch(EQUIPMENT_HTTP_REQUESTS) as mock_http, ): mock_requests.get.return_value = _mock_response(500) mock_requests.exceptions.RequestException = Exception - self.service_instance.check_http_status() + self.env["service.instance"].cron_check_http_services() mock_http.post.assert_called_once() call_kwargs = mock_http.post.call_args @@ -189,7 +201,7 @@ class TestHttpMonitoring(TransactionCase): self.assertEqual(payload["equipment"], self.equipment.name) # ------------------------------------------------------------------ - # Test 10 — Webhook skipped when no URL configured + # Test 10 -- Webhook skipped when no URL configured # ------------------------------------------------------------------ def test_webhook_skipped_when_no_url(self): self.env["ir.config_parameter"].sudo().set_param( @@ -197,16 +209,17 @@ class TestHttpMonitoring(TransactionCase): ) with ( patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP), patch(EQUIPMENT_HTTP_REQUESTS) as mock_http, ): mock_requests.get.return_value = _mock_response(500) mock_requests.exceptions.RequestException = Exception - self.service_instance.check_http_status() + self.env["service.instance"].cron_check_http_services() mock_http.post.assert_not_called() # ------------------------------------------------------------------ - # Test 11 — Service without equipment → check_http_status skips it + # Test 11 -- Service without equipment -> check_http_status skips it # ------------------------------------------------------------------ def test_service_without_equipment_is_ignored(self): self.service_instance.write({"equipment_id": False}) @@ -218,3 +231,52 @@ class TestHttpMonitoring(TransactionCase): mock_requests.get.assert_not_called() self.assertFalse(self.service_instance.last_http_check_date) + + # ------------------------------------------------------------------ + # Test 12 -- Transient failure (KO pass 1, OK pass 2) -> no request created + # ------------------------------------------------------------------ + def test_transient_failure_no_request_created(self): + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP), + ): + mock_requests.get.side_effect = [ + _mock_response(500), # pass 1: KO + _mock_response(200), # pass 2 (retry): OK + ] + mock_requests.exceptions.RequestException = Exception + self.env["service.instance"].cron_check_http_services() + + # Final status reflects pass 2 result + self.assertTrue(self.service_instance.http_status_ok) + self.assertEqual(self.service_instance.last_http_status_code, 200) + # No maintenance.request must have been created + self.assertFalse(self.equipment.http_maintenance_request) + self.assertEqual( + self.env["maintenance.request"].search_count( + [("equipment_id", "=", self.equipment.id)] + ), + 0, + ) + + # ------------------------------------------------------------------ + # Test 13 -- Confirmed failure (KO pass 1 and 2) -> request created + # ------------------------------------------------------------------ + def test_confirmed_failure_creates_request(self): + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP) as mock_sleep, + ): + mock_requests.get.return_value = _mock_response(503) + mock_requests.exceptions.RequestException = Exception + self.env["service.instance"].cron_check_http_services() + + # sleep must have been called between the two passes + mock_sleep.assert_called_once_with(2) + # requests.get must have been called twice (pass 1 + pass 2) + self.assertEqual(mock_requests.get.call_count, 2) + # Final status is KO + self.assertFalse(self.service_instance.http_status_ok) + self.assertEqual(self.service_instance.last_http_status_code, 503) + # maintenance.request created + self.assertTrue(self.equipment.http_maintenance_request) -- 2.49.1 From 2724d29f2595db8e0c0bae1ec98d17a52b2b950c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Mon, 15 Jun 2026 17:16:15 +0200 Subject: [PATCH 4/6] [CLN] maintenance_service_http_monitoring: apply ruff --- .../models/maintenance_equipment.py | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/maintenance_service_http_monitoring/models/maintenance_equipment.py b/maintenance_service_http_monitoring/models/maintenance_equipment.py index 644ffa5..21b107f 100644 --- a/maintenance_service_http_monitoring/models/maintenance_equipment.py +++ b/maintenance_service_http_monitoring/models/maintenance_equipment.py @@ -3,7 +3,6 @@ from datetime import timedelta from odoo import api, fields, models - try: import requests as http_requests except ImportError: @@ -13,20 +12,18 @@ _logger = logging.getLogger(__name__) WEBHOOK_TIMEOUT = 10 # seconds + class MaintenanceEquipment(models.Model): _inherit = "maintenance.equipment" maintenance_mode = fields.Boolean( - string="Maintenance Mode", default=False, tracking=True, ) maintenance_mode_start = fields.Datetime( - string="Maintenance Mode Start", readonly=True, ) maintenance_mode_end = fields.Datetime( - string="Maintenance Mode End", readonly=True, help="Computed from start + configured duration", ) @@ -116,31 +113,32 @@ class MaintenanceEquipment(models.Model): self._notify_webhook(request, ko_services) return request - def _notify_webhook(self, request, ko_services): - """Send a webhook notification when a new maintenance request is created.""" - ICP = self.env['ir.config_parameter'].sudo() + def _notify_webhook(self, request, ko_service): + """ + Send a webhook notification when a new maintenance request is created. + """ + ICP = self.env["ir.config_parameter"].sudo() webhook_url = ICP.get_param( - 'maintenance_service_http_monitoring.webhook_url', '' + "maintenance_service_http_monitoring.webhook_url", "" ) if not webhook_url: return webhook_user = ICP.get_param( - 'maintenance_service_http_monitoring.webhook_user', '' + "maintenance_service_http_monitoring.webhook_user", "" ) webhook_password = ICP.get_param( - 'maintenance_service_http_monitoring.webhook_password', '' + "maintenance_service_http_monitoring.webhook_password", "" ) - base_url = ICP.get_param('web.base.url', '') + base_url = ICP.get_param("web.base.url", "") link = ( - f"{base_url}/web#id={request.id}" - f"&model=maintenance.request&view_type=form" + f"{base_url}/web#id={request.id}&model=maintenance.request&view_type=form" ) payload = { - 'id': request.id, - 'name': request.name, - 'description': request.description or '', - 'equipment': self.name, - 'link': link, + "id": request.id, + "name": request.name, + "description": request.description or "", + "equipment": self.name, + "link": link, } auth = None if webhook_user and webhook_password: @@ -155,7 +153,8 @@ class MaintenanceEquipment(models.Model): except Exception as e: _logger.warning( "Webhook notification failed for maintenance request %s: %s", - request.id, e, + request.id, + e, ) def _build_ko_services_description(self, ko_services): -- 2.49.1 From c238e548087ddbe3fb00cce6743ade6eb976f882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Mon, 15 Jun 2026 17:31:57 +0200 Subject: [PATCH 5/6] [IMP] maintenance_service_http_monitoring: rework maintenance.request creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, a single ``maintenance.request`` was created per equipment, regardless of how many services were down on that equipment. The name was ``[HTTP KO] {equipment.name}`` and deduplication relied on a name+date+equipment search that was fragile (manual clear of the field would lose the reference to an existing open request). This commit reworks the whole creation logic: - **1 request per KO service** instead of 1 per equipment. Each failing ``service.instance`` gets its own ``maintenance.request``, allowing fine-grained tracking and independent resolution. - **Request name** is now ``[HTTP KO] {service_url}``, making it immediately identifiable without opening the record. - **Description** includes the error detail: ``HTTP {status_code}`` for HTTP errors, or a human-readable network error label when ``last_http_status_code == -1`` (timeout / DNS / SSL failures). - **Deduplication** is now based solely on whether an open (non-done) ``maintenance.request`` already exists on the ``service.instance`` via the new ``http_maintenance_request`` field. No date boundary — as long as the request is open, no new one is created. - **``http_maintenance_request``** field moved from ``maintenance.equipment`` to ``service.instance``, where it belongs given the 1-request-per-service model. It is exposed as an optional hidden column in the service instance list view. - ``_build_ko_services_description()`` is removed (no longer needed). - ``create_http_maintenance_request()`` now receives a single ``service.instance`` recordset instead of a list. Tests updated accordingly (14 tests total): - Tests 2, 4, 9, 10, 12, 13 now assert on ``service_instance.http_maintenance_request``. - Test 2 also verifies the request name contains the service URL and the description contains the HTTP status code. - New test 14 asserts that two KO services on the same equipment produce two distinct requests with the correct names. --- maintenance_service_http_monitoring/README.md | 9 +-- .../models/maintenance_equipment.py | 48 ++++++--------- .../models/service_instance.py | 7 ++- .../tests/test_http_monitoring.py | 58 ++++++++++++++----- .../views/maintenance_equipment_views.xml | 1 - .../views/service_instance_views.xml | 1 + 6 files changed, 76 insertions(+), 48 deletions(-) diff --git a/maintenance_service_http_monitoring/README.md b/maintenance_service_http_monitoring/README.md index 603b247..eeee667 100644 --- a/maintenance_service_http_monitoring/README.md +++ b/maintenance_service_http_monitoring/README.md @@ -98,10 +98,11 @@ On service instances, you can see: ## Automatic Maintenance Requests When a service fails HTTP checks: -- A corrective maintenance request is created with prefix "[HTTP KO]" -- The request is linked to the equipment -- Only one request per equipment per day is created -- The request description lists all failing services +- A corrective maintenance request is created per failing service, named + ``[HTTP KO] {service_url}`` +- The request description includes the error detail: the HTTP status code, + or a network error label (timeout / DNS / SSL) when no HTTP response was received +- No duplicate is created as long as an open request already exists for that service - A **double-check** is performed before creating the request: the service is retested after 2 seconds. A maintenance request is only created if the service fails **both** checks, reducing noise from transient HTTP errors diff --git a/maintenance_service_http_monitoring/models/maintenance_equipment.py b/maintenance_service_http_monitoring/models/maintenance_equipment.py index 21b107f..2a409fa 100644 --- a/maintenance_service_http_monitoring/models/maintenance_equipment.py +++ b/maintenance_service_http_monitoring/models/maintenance_equipment.py @@ -27,11 +27,6 @@ class MaintenanceEquipment(models.Model): readonly=True, help="Computed from start + configured duration", ) - http_maintenance_request = fields.Many2one( - "maintenance.request", - string="HTTP Maintenance Request", - readonly=True, - ) def action_activate_maintenance_mode(self): for rec in self: @@ -72,31 +67,30 @@ class MaintenanceEquipment(models.Model): ) expired.action_deactivate_maintenance_mode() - def create_http_maintenance_request(self, ko_services): + def create_http_maintenance_request(self, ko_service): + """ + Create or return the open maintenance.request for a single KO service. + + Deduplication: if ko_service already has an open (non-done) request, + return it without creating a new one. + """ self.ensure_one() - today = fields.Date.context_today(self) - name = f"[HTTP KO] {self.name}" - domain = [ - ("name", "=", name), - ("equipment_id", "=", self.id), - ("maintenance_type", "=", "corrective"), - ("create_date", ">=", f"{today} 00:00:00"), - ("create_date", "<=", f"{today} 23:59:59"), - ] - existing = self.env["maintenance.request"].search(domain, limit=1) - # Check if a task with same name already exist for the day, if it’s the case : skip - if existing: - self.http_maintenance_request = existing.id + existing = ko_service.http_maintenance_request + if existing and not existing.stage_id.done: return existing - request = self.http_maintenance_request - if request and not request.stage_id.done: - return request + status_code = ko_service.last_http_status_code + if status_code == -1: + error_detail = "Erreur réseau (timeout / DNS / SSL)" + else: + error_detail = f"HTTP {status_code}" + name = f"[HTTP KO] {ko_service.service_url}" + description = f"Service KO: {ko_service.service_url}\n{error_detail}" vals = { "name": name, "equipment_id": self.id, "priority": "2", "maintenance_type": "corrective", - "description": self._build_ko_services_description(ko_services), + "description": description, } if self.employee_id: vals["employee_id"] = self.employee_id.id @@ -109,8 +103,8 @@ class MaintenanceEquipment(models.Model): if team: vals["maintenance_team_id"] = team.id request = self.env["maintenance.request"].create(vals) - self.http_maintenance_request = request.id - self._notify_webhook(request, ko_services) + ko_service.http_maintenance_request = request.id + self._notify_webhook(request, ko_service) return request def _notify_webhook(self, request, ko_service): @@ -156,7 +150,3 @@ class MaintenanceEquipment(models.Model): request.id, e, ) - - def _build_ko_services_description(self, ko_services): - lines = [f"Service KO: {s.service_url or s.name}" for s in ko_services] - return "\n".join(lines) diff --git a/maintenance_service_http_monitoring/models/service_instance.py b/maintenance_service_http_monitoring/models/service_instance.py index 72ecd5c..2360a1f 100644 --- a/maintenance_service_http_monitoring/models/service_instance.py +++ b/maintenance_service_http_monitoring/models/service_instance.py @@ -31,6 +31,11 @@ class ServiceInstance(models.Model): readonly=True, default=True, ) + http_maintenance_request = fields.Many2one( + "maintenance.request", + string="HTTP Maintenance Request", + readonly=True, + ) def check_http_status(self): """ @@ -98,4 +103,4 @@ class ServiceInstance(models.Model): ko_confirmed = ko_after_pass1.check_http_status() for service in ko_confirmed: - service.equipment_id.create_http_maintenance_request([service]) + service.equipment_id.create_http_maintenance_request(service) diff --git a/maintenance_service_http_monitoring/tests/test_http_monitoring.py b/maintenance_service_http_monitoring/tests/test_http_monitoring.py index 2040f56..e74500c 100644 --- a/maintenance_service_http_monitoring/tests/test_http_monitoring.py +++ b/maintenance_service_http_monitoring/tests/test_http_monitoring.py @@ -55,7 +55,7 @@ class TestHttpMonitoring(TransactionCase): self.assertIsNotNone(self.service_instance.last_http_check_date) # ------------------------------------------------------------------ - # Test 2 -- Two KO passes -> maintenance.request created + # Test 2 -- Two KO passes -> maintenance.request created on the service # ------------------------------------------------------------------ def test_http_500_creates_maintenance_request(self): with ( @@ -69,11 +69,13 @@ class TestHttpMonitoring(TransactionCase): self.assertFalse(self.service_instance.http_status_ok) self.assertEqual(self.service_instance.last_http_status_code, 500) - request = self.equipment.http_maintenance_request + request = self.service_instance.http_maintenance_request self.assertTrue(request) - self.assertEqual(request.name, f"[HTTP KO] {self.equipment.name}") + self.assertEqual(request.name, f"[HTTP KO] {self.service_instance.service_url}") self.assertEqual(request.priority, "2") self.assertEqual(request.maintenance_type, "corrective") + self.assertIn("HTTP 500", request.description) + self.assertIn(self.service_instance.service_url, request.description) # ------------------------------------------------------------------ # Test 3 -- Network error -> KO with code -1 @@ -99,7 +101,7 @@ class TestHttpMonitoring(TransactionCase): mock_requests.exceptions.RequestException = Exception self.env["service.instance"].cron_check_http_services() - request_1 = self.equipment.http_maintenance_request + request_1 = self.service_instance.http_maintenance_request self.assertTrue(request_1) with ( @@ -110,7 +112,7 @@ class TestHttpMonitoring(TransactionCase): mock_requests.exceptions.RequestException = Exception self.env["service.instance"].cron_check_http_services() - self.assertEqual(self.equipment.http_maintenance_request, request_1) + self.assertEqual(self.service_instance.http_maintenance_request, request_1) self.assertEqual( self.env["maintenance.request"].search_count( [("equipment_id", "=", self.equipment.id)] @@ -247,11 +249,9 @@ class TestHttpMonitoring(TransactionCase): mock_requests.exceptions.RequestException = Exception self.env["service.instance"].cron_check_http_services() - # Final status reflects pass 2 result self.assertTrue(self.service_instance.http_status_ok) self.assertEqual(self.service_instance.last_http_status_code, 200) - # No maintenance.request must have been created - self.assertFalse(self.equipment.http_maintenance_request) + self.assertFalse(self.service_instance.http_maintenance_request) self.assertEqual( self.env["maintenance.request"].search_count( [("equipment_id", "=", self.equipment.id)] @@ -271,12 +271,44 @@ class TestHttpMonitoring(TransactionCase): mock_requests.exceptions.RequestException = Exception self.env["service.instance"].cron_check_http_services() - # sleep must have been called between the two passes mock_sleep.assert_called_once_with(2) - # requests.get must have been called twice (pass 1 + pass 2) self.assertEqual(mock_requests.get.call_count, 2) - # Final status is KO self.assertFalse(self.service_instance.http_status_ok) self.assertEqual(self.service_instance.last_http_status_code, 503) - # maintenance.request created - self.assertTrue(self.equipment.http_maintenance_request) + self.assertTrue(self.service_instance.http_maintenance_request) + + # ------------------------------------------------------------------ + # Test 14 -- 2 KO services on same equipment -> 2 distinct requests + # ------------------------------------------------------------------ + def test_two_ko_services_same_equipment_create_two_requests(self): + service2 = self.env["service"].create({"name": "Test Service 2"}) + service_instance2 = self.env["service.instance"].create( + { + "equipment_id": self.equipment.id, + "service_id": service2.id, + "service_url": "https://other.example.com", + } + ) + + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP), + ): + mock_requests.get.return_value = _mock_response(500) + mock_requests.exceptions.RequestException = Exception + self.env["service.instance"].cron_check_http_services() + + req1 = self.service_instance.http_maintenance_request + req2 = service_instance2.http_maintenance_request + + self.assertTrue(req1) + self.assertTrue(req2) + self.assertNotEqual(req1, req2) + self.assertEqual(req1.name, f"[HTTP KO] {self.service_instance.service_url}") + self.assertEqual(req2.name, f"[HTTP KO] {service_instance2.service_url}") + self.assertEqual( + self.env["maintenance.request"].search_count( + [("equipment_id", "=", self.equipment.id)] + ), + 2, + ) diff --git a/maintenance_service_http_monitoring/views/maintenance_equipment_views.xml b/maintenance_service_http_monitoring/views/maintenance_equipment_views.xml index 9031d62..16a49eb 100644 --- a/maintenance_service_http_monitoring/views/maintenance_equipment_views.xml +++ b/maintenance_service_http_monitoring/views/maintenance_equipment_views.xml @@ -36,7 +36,6 @@ - diff --git a/maintenance_service_http_monitoring/views/service_instance_views.xml b/maintenance_service_http_monitoring/views/service_instance_views.xml index 9b89a90..4536ee0 100644 --- a/maintenance_service_http_monitoring/views/service_instance_views.xml +++ b/maintenance_service_http_monitoring/views/service_instance_views.xml @@ -17,6 +17,7 @@ + -- 2.49.1 From c4d7e9b8a94de68ab8ef0c999fb76240dec6f332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phan=20Sainl=C3=A9ger?= Date: Mon, 15 Jun 2026 18:03:31 +0200 Subject: [PATCH 6/6] [IMP] maintenance_service_http_monitoring: auto-close request on service recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, maintenance requests created on HTTP failures were never automatically resolved. Operators had to close them manually, with no traceability of when or why the request was closed. This commit adds automatic resolution when a service returns HTTP 200 while an open maintenance request exists for it. **Detection logic** (in ``cron_check_http_services``): Before pass 1, the cron takes a snapshot of all services that currently have an open (non-done) ``maintenance.request`` via ``http_maintenance_request``. After pass 1, services in that snapshot that are now OK (``http_status_ok = True``) are identified as recovered and passed to the new ``_close_http_maintenance_request()`` method. **Closure logic** (new ``_close_http_maintenance_request`` method): 1. Finds the first ``maintenance.stage`` with ``done = True``. If none exists (misconfigured instance), the method is a no-op. 2. Moves the ``maintenance.request`` to that done stage via ``sudo()`` to bypass ACL restrictions from the cron user context. 3. Posts a chatter note on the request as OdooBot (``base.partner_root``) using ``subtype_xmlid="mail.mt_note"`` (internal note, not a follower notification) indicating the service URL and that the closure was performed automatically by the monitoring cron. 4. Clears ``http_maintenance_request`` on the ``service.instance``, allowing a fresh request to be created if the service fails again. **Tests** (2 new, 16 total): - ``test_service_recovery_closes_request``: full end-to-end scenario — first cron run produces a KO request, second cron run with HTTP 200 asserts the request is in a done stage, the chatter note mentioning the service URL exists, and ``http_maintenance_request`` is cleared. - ``test_no_close_when_no_open_request``: calling ``_close_http_maintenance_request`` on a service with no open request is a no-op and does not raise. **README**: "Automatic Maintenance Requests" section extended with the recovery behaviour (done stage, OdooBot note, field cleared). --- maintenance_service_http_monitoring/README.md | 6 +++ .../models/service_instance.py | 43 ++++++++++++++++- .../tests/test_http_monitoring.py | 46 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/maintenance_service_http_monitoring/README.md b/maintenance_service_http_monitoring/README.md index eeee667..3b9bbc7 100644 --- a/maintenance_service_http_monitoring/README.md +++ b/maintenance_service_http_monitoring/README.md @@ -107,6 +107,12 @@ When a service fails HTTP checks: after 2 seconds. A maintenance request is only created if the service fails **both** checks, reducing noise from transient HTTP errors +When a service recovers (returns HTTP 200 after having an open request): +- The open maintenance request is automatically moved to the first **done** stage +- A chatter note is posted on the request by OdooBot to record the automatic closure +- The link between the service and the request is cleared, allowing a new request to + be created if the service fails again in the future + ## Webhook notifications When a new maintenance request is created (HTTP check failure), the module can diff --git a/maintenance_service_http_monitoring/models/service_instance.py b/maintenance_service_http_monitoring/models/service_instance.py index 2360a1f..3c7cc66 100644 --- a/maintenance_service_http_monitoring/models/service_instance.py +++ b/maintenance_service_http_monitoring/models/service_instance.py @@ -74,13 +74,43 @@ class ServiceInstance(models.Model): ko_records |= rec return ko_records + def _close_http_maintenance_request(self): + """ + Close the open maintenance.request for each recovered service. + + Moves the request to the first done stage, posts a chatter note as OdooBot, and + clears http_maintenance_request on the service instance. + """ + done_stage = self.env["maintenance.stage"].search( + [("done", "=", True)], limit=1 + ) + if not done_stage: + return + odoobot = self.env.ref("base.partner_root") + for rec in self: + request = rec.http_maintenance_request + if not request or request.stage_id.done: + continue + request.sudo().write({"stage_id": done_stage.id}) + request.sudo().message_post( + body=( + f"Service {rec.service_url} is back online. " + "This request has been automatically closed by the monitoring cron." + ), + author_id=odoobot.id, + message_type="comment", + subtype_xmlid="mail.mt_note", + ) + rec.http_maintenance_request = False + @api.model def cron_check_http_services(self): """ Check all active services with a URL, with one retry on failure. Pass 1: test every eligible service. - If any fail, wait HTTP_RETRY_DELAY seconds then retest only the KO ones. + - Services that had an open request and are now OK are auto-resolved. + - Services still KO after pass 1 are retested after HTTP_RETRY_DELAY seconds. maintenance.request is created only for services that fail both passes, reducing noise from transient HTTP errors. """ @@ -93,8 +123,19 @@ class ServiceInstance(models.Model): lambda s: not s.equipment_id.maintenance_mode ) + # Snapshot services that currently have an open request before pass 1 + services_with_open_request = services.filtered( + lambda s: s.http_maintenance_request + and not s.http_maintenance_request.stage_id.done + ) + ko_after_pass1 = services.check_http_status() + # Auto-resolve services that recovered during pass 1 + recovered = services_with_open_request.filtered(lambda s: s.http_status_ok) + if recovered: + recovered._close_http_maintenance_request() + if not ko_after_pass1: return diff --git a/maintenance_service_http_monitoring/tests/test_http_monitoring.py b/maintenance_service_http_monitoring/tests/test_http_monitoring.py index e74500c..7de03d8 100644 --- a/maintenance_service_http_monitoring/tests/test_http_monitoring.py +++ b/maintenance_service_http_monitoring/tests/test_http_monitoring.py @@ -312,3 +312,49 @@ class TestHttpMonitoring(TransactionCase): ), 2, ) + + # ------------------------------------------------------------------ + # Test 15 -- Service recovery closes the open request and posts a note + # ------------------------------------------------------------------ + def test_service_recovery_closes_request(self): + # First cron run: service is KO -> request created + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP), + ): + mock_requests.get.return_value = _mock_response(500) + mock_requests.exceptions.RequestException = Exception + self.env["service.instance"].cron_check_http_services() + + request = self.service_instance.http_maintenance_request + self.assertTrue(request) + self.assertFalse(request.stage_id.done) + + # Second cron run: service is back OK -> request auto-closed + with ( + patch(SERVICE_INSTANCE_REQUESTS) as mock_requests, + patch(SERVICE_INSTANCE_SLEEP), + ): + mock_requests.get.return_value = _mock_response(200) + mock_requests.exceptions.RequestException = Exception + self.env["service.instance"].cron_check_http_services() + + # Request must be in a done stage + self.assertTrue(request.stage_id.done) + # http_maintenance_request must be cleared on the service instance + self.assertFalse(self.service_instance.http_maintenance_request) + # A chatter note must have been posted mentioning the service URL + notes = request.message_ids.filtered( + lambda m: self.service_instance.service_url in (m.body or "") + ) + self.assertTrue(notes) + + # ------------------------------------------------------------------ + # Test 16 -- No open request -> _close_http_maintenance_request is a no-op + # ------------------------------------------------------------------ + def test_no_close_when_no_open_request(self): + # Service is OK from the start, no request exists + self.assertFalse(self.service_instance.http_maintenance_request) + # Calling close directly must not raise + self.service_instance._close_http_maintenance_request() + self.assertFalse(self.service_instance.http_maintenance_request) -- 2.49.1