11 Commits

Author SHA1 Message Date
e48a3443f1 [IMP] maintenance_service_http_monitoring : modify logic to avoid false-positive http alerts
Some checks failed
pre-commit / pre-commit (pull_request) Has been cancelled
2026-08-18 18:07:26 +02:00
Stéphan Sainléger
6e19575012 [ADD] maintenance_user_ssh_key: create add-on 2026-07-07 12:01:42 +02:00
Stéphan Sainléger
ae5977ef81 [IMP] maintenance_server_data: add unique constraint rule on some equipement fields 2026-07-07 10:41:58 +02:00
Stéphan Sainléger
4581b3d019 [FIX] maintenance_server_data: add code protection if fr_FR language not installed 2026-07-07 10:41:58 +02:00
Stéphan Sainléger
bb6f12945f [IMP] maintenance_server_data: add ssh_target field in equipments 2026-07-07 10:41:45 +02:00
Stéphan Sainléger
c4d7e9b8a9 [IMP] maintenance_service_http_monitoring: auto-close request on service recovery
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 6m50s
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).
2026-06-15 18:03:31 +02:00
Stéphan Sainléger
c238e54808 [IMP] maintenance_service_http_monitoring: rework maintenance.request creation
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.
2026-06-15 17:47:25 +02:00
Stéphan Sainléger
2724d29f25 [CLN] maintenance_service_http_monitoring: apply ruff 2026-06-15 17:41:48 +02:00
Stéphan Sainléger
b9b8662bad [IMP] maintenance_service_http_monitoring: add double check on HTTP errors
to reduce "noise" from transient HTTP errors
2026-06-15 17:41:48 +02:00
Stéphan Sainléger
cb3ed485b8 [IMP] maintenance_service_http_monitoring: add tests 2026-06-15 15:47:15 +02:00
Stéphan Sainléger
959374f75f [FIX] maintenance_service_http_monitoring: add missing hr_maintenance dependency 2026-06-15 15:46:46 +02:00
22 changed files with 781 additions and 74 deletions

View File

@@ -4,7 +4,14 @@ from odoo import api, fields, models
class MaintenanceEquipment(models.Model):
_inherit = "maintenance.equipment"
_sql_constraints = [
('unique_name', 'UNIQUE(name)', 'Name must be unique.'),
('unique_server_ip', 'UNIQUE(server_ip)', 'Server IP must be unique.'),
('unique_ssh_target', 'UNIQUE(ssh_target)', 'Main Domain Name must be unique.'),
]
server_ip = fields.Char("Server Ip Address")
ssh_target = fields.Char("SSH Target", )
distribution_id = fields.Many2one("os.distribution", string="Distribution")
service_ids = fields.One2many("service.instance", "equipment_id", string="Services")
hosting_city = fields.Char("Hosting City")
@@ -17,8 +24,24 @@ class MaintenanceEquipment(models.Model):
name_fr = fields.Char("Name (FR)", compute="_compute_name_fr", store=True)
def copy_data(self, default=None):
default = dict(default or {})
if "server_ip" not in default:
default["server_ip"] = False
if "ssh_target" not in default:
default["ssh_target"] = False
vals_list = super().copy_data(default=default)
if "name" not in default:
for equipment, vals in zip(self, vals_list):
vals["name"] = self.env._("%s (copy)", equipment.name)
return vals_list
@api.depends("name")
def _compute_name_fr(self):
if not self.env["res.lang"]._lang_get("fr_FR"):
for record in self:
record.name_fr = record.name
return
for record in self:
record.name_fr = record.with_context(lang="fr_FR").name

View File

@@ -8,6 +8,7 @@
<xpath expr="//field[@name='effective_date']/.." position="after">
<group name="server_data" string="Server data">
<field name="server_ip" />
<field name="ssh_target" />
<field name="hosting_city" />
<field name="distribution_id" />
<field name="nb_cores" />
@@ -45,6 +46,7 @@
<field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after">
<field name="server_ip" optional="hide" />
<field name="ssh_target" optional="hide" />
<field name="hosting_city" optional="hide" />
<field name="distribution_id" optional="hide" />
<field name="nb_cores" optional="hide" />

View File

@@ -0,0 +1,2 @@
*.*~
*pyc

View File

@@ -98,10 +98,20 @@ 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
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

View File

