Compare commits

..

31 Commits

Author SHA1 Message Date
Stéphan Sainléger
3639238d63 [IMP] maintenance_server_data: add unique constraint rule on some equipement fields
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 6m8s
2026-06-30 16:17:04 +02:00
Stéphan Sainléger
e4c485576c [FIX] maintenance_server_data: add code protection if fr_FR language not installed 2026-06-30 16:16:31 +02:00
Stéphan Sainléger
3e7e30ac14 [IMP] maintenance_server_data: add main_domain_name field in equipments 2026-06-30 11:20:36 +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
Boris Gallet
da0cbab39b [ADD] maintenance_service_http_monitoring: webhook_rocketchat_via_n8n + parameters 2026-03-29 23:44:25 +02:00
Stéphan Sainléger
3bf8ccfb61 [MIG] maintenance_create_requests_from_project_task: Migration to 18.0 2026-03-29 23:44:25 +02:00
Stéphan Sainléger
1f9a0996c0 [MIG] maintenance_service_http_monitoring: Migration to 18.0 2026-03-29 23:44:25 +02:00
Stéphan Sainléger
d0c23d9246 [MIG] maintenance_server_data: Migration to 18.0 2026-03-29 23:44:25 +02:00
Stéphan Sainléger
163abfb23d [MIG] maintenance_project_task_domain: Migration to 18.0 2026-03-17 22:44:23 +01:00
Stéphan Sainléger
a563a9f860 [IMP] maintenance_project_task_domain, maintenance_server_data, maintenance_service_http_monitoring, maintenance_create_requests_from_project_task: pre-commit execution 2026-03-17 22:01:38 +01:00
30a19649d2 [CI] sync config from odoo-elabore-ci 2026-03-10 16:25:38 +00:00
Stéphan Sainléger
51d3d42491 [NEW] maintenance_service_http_monitoring: create add-on
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m33s
2026-02-26 15:49:48 +01:00
Stéphan Sainléger
00a97e876c [IMP] maintenance_server_data: add active field in service.instance model
and make the active field value follow the equipement active field
2026-02-26 15:49:48 +01:00
Stéphan Sainléger
5b0f220834 [IMP] maintenance_server_data: add service.instance views 2026-02-26 15:49:48 +01:00
edeacc4555 [ADD] maintenance_project_task_domain
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m29s
2025-10-21 16:22:39 +02:00
ab57dc9c40 Sync config from odoo-elabore-ci:16.0
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 7m35s
2025-09-17 13:39:43 +00:00
Stéphan Sainléger
d3a9afa8a5 [IMP] maintenance_create_requests_from_project_task: add maintenance_team of the equipment 2025-06-04 15:43:31 +02:00
Stéphan Sainléger
fe6928d7c6 [IMP] maintenance_create_requests_from_project_task: add default domain 2025-06-03 16:20:31 +02:00
Stéphan Sainléger
1e7671c119 [ADD] maintenance_create_requests_from_project_task: create add-on 2025-06-03 16:20:31 +02:00
Boris Gallet
ff75a1b4cd [IMP] add `name_fr` field to maintenance equipment 2025-05-13 17:45:50 +02:00
clementthomas
4373d8a23d [IMP] maintenance_server_monitoring
* add test_ok, test_warning and test_error functions to simplify code
* add ssh_ok test
2024-04-05 12:38:51 +02:00
clementthomas
373c7f406b [IMP] maintenance_server_monitoring:
quick fix
2024-04-03 18:18:20 +02:00
clementthomas
b18940fe56 [IMP] maintenance_server_monitoring:
* add cron
* ssh in other module
* new maintenance request if error
* disk usage test
2024-04-03 18:13:02 +02:00
clementthomas
5f9119c4e8 [IMP] maintenance_server_monitoring:
+Readme
2024-04-02 13:34:52 +02:00
clementthomas
e4d6071e65 [NEW] maintenance_server_monitoring 2024-04-02 13:26:45 +02:00
clementthomas
2e9f01cb0d [MIG] migration of maintenance_server 2024-04-02 13:26:31 +02:00
100 changed files with 4113 additions and 1382 deletions

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# Configuration for known file extensions
[*.{css,js,json,less,md,py,rst,sass,scss,xml,yaml,yml}]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{json,yml,yaml,rst,md}]
indent_size = 2
# Do not configure editor for libs and autogenerated content
[{*/static/{lib,src/lib}/**,*/static/description/index.html,*/readme/../README.rst}]
charset = unset
end_of_line = unset
indent_size = unset
indent_style = unset
insert_final_newline = false
trim_trailing_whitespace = false

188
.eslintrc.yml Normal file
View File

@@ -0,0 +1,188 @@
env:
browser: true
es6: true
# See https://github.com/OCA/odoo-community.org/issues/37#issuecomment-470686449
parserOptions:
ecmaVersion: 2019
overrides:
- files:
- "**/*.esm.js"
parserOptions:
sourceType: module
# Globals available in Odoo that shouldn't produce errorings
globals:
_: readonly
$: readonly
fuzzy: readonly
jQuery: readonly
moment: readonly
odoo: readonly
openerp: readonly
owl: readonly
luxon: readonly
# Styling is handled by Prettier, so we only need to enable AST rules;
# see https://github.com/OCA/maintainer-quality-tools/pull/618#issuecomment-558576890
rules:
accessor-pairs: warn
array-callback-return: warn
callback-return: warn
capitalized-comments:
- warn
- always
- ignoreConsecutiveComments: true
ignoreInlineComments: true
complexity:
- warn
- 15
constructor-super: warn
dot-notation: warn
eqeqeq: warn
global-require: warn
handle-callback-err: warn
id-blacklist: warn
id-match: warn
init-declarations: error
max-depth: warn
max-nested-callbacks: warn
max-statements-per-line: warn
no-alert: warn
no-array-constructor: warn
no-caller: warn
no-case-declarations: warn
no-class-assign: warn
no-cond-assign: error
no-const-assign: error
no-constant-condition: warn
no-control-regex: warn
no-debugger: error
no-delete-var: warn
no-div-regex: warn
no-dupe-args: error
no-dupe-class-members: error
no-dupe-keys: error
no-duplicate-case: error
no-duplicate-imports: error
no-else-return: warn
no-empty-character-class: warn
no-empty-function: error
no-empty-pattern: error
no-empty: warn
no-eq-null: error
no-eval: error
no-ex-assign: error
no-extend-native: warn
no-extra-bind: warn
no-extra-boolean-cast: warn
no-extra-label: warn
no-fallthrough: warn
no-func-assign: error
no-global-assign: error
no-implicit-coercion:
- warn
- allow: ["~"]
no-implicit-globals: warn
no-implied-eval: warn
no-inline-comments: warn
no-inner-declarations: warn
no-invalid-regexp: warn
no-irregular-whitespace: warn
no-iterator: warn
no-label-var: warn
no-labels: warn
no-lone-blocks: warn
no-lonely-if: error
no-mixed-requires: error
no-multi-str: warn
no-native-reassign: error
no-negated-condition: warn
no-negated-in-lhs: error
no-new-func: warn
no-new-object: warn
no-new-require: warn
no-new-symbol: warn
no-new-wrappers: warn
no-new: warn
no-obj-calls: warn
no-octal-escape: warn
no-octal: warn
no-param-reassign: warn
no-path-concat: warn
no-process-env: warn
no-process-exit: warn
no-proto: warn
no-prototype-builtins: warn
no-redeclare: warn
no-regex-spaces: warn
no-restricted-globals: warn
no-restricted-imports: warn
no-restricted-modules: warn
no-restricted-syntax: warn
no-return-assign: error
no-script-url: warn
no-self-assign: warn
no-self-compare: warn
no-sequences: warn
no-shadow-restricted-names: warn
no-shadow: warn
no-sparse-arrays: warn
no-sync: warn
no-this-before-super: warn
no-throw-literal: warn
no-undef-init: warn
no-undef: error
no-unmodified-loop-condition: warn
no-unneeded-ternary: error
no-unreachable: error
no-unsafe-finally: error
no-unused-expressions: error
no-unused-labels: error
no-unused-vars: error
no-use-before-define: error
no-useless-call: warn
no-useless-computed-key: warn
no-useless-concat: warn
no-useless-constructor: warn
no-useless-escape: warn
no-useless-rename: warn
no-void: warn
no-with: warn
operator-assignment: [error, always]
prefer-const: warn
radix: warn
require-yield: warn
sort-imports: warn
spaced-comment: [error, always]
strict: [error, function]
use-isnan: error
valid-jsdoc:
- warn
- prefer:
arg: param
argument: param
augments: extends
constructor: class
exception: throws
func: function
method: function
prop: property
return: returns
virtual: abstract
yield: yields
preferType:
array: Array
bool: Boolean
boolean: Boolean
number: Number
object: Object
str: String
string: String
requireParamDescription: false
requireReturn: false
requireReturnDescription: false
requireReturnType: false
valid-typeof: warn
yoda: warn

View File

@@ -0,0 +1,40 @@
name: pre-commit
on:
pull_request:
jobs:
pre-commit:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Get python version
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
- uses: https://gitea.com/actions/cache@v3
with:
path: ~/.cache/pre-commit
key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit
run: pre-commit run --all-files --show-diff-on-failure --color=always
env:
# Consider valid a PR that changes README fragments but doesn't
# change the README.rst file itself. It's not really a problem
# because the bot will update it anyway after merge. This way, we
# lower the barrier for functional contributors that want to fix the
# readme fragments, while still letting developers get README
# auto-generated (which also helps functionals when using runboat).
# DOCS https://pre-commit.com/#temporarily-disabling-hooks
SKIP: oca-gen-addon-readme
- name: Check that all files generated by pre-commit are in git
run: |
newfiles="$(git ls-files --others --exclude-from=.gitignore)"
if [ "$newfiles" != "" ] ; then
echo "Please check-in the following files:"
echo "$newfiles"
exit 1
fi

