Compare commits

..

3 Commits

Author SHA1 Message Date
Stéphan Sainléger
fd9b169925 [MIG] maintenance_create_requests_from_project_task: Migration to 18.0 2026-03-17 22:44:45 +01:00
Stéphan Sainléger
9897d7ebe8 [MIG] maintenance_service_http_monitoring: Migration to 18.0 2026-03-17 22:44:45 +01:00
Stéphan Sainléger
8558f5b79f [MIG] maintenance_server_data: Migration to 18.0 2026-03-17 22:44:34 +01:00
13 changed files with 43 additions and 900 deletions

View File

@@ -1,92 +0,0 @@
============================================
maintenance_create_requests_from_project_task
============================================
This module allows the bulk creation of maintenance requests directly from a
project task. It is particularly useful when a task requires maintenance
actions on multiple equipment items simultaneously.
Key features:
- **Bulk creation**: Create multiple maintenance requests at once from a task
- **Equipment filtering**: Use domain filters to select target equipment
- **Smart defaults**: Pre-fills equipment from the task's project
- **Request tracking**: View all maintenance requests linked to a task
# Installation
Use Odoo normal module installation procedure to install
`maintenance_create_requests_from_project_task`.
This module depends on:
- `maintenance`
- `maintenance_project`
- `project`
# Configuration
No specific configuration is required.
# Usage
## Creating Maintenance Requests from a Task
1. Go to Project > Tasks
2. Open a task
3. In the action menu (or via the server action), click "Create maintenance requests"
4. A wizard opens with:
- **Task**: The source task (read-only)
- **Title**: The name for all created maintenance requests
- **Equipment Domain**: Filter to select which equipment to target
- By default, shows equipment linked to the task's project
- Use the domain builder to refine the selection
- **Technician**: Assign a technician to all requests
- **Maintenance Type**: Corrective or Preventive
- **Priority**: From Very Low to High
- **Duration**: Estimated duration in hours
- **Scheduled Date**: When the maintenance should occur
- **Description**: Details about the maintenance work
5. Click "Create"
6. All matching equipment will have a maintenance request created
7. You are redirected to the list of created requests
## Viewing Linked Maintenance Requests
On the task form:
- A smart button shows the count of open (not done) maintenance requests
- Click it to view all maintenance requests linked to this task
## Equipment Domain Examples
- All equipment in the project: `[('project_id', '=', project_id)]`
- Only servers: `[('category_id.name', '=', 'Server')]`
- Equipment needing backup: `[('backup_activated', '=', True)]`
- Combine conditions: `[('project_id', '=', project_id), ('category_id.name', '=', 'Server')]`
# Known issues / Roadmap
- Add template support for common maintenance scenarios
- Add option to create a single request for multiple equipment
# 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

@@ -1,89 +0,0 @@
=======================
maintenance_server_data
=======================
This module extends the maintenance equipment model to store detailed server
information, enabling comprehensive server infrastructure management within Odoo.
It adds several new models and fields to track:
- **Server specifications**: IP address, CPU cores, RAM, disk storage
- **Operating system**: Distribution name and version
- **Services**: Track services running on each server with their versions and URLs
- **Backup information**: Backup server, activation status, and health
# Installation
Use Odoo normal module installation procedure to install
`maintenance_server_data`.
This module depends on `maintenance`.
# Configuration
No specific configuration is required. After installation, new fields will be
available on the maintenance equipment form.
# Usage
## Managing Server Equipment
1. Go to Maintenance > Equipments
2. Create or edit an equipment record
3. Fill in the server-specific fields:
- **Server IP Address**: The server's IP address
- **Distribution**: Select or create an OS distribution
- **Hosting City**: Physical location of the server
- **Nb Cores**: Number of CPU cores
- **RAM (Go)**: Amount of RAM in gigabytes
- **Disk Storage (Go)**: Disk capacity in gigabytes
- **Backup Activated**: Whether backups are enabled
- **Backup Server**: The backup destination server
- **Backup OK**: Current backup health status
## Managing OS Distributions
1. Go to Maintenance > Configuration > OS Distributions
2. Create distributions with name and version (e.g., "Ubuntu", "22.04")
3. The display name is automatically computed from name + version
## Managing Services
1. Go to Maintenance > Configuration > Services
2. Create service definitions (e.g., "PostgreSQL", "Nginx", "Odoo")
3. Create service versions for each service
4. Mark the latest version with "Is Last Version?"
## Managing Service Instances
1. Go to Maintenance > Configuration > Service Instances
2. Link services to equipment with their specific version and URL
3. Service instances are automatically archived when their equipment is archived
# Known issues / Roadmap
- Add monitoring integration for automated backup status checks
- Add service version upgrade tracking
# Bug Tracker
Bugs are tracked on
[our issues website](https://github.com/elabore-coop/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

@@ -4,14 +4,7 @@ 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_main_domain_name', 'UNIQUE(main_domain_name)', 'Main Domain Name must be unique.'),
]
server_ip = fields.Char("Server Ip Address")
main_domain_name = fields.Char("Main Domain Name", )
distribution_id = fields.Many2one("os.distribution", string="Distribution")
service_ids = fields.One2many("service.instance", "equipment_id", string="Services")
hosting_city = fields.Char("Hosting City")
@@ -24,24 +17,8 @@ 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 "main_domain_name" not in default:
default["main_domain_name"] = 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,7 +8,6 @@
<xpath expr="//field[@name='effective_date']/.." position="after">
<group name="server_data" string="Server data">
<field name="server_ip" />
<field name="main_domain_name" />
<field name="hosting_city" />
<field name="distribution_id" />
<field name="nb_cores" />
@@ -46,7 +45,6 @@
<field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after">
<field name="server_ip" optional="hide" />
<field name="main_domain_name" optional="hide" />
<field name="hosting_city" optional="hide" />
<field name="distribution_id" optional="hide" />
<field name="nb_cores" optional="hide" />

