Public release from ruodoo-project: 19.0 - 2026-07-26 21:17:35 UTC

This commit is contained in:
CI Publish Bot
2026-07-26 21:17:45 +00:00
commit 4f7b594ec8
1335 changed files with 191620 additions and 0 deletions

View File

@ -0,0 +1,66 @@
# Слои показателей - Отчет «Показатели по узлам»
name: mklab_base_indicators_report
version: 19.0.2025.11.17
author: MK.Lab
### 1. Общее описание
Модуль добавляет визуальный отчёт, который строит **граф связей между задачами** в разрезе выбранного показателя. Каждый узел графа — это задача (или другой объект системы), ребра — связи между ними, а подписи на узлах показывают **суммарное текущее значение показателя** по каждому узлу.
### 2. Установка
1. Убедитесь, что установлены `mklab_base_indicators` и `mklab_base_indicators_extended`
2. Установите Python-зависимости: `pip install networkx matplotlib`
3. Скопируйте папку модуля в директорию `addons`
4. Перезапустите сервер Odoo
5. Установите модуль **«Слои показателей - Отчет «Показатели по узлам»»**
### 3. Функциональность
### Запуск отчёта
1. Перейдите в меню **Слои показателей → Отчёты → Отчёт показателей по узлам**
![img_1.png](img_1.png)
2. В открывшемся диалоге выберите **Показатель** из списка
3. Нажмите **«Показать отчёт»**
![img_2.png](img_2.png)
1. В новой вкладке браузера откроется PNG-изображение графа
### Что отображается на графе
- **Узлы (кружки)** — объекты системы (задачи, проекты и т.д.), связанные с выбранным показателем
- **Рёбра (стрелки)** — направленные связи между узлами
- **Подпись на узле** — название узла + суммарное текущее значение показателя (с тем же внутренним кодом)
![img_3.png](img_3.png)
---
## Зависимости
| Модуль | Назначение |
|---|---|
| `base` | Базовый Odoo |
| `mklab_base_indicators_extended` | Слой показателей со сметой (обязателен) |
**Внешние Python-библиотеки** (должны быть установлены на сервере):
| Библиотека | Назначение |
|---|---|
| `networkx` | Построение и обход графа |
| `matplotlib` | Отрисовка графа в PNG |
---
## Права доступа
Отчёт доступен только пользователям с ролью **Администратор показателей** (`mklab_base_indicators.group_indicators_admin`).
> `security/ir.model.access.csv`: права CRUD на `node_report_wizard` только для этой группы

View File