147
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,147 @@
exclude: |
(?x)
# NOT INSTALLABLE ADDONS
# END NOT INSTALLABLE ADDONS
# Files and folders generated by bots, to avoid loops
^setup/|/static/description/index\.html$|
# We don't want to mess with tool-generated files
.svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/|^eslint.config.cjs|^prettier.config.cjs|
# Maybe reactivate this when all README files include prettier ignore tags?
^README\.md$|
# Library files can have extraneous formatting (even minimized)
/static/(src/)?lib/|
# Repos using Sphinx to generate docs don't need prettying
^docs/_templates/.*\.html$|
# Don't bother non-technical authors with formatting issues in docs
readme/.*\.(rst|md)$|
# Ignore build and dist directories in addons
/build/|/dist/|
# Ignore test files in addons
/tests/samples/.*|
# You don't usually want a bot to modify your legal texts
(LICENSE.*|COPYING.*)
default_language_version:
python: python3
node: "16.17.0"
repos:
- repo: local
hooks:
# These files are most likely copier diff rejection junks; if found,
# review them manually, fix the problem (if needed) and remove them
- id: forbidden-files
name: forbidden files
entry: found forbidden files; remove them
language: fail
files: "\\.rej$"
- id: en-po-files
name: en.po files cannot exist
entry: found a en.po file
language: fail
files: '[a-zA-Z0-9_]*/i18n/en\.po$'
- repo: https://github.com/oca/maintainer-tools
rev: f9b919b9868143135a9c9cb03021089cabba8223
hooks:
# update the NOT INSTALLABLE ADDONS section above
- id: oca-update-pre-commit-excluded-addons
- id: oca-fix-manifest-website
entry:
bash -c 'oca-fix-manifest-website "https://git.elabore.coop/elabore/$(basename
$(git rev-parse --show-toplevel))"'
- id: oca-gen-addon-readme
entry:
bash -c 'oca-gen-addon-readme --addons-dir=. --branch=$(git symbolic-ref
refs/remotes/origin/HEAD | sed "s@^refs/remotes/origin/@@")
--repo-name=$(basename $(git rev-parse --show-toplevel)) --org-name="Elabore"
--if-source-changed --keep-source-digest'
- repo: https://github.com/OCA/odoo-pre-commit-hooks
rev: v0.1.4
hooks:
- id: oca-checks-odoo-module
- id: oca-checks-po
args:
- --disable=po-pretty-format
- repo: local
hooks:
- id: prettier
name: prettier (with plugin-xml)
entry: prettier
args:
- --write
- --list-different
- --ignore-unknown
types: [text]
files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$
language: node
additional_dependencies:
- "prettier@2.7.1"
- "@prettier/plugin-xml@2.2.0"
- repo: local
hooks:
- id: eslint
name: eslint
entry: eslint
args:
- --color
- --fix
verbose: true
types: [javascript]
language: node
additional_dependencies:
- "eslint@8.24.0"
- "eslint-plugin-jsdoc@"
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
# exclude autogenerated files
exclude: /README\.rst$|\.pot?$
- id: end-of-file-fixer
# exclude autogenerated files
exclude: /README\.rst$|\.pot?$
- id: debug-statements
- id: fix-encoding-pragma
args: ["--remove"]
- id: check-case-conflict
- id: check-docstring-first
- id: check-executables-have-shebangs
- id: check-merge-conflict
# exclude files where underlines are not distinguishable from merge conflicts
exclude: /README\.rst$|^docs/.*\.rst$
- id: check-symlinks
- id: check-xml
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: https://github.com/PyCQA/docformatter
rev: v1.7.7
hooks:
- id: docformatter
args: [
"--in-place", # modify the files
"--recursive", # run on all the files
"--wrap-summaries",
"88", # max length of 1st line
"--wrap-descriptions",
"88", # max length of other lines
"--pre-summary-newline", # new line before a long summary
"--make-summary-multi-line", # force summary on multilines
]
additional_dependencies: ["tomli"] # if Python <3.11
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/OCA/pylint-odoo
rev: v9.1.3
hooks:
- id: pylint_odoo
name: pylint with optional checks
args:
- --rcfile=.pylintrc
- --exit-zero
verbose: true
- id: pylint_odoo
args:
- --rcfile=.pylintrc-mandatory

8
.prettierrc.yml Normal file
View File

@@ -0,0 +1,8 @@
# Defaults for all prettier-supported languages.
# Prettier will complete this with settings from .editorconfig file.
bracketSpacing: false
printWidth: 88
proseWrap: always
semi: true
trailingComma: "es5"
xmlWhitespaceSensitivity: "strict"

123
.pylintrc Normal file
View File

@@ -0,0 +1,123 @@
[MASTER]
load-plugins=pylint_odoo
score=n
[ODOOLINT]
readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst"
manifest-required-authors=Elabore
manifest-required-keys=license
manifest-deprecated-keys=description,active
license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3
valid-odoo-versions=16.0
[MESSAGES CONTROL]
disable=all
# This .pylintrc contains optional AND mandatory checks and is meant to be
# loaded in an IDE to have it check everything, in the hope this will make
# optional checks more visible to contributors who otherwise never look at a
# green travis to see optional checks that failed.
# .pylintrc-mandatory containing only mandatory checks is used the pre-commit
# config as a blocking check.
enable=anomalous-backslash-in-string,
api-one-deprecated,
api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv,
duplicate-key,
duplicate-xml-fields,
duplicate-xml-record-id,
eval-referenced,
eval-used,
incoherent-interpreter-exec-perm,
license-allowed,
manifest-author-string,
manifest-deprecated-key,
manifest-required-author,
manifest-required-key,
manifest-version-format,
method-compute,
method-inverse,
method-required-super,
method-search,
openerp-exception-warning,
pointless-statement,
pointless-string-statement,
print-used,
redundant-keyword-arg,
redundant-modulename-xml,
reimported,
relative-import,
return-in-init,
rst-syntax-error,
sql-injection,
too-few-format-args,
translation-field,
translation-required,
unreachable,
use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password,
dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node,
deprecated-openerp-xml-node,
duplicate-po-message-definition,
except-pass,
file-not-used,
invalid-commit,
manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme,
missing-return,
odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter,
resource-not-exist,
str-format-used,
test-folder-imported,
translation-contains-variable,
translation-positional-used,
unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout,
# messages that do not cause the lint step to fail
consider-merging-classes-inherited,
create-user-wo-reset-password,
dangerous-filter-wo-user,
deprecated-module,
file-not-used,
invalid-commit,
missing-manifest-dependency,
missing-newline-extrafiles,
missing-readme,
no-utf8-coding-comment,
odoo-addons-relative-import,
old-api7-method-defined,
redefined-builtin,
too-complex,
unnecessary-utf8-coding-comment
[REPORTS]
msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg}
output-format=colorized
reports=no

98
.pylintrc-mandatory Normal file
View File

@@ -0,0 +1,98 @@
[MASTER]
load-plugins=pylint_odoo
score=n
[ODOOLINT]
readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst"
manifest-required-authors=Elabore
manifest-required-keys=license
manifest-deprecated-keys=description,active
license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3
valid-odoo-versions=16.0
[MESSAGES CONTROL]
disable=all
enable=anomalous-backslash-in-string,
api-one-deprecated,
api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv,
duplicate-key,
duplicate-xml-fields,
duplicate-xml-record-id,
eval-referenced,
eval-used,
incoherent-interpreter-exec-perm,
license-allowed,
manifest-author-string,
manifest-deprecated-key,
manifest-required-author,
manifest-required-key,
manifest-version-format,
method-compute,
method-inverse,
method-required-super,
method-search,
openerp-exception-warning,
pointless-statement,
pointless-string-statement,
print-used,
redundant-keyword-arg,
redundant-modulename-xml,
reimported,
relative-import,
return-in-init,
rst-syntax-error,
sql-injection,
too-few-format-args,
translation-field,
translation-required,
unreachable,
use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password,
dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node,
deprecated-openerp-xml-node,
duplicate-po-message-definition,
except-pass,
file-not-used,
invalid-commit,
manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme,
missing-return,
odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter,
resource-not-exist,
str-format-used,
test-folder-imported,
translation-contains-variable,
translation-positional-used,
unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout
[REPORTS]
msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg}
output-format=colorized
reports=no

31
.ruff.toml Normal file
View File

@@ -0,0 +1,31 @@
target-version = "py310"
fix = true
[lint]
extend-select = [
"B",
"C90",
"E501", # line too long (default 88)
"I", # isort
"UP", # pyupgrade
]
extend-safe-fixes = ["UP008"]
exclude = ["setup/*"]
[format]
exclude = ["setup/*"]
[lint.per-file-ignores]
"__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py
"__manifest__.py" = ["B018"] # useless expression
[lint.isort]
section-order = ["future", "standard-library", "third-party", "odoo", "odoo-addons", "first-party", "local-folder"]
[lint.isort.sections]
"odoo" = ["odoo"]
"odoo-addons" = ["odoo.addons"]
[lint.mccabe]
max-complexity = 16

View File

@@ -0,0 +1,92 @@
============================================
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

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

View File

@@ -2,18 +2,20 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_server_ssh",
"version": "14.0.1.0.0",
"name": "maintenance_create_requests_from_project_task",
"version": "18.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Clément Thomas",
"maintainer": "Stéphan Sainléger",
"license": "AGPL-3",
"category": "Tools",
"summary": "Monitor some data on remote hosts",
"summary": "Allow the creation of multiple maintenance requests from a projet task.",
# any module necessary for this one to work correctly
"depends": [
"base",
"maintenance",
"maintenance_project",
"project",
],
"qweb": [
# "static/src/xml/*.xml",
@@ -22,8 +24,10 @@
"python": [],
},
# always loaded
"data": [
"views/maintenance_equipment_views.xml",
"data": [
"security/ir.model.access.csv",
"views/project_task.xml",
"wizard/create_maintenance_requests_wizard.xml",
],
# only loaded in demonstration mode
"demo": [],
@@ -34,4 +38,4 @@
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}
}

View File