@@ -5,7 +5,7 @@
"license": "AGPL-3",
"category": "Tools",
"summary": "Monitor HTTP availability of services",
"depends": ["base", "maintenance", "maintenance_server_data"],
"depends": ["base", "maintenance", "base_maintenance", "hr_maintenance", "maintenance_server_data"],
"external_dependencies": {"python": ["requests"]},
"data": [
"data/ir_config_parameter.xml",

View File

@@ -5,7 +5,7 @@
<field name="model_id" ref="maintenance_server_data.model_service_instance" />
<field name="state">code</field>
<field name="code">model.cron_check_http_services()</field>
<field name="interval_number">15</field>
<field name="interval_number">10</field>
<field name="interval_type">minutes</field>
</record>
<record id="ir_cron_maintenance_mode_expiry" model="ir.cron">

View File

@@ -3,7 +3,6 @@ from datetime import timedelta
from odoo import api, fields, models
try:
import requests as http_requests
except ImportError:
@@ -13,28 +12,21 @@ _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",
)
http_maintenance_request = fields.Many2one(
"maintenance.request",
string="HTTP Maintenance Request",
readonly=True,
)
def action_activate_maintenance_mode(self):
for rec in self:
@@ -75,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 its 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
@@ -112,35 +103,36 @@ 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_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,9 +147,6 @@ 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):
lines = [f"Service KO: {s.service_url or s.name}" for s in ko_services]
return "\n".join(lines)

View File

@@ -1,4 +1,5 @@
import logging
from datetime import timedelta
from odoo import api, fields, models
@@ -9,7 +10,8 @@ except ImportError:
_logger = logging.getLogger(__name__)
HTTP_CHECK_TIMEOUT = 10 # seconds
HTTP_CHECK_TIMEOUT = 20 # seconds
HTTP_KO_CONFIRMATION_DELAY = timedelta(minutes=5)
class ServiceInstance(models.Model):
@@ -29,13 +31,31 @@ class ServiceInstance(models.Model):
readonly=True,
default=True,
)
http_maintenance_request = fields.Many2one(
"maintenance.request",
string="HTTP Maintenance Request",
readonly=True,
)
http_first_ko_at = fields.Datetime(
string="First HTTP KO Check (current streak)",
readonly=True,
)
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 — the cron only opens one
once the service has been continuously KO for at least
HTTP_KO_CONFIRMATION_DELAY, to avoid flagging transient outages (e.g. a short
server overload) as real incidents.
"""
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
@@ -49,28 +69,84 @@ class ServiceInstance(models.Model):
status_ok = status_code == 200
except requests.exceptions.RequestException as e:
_logger.warning("HTTP check failed for %s: %s", rec.service_url, e)
rec.write(
{
"last_http_status_code": status_code,
"last_http_check_date": now,
"http_status_ok": status_ok,
}
)
vals = {
"last_http_status_code": status_code,
"last_http_check_date": now,
"http_status_ok": status_ok,
}
if status_ok:
vals["http_first_ko_at"] = False
elif not rec.http_first_ko_at:
vals["http_first_ko_at"] = now
rec.write(vals)
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
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.
A service must be continuously KO for at least HTTP_KO_CONFIRMATION_DELAY
before a maintenance.request is created — this tolerates transient outages
(e.g. a temporary server overload) regardless of how often this cron runs.
Services that had an open request and are now OK are auto-resolved.
"""
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
)
# Snapshot services that currently have an open request before the check
services_with_open_request = services.filtered(
lambda s: s.http_maintenance_request
and not s.http_maintenance_request.stage_id.done
)
ko_services = services.check_http_status()
# Auto-resolve services that recovered
recovered = services_with_open_request.filtered(lambda s: s.http_status_ok)
if recovered:
recovered._close_http_maintenance_request()
confirmed_ko = ko_services.filtered(
lambda s: s.last_http_check_date - s.http_first_ko_at
>= HTTP_KO_CONFIRMATION_DELAY
)
for service in confirmed_ko:
service.equipment_id.create_http_maintenance_request(service)

View File

@@ -0,0 +1 @@
from . import test_http_monitoring

View File