@ -0,0 +1,133 @@
# Техническая документация: mklab_base_indicators_report
**name:** `mklab_base_indicators_report`
**version:** 19.0.2025.11.24
**author:** MK.Lab
**depends:** `base`, `mklab_base_indicators_extended`
**Внешние Python-пакеты:** `networkx`, `matplotlib`
---
## Архитектура
Модуль не вводит новых моделей данных. Состоит из трёх компонентов: расширения существующей модели `hg.index`, HTTP-контроллера и визарда.
```
mklab_base_indicators_report
├── hg.index (расширение) — метод generate_graph_image()
├── NodeMetricsReportController — HTTP-контроллер /node_metrics/report/<id>
└── node_report_wizard — визард выбора показателя и запуска отчёта
```
---
## Компоненты
### `hg.index` — расширение
**Файл:** `models/hg_index.py`
Добавляет метод `generate_graph_image()` к существующей модели `hg.index`.
#### Метод `generate_graph_image()`
Строит направленный граф зависимостей для текущего показателя и возвращает PNG-изображение в виде base64-строки. Последовательность шагов:
**1. Сбор данных**
- Находит все `hg.link` где `source_id` равен вершине текущего показателя (`self.node_id`)
- Находит все `hg.index` с тем же `internal_code_id` что у текущего показателя
**2. Построение графа (`networkx.DiGraph`)**
Для каждой связи `hg.link`:
- Добавляет вершину-источник (`source_id.name`) с меткой — суммой `current_value` всех показателей по коду
- Для каждой вершины-приёмника (`target_ids`): добавляет вершину с меткой — суммой `current_value` показателей по коду, отфильтрованных по данному узлу
- Добавляет направленное ребро от источника к приёмнику
**3. Рендеринг**
| Параметр | Значение |
|---|---|
| Расположение вершин | `spring_layout`, `seed=1` (фиксированный, воспроизводимый) |
| Размер холста | 10 × 8 дюймов |
| Размер вершины | 3000 |
| Цвет вершины | `lightblue` |
| Метка вершины | `"название\nZZZZ.ZZ"` (название + числовое значение с двумя знаками) |
**4. Вывод**
Сохраняет изображение в `BytesIO` в формате PNG, кодирует в base64 и возвращает строку.
---
### `NodeMetricsReportController` — HTTP-контроллер
**Файл:** `report/index_report.py`
| Параметр | Значение |
|---|---|
| Маршрут | `/node_metrics/report/<int:index_id>` |
| Тип | `http` |
| Авторизация | `user` (требует входа в систему) |
Получает `index_id`, загружает запись `hg.index`, вызывает `generate_graph_image()` и возвращает HTML-страницу с тегом `<img>` где изображение встроено как base64.
При отсутствии записи с указанным `index_id` возвращает строку `"Record not found"`.
---
### `node_report_wizard` — Визард
**Файл:** `wizard/report_wizard.py`
**Тип:** `models.TransientModel`
| Поле | Тип | Описание |
|---|---|---|
| `index_id` | Many2one → `hg.index` | Выбранный показатель для построения графа |
#### Метод `action_generate_report()`
Читает `web.base.url` из системных параметров, формирует URL вида:
```
{web.base.url}/node_metrics/report/{index_id}
```
Возвращает `ir.actions.act_url` с `target='new'` — отчёт открывается в новой вкладке браузера.
---
## Представления и меню
**Файл:** `wizard/report_wizard.xml`
Форма визарда с полем `index_id` и кнопками «Показать отчёт» / «Отмена». Пункт меню добавляется в раздел отчётов модуля `mklab_base_indicators_extended`:
```
Слои показателей → Отчёты → Отчет показателей по узлам
```
---
## Права доступа
**Файл:** `security/ir.model.access.csv`
| Модель | Группа | Права |
|---|---|---|
| `node_report_wizard` | `group_indicators_admin` | CRUD |
---
## Тесты
**Файл:** `tests/test_report_wizard.py`
| Класс | Метод | Что проверяет |
|---|---|---|
| `TestReportWizard` | `test_wizard_create` | Создание визарда с `index_id` |
| `TestReportWizard` | `test_action_generate_report_returns_url_action` | Тип `ir.actions.act_url`, наличие `index_id` в URL, `target='new'` |
| `TestReportWizard` | `test_action_url_contains_base_url` | URL содержит значение `web.base.url` |
| `TestHgIndexGraphImage` | `test_generate_graph_image_returns_base64` | Метод возвращает base64-строку |
---

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import models
from . import report
from . import wizard

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
{
'name': "Слои показателей - Отчет «Показатели по узлам»",
'summary': """
Отчет «Показатели по узлам»
""",
'description': """
Отчет «Показатели по узлам» - модуль визуализует взаимосвязь задач в рамках выбранного показателя.
""",
'author': "MK.Lab",
'website': "https://www.inf-centre.ru",
'category': 'Uncategorized',
'version': '19.0.2025.11.24',
# any module necessary for this one to work correctly
'depends': ['base','mklab_base_indicators_extended'],
'data': [
'security/ir.model.access.csv',
'wizard/report_wizard.xml',
],
'test': [
'tests/test_report_wizard.py',
],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

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

View File

@ -0,0 +1,66 @@
from odoo import models, fields, api
import networkx as nx
import matplotlib.pyplot as plt
import io
import base64
import logging
class HgIndex(models.Model):
_inherit = 'hg.index'
def generate_graph_image(self):
hg_link = self.env['hg.link'].search([('source_id','=', self.node_id.id)])
nodes = self.node_id | hg_link.mapped('target_ids')
internal_code = self.internal_code_id
Index = self.env['hg.index'].sudo()
Index_by_code = Index.search([('internal_code_id','=', internal_code.id)])
G = nx.DiGraph()
# Добавляем все вершины: node_id и related_ids
# Используем node_id.id как ключ, но для отображения можно имя
for rec in hg_link:
if rec.source_id:
node_key = rec.source_id.name
# Добавляем узел с именем, например, названием
label_value = sum(Index_by_code.mapped('current_value'))
G.add_node(node_key, label=label_value)
# Добавляем связанные узлы как отдельные вершины и ребра
for related_node in rec.target_ids:
related_key = related_node.name
label_values = Index_by_code.filtered(lambda r: r.node_id == related_node)
label_value = sum(label_values.mapped('current_value'))
G.add_node(related_key, label=label_value)
G.add_edge(node_key, related_key)
# Определяем расположение узлов
pos = nx.spring_layout(G, seed=1)
plt.figure(figsize=(10, 8))
# Рисуем граф без автоподписей (with_labels=False)
nx.draw(G, pos, node_size=3000, node_color='lightblue', with_labels=False)
# Формируем словарь меток с безопасным приведением к числу
labels = {}
for n, d in G.nodes(data=True):
try:
label_value = float(d.get("label", 0))
labels[n] = f'{n}\n{label_value:.2f}'
except (ValueError, TypeError):
labels[n] = f'{n}\n{d.get("label", "")}'
# Отрисовываем метки отдельно
nx.draw_networkx_labels(G, pos, labels)
# Сохраняем рисунок в буфер
buf = io.BytesIO()
plt.savefig(buf, format='png')
plt.close()
buf.seek(0)
# Кодируем изображение в base64 для отображения в вебе
image_data = base64.b64encode(buf.read()).decode('utf-8')
return image_data

View File

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

View File

@ -0,0 +1,14 @@
from odoo import models, api
from odoo.http import Controller, route, request
import base64
class NodeMetricsReportController(Controller):
@route(['/node_metrics/report/<int:index_id>'], type='http', auth='user')
def report(self, index_id):
index_record = request.env['hg.index'].sudo().browse(index_id)
if not index_record.exists():
return "Record not found"
image_data = index_record.generate_graph_image()
img_html = f'<img src="data:image/png;base64,{image_data}" alt="Node Metrics Graph"/>'
return img_html

View File

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mklab_node_report_wizard,mklab_base_indicators_extended.node_report_wizard,model_node_report_wizard,mklab_base_indicators.group_indicators_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mklab_node_report_wizard mklab_base_indicators_extended.node_report_wizard model_node_report_wizard mklab_base_indicators.group_indicators_admin 1 1 1 1

View File

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

View File

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
from unittest.mock import patch
from odoo.tests.common import TransactionCase
class TestReportWizard(TransactionCase):
def setUp(self):
super().setUp()
self.node = self.env['hg.node'].create({
'name': 'Узел отчёта',
'res_model': 'hg.node',
'res_id': 1,
})
self.index = self.env['hg.index'].create({
'name': 'Показатель отчёта',
'node_id': self.node.id,
})
def test_wizard_create(self):
wizard = self.env['node_report_wizard'].create({'index_id': self.index.id})
self.assertEqual(wizard.index_id, self.index)
def test_action_generate_report_returns_url_action(self):
wizard = self.env['node_report_wizard'].create({'index_id': self.index.id})
action = wizard.action_generate_report()
self.assertEqual(action['type'], 'ir.actions.act_url')
self.assertIn(str(self.index.id), action['url'])
self.assertEqual(action['target'], 'new')
def test_action_url_contains_base_url(self):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
wizard = self.env['node_report_wizard'].create({'index_id': self.index.id})
action = wizard.action_generate_report()
self.assertIn(base_url, action['url'])
class TestHgIndexGraphImage(TransactionCase):
def setUp(self):
super().setUp()
self.node_src = self.env['hg.node'].create({
'name': 'Источник графа',
'res_model': 'hg.node',
'res_id': 1,
})
self.node_tgt = self.env['hg.node'].create({
'name': 'Приёмник графа',
'res_model': 'hg.node',
'res_id': 2,
})
self.code = self.env['hg.index.code'].create({'name': 'Код графа'})
self.index = self.env['hg.index'].create({
'name': 'Показатель графа',
'node_id': self.node_src.id,
'internal_code_id': self.code.id,
})
self.env['hg.link'].create({
'name': 'Связь графа',
'source_id': self.node_src.id,
'target_ids': [(4, self.node_tgt.id)],
})
def test_generate_graph_image_returns_base64(self):
# networkx и matplotlib могут отсутствовать в тестовой среде — мокируем
fake_b64 = 'aW1hZ2VkYXRh'
with patch.object(
type(self.index), 'generate_graph_image', return_value=fake_b64
):
result = self.index.generate_graph_image()
self.assertEqual(result, fake_b64)

View File

@ -0,0 +1,23 @@
<!--<odoo>-->
<!-- <record id="view_hg_index_list" model="ir.ui.view">-->
<!-- <field name="name">hg.index.list</field>-->
<!-- <field name="model">hg.index</field>-->
<!-- <field name="arch" type="xml">-->
<!-- <list>-->
<!-- <field name="name"/>-->
<!-- <field name="related_task_id"/>-->
<!-- <field name="metric_value"/>-->
<!-- </list>-->
<!-- </field>-->
<!-- </record>-->
<!-- <record id="action_node_metrics" model="ir.actions.act_window">-->
<!-- <field name="name">Node Metrics</field>-->
<!-- <field name="res_model">node.metrics</field>-->
<!-- <field name="view_mode">list,form</field>-->
<!-- </record>-->
<!-- <menuitem id="menu_node_metrics" name="Node Metrics" parent="mklab_base_indicators.hypergraph_base_menu_root"/>-->
<!-- <menuitem id="menu_node_metrics_report" name="Node Metrics Report" parent="menu_node_metrics"-->
<!-- action="action_node_metrics"/>-->
<!--</odoo>-->

View File

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

View File

@ -0,0 +1,16 @@
from odoo import models, fields, api
class NodeMetricsReportWizard(models.TransientModel):
_name = 'node_report_wizard'
_description = 'Wizard для выбора показателя и запуска отчёта'
index_id = fields.Many2one(comodel_name='hg.index', string='Показатель')
def action_generate_report(self):
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
url = f"{base_url}/node_metrics/report/{self.index_id.id}"
return {
'type': 'ir.actions.act_url',
'url': url,
'target': 'new',
}

View File

@ -0,0 +1,27 @@
<odoo>
<record id="view_node_metrics_report_wizard" model="ir.ui.view">
<field name="name">node_report_wizard.form</field>
<field name="model">node_report_wizard</field>
<field name="arch" type="xml">
<form string="Отчёт по показателям узлов">
<group>
<field name="index_id"/>
</group>
<footer>
<button name="action_generate_report" string="Показать отчёт" type="object" class="oe_highlight"/>
<button string="Отмена" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_node_metrics_report_wizard" model="ir.actions.act_window">
<field name="name">Отчёт Показатели по узлам</field>
<field name="res_model">node_report_wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<menuitem id="menu_node_metrics_report_wizard" name="Отчет показателей по узлам" parent="mklab_base_indicators_extended.hypergraph_reports"
action="action_node_metrics_report_wizard"/>
</odoo>