@@ -0,0 +1,182 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * maintenance_create_requests_from_project_task
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-02 22:52+0000\n"
"PO-Revision-Date: 2025-06-03 00:58+0200\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: maintenance_create_requests_from_project_task
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
msgid "Cancel"
msgstr "Annuler"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model,name:maintenance_create_requests_from_project_task.model_create_maintenance_requests_wizard
msgid "Configure the maintenance requests to create from the current task."
msgstr "Définir les données des demandes de maintenance à créer depuis la tâche actuelle."
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__maintenance_type__corrective
msgid "Corrective"
msgstr "Corrective"
#. module: maintenance_create_requests_from_project_task
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
msgid "Create"
msgstr "Créer"
#. module: maintenance_create_requests_from_project_task
#: model:ir.actions.act_window,name:maintenance_create_requests_from_project_task.action_create_maintenance_requests_wizard
msgid "Create Maintenance Requests"
msgstr "Créer des demandes de maintenance"
#. module: maintenance_create_requests_from_project_task
#. odoo-python
#: code:addons/maintenance_create_requests_from_project_task/wizard/create_maintenance_requests_wizard.py:0
#: model:ir.actions.server,name:maintenance_create_requests_from_project_task.task_wizard_action_create_maintenance_requests
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
#, python-format
msgid "Create maintenance requests"
msgstr "Créer des demandes de maintenance"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__create_uid
msgid "Created by"
msgstr "Créé par"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__create_date
msgid "Created on"
msgstr "Créé le"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__description
msgid "Description"
msgstr "Description"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__display_name
msgid "Display Name"
msgstr "Nom affiché"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__duration
msgid "Duration"
msgstr "Durée"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,help:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__duration
msgid "Duration in hours."
msgstr "Durée en heures."
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__equipment_domain
msgid "Equipment Domain"
msgstr "Domaine des équipements"
#. module: maintenance_create_requests_from_project_task
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
msgid "Equipments targetted"
msgstr "Équipements ciblés"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__priority__3
msgid "High"
msgstr "Élevé"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__id
msgid "ID"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard____last_update
msgid "Last Modified on"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__write_uid
msgid "Last Updated by"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__write_date
msgid "Last Updated on"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__priority__1
msgid "Low"
msgstr "Bas"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_project_task__maintenance_request_count
msgid "Maintenance Request Count"
msgstr "Nombre de demandes de maintenance"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_project_task__maintenance_request_ids
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.view_task_form2_maintenance_inherited
msgid "Maintenance Requests"
msgstr "Demandes de maintenance"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__maintenance_type
msgid "Maintenance Type"
msgstr "Type de maintenance"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__priority__2
msgid "Normal"
msgstr "Normal"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__maintenance_type__preventive
msgid "Preventive"
msgstr "Préventive"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__priority
msgid "Priority"
msgstr "Priorité"
#. module: maintenance_create_requests_from_project_task
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
msgid "Requests data"
msgstr "Données"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__schedule_date
msgid "Scheduled Date"
msgstr "Date prévue"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model,name:maintenance_create_requests_from_project_task.model_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__task_id
msgid "Task"
msgstr "Tâche"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__user_id
msgid "Technician"
msgstr "Technicien"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__name
msgid "Title"
msgstr "Nom"
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__priority__0
msgid "Very Low"
msgstr "Très bas"

View File

@@ -0,0 +1,182 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * maintenance_create_requests_from_project_task
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-02 22:52+0000\n"
"PO-Revision-Date: 2025-06-02 22:52+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: maintenance_create_requests_from_project_task
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
msgid "Cancel"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model,name:maintenance_create_requests_from_project_task.model_create_maintenance_requests_wizard
msgid "Configure the maintenance requests to create from the current task."
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__maintenance_type__corrective
msgid "Corrective"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
msgid "Create"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.actions.act_window,name:maintenance_create_requests_from_project_task.action_create_maintenance_requests_wizard
msgid "Create Maintenance Requests"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#. odoo-python
#: code:addons/maintenance_create_requests_from_project_task/wizard/create_maintenance_requests_wizard.py:0
#: model:ir.actions.server,name:maintenance_create_requests_from_project_task.task_wizard_action_create_maintenance_requests
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
#, python-format
msgid "Create maintenance requests"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__create_uid
msgid "Created by"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__create_date
msgid "Created on"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__description
msgid "Description"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__display_name
msgid "Display Name"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__duration
msgid "Duration"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,help:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__duration
msgid "Duration in hours."
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__equipment_domain
msgid "Equipment Domain"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
msgid "Equipments targetted"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__priority__3
msgid "High"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__id
msgid "ID"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard____last_update
msgid "Last Modified on"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__write_uid
msgid "Last Updated by"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__write_date
msgid "Last Updated on"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__priority__1
msgid "Low"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_project_task__maintenance_request_count
msgid "Maintenance Request Count"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_project_task__maintenance_request_ids
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.view_task_form2_maintenance_inherited
msgid "Maintenance Requests"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__maintenance_type
msgid "Maintenance Type"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__priority__2
msgid "Normal"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__maintenance_type__preventive
msgid "Preventive"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__priority
msgid "Priority"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model_terms:ir.ui.view,arch_db:maintenance_create_requests_from_project_task.create_maintenance_requests_wizard_view_form
msgid "Requests data"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__schedule_date
msgid "Scheduled Date"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model,name:maintenance_create_requests_from_project_task.model_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__task_id
msgid "Task"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__user_id
msgid "Technician"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields,field_description:maintenance_create_requests_from_project_task.field_create_maintenance_requests_wizard__name
msgid "Title"
msgstr ""
#. module: maintenance_create_requests_from_project_task
#: model:ir.model.fields.selection,name:maintenance_create_requests_from_project_task.selection__create_maintenance_requests_wizard__priority__0
msgid "Very Low"
msgstr ""

View File

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

View File

@@ -0,0 +1,31 @@
from odoo import api, fields, models
class ProjectTask(models.Model):
_inherit = "project.task"
maintenance_request_ids = fields.One2many(
"maintenance.request", "task_id", string="Maintenance Requests"
)
maintenance_request_count = fields.Integer(
compute="_compute_maintenance_request_count"
)
@api.depends("maintenance_request_ids")
def _compute_maintenance_request_count(self):
for task in self:
task.maintenance_request_count = len(
task.maintenance_request_ids.filtered(lambda x: not x.stage_id.done)
)
def action_view_maintenance_request_ids(self):
"""
Access to the undone maintenance requests for this task.
"""
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id(
"maintenance.hr_equipment_request_action"
)
action["domain"] = [("task_id", "=", self.id), ("stage_id.done", "=", False)]
action["context"] = {"default_task_id": self.id}
return action

View File

@@ -0,0 +1,2 @@
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
access_create_maintenance_requests_wizard,maintenance_create_requests_from_portal_tasks.create_maintenance_requests_wizard.access,model_create_maintenance_requests_wizard,base.group_user,1,1,1,0
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
2 access_create_maintenance_requests_wizard maintenance_create_requests_from_portal_tasks.create_maintenance_requests_wizard.access model_create_maintenance_requests_wizard base.group_user 1 1 1 0

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_task_form2_maintenance_inherited" model="ir.ui.view">
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_form2" />
<field name="arch" type="xml">
<xpath expr="//field[@name='company_id']" position="after">
<field name="maintenance_request_count" invisible="1" />
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button
name="action_view_maintenance_request_ids"
type="object"
invisible="maintenance_request_count == 0"
class="oe_stat_button"
icon="fa-tasks"
>
<div class="o_field_widget o_stat_info">
<span class="o_stat_value ">
<field
name="maintenance_request_count"
widget="statinfo"
nolabel="1"
/>
Maintenance Requests
</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,130 @@
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval
class CreateMaintenanceRequestsWizard(models.TransientModel):
_name = "create.maintenance.requests.wizard"
_description = "Configure the maintenance requests to create from the current task."
@api.model
def _default_task_id(self):
return self.env["project.task"].browse(self._context.get("active_ids"))
@api.model
def _default_equipment_model(self):
default_domain = []
task_id = self.env["project.task"].browse(self._context.get("active_ids"))
project_equipements = self.env["maintenance.equipment"].search(
[("project_id", "=", task_id.project_id.id)]
)
if project_equipements:
equipment_ids_list = [x.id for x in project_equipements]
default_domain.append(("id", "in", equipment_ids_list))
return default_domain
name = fields.Char("Title", required=True)
user_id = fields.Many2one("res.users", string="Technician")
priority = fields.Selection(
[("0", "Very Low"), ("1", "Low"), ("2", "Normal"), ("3", "High")],
string="Priority",
)
maintenance_type = fields.Selection(
[("corrective", "Corrective"), ("preventive", "Preventive")],
string="Maintenance Type",
default="corrective",
)
schedule_date = fields.Datetime("Scheduled Date")
duration = fields.Float(help="Duration in hours.")
description = fields.Html("Description")
equipment_domain = fields.Char("Equipment Domain", default=_default_equipment_model)
task_id = fields.Many2one(
"project.task",
string="Task",
required=True,
default=_default_task_id,
)
@api.model
def action_open_wizard(self):
"""
Open the form view.
"""
return {
"name": _("Create maintenance requests"),
"type": "ir.actions.act_window",
"res_model": "create.maintenance.requests.wizard",
"view_type": "form",
"view_mode": "form",
"target": "new",
}
def create_maintenance_requests(self):
"""
Create the maintenance requests with the data filled in the wizard form.
"""
vals_list = self._compute_vals_list()
maintenance_requests = self.env["maintenance.request"].sudo().create(vals_list)
return self._get_action(maintenance_requests)
def _compute_vals_list(self):
"""
Compute the list of data to use for all the maintenance requests creation.
"""
equipment_list = self.env["maintenance.equipment"].search(
safe_eval(self.equipment_domain)
)
if len(equipment_list) == 0:
raise UserError(
_(
"No equipment is matching the domain. "
"Maintenance request creation is not possible."
)
)
vals_list = []
common_vals = {
"name": self.name,
"user_id": self.user_id.id,
"priority": self.priority,
"maintenance_type": self.maintenance_type,
"schedule_date": self.schedule_date,
"duration": self.duration,
"description": self.description,
"task_id": self.task_id.id,
"project_id": self.task_id.project_id.id,
}
for equipment in equipment_list:
vals = common_vals.copy()
vals["equipment_id"] = equipment.id
if equipment.maintenance_team_id:
vals["maintenance_team_id"] = equipment.maintenance_team_id.id
vals_list.append(vals)
return vals_list
def _get_action(self, maintenance_requests):
"""
Provide the action to go to the list view of the maintenance requests created.
"""
search_view_ref = self.env.ref(
"maintenance.hr_equipment_request_view_search", False
)
form_view_ref = self.env.ref(
"maintenance.hr_equipment_request_view_form", False
)
list_view_ref = self.env.ref(
"maintenance.hr_equipment_request_view_tree", False
)
return {
"domain": [("id", "in", maintenance_requests.ids)],
"name": "Maintenance Requests",
"res_model": "maintenance.request",
"type": "ir.actions.act_window",
"views": [(list_view_ref.id, "list"), (form_view_ref.id, "form")],
"search_view_id": search_view_ref and [search_view_ref.id],
}

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="create_maintenance_requests_wizard_view_form" model="ir.ui.view">
<field name="name">create.maintenance.requests.wizard.view.form</field>
<field name="model">create.maintenance.requests.wizard</field>
<field name="arch" type="xml">
<form string="Create maintenance requests">
<sheet>
<group name="name">
<field name="task_id" readonly="1" />
<field name="name" />
</group>
<group name="domain" string="Equipments targetted">
<field
name="equipment_domain"
widget="domain"
options='{"model": "maintenance.equipment"}'
/>
</group>
<group name="data" string="Requests data">
<field name="user_id" />
<field name="maintenance_type" />
<field name="priority" />
<field name="duration" />
<field name="schedule_date" />
<field name="description" />
</group>
</sheet>
<footer>
<button
string="Create"
name="create_maintenance_requests"
type="object"
class="btn-primary"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record
id="action_create_maintenance_requests_wizard"
model="ir.actions.act_window"
>
<field name="name">Create Maintenance Requests</field>
<field name="res_model">create.maintenance.requests.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="create_maintenance_requests_wizard_view_form" />
<field name="target">new</field>
</record>
<record
id="task_wizard_action_create_maintenance_requests"
model="ir.actions.server"
>
<field name="name">Create maintenance requests</field>
<field
name="model_id"
ref="maintenance_create_requests_from_project_task.model_create_maintenance_requests_wizard"
/>
<field name="binding_model_id" ref="project.model_project_task" />
<field name="state">code</field>
<field name="code">action = model.action_open_wizard()</field>
</record>
</odoo>