@@ -0,0 +1,413 @@
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"
)
# Comfortably past HTTP_KO_CONFIRMATION_DELAY (currently 10 minutes).
PAST_CONFIRMATION_DELAY = timedelta(minutes=20)
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",
}
)
def _backdate_first_ko(self, *service_instances):
"""Simulate that the current KO streak started long enough ago to be confirmed."""
for service_instance in service_instances:
service_instance.write(
{"http_first_ko_at": fields.Datetime.now() - PAST_CONFIRMATION_DELAY}
)
# ------------------------------------------------------------------
# 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 -- KO confirmed only once continuously KO for HTTP_KO_CONFIRMATION_DELAY,
# not on the first observed failure
# ------------------------------------------------------------------
def test_http_500_creates_request_after_confirmation_delay(self):
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()
self.assertFalse(self.service_instance.http_status_ok)
self.assertEqual(self.service_instance.last_http_status_code, 500)
self.assertTrue(self.service_instance.http_first_ko_at)
self.assertFalse(self.service_instance.http_maintenance_request)
self._backdate_first_ko(self.service_instance)
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()
request = self.service_instance.http_maintenance_request
self.assertTrue(request)
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
# ------------------------------------------------------------------
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 -- Repeated failure after confirmation -> a single request, no duplicate
# ------------------------------------------------------------------
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.env["service.instance"].cron_check_http_services() # first_ko_at set
self.assertFalse(self.service_instance.http_maintenance_request)
self._backdate_first_ko(self.service_instance)
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() # confirmed, request created
request_1 = self.service_instance.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.env["service.instance"].cron_check_http_services() # still KO
self.assertEqual(self.service_instance.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()
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 only once the request is actually created
# (after confirmation delay), not on the first observed failure
# ------------------------------------------------------------------
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.env["service.instance"].cron_check_http_services()
mock_http.post.assert_not_called()
self._backdate_first_ko(self.service_instance)
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.env["service.instance"].cron_check_http_services()
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:
mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services()
self._backdate_first_ko(self.service_instance)
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.env["service.instance"].cron_check_http_services()
self.assertTrue(self.service_instance.http_maintenance_request)
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)
# ------------------------------------------------------------------
# Test 12 -- Transient failure (KO, then back OK before confirmation) ->
# no request created, and the KO streak is reset
# ------------------------------------------------------------------
def test_transient_failure_no_request_created(self):
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()
self.assertTrue(self.service_instance.http_first_ko_at)
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()
self.assertTrue(self.service_instance.http_status_ok)
self.assertEqual(self.service_instance.last_http_status_code, 200)
self.assertFalse(self.service_instance.http_first_ko_at)
self.assertFalse(self.service_instance.http_maintenance_request)
self.assertEqual(
self.env["maintenance.request"].search_count(
[("equipment_id", "=", self.equipment.id)]
),
0,
)
# ------------------------------------------------------------------
# Test 13 -- Confirmed failure (KO continuously past the confirmation delay)
# -> request created
# ------------------------------------------------------------------
def test_confirmed_failure_creates_request(self):
with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
mock_requests.get.return_value = _mock_response(503)
mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services()
self.assertEqual(mock_requests.get.call_count, 1)
self.assertFalse(self.service_instance.http_maintenance_request)
self._backdate_first_ko(self.service_instance)
with patch(SERVICE_INSTANCE_REQUESTS) as mock_requests:
mock_requests.get.return_value = _mock_response(503)
mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services()
self.assertEqual(mock_requests.get.call_count, 1)
self.assertFalse(self.service_instance.http_status_ok)
self.assertEqual(self.service_instance.last_http_status_code, 503)
self.assertTrue(self.service_instance.http_maintenance_request)
# ------------------------------------------------------------------
# Test 14 -- 2 KO services on same equipment -> 2 distinct requests
# once both reach the confirmation delay
# ------------------------------------------------------------------
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:
mock_requests.get.return_value = _mock_response(500)
mock_requests.exceptions.RequestException = Exception
self.env["service.instance"].cron_check_http_services()
self._backdate_first_ko(self.service_instance, service_instance2)
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()
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,
)
# ------------------------------------------------------------------
# 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, streak just started -> no request yet
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()
self._backdate_first_ko(self.service_instance)
# Second cron run: streak confirmed -> request created
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()
request = self.service_instance.http_maintenance_request
self.assertTrue(request)
self.assertFalse(request.stage_id.done)
# Next cron run: service is back OK -> request auto-closed
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()
# 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)
# KO streak must be reset
self.assertFalse(self.service_instance.http_first_ko_at)
# 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)

View File

@@ -36,7 +36,6 @@
<field name="maintenance_mode" />
<field name="maintenance_mode_start" />
<field name="maintenance_mode_end" />
<field name="http_maintenance_request" readonly="1" />
</group>
</page>
</xpath>

View File

