442 lines
26 KiB
Markdown
442 lines
26 KiB
Markdown
# Directive Layer (Слой директив)
|
||
**name**: directive_layer
|
||
**version**: 19.0.1.0.0
|
||
**author**: Mk.Lab
|
||
**depends:** `base`, `web`, `base_user_role`, `project`
|
||
|
||
## Архитектура
|
||
|
||
```
|
||
directive_layer
|
||
├── directive.directive — основная сущность: директива
|
||
├── directive.stage — справочник стадий (7 стадий)
|
||
├── directive.work.type — справочник видов работ
|
||
├── directive.template — шаблон директивы
|
||
├── directive.template.group — группа шаблонов (пакет директив)
|
||
├── directive.template.group.line — строки группы
|
||
├── directive.origin — связка «модель + ID»
|
||
├── directive.policy — правило автогенерации директив
|
||
├── directive.mixin — AbstractModel для подключения к любой модели
|
||
├── res.users (расширение) — авто-установка домашней страницы исполнителя
|
||
└── ExecutorBoard (OWL) — рабочий стол исполнителя (fullscreen)
|
||
```
|
||
|
||
---
|
||
|
||
## Модели
|
||
|
||
### `directive.directive` — Директива
|
||
|
||
**Файл:** `models/directive.py`
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `name` | Char (required) | Название директивы |
|
||
| `active` | Boolean | Признак активности |
|
||
| `deadline_at` | Datetime (required) | Дедлайн исполнения |
|
||
| `planned_hours` | Float (required) | Трудоёмкость в часах |
|
||
| `load_percent` | Integer (default: 10) | Процент загрузки ресурса |
|
||
| `executor_type` | Selection | `human` / `machine` / `software` |
|
||
| `success_probability` | Float (default: 98.0) | Вероятность успешного выполнения, % |
|
||
| `resource_cost` | Float | Стоимость ресурса |
|
||
| `retry_policy` | Selection | `none` / `retry_3` / `backoff` |
|
||
| `priority` | Selection | `0` — низкий / `1` — нормальный / `2` — высокий |
|
||
| `work_type_id` | Many2one → `directive.work.type` (required) | Тип работ |
|
||
| `executor_role_id` | Many2one → `res.users.role` | Роль исполнителя |
|
||
| `requester_role_id` | Many2one → `res.users.role` | Роль заявителя |
|
||
| `stage_id` | Many2one → `directive.stage` | Стадия (вид диспетчера) |
|
||
| `stage_code` | Char (related, stored) | Технический код текущей стадии |
|
||
| `executor_stage_id` | Many2one → `directive.stage` | Стадия (вид исполнителя) |
|
||
| `requester_stage_id` | Many2one → `directive.stage` | Стадия (вид заявителя) |
|
||
| `executor_traffic_light` | Selection | Светофор исполнителя: `white/green/yellow/red/black` |
|
||
| `resource_traffic_light` | Selection | Светофор ресурса: `white/green/yellow/red/black` |
|
||
| `executor_user_id` | Many2one → `res.users` | Назначенный исполнитель |
|
||
| `origin_id` | Many2one → `directive.origin` (required, cascade) | Объект-источник |
|
||
| `template_id` | Many2one → `directive.template` | Шаблон директивы |
|
||
| `policy_id` | Many2one → `directive.policy` | Политика создания |
|
||
| `generator_key` | Char (index) | Ключ дедупликации для режима `create_once` |
|
||
| `predecessor_ids` | Many2many → `directive.directive` | Директивы-предшественники |
|
||
| `sequence` | Integer | Порядок в планировщике |
|
||
|
||
**Вычисляемые поля:**
|
||
|
||
| Поле | Описание |
|
||
|---|---|
|
||
| `deadline_display` | Дедлайн в формате `дд.мм.гг Ч:ММ` с учётом часового пояса пользователя |
|
||
| `time_left_display` | Оставшееся время в формате `Xд Yч Zм` (отрицательное, если просрочено) |
|
||
| `is_overdue` | Boolean: дедлайн прошёл |
|
||
| `priority_stars` | Строка ★ / ★★ / ★★★ |
|
||
|
||
**Порядок сортировки по умолчанию:** `deadline_at asc, priority desc, id desc`
|
||
|
||
#### Статусы
|
||
|
||
Допустимые переходы между стадиями:
|
||
|
||
| Текущая стадия | Допустимые следующие |
|
||
|---|---|
|
||
| `draft` | `confirmed`, `planned`, `cancelled` |
|
||
| `confirmed` | `planned`, `in_progress`, `cancelled` |
|
||
| `planned` | `in_progress`, `cancelled` |
|
||
| `in_progress` | `paused`, `done` |
|
||
| `paused` | `in_progress`, `cancelled` |
|
||
| `done` | — (финальная) |
|
||
| `cancelled` | — (финальная) |
|
||
|
||
Переходы контролируются методом `_check_stage_transition()`. Недопустимый переход вызывает `UserError`.
|
||
|
||
#### Методы
|
||
|
||
| Метод | Описание |
|
||
|---|---|
|
||
| `action_assign_to_me()` | Назначить на текущего пользователя (из стадий `confirmed` / `planned`) |
|
||
| `action_start()` | Перевести в `in_progress` |
|
||
| `action_pause()` | Перевести в `paused` (только из `in_progress`) |
|
||
| `action_done()` | Завершить директиву и вызвать callback в записи-источнике |
|
||
| `action_cancel()` | Отменить (обновляет все три stage-поля одновременно) |
|
||
| `action_open_delete_wizard()` | Открыть визард удаления |
|
||
| `_set_stage(code)` | Установить стадию по коду (обновляет `stage_id`, `executor_stage_id`, `requester_stage_id`) |
|
||
| `_get_origin_record()` | Получить запись-источник через `origin_id.res_model` + `origin_id.res_id` |
|
||
| `_build_payload(event)` | Сформировать словарь данных для передачи в callback |
|
||
| `action_quick_change_stage(id, code)` | `@api.model` — смена стадии по вызову из JS |
|
||
|
||
#### Механизм callback при завершении
|
||
|
||
При вызове `action_done()` выполняется следующая последовательность:
|
||
|
||
1. Стадия устанавливается в `done` через `_set_stage("done")`
|
||
2. Из шаблона директивы читается `handler_key` — имя метода в модели-источнике
|
||
3. Через `_get_origin_record()` получается запись-источник
|
||
4. Метод `handler_key` вызывается на записи-источнике с payload
|
||
|
||
**Состав payload:**
|
||
|
||
`event`, `directive_id`, `directive_name`, `template_id`, `template_code`, `work_type_id`, `executor_user_id`, `planned_start_at`, `planned_end_at`, `stage_code`, `priority`, `load_percent`
|
||
|
||
---
|
||
|
||
### `directive.stage` — Стадии
|
||
|
||
**Файл:** `models/directive_stage.py`
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `name` | Char (required) | Название стадии |
|
||
| `code` | Char (required, unique) | Технический код |
|
||
| `is_hidden` | Boolean | Скрыть колонку в канбане |
|
||
|
||
**Предустановленные стадии** (`data/directive_stage_data.xml`, `noupdate="1"`):
|
||
|
||
| Код | Название | Скрыта в канбане |
|
||
|---|---|---|
|
||
| `draft` | Черновик | Нет |
|
||
| `confirmed` | Подтверждена | Нет |
|
||
| `planned` | Запланирована | Нет |
|
||
| `in_progress` | В работе | Нет |
|
||
| `paused` | На паузе | Нет |
|
||
| `done` | Выполнена | Да |
|
||
| `cancelled` | Отменена | Да |
|
||
|
||
---
|
||
|
||
### `directive.template` — Шаблон директивы
|
||
|
||
**Файл:** `models/directive_template.py`
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `name` | Char (required) | Название шаблона |
|
||
| `code` | Char (required, unique) | Технический код |
|
||
| `work_type_id` | Many2one → `directive.work.type` | Тип работ |
|
||
| `planned_hours` | Float (required) | Трудоёмкость в часах |
|
||
| `load_percent` | Integer | Процент загрузки ресурса |
|
||
| `priority` | Selection 0/1/2 | Приоритет директив по этому шаблону |
|
||
| `executor_role_id` | Many2one → `res.users.role` | Роль исполнителя по умолчанию |
|
||
| `requester_role_id` | Many2one → `res.users.role` | Роль заявителя по умолчанию |
|
||
| `default_stage_id` | Many2one → `directive.stage` | Стартовая стадия (по умолчанию: `draft`) |
|
||
| `handler_key` | Char | Имя метода в модели-источнике, вызываемого при завершении директивы |
|
||
| `description` | Text | Описание и инструкции для исполнителя |
|
||
|
||
---
|
||
|
||
### `directive.template.group` — Группа шаблонов
|
||
|
||
**Файл:** `models/directive_template_group.py`
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `name` | Char (required) | Название группы |
|
||
| `code` | Char | Технический код |
|
||
| `applicable_model` | Char | Техническое имя модели, к которой применяется группа |
|
||
| `template_line_ids` | One2many → `directive.template.group.line` | Строки группы |
|
||
|
||
**`directive.template.group.line`:**
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `group_id` | Many2one → `directive.template.group` (cascade) | Родительская группа |
|
||
| `sequence` | Integer | Порядок создания директив из этой строки |
|
||
| `template_id` | Many2one → `directive.template` | Шаблон директивы |
|
||
|
||
---
|
||
|
||
### `directive.origin` — Источник директив
|
||
|
||
**Файл:** `models/directive_origin.py`
|
||
|
||
Хранит пару `(res_model, res_id)` и агрегирует все директивы для данного объекта. На каждую запись создаётся ровно один `directive.origin`.
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `res_model` | Char (required, index) | Техническое имя модели Odoo |
|
||
| `res_id` | Integer (required, index) | ID записи |
|
||
| `res_ref` | Reference (computed, stored) | Прямая ссылка на запись |
|
||
| `directive_ids` | One2many → `directive.directive` | Все директивы источника |
|
||
| `display_name` | Char (computed, stored) | Отображаемое имя в формате `res_model,res_id` |
|
||
|
||
---
|
||
|
||
### `directive.policy` — Политика автогенерации
|
||
|
||
**Файл:** `models/directive_policy.py`
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `name` | Char (required) | Название политики |
|
||
| `active` | Boolean | Политика активна |
|
||
| `sequence` | Integer | Порядок проверки политик |
|
||
| `origin_model` | Char (required) | Модель, к которой применяется политика |
|
||
| `event` | Selection | Тип события: `manual` / `on_create` / `on_write` / `on_stage` / `on_cron` / `on_callback` |
|
||
| `mode` | Selection | `create_once` — однократно; `create_each_time` — при каждом срабатывании |
|
||
| `use_record_template_group` | Boolean | Брать группу шаблонов из поля записи, а не из фиксированной |
|
||
| `template_group_id` | Many2one → `directive.template.group` | Фиксированная группа шаблонов |
|
||
| `trigger_field` | Char | Имя поля (для `on_write`) или метода (для `on_callback`) |
|
||
| `trigger_value_int` | Integer | Ожидаемое значение для Many2one/Integer-поля (ID записи или число) |
|
||
| `trigger_value_char` | Char | Ожидаемое значение для Char/Selection-поля (например `mrp.workorder.state = 'progress'`) |
|
||
| `domain_filter` | Char | Odoo-домен (строка), применяется к записи-источнику перед генерацией (вкл. `on_callback`) |
|
||
|
||
#### Логика `_policy_should_fire()`
|
||
|
||
1. Если задан `domain_filter` — он вычисляется через `safe_eval` и проверяется
|
||
`record.filtered_domain(...)` для **всех** событий (включая `on_callback`).
|
||
Непарсящийся домен логируется (`_logger.error`) и даёт `False` (fail-soft —
|
||
бизнес-операция записи не падает).
|
||
2. Для события `on_callback` (при пройденном домене) — возвращает `True`
|
||
(сам факт вызова = триггер).
|
||
3. Для остальных событий читает значение `trigger_field` у записи и сравнивает:
|
||
Many2one — по `.id` с `trigger_value_int`; Char/Selection — с
|
||
`trigger_value_char` (если он пуст — fail-loud: `False` + warning, без
|
||
проваливания в целочисленную ветку); Integer/число — с `trigger_value_int`.
|
||
|
||
#### Логика `apply_to_records()`
|
||
|
||
Для каждой записи: в режиме `create_once` проверяет, нет ли уже директив от этой политики для данного `origin_id`. Если нет — определяет группу шаблонов и вызывает `_directive_create_from_group()`.
|
||
|
||
---
|
||
|
||
### `directive.mixin` — Миксин для моделей
|
||
|
||
**Файл:** `models/directive_mixin.py`
|
||
**Тип:** `models.AbstractModel`
|
||
|
||
Подключается к модели через `_inherit`:
|
||
|
||
```python
|
||
class ProjectTask(models.Model):
|
||
_name = 'project.task'
|
||
_inherit = ['project.task', 'directive.mixin']
|
||
```
|
||
|
||
#### Поля, добавляемые в модель
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `directive_origin_id` | Many2one → `directive.origin` (readonly) | Origin-запись данного объекта |
|
||
| `directive_ids` | One2many (related) | Все директивы объекта |
|
||
| `directive_execution_status` | Char (computed, stored) | Текстовый статус исполнения |
|
||
| `directive_template_group_id` | Many2one → `directive.template.group` | Группа шаблонов объекта |
|
||
|
||
#### Методы
|
||
|
||
**`_ensure_directive_origin()`** — находит или создаёт `directive.origin` для записи. Вызывается при `create` и `write`.
|
||
|
||
**`write()`** — перехватывает изменения полей. Фиксирует значения до изменения, выполняет `super().write()`, затем проверяет политики с `event='on_write'` и вызывает `apply_to_records()` при совпадении.
|
||
|
||
**`_directive_run_callback_policies(callback_name)`** — публичный хелпер для
|
||
политик `on_callback`. Вызывается **явно** из override штатного метода
|
||
модели-источника (в `directive_common`) после `super()`. Находит активные
|
||
политики с `event='on_callback'` и `trigger_field == callback_name` для
|
||
`origin_model = self._name` и прогоняет `apply_to_records(self)` под `sudo()`
|
||
(автогенерация — системное действие). Заменяет прежний monkey-patch механизм:
|
||
удалены `_register_hook`, `_directive_register_callback_wrappers`,
|
||
`functools`-оборачивание, `setattr(cls, ...)` и прямой SQL к
|
||
`information_schema`.
|
||
|
||
**`_compute_directive_execution_status()`** — формирует строку статуса на основе наиболее приоритетной активной директивы. Приоритет стадий: `paused > in_progress > planned/confirmed > draft`. Результат: `"{stage_name}, до {nearest_deadline}, ожидается завершение к {expected_end}"`.
|
||
|
||
**`action_choose_directive_group()`** — открывает визард `directive.choose.group.wizard`.
|
||
|
||
**`action_open_directives()`** — открывает список директив, отфильтрованных по `origin_id` текущей записи.
|
||
|
||
**`_directive_create_from_group(template_group, generator_key, policy)`** — создаёт директивы по строкам группы в порядке `sequence`. Каждая директива получает параметры из шаблона, ссылку на `origin` и `policy`.
|
||
|
||
> **Прим.** Прежние методы-заглушки `_directive_generation_spec`,
|
||
> `_directive_on_assign/start/pause/done`, `_directive_update/cancel/delete`
|
||
> удалены как мёртвый код. Автогенерация настраивается декларативно через
|
||
> `directive.policy`; завершение директивы обрабатывается через `handler_key`
|
||
> (путь B) и явный хелпер `_directive_run_callback_policies` (путь A) в
|
||
> `directive_common`.
|
||
|
||
---
|
||
|
||
### `res.users` — Расширение
|
||
|
||
**Файл:** `models/res_users.py`
|
||
|
||
При создании или изменении пользователя проверяет вхождение в группу `role_directive_executor_res_groups`. При совпадении устанавливает `action_id = action_executor_dashboard_client` — рабочий стол исполнителя становится домашней страницей пользователя.
|
||
|
||
---
|
||
|
||
## Визарды
|
||
|
||
### `directive.choose.group.wizard`
|
||
|
||
**Файл:** `wizard/directive_choose_group_wizard.py`
|
||
|
||
| Поле | Тип | Описание |
|
||
|---|---|---|
|
||
| `template_group_id` | Many2one → `directive.template.group` (required) | Выбранная группа шаблонов |
|
||
|
||
Метод `action_confirm()` берёт `active_model` и `active_id` из контекста и записывает выбранную группу в поле `directive_template_group_id` исходной записи. Сами директивы не создаются — только сохраняется группа.
|
||
|
||
### `directive.delete.wizard`
|
||
|
||
**Файл:** `wizard/directive_delete_wizard.py`
|
||
|
||
| Режим | Действие |
|
||
|---|---|
|
||
| `archive` | Мягкое удаление: `active=False` |
|
||
| `delete` | Жёсткое удаление: `unlink()` |
|
||
|
||
---
|
||
|
||
## Представления и меню
|
||
|
||
**Файл:** `views/directive_views.xml`, `views/directive_policy_views.xml`
|
||
|
||
### Структура меню
|
||
|
||
```
|
||
Директивы (menu_directive_root)
|
||
├── Все директивы (menu_directive_all)
|
||
│ → action_directive_all: directive.directive (kanban/list/form)
|
||
├── Политики (menu_directive_policy_action)
|
||
│ → action_directive_policy: directive.policy (list/form)
|
||
└── Настройки (menu_directive_settings_root)
|
||
├── Источники директив → directive.origin (list/form)
|
||
├── Статусы директив → directive.stage (list/form)
|
||
├── Виды работ → directive.work.type (list/form)
|
||
├── Шаблоны директив → directive.template (list/form)
|
||
└── Группы шаблонов директив → directive.template.group (list/form)
|
||
```
|
||
|
||
### Рабочий стол исполнителя
|
||
|
||
Зарегистрирован как `ir.actions.client` (`action_executor_dashboard_client`) с тегом `directive_layer.executor_dashboard_action`. Открывается в режиме `fullscreen`. Не является пунктом меню — устанавливается как `action_id` (домашняя страница) пользователям с ролью исполнителя через `res.users`.
|
||
|
||
---
|
||
|
||
## `ExecutorBoard` (OWL-компонент)
|
||
|
||
**Файлы:** `static/src/components/executor_board/executor_board.{js,xml,scss}`
|
||
|
||
Зарегистрирован как `ir.actions.client` с тегом `directive_layer.executor_dashboard_action`, открывается в режиме `fullscreen`.
|
||
|
||
**Логика загрузки данных:**
|
||
|
||
1. Определяет `userId` из `session.user_id` или `storeData`
|
||
2. Загружает все стадии (`directive.stage`)
|
||
3. Загружает директивы с фильтром `executor_user_id = userId`
|
||
4. Строит колонки канбана: стадия и директивы с совпадающим `executor_stage_id`
|
||
|
||
**Быстрые действия на карточке директивы:**
|
||
|
||
| Действие | Переход |
|
||
|---|---|
|
||
| Начать | `action_quick_change_stage(id, 'in_progress')` |
|
||
| Пауза | `action_quick_change_stage(id, 'paused')` |
|
||
| Завершить | `action_quick_change_stage(id, 'done')` |
|
||
|
||
После каждого действия данные перезагружаются.
|
||
|
||
---
|
||
|
||
## Безопасность
|
||
|
||
### Роли (`security/roles.xml`)
|
||
|
||
Каждая роль создаётся одновременно в двух системах:
|
||
- `res.users.role` (модуль `base_user_role`) — бизнес-роль для назначения через интерфейс ролей и для полей `executor_role_id`/`requester_role_id`
|
||
- `res.groups` (`*_res_groups`) — техническая группа для record rules, `ir.model.access` и `has_group`
|
||
|
||
Бизнес-роль связана со своей технической группой через `implied_ids`
|
||
(`[(4, ref('role_directive_<роль>_res_groups'))]`). Назначение бизнес-роли
|
||
пользователю вызывает `set_groups_from_roles` (`base_user_role`), который
|
||
проставляет `role.implied_ids` → пользователь попадает в `_res_groups`.
|
||
Без этой связи назначение роли не давало бы технической группы и доступ
|
||
был бы закрыт (fail-shut). Поэтому `res.groups` определены в `roles.xml`
|
||
ВЫШЕ `res.users.role` — чтобы `ref(...)` в `implied_ids` резолвился, —
|
||
а `roles.xml` грузится в манифесте ДО `ir.model.access.csv`.
|
||
|
||
| Роль | Назначение |
|
||
|---|---|
|
||
| `role_directive_executor` | Исполнитель: видит свои директивы, берёт в работу, завершает |
|
||
| `role_directive_requester` | Заявитель: создаёт и отслеживает директивы |
|
||
| `role_directive_dispatcher` | Диспетчер: назначает исполнителей, управляет очередью |
|
||
|
||
### Record Rules (`security/directives_rules.xml`)
|
||
|
||
Одно **global**-правило `rule_directive_base_access` с тернарным bypass для
|
||
диспетчера:
|
||
- диспетчер (член `role_directive_dispatcher_res_groups`) → `[(1,'=',1)]` (видит все);
|
||
- остальные → свои/ролевые директивы (OR по `executor_user_id` / `create_uid` /
|
||
`executor_role_id` / `requester_role_id`);
|
||
- пользователь без ролей директив → ноль строк (fail-closed, NFR-6).
|
||
|
||
**Почему одно global-правило, а не два.** В Odoo global-правила комбинируются
|
||
по AND и только ограничивают; group-правила комбинируются по OR и лишь
|
||
расширяют доступ в пределах global. Поэтому вынести «диспетчер видит всё» в
|
||
отдельное group-правило нельзя: `global(own) AND group(all) = own`, диспетчер
|
||
потерял бы чужие директивы. Единое global-правило с `has_group`-bypass — это
|
||
сознательный, поведение-сохраняющий выбор, а не незавершённый рефактор.
|
||
|
||
### Права доступа (`security/ir.model.access.csv`)
|
||
|
||
CRUD-права распределены по техническим группам ролей (не по `base.group_user`):
|
||
- `directive.directive` — диспетчер `1,1,1,1`; исполнитель `1,1,0,0` (двигает свои,
|
||
не создаёт/не удаляет); заявитель `1,0,0,0`.
|
||
- `directive.policy` — диспетчер `1,1,1,1`; исполнитель/заявитель только `read`
|
||
(автогенерация идёт под `sudo()`, поэтому `create` рядовому пользователю не нужен).
|
||
- Справочники (`stage`, `work.type`, `template`, `template.group`,
|
||
`template.group.line`, `origin`) — `read` всем трём ролям, полный CRUD только диспетчеру.
|
||
- Визарды (`choose.group.wizard`, `delete.wizard`) — `read,create` по ролям.
|
||
- Bounded `base.group_user` **read-only** на `directive.directive`, `directive.origin`,
|
||
`directive.stage` — чтобы related-поля (`directive_ids`, `directive_execution_status`)
|
||
на формах-источниках грузились у любого internal user. Выборку это не расширяет:
|
||
global record rule оставляет role-less пользователю ноль строк. На `directive.policy`
|
||
такой read НЕ добавлен (ни одна форма-источник не читает поле политики).
|
||
|
||
---
|
||
|
||
## Тесты
|
||
|
||
**Файл:** `tests/test_directive_layer.py`
|
||
|
||
| Что покрывает |
|
||
|---|
|
||
| Создание директив через шаблоны и группы |
|
||
| Статусная машина: допустимые и недопустимые переходы (включая `UserError` при недопустимом переходе) |
|
||
| Политики: срабатывание `on_write` |
|
||
| Политики: срабатывание `on_callback` через явный хелпер `_directive_run_callback_policies` (на `project.task`) |
|
||
| Модель безопасности: диспетчер видит все директивы, исполнитель — свои, пользователь без ролей — ноль (fail-closed) |
|