View File

@@ -0,0 +1,58 @@
===============================
maintenance_project_task_domain
===============================
This module adjusts the domain applied to the task field on maintenance requests
in order to limit selection to tasks in any of unfolded stages only.
When linking a maintenance request to a project task, the default behavior shows
all tasks from the project. This module filters out tasks that are in folded
stages (typically completed or cancelled tasks), making it easier to select
relevant active tasks.
# Installation
Use Odoo normal module installation procedure to install
`maintenance_project_task_domain`.
This module depends on `maintenance_project` and will be auto-installed when
that module is present.
# Configuration
No configuration is needed. The module automatically applies the domain filter
to the task field on maintenance request forms.
# Usage
1. Go to Maintenance > Maintenance Requests
2. Create or edit a maintenance request
3. When selecting a task in the "Task" field, only tasks from unfolded stages
will be displayed
# Known issues / Roadmap
- Consider making the stage filter configurable via settings
# 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
- Quentin Mondot
## 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,18 @@
# Copyright 2025 Quentin Mondot
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_project_task_domain",
"version": "18.0.1.0.0",
"author": "Elabore",
"website": "https://git.elabore.coop/elabore/maintenance-tools",
"maintainer": "Quentin Mondot",
"license": "AGPL-3",
"category": "Tools",
"summary": "This module adjusts the domain applied to task field in order to limit "
"selection to tasks in any of unfolded stages.",
"depends": ["maintenance_project"],
"data": ["views/maintenance_view_form_task_domain.xml"],
"installable": True,
"auto_install": True,
"application": False,
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="maintenance_view_form_task_domain" model="ir.ui.view">
<field name="name">maintenance.form.task.domain</field>
<field name="model">maintenance.request</field>
<field
name="inherit_id"
ref="maintenance_project.hr_equipment_request_view_form"
/>
<field name="priority" eval="99" />
<field name="arch" type="xml">
<xpath expr="//field[@name='task_id' and @domain]" position="attributes">
<attribute
name="domain"
>[('project_id', '=', project_id),('stage_id.fold', '=', False)]</attribute>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,89 @@
=======================
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

@@ -1,44 +0,0 @@
======================================
maintenance_server_data
======================================
Gather several identification data about the servers to maintain.
Installation
============
Use Odoo normal module installation procedure to install
``maintenance_server_data``.
Known issues / Roadmap
======================
None yet.
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

@@ -1,3 +1 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -3,9 +3,9 @@
{
"name": "maintenance_server_data",
"version": "14.0.1.0.0",
"version": "18.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"website": "https://git.elabore.coop/elabore/maintenance-tools",
"maintainer": "Stéphan Sainléger",
"license": "AGPL-3",
"category": "Tools",
@@ -37,4 +37,4 @@
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}
}

View File

@@ -1,3 +1,3 @@
from . import os_distribution
from . import service
from . import maintenance_equipment
from . import maintenance_equipment

View File

@@ -1,17 +1,54 @@
from odoo import fields, models
from odoo import api, fields, models
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
_inherit = "maintenance.equipment"
server_ip = fields.Char('Server Ip Address')
distribution_id = fields.Many2one('os.distribution', string='Distribution')
service_ids = fields.One2many('service.instance', 'equipment_id', string='Services')
hosting_city = fields.Char('Hosting City')
nb_cores = fields.Integer('Nb Cores')
ram = fields.Integer('RAM (Go)')
disk_storage = fields.Integer('Disk Storage (Go)')
backup_activated = fields.Boolean('Backup Activated ?')
backup_server_id = fields.Many2one('backup.server', string='Backup Server')
backup_ok = fields.Boolean('Backup OK ?')
_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")
nb_cores = fields.Integer("Nb Cores")
ram = fields.Integer("RAM (Go)")
disk_storage = fields.Integer("Disk Storage (Go)")
backup_activated = fields.Boolean("Backup Activated ?")
backup_server_id = fields.Many2one("backup.server", string="Backup Server")
backup_ok = fields.Boolean("Backup OK ?")
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
def write(self, vals):
res = super().write(vals)
if "active" in vals:
self.with_context(active_test=False).service_ids.write(
{"active": vals["active"]}
)
return res

View File

@@ -1,17 +1,18 @@
from odoo import api, fields, models
class OsDistribution(models.Model):
_name = 'os.distribution'
_name = "os.distribution"
name = fields.Char('Name', compute="_compute_name")
distrib_name = fields.Char('Distrib Name', required=True)
distrib_version = fields.Char('Distrib Version')
name = fields.Char("Name", compute="_compute_name")
distrib_name = fields.Char("Distrib Name", required=True)
distrib_version = fields.Char("Distrib Version")
@api.depends("distrib_name","distrib_version")
@api.depends("distrib_name", "distrib_version")
def _compute_name(self):
for distrib in self:
distrib.name = ""
if distrib.distrib_name != "":
distrib.name = distrib.distrib_name
if distrib.distrib_version != "":
distrib.name = distrib.name + ' ' + distrib.distrib_version
distrib.name = distrib.name + " " + distrib.distrib_version

View File

@@ -1,27 +1,31 @@
from odoo import fields, models
class Service(models.Model):
_name = 'service'
name = fields.Char('Name', required=True)
class Service(models.Model):
_name = "service"
name = fields.Char("Name", required=True)
class ServiceVersion(models.Model):
_name = "service.version"
service_id = fields.Many2one('service', string='Service', required=True)
name = fields.Char('Name')
is_last_version = fields.Boolean('Is Last Version?')
service_id = fields.Many2one("service", string="Service", required=True)
name = fields.Char("Name")
is_last_version = fields.Boolean("Is Last Version?")
class ServiceInstance(models.Model):
_name = "service.instance"
equipment_id = fields.Many2one('maintenance.equipment', string='Equipment')
service_id = fields.Many2one('service', string='Service', required=True)
version_id = fields.Many2one('service.version', string='Version')
service_url = fields.Char(string='Service Url')
equipment_id = fields.Many2one("maintenance.equipment", string="Equipment")
service_id = fields.Many2one("service", string="Service", required=True)
version_id = fields.Many2one("service.version", string="Version")
service_url = fields.Char(string="Service Url")
active = fields.Boolean(default=True)
class BackupServer(models.Model):
_name = 'backup.server'
_name = "backup.server"
name = fields.Char('Name', required=True)
name = fields.Char("Name", required=True)

View File

@@ -8,4 +8,4 @@ service_version_manager,service_version_manager,model_service_version,maintenanc
service_instance_user,service_instance_user,model_service_instance,base.group_user,1,0,0,0
service_instance_manager,service_instance_manager,model_service_instance,maintenance.group_equipment_manager,1,1,1,1
backup_server_user,backup_server_user,model_backup_server,base.group_user,1,0,0,0
backup_server_manager,backup_server_manager,model_backup_server,maintenance.group_equipment_manager,1,1,1,1
backup_server_manager,backup_server_manager,model_backup_server,maintenance.group_equipment_manager,1,1,1,1
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
8 service_instance_user service_instance_user model_service_instance base.group_user 1 0 0 0
9 service_instance_manager service_instance_manager model_service_instance maintenance.group_equipment_manager 1 1 1 1
10 backup_server_user backup_server_user model_backup_server base.group_user 1 0 0 0
11 backup_server_manager backup_server_manager model_backup_server maintenance.group_equipment_manager 1 1 1 1

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="equipment_view_form_server_inherit" model="ir.ui.view">
<field name="name">equipment.form.server.inherit</field>
@@ -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="main_domain_name" />
<field name="hosting_city" />
<field name="distribution_id" />
<field name="nb_cores" />
@@ -23,11 +24,14 @@
<xpath expr="//notebook" position="inside">
<page name="services" string="Services">
<field name="service_ids" nolabel="1">
<tree create="true" delete="true" editable="top">
<list create="true" delete="true" editable="top">
<field name="service_id" />
<field name="version_id" domain="[('service_id', '=', service_id)]" />
<field
name="version_id"
domain="[('service_id', '=', service_id)]"
/>
<field name="service_url" />
</tree>
</list>
</field>
</page>
</xpath>
@@ -36,12 +40,13 @@
<record id="equipment_view_tree_server_inherit" model="ir.ui.view">
<field name="name">equipment.tree.server.inherit</field>
<field name="name">equipment.list.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree" />
<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" />
@@ -53,4 +58,4 @@
</xpath>
</field>
</record>
</odoo>
</odoo>

View File