@@ -17,6 +17,8 @@
<field name="last_http_check_date" />
<field name="last_http_status_code" />
<field name="http_status_ok" />
<field name="http_maintenance_request" optional="hide" />
<field name="http_first_ko_at" optional="hide" />
</field>
</field>
</record>

2
maintenance_user_ssh_key/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.*~
*pyc

View File

@@ -0,0 +1,63 @@
==============================
maintenance_user_ssh_key
==============================
This module adds SSH key management to Odoo users. It introduces a new
``ssh.key`` model and a ``ssh_key_ids`` One2many field on ``res.users``.
It allows administrators to associate SSH public keys with each user for
authentication purposes (e.g., remote server access, deployment).
Features:
- **SSH Key model**: Store public keys per user.
- **User integration**: SSH keys are displayed and editable directly on the
user form view.
- **Access control**: All internal users can view SSH keys; only users with
``Settings / Administration`` rights can create, edit, or delete them.
# Installation
Use Odoo normal module installation procedure to install
``maintenance_user_ssh_key``.
This module depends on ``base`` and requires no external Python dependencies.
# Configuration
No specific configuration is required. After installation:
1. Go to *Settings > Users & Companies > Users*.
2. Open any user and navigate to the **SSH Keys** notebook page.
3. Add SSH keys with the public key content.
# Usage
## Managing SSH Keys
- Open a user form and go to the *SSH Keys* tab.
- Click **Add a line** and paste the public key content.
# Bug Tracker
Bugs are tracked on
`our issues website <https://git.elabore.coop/Elabore/maintenance-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and
welcomed feedback.
# Credits
## Contributors
- Stéphan Sainléger
## Funders
The development of this module has been financially supported by:
- Elabore (https://elabore.coop)
## Maintainer
This module is maintained by Elabore.

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,22 @@
# Copyright 2026 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_user_ssh_key",
"version": "18.0.1.0.0",
"author": "Elabore",
"website": "https://git.elabore.coop/elabore/maintenance-tools",
"maintainer": "Stéphan Sainléger",
"license": "AGPL-3",
"category": "Tools",
"summary": "Manage SSH keys per user.",
"depends": ["base"],
"data": [
"security/ir.model.access.csv",
"views/ssh_key_views.xml",
"views/res_users_views.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View File

@@ -0,0 +1,2 @@
from . import ssh_key
from . import res_users

View File

@@ -0,0 +1,14 @@
# Copyright 2026 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResUsers(models.Model):
_inherit = "res.users"
ssh_key_ids = fields.One2many(
comodel_name="ssh.key",
inverse_name="user_id",
string="SSH Keys",
)

View File

@@ -0,0 +1,18 @@
# Copyright 2026 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class SshKey(models.Model):
_name = "ssh.key"
_description = "SSH Key"
key = fields.Text(string="Public Key", required=True)
user_id = fields.Many2one(
comodel_name="res.users",
string="User",
required=True,
ondelete="cascade",
index=True,
)

View File

@@ -0,0 +1,3 @@
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
access_ssh_key_user,ssh.key.user,model_ssh_key,base.group_user,1,0,0,0
access_ssh_key_manager,ssh.key.manager,model_ssh_key,base.group_system,1,1,1,1
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
2 access_ssh_key_user ssh.key.user model_ssh_key base.group_user 1 0 0 0
3 access_ssh_key_manager ssh.key.manager model_ssh_key base.group_system 1 1 1 1

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_users_form_ssh_key_inherit" model="ir.ui.view">
<field name="name">res.users.form.ssh.key</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="SSH Keys" name="ssh_keys">
<field name="ssh_key_ids">
<list editable="top">
<field name="key"/>
</list>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="ssh_key_view_tree" model="ir.ui.view">
<field name="name">ssh.key.tree</field>
<field name="model">ssh.key</field>
<field name="arch" type="xml">
<list editable="top">
<field name="key"/>
<field name="user_id"/>
</list>
</field>
</record>
<record id="ssh_key_view_form" model="ir.ui.view">
<field name="name">ssh.key.form</field>
<field name="model">ssh.key</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="user_id"/>
</group>
<group>
<field name="key" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="ssh_key_action" model="ir.actions.act_window">
<field name="name">SSH Keys</field>
<field name="res_model">ssh.key</field>
<field name="view_mode">list,form</field>
</record>
<menuitem
id="menu_ssh_key"
action="ssh_key_action"
parent="base.menu_security"
sequence="50"/>
</odoo>