View File

@@ -1,161 +0,0 @@
===================================
maintenance_service_http_monitoring
===================================
This module provides automated HTTP availability monitoring for services
defined on maintenance equipment. It periodically checks the HTTP status of
service URLs and automatically creates maintenance requests when services
are detected as unavailable.
Key features:
- **Automated HTTP checks**: Scheduled cron job checks all active services
- **Maintenance mode**: Temporarily disable monitoring during planned maintenance
- **Automatic maintenance requests**: Creates corrective maintenance requests
when services fail HTTP checks
- **Status tracking**: Records last HTTP status code and check date per service
# Installation
Use Odoo normal module installation procedure to install
`maintenance_service_http_monitoring`.
This module depends on:
- `maintenance`
- `maintenance_server_data`
**Python dependencies**: This module requires the `requests` library.
# Configuration
## Maintenance Mode Duration
By default, maintenance mode lasts 4 hours. To change this:
1. Go to Settings > Technical > System Parameters
2. Create or edit the parameter:
- Key: `maintenance_service_http_monitoring.maintenance_mode_duration`
- Value: Duration in hours (e.g., `8`)
## Cron Jobs
Two scheduled actions are installed:
1. **HTTP Service Monitoring: check all services**
- Runs every 15 minutes
- Checks HTTP status of all active service instances with URLs
2. **HTTP Service Monitoring: deactivate expired maintenance mode**
- Runs every 15 minutes
- Automatically disables maintenance mode when the end time is reached
## Webhook Notifications
Go to **Settings > Technical > Parameters > System Parameters** and configure:
+--------------------------------------------------------+----------------------------------------+
| Key | Description |
+========================================================+========================================+
| ``maintenance_service_http_monitoring.webhook_url`` | Webhook URL (POST endpoint) |
+--------------------------------------------------------+----------------------------------------+
| ``maintenance_service_http_monitoring.webhook_user`` | Basic Auth username (optional) |
+--------------------------------------------------------+----------------------------------------+
| ``maintenance_service_http_monitoring.webhook_password``| Basic Auth password (optional) |
+--------------------------------------------------------+----------------------------------------+
# Usage
## Monitoring Services
Services are automatically monitored if they have:
- A service URL defined
- An associated equipment
- The equipment is not in maintenance mode
- The service instance is active
The monitoring checks HTTPS availability (HTTP URLs are automatically
upgraded to HTTPS). A service is considered OK if it returns HTTP 200.
## Using Maintenance Mode
When performing planned maintenance on a server:
1. Go to Maintenance > Equipments
2. Open the equipment record
3. Click "Activer le mode maintenance" (Activate maintenance mode)
4. HTTP monitoring is suspended for this equipment
5. The mode automatically expires after the configured duration
6. Or click "Désactiver le mode maintenance" to end it manually
## Viewing HTTP Status
On service instances, you can see:
- **Last HTTP Status Code**: The last received HTTP status (200, 404, 500, etc.)
- **Last HTTP Check Date**: When the last check was performed
- **HTTP Status OK**: Quick visual indicator of service health
## Automatic Maintenance Requests
When a service fails HTTP checks:
- 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
When a new maintenance request is created (HTTP check failure), the module can
send a webhook notification to an external service (e.g., n8n, Rocket.Chat, Slack).
The webhook sends a JSON POST with the following structure::
{
"id": 42,
"name": "[HTTP KO] Server Name",
"priority": "2",
"description": "Service KO: https://example.com",
"equipment": "Server Name",
"link": "https://odoo.example.com/web#id=42&model=maintenance.request&view_type=form"
}
# Known issues / Roadmap
- Add configurable alert thresholds (e.g., alert after N consecutive failures)
- Add email/notification on service failure
- Support custom HTTP check endpoints (e.g., /health)
- Add support for basic authentication
# Bug Tracker
Bugs are tracked on
[our issues website](https://github.com/elabore-coop/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

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

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<record id="config_param_webhook_url" model="ir.config_parameter">
<field name="key">maintenance_service_http_monitoring.webhook_url</field>
<field name="value"></field>
</record>
<record id="config_param_webhook_user" model="ir.config_parameter">
<field name="key">maintenance_service_http_monitoring.webhook_user</field>
<field name="value"></field>
</record>
<record id="config_param_webhook_password" model="ir.config_parameter">
<field name="key">maintenance_service_http_monitoring.webhook_password</field>
<field name="value"></field>
</record>
</odoo>

View File

@@ -1,32 +1,30 @@
import logging
from datetime import timedelta
from odoo import api, fields, models
try:
import requests as http_requests
except ImportError:
http_requests = None
_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:
@@ -67,30 +65,31 @@ class MaintenanceEquipment(models.Model):
)
expired.action_deactivate_maintenance_mode()
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.
"""
def create_http_maintenance_request(self, ko_services):
self.ensure_one()
existing = ko_service.http_maintenance_request
if existing and not existing.stage_id.done:
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
return existing
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}"
request = self.http_maintenance_request
if request and not request.stage_id.done:
return request
vals = {
"name": name,
"equipment_id": self.id,
"priority": "2",
"maintenance_type": "corrective",
"description": description,
"description": self._build_ko_services_description(ko_services),
}
if self.employee_id:
vals["employee_id"] = self.employee_id.id
@@ -103,50 +102,9 @@ class MaintenanceEquipment(models.Model):
if team:
vals["maintenance_team_id"] = team.id
request = self.env["maintenance.request"].create(vals)
ko_service.http_maintenance_request = request.id
self._notify_webhook(request, ko_service)
self.http_maintenance_request = request.id
return request
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", ""
)
if not webhook_url:
return
webhook_user = ICP.get_param(
"maintenance_service_http_monitoring.webhook_user", ""
)
webhook_password = ICP.get_param(
"maintenance_service_http_monitoring.webhook_password", ""
)
base_url = ICP.get_param("web.base.url", "")
link = (
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,
}
auth = None
if webhook_user and webhook_password:
auth = (webhook_user, webhook_password)
try:
http_requests.post(
webhook_url,
json=payload,
auth=auth,
timeout=WEBHOOK_TIMEOUT,
)
except Exception as e:
_logger.warning(
"Webhook notification failed for maintenance request %s: %s",
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,5 +1,4 @@
import logging
import time
from odoo import api, fields, models
@@ -11,7 +10,6 @@ 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,25 +29,13 @@ 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):
"""
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
if rec.equipment_id.maintenance_mode:
equipment = rec.equipment_id
if getattr(equipment, "maintenance_mode", False):
continue
status_ok = False
status_code = -1
@@ -71,77 +57,20 @@ class ServiceInstance(models.Model):
}
)
if not status_ok:
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
# Delegate maintenance.request creation to equipment
if hasattr(equipment, "create_http_maintenance_request"):
equipment.create_http_maintenance_request([rec])
@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.
- 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.
"""
domain = [
("active", "=", True),
("service_url", "!=", False),
("equipment_id", "!=", False),
]
services = self.search(domain).filtered(
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
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)
services = self.search(domain)
for service in services:
equipment = service.equipment_id
if getattr(equipment, "maintenance_mode", False):
continue
service.check_http_status()

View File

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

View File

@@ -1,360 +0,0 @@
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"
)
SERVICE_INSTANCE_SLEEP = (
"odoo.addons.maintenance_service_http_monitoring.models.service_instance.time.sleep"
)
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 -- Two KO passes -> maintenance.request created on the service
# ------------------------------------------------------------------
def test_http_500_creates_maintenance_request(self):
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()
self.assertFalse(self.service_instance.http_status_ok)
self.assertEqual(self.service_instance.last_http_status_code, 500)
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 -- 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,
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_1 = self.service_instance.http_maintenance_request
self.assertTrue(request_1)
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()
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 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(SERVICE_INSTANCE_SLEEP),
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,
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.env["service.instance"].cron_check_http_services()
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 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()
self.assertTrue(self.service_instance.http_status_ok)
self.assertEqual(self.service_instance.last_http_status_code, 200)
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 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()
mock_sleep.assert_called_once_with(2)
self.assertEqual(mock_requests.get.call_count, 2)
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
# ------------------------------------------------------------------
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,
)
# ------------------------------------------------------------------
# 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)

View File

@@ -36,6 +36,7 @@
<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,7 +17,6 @@
<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>
</field>
</record>