@@ -1,20 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="os_distribution_view_tree" model="ir.ui.view">
<field name="name">os.distribution.view.tree</field>
<field name="name">os.distribution.view.list</field>
<field name="model">os.distribution</field>
<field name="arch" type="xml">
<tree string="OS Distributions" editable="top">
<list editable="top">
<field name="distrib_name" />
<field name="distrib_version" />
</tree>
</list>
</field>
</record>
<record id="os_distribution_action" model="ir.actions.act_window">
<field name="name">OS Distribution</field>
<field name="res_model">os.distribution</field>
<field name="view_mode">tree</field>
<field name="view_mode">list</field>
<field name="view_id" ref="os_distribution_view_tree" />
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -28,6 +28,7 @@
name="OS Distributions"
parent="maintenance.menu_maintenance_configuration"
action="os_distribution_action"
sequence="3" />
sequence="3"
/>
</odoo>
</odoo>

View File

@@ -1,35 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- VIEWS -->
<record id="service_view_tree" model="ir.ui.view">
<field name="name">service.view.tree</field>
<field name="name">service.view.list</field>
<field name="model">service</field>
<field name="arch" type="xml">
<tree string="Services" editable="top">
<list editable="top">
<field name="name" />
</tree>
</list>
</field>
</record>
<record id="service_version_view_tree" model="ir.ui.view">
<field name="name">service.version.view.tree</field>
<field name="name">service.version.view.list</field>
<field name="model">service.version</field>
<field name="arch" type="xml">
<tree string="Service versions" editable="top">
<list editable="top">
<field name="service_id" />
<field name="name" />
<field name="is_last_version" />
</tree>
</list>
</field>
</record>
<record id="backup_server_view_tree" model="ir.ui.view">
<field name="name">backup.server.view.tree</field>
<field name="name">backup.server.view.list</field>
<field name="model">backup.server</field>
<field name="arch" type="xml">
<tree string="Backup Servers" editable="top">
<list editable="top">
<field name="name" />
</tree>
</list>
</field>
</record>
<record id="service_instance_view_tree" model="ir.ui.view">
<field name="name">service.instance.view.list</field>
<field name="model">service.instance</field>
<field name="arch" type="xml">
<list>
<field name="equipment_id" />
<field name="service_id" />
<field name="version_id" />
</list>
</field>
</record>
<record id="service_instance_view_search" model="ir.ui.view">
<field name="name">service.instance.view.search</field>
<field name="model">service.instance</field>
<field name="arch" type="xml">
<search string="Search Service Instances">
<field name="equipment_id" />
<field name="service_id" />
<field name="version_id" />
<field name="service_url" />
<separator />
<filter
string="Archived"
name="inactive"
domain="[('active', '=', False)]"
/>
<separator />
<group expand="0" string="Group By">
<filter
string="Equipment"
name="group_equipment"
context="{'group_by': 'equipment_id'}"
/>
<filter
string="Service"
name="group_service"
context="{'group_by': 'service_id'}"
/>
<filter
string="Version"
name="group_version"
context="{'group_by': 'version_id'}"
/>
</group>
</search>
</field>
</record>
@@ -37,7 +86,7 @@
<record id="service_action" model="ir.actions.act_window">
<field name="name">Service</field>
<field name="res_model">service</field>
<field name="view_mode">tree</field>
<field name="view_mode">list</field>
<field name="view_id" ref="service_view_tree" />
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -49,7 +98,7 @@
<record id="service_version_action" model="ir.actions.act_window">
<field name="name">Service Version</field>
<field name="res_model">service.version</field>
<field name="view_mode">tree</field>
<field name="view_mode">list</field>
<field name="view_id" ref="service_version_view_tree" />
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -61,7 +110,7 @@
<record id="backup_server_action" model="ir.actions.act_window">
<field name="name">Backup server</field>
<field name="res_model">backup.server</field>
<field name="view_mode">tree</field>
<field name="view_mode">list</field>
<field name="view_id" ref="backup_server_view_tree" />
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
@@ -70,26 +119,49 @@
</field>
</record>
<record id="service_instance_action" model="ir.actions.act_window">
<field name="name">Services</field>
<field name="res_model">service.instance</field>
<field name="view_mode">list</field>
<field name="view_id" ref="service_instance_view_tree" />
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a new Service Instance
</p>
</field>
</record>
<!-- MENUS -->
<menuitem
id="menu_maintenance_service"
name="Services"
parent="maintenance.menu_maintenance_configuration"
action="service_action"
sequence="4" />
sequence="4"
/>
<menuitem
id="menu_maintenance_service_version"
name="Service Versions"
parent="maintenance.menu_maintenance_configuration"
action="service_version_action"
sequence="5" />
sequence="5"
/>
<menuitem
id="menu_maintenance_backup_server"
name="Backup Servers"
parent="maintenance.menu_maintenance_configuration"
action="backup_server_action"
sequence="5" />
sequence="5"
/>
</odoo>
<menuitem
id="menu_maintenance_service_instance"
name="Services"
parent="maintenance.menu_maintenance_title"
action="service_instance_action"
sequence="10"
/>
</odoo>

View File

@@ -1,44 +0,0 @@
======================================
maintenance_server_monitoring
======================================
Monitor some data on remote hosts
Installation
============
Use Odoo normal module installation procedure to install
``maintenance_server_monitoring``.
Known issues / Roadmap
======================
None yet.
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
------------
* Clément Thomas
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,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -1,39 +0,0 @@
# Copyright 2023 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_server_monitoring",
"version": "14.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Clément Thomas",
"license": "AGPL-3",
"category": "Tools",
"summary": "Monitor some data on remote hosts",
# any module necessary for this one to work correctly
"depends": [
"base",
"maintenance",
"maintenance_server_ssh"
],
"qweb": [
# "static/src/xml/*.xml",
],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
"views/maintenance_equipment_views.xml",
"data/cron.xml",
],
# only loaded in demonstration mode
"demo": [],
"js": [],
"css": [],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}

View File

@@ -1,12 +0,0 @@
<odoo>
<record id="ir_cron_server_monitoring" model="ir.cron">
<field name="name">Server Monitoring : check all equipments</field>
<field name="model_id" ref="model_maintenance_equipment"/>
<field name="state">code</field>
<field name="code">model.cron_monitoring_test()</field>
<field name="interval_number">1</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall"/>
</record>
</odoo>

View File

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

View File

@@ -1,221 +0,0 @@
from odoo import fields, models, api
import subprocess
import sys
import psutil
from io import StringIO
LOG_LIMIT = 100000
"""
if you want to add a new test :
* create new module named maintenance_server_monitoring_{your_test}
* add new field to MaintenanceEquipment (named {fieldname} below)
* add a new function named test_{fieldname} which return a filled MonitoringTest class with :
-> log = logs you want to appear in logs
-> result = value which will be set to {fieldname}
-> error = MonitoringTest.ERROR or MonitoringTest.WARNING to generate maintenance request
** Note you can use test_ok, test_warning, and test_error functions to simplify code **
* inherit get_tests() to reference your field {fieldname}
be inspired by maintenance_server_monitoring_ping for exemple
"""
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
last_monitoring_test_date = fields.Datetime('Date of last monitoring test', readonly=True)
enable_monitoring = fields.Boolean('Monitoring enabled', help="If enabled, cron will test this equipment")
#log
log = fields.Html("Log", readonly=True)
#maintenance requests
error_maintenance_request = fields.Many2one('maintenance.request', "Error maintenance request")
warning_maintenance_request = fields.Many2one('maintenance.request', "Warning maintenance request")
class MonitoringTest:
"""Class to make the tests
"""
WARNING = "warning"
ERROR = "error"
def __init__(self, name):
self.name = name # name of the test
self.result = 0 # result of the test
self.log = "" # logs of the test
self.date = fields.Datetime.now() # date of the test
self.error = "" # errors of the test
def add_to_log(self, text):
"""
add a new line to logs composed with DATE > TEST NAME > WHAT TO LOG
"""
self.log += f"{self.date} > {self.name} > {text}\n"
def test_ok(self, result, log):
"""to call when the test is ok.
It just fill the test with result and embellished log
Args:
result: result of test
log (string): what to log
Returns:
MonitoringTest: filled test
"""
self.add_to_log(log)
self.result = result
return self
def test_error(self, result, log):
"""to call when test error.
It just fill the test with result, embellished log and set error value to ERROR
Args:
result: result of test
log (string): what to log
Returns:
MonitoringTest: filled test
"""
self.add_to_log(f"🚨 ERROR : {log}")
self.result = result
self.error = self.ERROR
return self
def test_warning(self, result, log):
"""to call when test warning.
It just fill the test with result, embellished log and set error value to WARNING
Args:
result: result of test
log (string): what to log
Returns:
MonitoringTest: filled test
"""
self.add_to_log(f"🔥 WARNING : {log}")
self.result = result
self.error = self.WARNING
return self
@api.model
def cron_monitoring_test(self):
"""cron launch test on all equipments
"""
self.search([("enable_monitoring","=",True)]).monitoring_test()
def launch_test(self, field_name, *test_function_args):
"""run test function with name = test_[attribute]
associate result of test to equipment
write logs of test
Args:
attribute_name (string): attribute of MaintenanceEquipment we want to test
*test_function_args = optionnal args to pass to function (unused for the moment)
Returns:
MonitoringTest: returned by test function
"""
test_function = getattr(self,"test_"+field_name)
test = test_function(*test_function_args)
setattr(self, field_name, test.result)
return test
def get_tests(self):
"""function to inherit in sub-modules
Returns:
array[string]: names of fields to test
"""
return []
def monitoring_test(self):
for equipment in self:
# array of all tests
tests_results = []
# run all tests referenced in get_tests and save result
for test in self.get_tests():
tests_results.append(equipment.launch_test(test))
# set test date
equipment.last_monitoring_test_date = fields.Datetime.now()
# write logs
#new logs are current datetime + join of test results logs
new_log = f'📣 {fields.Datetime.now()}\n{"".join([tests_result.log for tests_result in tests_results])}\n'.replace("\n","<br />")
#add new logs to the beginning of equipment log
equipment.log = f'{new_log}<br />{equipment.log}'[:LOG_LIMIT] #limit logs
#Create maintenance request only if monitoring is enabled
if not equipment.enable_monitoring:
return
# if error create maintenance request
error = warning =False
if any(test.error == test.ERROR for test in tests_results):
error = True # if any arror in tests
elif any(test.error == test.WARNING for test in tests_results):
warning = True # if any warning in tests
if error or warning:
# check if error or warning request (not done) already exists before creating a new one
# if only a warning request exists, error request will be created anyway
existing_not_done_error_request = None
existing_not_done_warning_request = None
if equipment.error_maintenance_request and not equipment.error_maintenance_request.stage_id.done:
existing_not_done_error_request = equipment.error_maintenance_request
if equipment.warning_maintenance_request and not equipment.warning_maintenance_request.stage_id.done:
existing_not_done_warning_request = equipment.warning_maintenance_request
if (error and not existing_not_done_error_request) \
or (warning and not existing_not_done_warning_request and not existing_not_done_error_request):
equipment.create_maintenance_request(self.MonitoringTest.ERROR if error else self.MonitoringTest.WARNING, new_log)
else:
equipment.no_error()
def create_maintenance_request(self, error_level, description):
"""create a maintenance request for equipment (self)
Args:
error_level (string): MonitoringTest.ERROR or MonitoringTest.WARNING
description (string): description of maintenance request
"""
maintenance_request = self.env['maintenance.request'].create({
"name":f'[{error_level.upper()}] {self.name}',
"equipment_id":self.id,
"user_id":self.technician_user_id.id,
"maintenance_team_id":self.maintenance_team_id.id or self.env["maintenance.team"].search([], limit=1),
"priority":'2' if error_level == self.MonitoringTest.ERROR else '3',
"maintenance_type":"corrective" if error_level == self.MonitoringTest.ERROR else "preventive",
"description":description
})
if error_level == self.MonitoringTest.ERROR:
self.error_maintenance_request = maintenance_request
self.warning_maintenance_request = None
else:
self.warning_maintenance_request = maintenance_request
self.error_maintenance_request = None
def no_error(self):
"""set error and warning maintenance request to None
"""
self.error_maintenance_request = None
self.warning_maintenance_request = None

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="equipment_view_form_server_inherit" model="ir.ui.view">
<field name="name">equipment.form.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form" />
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page name="monitoring" string="Monitoring">
<group name="monitoring_test" string="Test">
<field name="enable_monitoring" />
<field name="last_monitoring_test_date" />
<button name="monitoring_test" type="object" string="Test" />
</group>
<group name="monitoring_test_result" />
<group name="monitoring_log" string="Log">
<field name="log" />
</group>
</page>
</xpath>
</field>
</record>
<record id="equipment_view_tree_server_inherit" model="ir.ui.view">
<field name="name">equipment.tree.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after">
<field name="enable_monitoring" />
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,44 +0,0 @@
======================================
maintenance_server_monitoring_memory
======================================
Improve monitoring with ping test
Installation
============
Use Odoo normal module installation procedure to install
``maintenance_server_monitoring_memory``.
Known issues / Roadmap
======================
None yet.
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
------------
* Clément Thomas
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,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -1,37 +0,0 @@
# Copyright 2023 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_server_monitoring_memory",
"version": "14.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Clément Thomas",
"license": "AGPL-3",
"category": "Tools",
"summary": "Monitor memory on remote hosts",
# any module necessary for this one to work correctly
"depends": [
"maintenance_server_monitoring",
"maintenance_server_ssh"
],
"qweb": [
# "static/src/xml/*.xml",
],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
"views/maintenance_equipment_views.xml",
],
# only loaded in demonstration mode
"demo": [],
"js": [],
"css": [],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}

View File

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

View File

@@ -1,52 +0,0 @@
from odoo import fields, models, api
USED_DISK_SPACE_COMMAND = "df /srv -h | tail -n +2 | sed -r 's/ +/ /g' | cut -f 5 -d ' ' | cut -f 1 -d %"
MAX_USED_DISK_SPACE_WARNING = 70
MAX_USED_DISK_SPACE_ERROR = 90
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
used_disk_space = fields.Float('Percent of used disk space', readonly=True)
def get_tests(self):
res = super(MaintenanceEquipment, self).get_tests()
res.append("used_disk_space")
return res
def test_used_disk_space(self):
"""
test Used disk space with a bash command called by ssh
Args:
ssh (paramiko.SSHClient): ssh client
Returns:
MonitoringTest: representing current test with :
* result = -2 if error
* result = percent of Used disk space if no error
* error defined with MonitoringTest.ERROR or MonitoringTest.WARNING depending on result comparaison
with MAX_USED_DISK_SPACE_WARNING and MAX_USED_DISK_SPACE_ERROR
* log file
"""
test = self.MonitoringTest("Used disk space")
try:
ssh = self.get_ssh_connection()
if not ssh:
return test.test_error(-2, "No ssh connection")
_stdin, stdout, _stderr = ssh.exec_command(USED_DISK_SPACE_COMMAND)
used_disk_space = float(stdout.read().decode())
if used_disk_space < MAX_USED_DISK_SPACE_WARNING:
return test.test_ok(used_disk_space, f"{used_disk_space}% used")
elif used_disk_space < MAX_USED_DISK_SPACE_ERROR:
# disk usage between WARNING and ERROR steps
return test.test_warning(used_disk_space, f"{used_disk_space}% used (>{MAX_USED_DISK_SPACE_WARNING})")
else:
# disk usage higher than ERROR steps
return test.test_error(used_disk_space, f"{used_disk_space}% used (>{MAX_USED_DISK_SPACE_ERROR})")
except Exception as e:
return test.test_error(-2, f"{e}")

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="equipment_view_form_server_inherit" model="ir.ui.view">
<field name="name">equipment.form.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form" />
<field name="arch" type="xml">
<group name="monitoring_test_result" position="inside">
<field name="used_disk_space" />
</group>
</field>
</record>
<record id="equipment_view_tree_server_inherit" model="ir.ui.view">
<field name="name">equipment.tree.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='enable_monitoring']" position="after">
<field name="used_disk_space" optional="hide" />
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -1,44 +0,0 @@
======================================
maintenance_server_monitoring
======================================
Monitor some data on remote hosts
Installation
============
Use Odoo normal module installation procedure to install
``maintenance_server_monitoring``.
Known issues / Roadmap
======================
None yet.
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
------------
* Clément Thomas
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,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -1,38 +0,0 @@
# Copyright 2023 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_server_monitoring_maintenance_equipment_status",
"version": "14.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Clément Thomas",
"license": "AGPL-3",
"category": "Tools",
"summary": "Monitor some data on remote hosts",
# any module necessary for this one to work correctly
"depends": [
"base",
"maintenance_server_monitoring",
"maintenance_equipment_status"
],
"qweb": [
# "static/src/xml/*.xml",
],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
"views/maintenance_equipment_status_views.xml",
],
# only loaded in demonstration mode
"demo": [],
"js": [],
"css": [],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": True,
"application": False,
}

View File

@@ -1,2 +0,0 @@
from . import maintenance_equipment
from . import maintenance_equipment_status

View File

@@ -1,23 +0,0 @@
from odoo import fields, models, api
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
def create_maintenance_request(self, error_level, description):
res = super(MaintenanceEquipment, self).create_maintenance_request(error_level, description)
if self.error_maintenance_request:
error_status = self.env["maintenance.equipment.status"].search([("is_error_status",'=',True),'|', ('category_ids', 'in', [self.category_id.id]), ('category_ids', '=', False)], limit=1)
if error_status:
self.status_id = error_status
else:
warning_status = self.env["maintenance.equipment.status"].search([("is_warning_status",'=',True),'|', ('category_ids', 'in', [self.category_id.id]), ('category_ids', '=', False)], limit=1)
if warning_status:
self.status_id = warning_status
return res
def no_error(self):
res = super(MaintenanceEquipment, self).no_error()
ok_status = self.env["maintenance.equipment.status"].search([("is_error_status",'=',False),("is_warning_status",'=',False),'|', ('category_ids', 'in', [self.category_id.id]), ('category_ids', '=', False)], limit=1)
self.status_id = ok_status
return res

View File

@@ -1,11 +0,0 @@
from odoo import fields, models, api
import subprocess
import sys
import psutil
from io import StringIO
class MaintenanceEquipmentStatus(models.Model):
_inherit = "maintenance.equipment.status"
is_warning_status = fields.Boolean('Is warning status')
is_error_status = fields.Boolean('Is error status')

View File

@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="maintenance_equipment_status_view_form_inherit" model="ir.ui.view">
<field name="name">maintenance.equipment.status.form.inherit</field>
<field name="model">maintenance.equipment.status</field>
<field name="inherit_id" ref="maintenance_equipment_status.maintenance_equipment_status_view_form" />
<field name="arch" type="xml">
<group name="notes" position="after">
<group name="monitoring">
<field name="is_warning_status" />
<field name="is_error_status" />
</group>
</group>
</field>
</record>
<record id="maintenance_equipment_status_view_tree_inherit" model="ir.ui.view">
<field name="name">maintenance.equipment.status.tree.inherit</field>
<field name="model">maintenance.equipment.status</field>
<field name="inherit_id" ref="maintenance_equipment_status.maintenance_equipment_status_view_tree" />
<field name="arch" type="xml">
<field name="category_ids" position="after">
<field name="is_warning_status" />
<field name="is_error_status" />
</field>
</field>
</record>
</odoo>

View File

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

View File

@@ -1,44 +0,0 @@
======================================
maintenance_server_monitoring_memory
======================================
Improve monitoring with ping test
Installation
============
Use Odoo normal module installation procedure to install
``maintenance_server_monitoring_memory``.
Known issues / Roadmap
======================
None yet.
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
------------
* Clément Thomas
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,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -1,37 +0,0 @@
# Copyright 2023 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_server_monitoring_memory",
"version": "14.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Clément Thomas",
"license": "AGPL-3",
"category": "Tools",
"summary": "Monitor memory on remote hosts",
# any module necessary for this one to work correctly
"depends": [
"maintenance_server_monitoring",
"maintenance_server_ssh"
],
"qweb": [
# "static/src/xml/*.xml",
],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
"views/maintenance_equipment_views.xml",
],
# only loaded in demonstration mode
"demo": [],
"js": [],
"css": [],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}

View File

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

View File

@@ -1,51 +0,0 @@
from odoo import fields, models, api
AVAILABLE_MEMORY_PERCENT_COMMAND = "free | grep Mem | awk '{print $3/$2 * 100.0}'"
MIN_AVAILABLE_MEMORY_PERCENT_WARNING = 20
MIN_AVAILABLE_MEMORY_PERCENT_ERROR = 5
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
available_memory_percent = fields.Float('Percent of available memory', readonly=True)
def get_tests(self):
res = super(MaintenanceEquipment, self).get_tests()
res.append("available_memory_percent")
return res
def test_available_memory_percent(self):
"""
test available memory with a bash command called by ssh
Args:
ssh (paramiko.SSHClient): ssh client
Returns:
MonitoringTest: representing current test with :
* result = -2 if error
* result = percent of available memory if no error
* error defined with MonitoringTest.ERROR or MonitoringTest.WARNING depending on result comparaison
with MIN_AVAILABLE_MEMORY_PERCENT_WARNING and MIN_AVAILABLE_MEMORY_PERCENT_ERROR
* log file
"""
test = self.MonitoringTest("Available memory percent")
try:
ssh = self.get_ssh_connection()
if not ssh:
return test.test_error(-2, "No ssh connection")
_stdin, stdout, _stderr = ssh.exec_command(AVAILABLE_MEMORY_PERCENT_COMMAND)
available_memory_percent = float(stdout.read().decode())
if available_memory_percent > MIN_AVAILABLE_MEMORY_PERCENT_WARNING:
return test.test_ok(available_memory_percent, f"{available_memory_percent}% available")
elif available_memory_percent > MIN_AVAILABLE_MEMORY_PERCENT_ERROR:
# memory between warning and error step
return test.test_warning(available_memory_percent, f"{available_memory_percent}% available (<{MIN_AVAILABLE_MEMORY_PERCENT_WARNING})")
else:
# memory available lower than error step
return test.test_error(available_memory_percent, f"{available_memory_percent}% available (<{MIN_AVAILABLE_MEMORY_PERCENT_ERROR})")
except Exception as e:
return test.test_error(-2, f"{e}")

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="equipment_view_form_server_inherit" model="ir.ui.view">
<field name="name">equipment.form.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form" />
<field name="arch" type="xml">
<group name="monitoring_test_result" position="inside">
<field name="available_memory_percent" />
</group>
</field>
</record>
<record id="equipment_view_tree_server_inherit" model="ir.ui.view">
<field name="name">equipment.tree.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='enable_monitoring']" position="after">
<field name="available_memory_percent" optional="hide" />
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -1,44 +0,0 @@
======================================
maintenance_server_monitoring_ping
======================================
Improve monitoring with ping test
Installation
============
Use Odoo normal module installation procedure to install
``maintenance_server_monitoring_ping``.
Known issues / Roadmap
======================
None yet.
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
------------
* Clément Thomas
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,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -1,36 +0,0 @@
# Copyright 2023 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_server_monitoring_ping",
"version": "14.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Clément Thomas",
"license": "AGPL-3",
"category": "Tools",
"summary": "Monitor ping on remote hosts",
# any module necessary for this one to work correctly
"depends": [
"maintenance_server_monitoring"
],
"qweb": [
# "static/src/xml/*.xml",
],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
"views/maintenance_equipment_views.xml",
],
# only loaded in demonstration mode
"demo": [],
"js": [],
"css": [],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}

View File

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

View File

@@ -1,71 +0,0 @@
from odoo import fields, models, api
import subprocess
MAX_PING_MS_WARNING = 1000
MAX_PING_MS_ERROR = 5000
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
ping_ok = fields.Boolean("Ping ok", readonly=True)
def get_tests(self):
res = super(MaintenanceEquipment, self).get_tests()
res.append("ping_ok")
return res
def test_ping_ok(self):
"""
test PING with ping3 library
Returns:
MonitoringTest: representing current test with :
* result = False if error
* result = True if no error
* error defined with MonitoringTest.ERROR or MonitoringTest.WARNING depending on ping time comparaison
with MAX_PING_MS_WARNING and MAX_PING_MS_ERROR
* log file
"""
test = self.MonitoringTest("Ping")
try:
from ping3 import ping
except ImportError as e:
# unable to import ping3
try:
command = ['pip3','install',"ping3==4.0.5"]
response = subprocess.call(command) # run "pip install ping3" command
if response != 0:
return test.test_error(False, f"ping3 : unable to install : response = {response}")
else:
from ping3 import ping
except Exception as e:
return test.test_error(False, f"ping3 : unable to install : {e}")
hostname = self.server_domain
if not hostname:
# equipment host name not filled
return test.test_error(False, f"host name seems empty !")
try:
r = ping(hostname)
except Exception as e:
# Any problem when call ping
return test.test_error(False, f"unable to call ping ! > {e}")
if r:
test.result = True
ping_ms = int(r*1000)
if ping_ms < MAX_PING_MS_WARNING:
# ping OK
return test.test_ok(True, f"PING OK in {ping_ms} ms")
elif ping_ms < MAX_PING_MS_ERROR:
# ping result between WARNING and ERROR => WARNING
return test.test_warning(True, f"PING OK in {ping_ms}ms (> {MAX_PING_MS_WARNING})")
else:
# ping result higher than ERROR => ERROR
return test.test_error(True, f"PING OK in {ping_ms}ms (> {MAX_PING_MS_ERROR})")
else:
return test.test_error(False, "PING FAILED")

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="equipment_view_form_server_inherit" model="ir.ui.view">
<field name="name">equipment.form.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form" />
<field name="arch" type="xml">
<group name="monitoring_test_result" position="inside">
<field name="ping_ok" />
</group>
</field>
</record>
<record id="equipment_view_tree_server_inherit" model="ir.ui.view">
<field name="name">equipment.tree.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='enable_monitoring']" position="after">
<field name="ping_ok" optional="hide" />
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -1,44 +0,0 @@
======================================
maintenance_server_monitoring_ssh
======================================
Improve monitoring with ping test
Installation
============
Use Odoo normal module installation procedure to install
``maintenance_server_monitoring_ssh``.
Known issues / Roadmap
======================
None yet.
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
------------
* Clément Thomas
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,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -1,37 +0,0 @@
# Copyright 2023 Stéphan Sainléger (Elabore)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "maintenance_server_monitoring_ssh",
"version": "14.0.1.0.0",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Clément Thomas",
"license": "AGPL-3",
"category": "Tools",
"summary": "Monitor ssh response on remote hosts",
# any module necessary for this one to work correctly
"depends": [
"maintenance_server_monitoring",
"maintenance_server_ssh"
],
"qweb": [
# "static/src/xml/*.xml",
],
"external_dependencies": {
"python": [],
},
# always loaded
"data": [
"views/maintenance_equipment_views.xml",
],
# only loaded in demonstration mode
"demo": [],
"js": [],
"css": [],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": False,
"application": False,
}

View File

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

View File

@@ -1,30 +0,0 @@
from odoo import fields, models, api
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
ssh_ok = fields.Boolean("SSH ok", readonly=True)
def get_tests(self):
res = super(MaintenanceEquipment, self).get_tests()
res.append("ssh_ok")
return res
def test_ssh_ok(self):
"""
test ssh with maintenance_server_ssh module
Returns:
MonitoringTest: representing current test with :
* result = False if error
* result = ssh connection if no error
* error = MonitoringTest.ERROR if connection failed
* log file
"""
test = self.MonitoringTest("SSH OK")
try:
# SSH connection ok : set ssh connection in result, converted in boolean (True) when set in ssh_ok field
return test.test_ok(self.get_ssh_connection(), "SSH Connection OK") #ssh connection given by maintenance_server_ssh module
except Exception as e:
# SSH connection failed
return test.test_error(False, "connection failed {e}")

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="equipment_view_form_server_inherit" model="ir.ui.view">
<field name="name">equipment.form.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form" />
<field name="arch" type="xml">
<group name="monitoring_test_result" position="inside">
<field name="ssh_ok" />
</group>
</field>
</record>
<record id="equipment_view_tree_server_inherit" model="ir.ui.view">
<field name="name">equipment.tree.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='enable_monitoring']" position="after">
<field name="ssh_ok" />
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -1,44 +0,0 @@
======================================
maintenance_server_ssh
======================================
Create an SSH remote connection for maintenance equipment, usable for other modules
Installation
============
Use Odoo normal module installation procedure to install
``maintenance_server_ssh``.
Known issues / Roadmap
======================
None yet.
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
------------
* Clément Thomas
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,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import models

View File

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

View File

@@ -1,27 +0,0 @@
from odoo import fields, models
import subprocess
import sys
import psutil
class MaintenanceEquipment(models.Model):
_inherit = 'maintenance.equipment'
server_domain = fields.Char('Server Domain')
ssh_private_key_path = fields.Char("SSH private key path", default="/opt/odoo/auto/dev/ssh_keys/id_rsa")
def get_ssh_connection(self):
ssh_connections = self.env.context.get('ssh_connections',{})
if self.id in ssh_connections:
return ssh_connections[self.id]
import paramiko
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(self.server_domain, username="root", key_filename=self.ssh_private_key_path)
ssh_connections[self.id] = ssh
self = self.with_context(ssh_connections=ssh_connections)
return ssh

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="equipment_view_form_server_inherit" model="ir.ui.view">
<field name="name">equipment.form.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form" />
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page name="ssh" string="SSH">
<group name="ssh_connection" string="SSH Connection">
<field name="server_domain" />
<field name="ssh_private_key_path" />
</group>
</page>
</xpath>
</field>
</record>
<record id="equipment_view_tree_server_inherit" model="ir.ui.view">
<field name="name">equipment.tree.server.inherit</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after">
<field name="server_domain" optional="hide" />
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,111 @@
# Draft: Monitoring HTTP des services
## Contexte codebase
### Architecture des modules
```
maintenance_server_data (base, maintenance)
├── Définit: service, service.version, service.instance, backup.server
├── Extend maintenance.equipment: server_ip, service_ids (One2many), backup_*, etc.
└── Vues: service list, equipment form (onglet Services)
maintenance_server_ssh (base, maintenance)
├── Extend maintenance.equipment: server_domain, ssh_private_key_path
└── Méthode: get_ssh_connection()
maintenance_server_monitoring (base, maintenance, maintenance_server_ssh)
├── Extend maintenance.equipment: monitoring fields + test methods
├── Pattern MonitoringTest inner class (test_ok/warning/error)
├── Cron: toutes les 1 minute sur ALL equipment
├── Création auto de maintenance.request si error/warning
└── PAS de dépendance vers maintenance_server_data actuellement !
```
### Modèle service.instance actuel (dans maintenance_server_data)
- equipment_id (Many2one → maintenance.equipment)
- service_id (Many2one → service, required)
- version_id (Many2one → service.version)
- service_url (Char) ← URL déjà existante, parfait pour les checks HTTP
### Pattern de création maintenance.request existant
- Vérifie si une request non-done existe déjà avant d'en créer une nouvelle
- Stocke la référence sur equipment: error_maintenance_request /
warning_maintenance_request
- Assignation auto: employee_id, technician_user_id, maintenance_team_id
## Requirements confirmés (depuis les specs)
- [x] Modifier le module `maintenance_server_monitoring` (pas de nouveau module)
- [x] Ajouter `maintenance_server_data` aux dépendances (nécessaire pour accéder à
service.instance)
- [x] Étendre `service.instance` avec: last_status_code (Integer), last_check_date
(Datetime), status OK/KO (Boolean)
- [x] Nouvelle vue liste standalone pour service.instance (nom, version, URL, date
check, status code, statut)
- [x] Paramètres module: fréquence vérification + durée mode maintenance
- [x] Mode maintenance sur equipment: bool tracked + bandeau
- [x] Si service KO → création maintenance.request (pas de doublon si toujours KO)
## Décision architecturale : NOUVEAU MODULE
**Nom**: `maintenance_service_http_monitoring` **Raison**: Séparation des préoccupations
— monitoring infra (SSH/ping) vs monitoring applicatif (HTTP) **Dépendances**: `base`,
`maintenance`, `maintenance_server_data` **PAS de dépendance** vers
`maintenance_server_ssh` ni `maintenance_server_monitoring`
## Décisions techniques
- Paramètres via res.config.settings + ir.config_parameter (pattern standard Odoo)
- \_inherit = 'service.instance' dans le nouveau module pour étendre le modèle
- Cron dédié pour les checks HTTP (fréquence configurable)
- \_inherit = 'maintenance.equipment' pour ajouter maintenance_mode +
http_maintenance_request
- mail.thread déjà hérité par maintenance.equipment dans Odoo base → tracking fonctionne
## Décisions utilisateur (interview)
1. **Mode maintenance**: HTTP uniquement — le monitoring existant (ping, SSH, mémoire,
disque) continue normalement
2. **Lien maintenance.request**: Sur maintenance.equipment — nouveau champ
`http_maintenance_request` (Many2one). Si plusieurs services KO sur un même
equipment, UNE seule request qui liste les services KO.
3. **Menu service list**: Sous Maintenance > principal, même niveau que Équipements et
Demandes
4. **Tests**: OUI — setup pytest-odoo + tests unitaires pour la logique HTTP check et
création maintenance requests
## Scope boundaries
### IN
- NOUVEAU module `maintenance_service_http_monitoring`
- Étendre service.instance avec champs monitoring HTTP
- Cron dédié pour checks HTTP (fréquence configurable)
- Paramètres module (fréquence + durée mode maintenance)
- Mode maintenance sur equipment (bool tracked, bandeau, HTTP checks only)
- Création maintenance.request si service KO (référence sur equipment)
- Nouvelle vue liste services avec colonnes monitoring
- Setup pytest-odoo + tests unitaires
### OUT
- Aucune modification de `maintenance_server_monitoring` ni de `maintenance_server_ssh`
- Pas de notification email (juste la maintenance.request)
- Pas de dashboard / reporting
- Pas de tracking sur service.instance (pas de mail.thread sur ce modèle)
## Décisions Metis (post-gap-analysis)
- Services orphelins (sans equipment_id): N'existent pas en pratique, filtrés par
sécurité
- Récupération service KO→OK: Rien d'automatique, close manuelle de la
maintenance.request
- Définition KO: Tout échec = KO (timeout, DNS, SSL, connexion refusée, code != 200).
Log le détail.
- Timeout HTTP: Hardcodé (constante, comme les seuils existants dans
maintenance_server_monitoring)
- `requests` library: Déclarer dans external_dependencies.python
- Chaque requests.get() DOIT avoir timeout= (pylintrc enforce external-request-timeout)

View File

@@ -0,0 +1,161 @@
===================================
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

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

View File

@@ -0,0 +1,17 @@
{
"name": "maintenance_service_http_monitoring",
"version": "18.0.1.0.0",
"author": "Elabore",
"license": "AGPL-3",
"category": "Tools",
"summary": "Monitor HTTP availability of services",
"depends": ["base", "maintenance", "hr_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",
],
"installable": True,
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="ir_cron_http_service_monitoring" model="ir.cron">
<field name="name">HTTP Service Monitoring : check all services</field>
<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_type">minutes</field>
</record>
<record id="ir_cron_maintenance_mode_expiry" model="ir.cron">
<field
name="name"
>HTTP Service Monitoring : deactivate expired maintenance mode</field>
<field
name="model_id"
ref="maintenance_server_data.model_maintenance_equipment"
/>
<field name="state">code</field>
<field name="code">model.cron_deactivate_expired_maintenance_mode()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
</record>
</odoo>

View File

@@ -0,0 +1,15 @@
<?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

@@ -0,0 +1,2 @@
from . import service_instance
from . import maintenance_equipment

View File

@@ -0,0 +1,152 @@
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(
default=False,
tracking=True,
)
maintenance_mode_start = fields.Datetime(
readonly=True,
)
maintenance_mode_end = fields.Datetime(
readonly=True,
help="Computed from start + configured duration",
)
def action_activate_maintenance_mode(self):
for rec in self:
duration = int(
self.env["ir.config_parameter"]
.sudo()
.get_param(
"maintenance_service_http_monitoring.maintenance_mode_duration", 4
)
)
now = fields.Datetime.now()
rec.write(
{
"maintenance_mode": True,
"maintenance_mode_start": now,
"maintenance_mode_end": now + timedelta(hours=duration),
}
)
def action_deactivate_maintenance_mode(self):
for rec in self:
rec.write(
{
"maintenance_mode": False,
"maintenance_mode_start": False,
"maintenance_mode_end": False,
}
)
@api.model
def cron_deactivate_expired_maintenance_mode(self):
now = fields.Datetime.now()
expired = self.search(
[
("maintenance_mode", "=", True),
("maintenance_mode_end", "<=", now),
]
)
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.
"""
self.ensure_one()
existing = ko_service.http_maintenance_request
if existing and not existing.stage_id.done:
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}"
vals = {
"name": name,
"equipment_id": self.id,
"priority": "2",
"maintenance_type": "corrective",
"description": description,
}
if self.employee_id:
vals["employee_id"] = self.employee_id.id
if self.technician_user_id:
vals["user_id"] = self.technician_user_id.id
if self.maintenance_team_id:
vals["maintenance_team_id"] = self.maintenance_team_id.id
else:
team = self.env["maintenance.team"].search([], limit=1)
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)
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,
)

View File

@@ -0,0 +1,147 @@
import logging
import time
from odoo import api, fields, models
try:
import requests
except ImportError:
requests = None
_logger = logging.getLogger(__name__)
HTTP_CHECK_TIMEOUT = 10 # seconds
HTTP_RETRY_DELAY = 2 # seconds between pass 1 and pass 2
class ServiceInstance(models.Model):
_inherit = "service.instance"
last_http_status_code = fields.Integer(
string="Last HTTP Status Code",
readonly=True,
default=0,
)
last_http_check_date = fields.Datetime(
string="Last HTTP Check Date",
readonly=True,
)
http_status_ok = fields.Boolean(
string="HTTP Status OK",
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:
continue
status_ok = False
status_code = -1
now = fields.Datetime.now()
url = rec.service_url
if not url.lower().startswith("https://"):
url = "https://" + url.removeprefix("http://").removeprefix("HTTP://")
try:
response = requests.get(url, timeout=HTTP_CHECK_TIMEOUT)
status_code = response.status_code
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,
}
)
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
@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)

View File

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

View File

@@ -0,0 +1,360 @@
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

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_equipment_form_http_monitoring" model="ir.ui.view">
<field name="name">maintenance.equipment.form.http.monitoring</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_form" />
<field name="arch" type="xml">
<xpath expr="//sheet" position="before">
<div
class="alert alert-warning"
role="alert"
invisible="not maintenance_mode"
>
Mode maintenance actif — Vérifications HTTP désactivées.<br />
Fin prévue : <field name="maintenance_mode_end" readonly="1" />
</div>
</xpath>
<xpath expr="//header" position="inside">
<button
name="action_activate_maintenance_mode"
type="object"
string="Activer le mode maintenance"
invisible="maintenance_mode"
class="oe_highlight"
/>
<button
name="action_deactivate_maintenance_mode"
type="object"
string="Désactiver le mode maintenance"
invisible="not maintenance_mode"
/>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="HTTP Monitoring">
<group>
<field name="maintenance_mode" />
<field name="maintenance_mode_start" />
<field name="maintenance_mode_end" />
</group>
</page>
</xpath>
</field>
</record>
<record id="view_equipment_tree_http_monitoring" model="ir.ui.view">
<field name="name">maintenance.equipment.list.http.monitoring</field>
<field name="model">maintenance.equipment</field>
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree" />
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="maintenance_mode" optional="hide" />
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!-- Inherit from base list view to add HTTP monitoring fields -->
<record id="service_instance_http_monitoring_tree" model="ir.ui.view">
<field name="name">service.instance.http.monitoring.list</field>
<field name="model">service.instance</field>
<field
name="inherit_id"
ref="maintenance_server_data.service_instance_view_tree"
/>
<field name="arch" type="xml">
<list position="attributes">
<attribute name="decoration-danger">http_status_ok == False</attribute>
</list>
<field name="version_id" position="after">
<field name="service_url" />
<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>
<!-- Inherit from base search view to add HTTP monitoring filters -->
<record id="service_instance_http_monitoring_search" model="ir.ui.view">
<field name="name">service.instance.http.monitoring.search</field>
<field name="model">service.instance</field>
<field
name="inherit_id"
ref="maintenance_server_data.service_instance_view_search"
/>
<field name="arch" type="xml">
<field name="service_url" position="after">
<field name="last_http_status_code" string="Status Code" />
</field>
<filter name="inactive" position="before">
<filter
string="Status OK"
name="status_ok"
domain="[('http_status_ok', '=', True)]"
/>
<filter
string="Status Error"
name="status_error"
domain="[('http_status_ok', '=', False)]"
/>
<separator />
</filter>
<filter name="group_version" position="after">
<filter
string="Status Code"
name="group_status_code"
context="{'group_by': 'last_http_status_code'}"
/>
</filter>
</field>
</record>
</odoo>