Public release from ruodoo-project: 19.0 - 2026-07-26 21:17:35 UTC
101
docx_report/README.md
Normal file
@ -0,0 +1,101 @@
|
||||
# DOCX-шаблоны
|
||||
name: docx_report
|
||||
version: 19.0.1.0.0
|
||||
author: Mk.Lab
|
||||
|
||||
### 1. Общее описание
|
||||
|
||||
Модуль добавляет в Odoo систему DOCX-шаблонов для печати документов. Вместо стандартных HTML-отчётов пользователь загружает файл `.docx` с разметкой переменных — и система формирует готовый документ Word, подставляя данные из карточки записи.
|
||||
|
||||
Модуль расширяет возможности базового `docx_report_generation`: добавляет интерфейс управления шаблонами, валидацию, кастомные переменные и привязку шаблонов к моделям.
|
||||
|
||||
### 2. Установка
|
||||
|
||||
1. Убедитесь что установлены модули `docx_report_generation`, `account`, `sale`, `l10n_ru_contract`
|
||||
|
||||
2. Установите Python-пакеты: `pip install docxtpl docxcompose beautifulsoup4`
|
||||
|
||||
3. Скопируйте папку `docx_report` в директорию `addons`
|
||||
|
||||
4. Перезапустите сервер Odoo
|
||||
|
||||
5. Обновите список приложений: **Настройки → Приложения → Обновить список**
|
||||
|
||||
6. Найдите модуль **«DOCX Templates»** и установите
|
||||
|
||||
### 3. Функциональность
|
||||
|
||||
3.1 Основные возможности:
|
||||
|
||||
- Загрузка `.docx`-файла как шаблона отчёта для любой модели Odoo
|
||||
- Подстановка данных из записи через переменные `{{ object.field }}`
|
||||
- Условные конструкции `{% if %}` и циклы `{% for %}` в шаблоне
|
||||
- Кастомные переменные с произвольными Python-выражениями
|
||||
- Валидация шаблона: проверка синтаксиса и существования полей модели
|
||||
- Подсказки доступных полей выбранной модели прямо в форме шаблона
|
||||
- Привязка шаблона к форме записи через меню печати
|
||||
|
||||
3.2 Синтаксис шаблона:
|
||||
|
||||
| Конструкция | Пример | Описание |
|
||||
|---|---|---|
|
||||
| Переменная | `{{ object.name }}` | Значение поля записи |
|
||||
| Связанное поле | `{{ object.partner_id.name }}` | Поле через Many2one |
|
||||
| Условие | `{% if object.amount > 0 %}...{% endif %}` | Условный вывод |
|
||||
| Цикл | `{% for line in object.line_ids %}...{% endfor %}` | Перебор строк |
|
||||
| Кастомная переменная | `{{ my_var }}` | Переменная из вкладки «Кастомные переменные» |
|
||||
| Дата | `{{ time.strftime('%d.%m.%Y') }}` | Текущая дата |
|
||||
|
||||
3.3 Меню:
|
||||
|
||||
В главном меню появляется пункт **«Шаблоны для отчетов»** — список всех DOCX-шаблонов.
|
||||
|
||||

|
||||
|
||||
## Как это выглядит в интерфейсе
|
||||
|
||||
Форма шаблона содержит основные поля, кнопку **«Валидация»** и две вкладки: **«Кастомные переменные»** и **«Доступные переменные»**.
|
||||
|
||||

|
||||
|
||||
После привязки шаблона к модели в меню **⚙️ Печать** записи появляется дополнительный пункт с именем шаблона.
|
||||
|
||||

|
||||
|
||||
### Пример использования
|
||||
|
||||
### Создать шаблон
|
||||
|
||||
Перейдите в меню **«Шаблоны для отчетов»**, нажмите **Создать**. Укажите название, загрузите `.docx`-файл, выберите модель документа.
|
||||
|
||||

|
||||
|
||||
### Проверить доступные поля
|
||||
|
||||
На вкладке **«Доступные переменные»** выберите модель для подсказки — отобразится список всех полей с техническими именами и типами. Используйте их при создании шаблона.
|
||||
|
||||

|
||||
|
||||
### Добавить кастомную переменную (если нужно)
|
||||
|
||||
На вкладке **«Кастомные переменные»** добавьте переменную: укажите техническое имя и Python-выражение для вычисления значения.
|
||||
|
||||

|
||||
|
||||
### Проверить шаблон
|
||||
|
||||
Нажмите кнопку **«Валидация»** в шапке формы. Система проверит синтаксис и существование всех полей в шаблоне, отобразит результат.
|
||||
|
||||

|
||||
|
||||
### Привязать шаблон к модели
|
||||
|
||||
Нажмите кнопку **«Добавить печать в действия»** — шаблон появится в меню ⚙️ Печать для записей выбранной модели.
|
||||
|
||||

|
||||
|
||||
### Распечатать документ
|
||||
|
||||
Откройте нужную запись (договор, заказ и т.д.) → меню **⚙️ Печать** → выберите название шаблона. Файл `.docx` скачается автоматически.
|
||||
|
||||

|
||||
185
docx_report/TECHNICAL.md
Normal file
@ -0,0 +1,185 @@
|
||||
# DOCX-шаблоны
|
||||
name: docx_report
|
||||
version: 19.0.1.0.0
|
||||
author: MK.Lab
|
||||
depends: `base`, `account`, `sale`, `l10n_ru_contract`, `docx_report_generation`
|
||||
external_dependencies: `docxtpl`, `docxcompose`, `bs4`
|
||||
|
||||
## Архитектура
|
||||
|
||||
Модуль надстраивается над `docx_report_generation` и добавляет интерфейс управления шаблонами. Ключевая модель `docx.template` наследует технические поля в `ir.actions.report` через `_inherits`.
|
||||
|
||||
```
|
||||
docx_report
|
||||
├── docx.template — шаблон (наследует ir.actions.report через _inherits)
|
||||
├── docx.custom.field — кастомные переменные шаблона
|
||||
├── docx.template.mixin — AbstractModel для привязки шаблона к модели
|
||||
├── ir.actions.report — расширение: рендер DOCX, кастомные переменные, валидация
|
||||
├── ir.model — расширение: display_name в контексте docx_template
|
||||
├── ir.model.fields — расширение: вычисляемый тип поля для подсказки
|
||||
└── DocxReportController — HTTP-контроллер: скачивание DOCX и PDF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Модели
|
||||
|
||||
### `docx.template` — Шаблон
|
||||
|
||||
**Файл:** `models/docx_template.py`
|
||||
|
||||
Использует `_inherits = {"ir.actions.report": "report_id"}` — все технические поля (`name`, `model`, `report_type`, `report_docx_template`) берутся из связанного `ir.actions.report`.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `report_id` | Many2one → `ir.actions.report` (required, cascade) | Связанное действие отчёта |
|
||||
| `filename_pattern` | Char | Python-выражение для имени скачиваемого файла (без расширения). Переменные: `object`, `time` |
|
||||
| `docx_output_type` | Selection (`docx`) | Формат вывода |
|
||||
| `docx_model_id` | Many2one → `ir.model` | Модель документа (устанавливает `model` в `ir.actions.report`) |
|
||||
| `available_field_ids` | Many2many → `ir.model.fields` (computed) | Доступные поля модели |
|
||||
| `global_template` | Boolean (default: True) | Глобальный шаблон — доступен для всех записей модели |
|
||||
| `hint_model_id` | Many2one → `ir.model` | Модель для подсказки полей |
|
||||
| `report_docx_template_filename` | Char | Имя загруженного файла шаблона |
|
||||
|
||||
#### Методы
|
||||
|
||||
**`_compute_available_field_ids()`** — вычисляет список полей модели для вкладки «Доступные переменные».
|
||||
|
||||
**`create()` / `write()`** — при сохранении синхронизируют `docx_output_type` → `report_type` (`docx` → `docx-docx`) и `docx_model_id` → `model` в связанном `ir.actions.report`.
|
||||
|
||||
**`action_bind_to_actions()`** — создаёт `binding` для отображения шаблона в меню.
|
||||
|
||||
**`action_unbind_from_actions()`** — удаляет привязку через `unlink_action()`.
|
||||
|
||||
**`action_bind_all_to_actions()`** — добавляет в меню печати все шаблоны сразу.
|
||||
|
||||
**`action_validate_docx_template()`** — валидирует загруженный шаблон:
|
||||
1. Распаковывает `.docx` как ZIP, читает XML-файлы внутри `word/`
|
||||
2. Извлекает все выражения `{{ }}` и переменные циклов `{% for %}`
|
||||
3. Для каждого выражения разбирает цепочку полей и проверяет их существование через `self.env[model_name]._fields`
|
||||
4. Возвращает уведомление: успех или список неизвестных переменных
|
||||
|
||||
---
|
||||
|
||||
### `docx.custom.field` — Кастомная переменная
|
||||
|
||||
**Файл:** `models/docx_custom_field.py`
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `report_id` | Many2one → `ir.actions.report` (required, cascade) | Основной отчёт |
|
||||
| `technical_name` | Char (required) | Имя переменной в шаблоне |
|
||||
| `name` | Char (required) | Отображаемое название |
|
||||
| `value_python` | Text (required) | Python-выражение. Контекст: `env`, `user`, `record`, `docs`, `time`, `context` |
|
||||
|
||||
---
|
||||
|
||||
### `docx.template.mixin` — Миксин
|
||||
|
||||
**Файл:** `models/docx_template_mixin.py`
|
||||
|
||||
Добавляет поле `docx_template_id` (Many2one → `docx.template`) в любую модель. Используется для хранения шаблона по умолчанию для конкретного объекта (например, вид договора → шаблон).
|
||||
|
||||
---
|
||||
|
||||
### `ir.actions.report` — Расширение
|
||||
|
||||
**Файл:** `models/ir_actions_report.py`
|
||||
|
||||
Добавляет поля и реализует рендер DOCX.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `docx_custom_field_ids` | One2many → `docx.custom.field` | Кастомные переменные |
|
||||
| `report_docx_template` | Binary | Загруженный `.docx`-файл шаблона |
|
||||
| `report_type` | Selection (расширение) | Добавлен тип `docx-docx` |
|
||||
| `report_name` | Char (computed) | Формат: `{model}-docx_report+{id}` для DOCX-отчётов |
|
||||
|
||||
#### Метод `_render_docx_template(template, values)`
|
||||
|
||||
Основной метод рендеринга. Последовательность:
|
||||
|
||||
1. Формирует базовый контекст: `record`, `time`, `user`, `res_company`, `web_base_url`, `website`
|
||||
2. Вычисляет кастомные переменные через `safe_eval` и добавляет их в контекст
|
||||
3. Рендерит шаблон через `DocxTemplate` (библиотека `docxtpl`)
|
||||
4. Возвращает `BytesIO` с готовым DOCX
|
||||
|
||||
#### Метод `_render_docx_docx(res_ids, data)`
|
||||
|
||||
Оркестрирует рендер для списка записей: поддерживает кэш вложений, рендерит по одной записи, объединяет через `_merge_docx()` если несколько.
|
||||
|
||||
#### Метод `_merge_docx(streams)`
|
||||
|
||||
Объединяет несколько DOCX-файлов в один через `docxcompose.Composer` с разрывами страниц между ними.
|
||||
|
||||
#### Метод `_parse_markup(markup_data)`
|
||||
|
||||
Конвертирует HTML-поля Odoo в plain text для вставки в DOCX через `BeautifulSoup`.
|
||||
|
||||
---
|
||||
|
||||
### `ir.model.fields` — Расширение
|
||||
|
||||
**Файл:** `models/ir_model_fields.py`
|
||||
|
||||
Добавляет вычисляемое поле `docx_type_label` — человекочитаемый тип поля для вкладки «Доступные переменные»:
|
||||
- Many2one/One2many/Many2many: `many2one (res.partner)`
|
||||
- Selection: `selection (value1, value2)`
|
||||
|
||||
---
|
||||
|
||||
## HTTP-контроллер
|
||||
|
||||
**Файл:** `controllers/main.py`
|
||||
**Класс:** `DocxReportController` (наследует `ReportController`)
|
||||
|
||||
### Маршрут `report_routes`
|
||||
|
||||
Расширяет стандартный маршрут `/report/<converter>/<reportname>/<docids>`:
|
||||
|
||||
| Converter | Действие |
|
||||
|---|---|
|
||||
| `docx` | Вызывает `_render_docx_docx()`, возвращает DOCX с MIME-типом `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
|
||||
| `pdf` (для DOCX-отчёта) | Вызывает `_render_docx_pdf()` (из `docx_report_generation`) |
|
||||
| Остальные | Передаёт в родительский `ReportController` |
|
||||
|
||||
### Маршрут `report_download`
|
||||
|
||||
Обрабатывает скачивание файлов типов `docx-docx` и `docx-pdf`. Формирует имя файла через `_get_docx_output_filename()`:
|
||||
|
||||
| Приоритет | Источник имени |
|
||||
|---|---|
|
||||
| 1 | `docx.template.filename_pattern` (Python-выражение) |
|
||||
| 2 | `ir.actions.report.print_report_name` |
|
||||
| 3 | `ir.actions.report.name` |
|
||||
|
||||
---
|
||||
|
||||
## Представления и меню
|
||||
|
||||
**Файл:** `views/docx_template_views.xml`
|
||||
|
||||
Форма шаблона содержит:
|
||||
- Кнопка **«Валидация»** в header
|
||||
- Кнопки **«Добавить печать в действия»** / **«Убрать печать из действий»** в button_box
|
||||
- Основные поля: название, формат, модель, файл шаблона, шаблон имени файла, флаг «Глобальный»
|
||||
- Вкладка **«Кастомные переменные»** — список `docx_custom_field_ids`
|
||||
- Вкладка **«Доступные переменные»** — readonly список полей модели с техническим именем, названием и типом
|
||||
|
||||
Пункт меню **«Шаблоны для отчетов»** добавляется в корень главного меню.
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
**Файл:** `security/ir.model.access.csv`
|
||||
|
||||
CRUD-права на `docx.template` и `docx.custom.field` для стандартных групп пользователей.
|
||||
|
||||
---
|
||||
|
||||
## Тесты
|
||||
|
||||
**Файл:** `tests/test_docx.py`
|
||||
|
||||
Покрывает создание шаблона, валидацию, рендер DOCX для тестовой модели.
|
||||
2
docx_report/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
29
docx_report/__manifest__.py
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "DOCX Templates",
|
||||
"summary": "DOCX-шаблоны",
|
||||
"version": "19.0.1.0.0",
|
||||
"license": "LGPL-3",
|
||||
"category": "Accounting/Localizations",
|
||||
"depends": [
|
||||
"base",
|
||||
"account",
|
||||
"sale",
|
||||
"l10n_ru_contract",
|
||||
],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"views/docx_template_views.xml",
|
||||
"views/docx_report.xml",
|
||||
"data/docx_template.xml",
|
||||
],
|
||||
"demo": [
|
||||
"demo/demo.xml",
|
||||
],
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"docx_report/static/src/js/contract_docx_print_menu.js",
|
||||
"docx_report/static/src/js/action_manager_report.js",
|
||||
],
|
||||
},
|
||||
"installable": True,
|
||||
}
|
||||
1
docx_report/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
221
docx_report/controllers/main.py
Normal file
@ -0,0 +1,221 @@
|
||||
from json import dumps as json_dumps, loads as json_loads
|
||||
from werkzeug.urls import url_decode
|
||||
import re
|
||||
|
||||
from odoo.http import (
|
||||
content_disposition,
|
||||
request,
|
||||
route,
|
||||
serialize_exception as _serialize_exception,
|
||||
)
|
||||
from odoo.tools import html_escape
|
||||
from odoo.tools.safe_eval import safe_eval, time
|
||||
|
||||
from odoo.addons.web.controllers.report import ReportController
|
||||
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class DocxReportController(ReportController):
|
||||
|
||||
def _prepare_docx_report_context(self, docids, data):
|
||||
"""
|
||||
По сути дублирует сбор контекста и docids в части базового report_routes. Новая логика непосредственно в переопределенном report_routes.
|
||||
Возможно есть и более изящный способ.
|
||||
"""
|
||||
context = dict(request.env.context)
|
||||
payload = {}
|
||||
docids_list = [int(i) for i in docids.split(",")] if docids else []
|
||||
if data.get("options"):
|
||||
payload.update(json_loads(data.pop("options")))
|
||||
if data.get("context"):
|
||||
payload["context"] = json_loads(data["context"])
|
||||
if payload["context"].get("lang") and not payload.get("force_context_lang"):
|
||||
del payload["context"]["lang"]
|
||||
context.update(payload["context"])
|
||||
return context, payload, docids_list
|
||||
|
||||
def _get_docx_report_from_name(self, reportname):
|
||||
template = request.env["docx.template"].search([("report_name", "=", reportname)], limit=1)
|
||||
if template:
|
||||
return template.report_id
|
||||
return request.env["ir.actions.report"]._get_report_from_name(reportname)
|
||||
|
||||
@route()
|
||||
def report_routes(self, reportname, docids=None, converter=None, **data):
|
||||
"""
|
||||
Расширенный маршрут `/report/<converter>/<reportname>/<docids>`
|
||||
Добавлена поддержка:
|
||||
- converter=docx → генерация DOCX
|
||||
- converter=pdf → конвертация DOCX → PDF (Gotenberg)
|
||||
"""
|
||||
_logger.info("[ROUTE] report_routes() called: reportname=%s converter=%s docids=%s",
|
||||
reportname, converter, docids)
|
||||
|
||||
context, payload, docids_list = self._prepare_docx_report_context(docids, data)
|
||||
|
||||
report = self._get_docx_report_from_name(reportname)
|
||||
|
||||
# -----------------------
|
||||
# 1) DOCX генерация
|
||||
# -----------------------
|
||||
if converter == "docx":
|
||||
_logger.info("[ROUTE] DOCX branch for report=%s", reportname)
|
||||
|
||||
docx_result = report.with_context(context)._render_docx_docx(
|
||||
docids_list, data=payload
|
||||
)
|
||||
# _render_docx_docx returns (bytes, "docx") tuple
|
||||
docx_bytes = docx_result[0] if isinstance(docx_result, tuple) else docx_result
|
||||
headers = [
|
||||
(
|
||||
"Content-Type",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
),
|
||||
]
|
||||
return request.make_response(docx_bytes, headers=headers)
|
||||
|
||||
# -----------------------
|
||||
# 2) PDF генерация из DOCX
|
||||
# -----------------------
|
||||
if converter == "pdf" and "docx" in (report.report_type or ""):
|
||||
_logger.info("[ROUTE] PDF branch for report=%s docids=%s", reportname, docids_list)
|
||||
|
||||
pdf_result = report.with_context(context)._render_docx_pdf(
|
||||
docids_list, data=payload
|
||||
)
|
||||
|
||||
# Может быть либо bytes, либо (bytes, ext)
|
||||
if isinstance(pdf_result, tuple):
|
||||
pdf_content = pdf_result[0]
|
||||
else:
|
||||
pdf_content = pdf_result
|
||||
|
||||
headers = [
|
||||
("Content-Type", "application/pdf"),
|
||||
("Content-Length", len(pdf_content)),
|
||||
]
|
||||
return request.make_response(pdf_content, headers=headers)
|
||||
|
||||
return super().report_routes(
|
||||
reportname, docids, converter, **data
|
||||
)
|
||||
|
||||
def _get_docx_output_filename(self, report, docids, extension):
|
||||
"""
|
||||
Возвращает финальное имя файла для скачивания DOCX.
|
||||
|
||||
Приоритет:
|
||||
1) docx.template.filename_pattern (если существует docx.template)
|
||||
2) report.print_report_name (как fallback, если оно всё же задано)
|
||||
3) report.name
|
||||
|
||||
Ожидается, что выражение возвращает строку БЕЗ расширения (.docx).
|
||||
"""
|
||||
filename = "%s.%s" % (report.name, extension)
|
||||
|
||||
template = request.env["docx.template"].sudo().search(
|
||||
[("report_id", "=", report.id)], limit=1
|
||||
)
|
||||
|
||||
pattern = None
|
||||
if template and template.filename_pattern:
|
||||
pattern = template.filename_pattern
|
||||
elif report.print_report_name:
|
||||
pattern = report.print_report_name
|
||||
|
||||
if not pattern or not docids:
|
||||
return filename
|
||||
|
||||
ids = [int(x) for x in docids.split(",") if x.isdigit()]
|
||||
if len(ids) != 1:
|
||||
return filename
|
||||
|
||||
obj = request.env[report.model].browse(ids)
|
||||
|
||||
try:
|
||||
report_name = safe_eval(
|
||||
pattern,
|
||||
{"object": obj, "time": time},
|
||||
)
|
||||
except Exception as e:
|
||||
return filename
|
||||
|
||||
if not isinstance(report_name, str):
|
||||
return filename
|
||||
|
||||
clean = report_name.strip()
|
||||
if not clean:
|
||||
return filename
|
||||
|
||||
clean = re.sub(r"\.(docx?|pdf)$", "", clean, flags=re.IGNORECASE)
|
||||
|
||||
return "%s.%s" % (clean, extension)
|
||||
|
||||
@route()
|
||||
def report_download(self, data, context=None, token=None, readonly=True):
|
||||
"""
|
||||
Обрабатывает запрос на скачивание файла отчета.
|
||||
Расширен для:
|
||||
- docx-docx (DOCX)
|
||||
- docx-pdf (PDF из DOCX)
|
||||
"""
|
||||
_logger.info("[REPORT DOWNLOAD] data=%s, token=%s", data, token)
|
||||
|
||||
requestcontent = json_loads(data)
|
||||
url, type_ = requestcontent[0], requestcontent[1]
|
||||
|
||||
try:
|
||||
if type_ in ["docx-docx", "docx-pdf"]:
|
||||
converter = "docx" if type_ == "docx-docx" else "pdf"
|
||||
extension = "docx" if type_ == "docx-docx" else "pdf"
|
||||
|
||||
pattern = "/report/%s/" % ("docx" if type_ == "docx-docx" else "pdf")
|
||||
reportname = url.split(pattern)[1].split("?")[0]
|
||||
|
||||
docids = None
|
||||
if "/" in reportname:
|
||||
reportname, docids = reportname.split("/")
|
||||
|
||||
_logger.info(
|
||||
"[REPORT DOWNLOAD] type=%s converter=%s extension=%s reportname=%s docids=%s",
|
||||
type_, converter, extension, reportname, docids,
|
||||
)
|
||||
|
||||
if docids:
|
||||
response = self.report_routes(
|
||||
reportname, docids=docids, converter=converter, context=context
|
||||
)
|
||||
|
||||
else:
|
||||
query_data = {}
|
||||
if "?" in url:
|
||||
query_data = dict(url_decode(url.split("?")[1]).items())
|
||||
|
||||
if "context" in query_data:
|
||||
base_ctx = json_loads(context or "{}") if context else {}
|
||||
data_context = json_loads(query_data.pop("context"))
|
||||
context = json_dumps({**base_ctx, **data_context})
|
||||
|
||||
response = self.report_routes(
|
||||
reportname, converter=converter, context=context, **query_data
|
||||
)
|
||||
|
||||
report = self._get_docx_report_from_name(reportname)
|
||||
filename = self._get_docx_output_filename(report, docids, extension)
|
||||
|
||||
response.headers.add(
|
||||
"Content-Disposition", content_disposition(filename)
|
||||
)
|
||||
return response
|
||||
|
||||
else:
|
||||
return super().report_download(
|
||||
data, context=context, token=token, readonly=readonly
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception("[REPORT DOWNLOAD] ERROR: %s", e)
|
||||
se = _serialize_exception(e)
|
||||
error = {"code": 200, "message": "Odoo Server Error", "data": se}
|
||||
return request.make_response(html_escape(json_dumps(error)))
|
||||
58
docx_report/data/docx_template.xml
Normal file
@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- Договор с покупателем -->
|
||||
<record id="docx_template_contract" model="docx.template">
|
||||
<field name="name">Контракт с клиентом</field>
|
||||
<field name="report_type">docx-docx</field>
|
||||
<field name="model">partner.contract.customer</field>
|
||||
<field name="docx_output_type">docx</field>
|
||||
<field name="docx_model_id" search="[('model','=','partner.contract.customer')]" model="ir.model"/>
|
||||
<field name="global_template" eval="True"/>
|
||||
<field name="filename_pattern">object.name or 'contract'</field>
|
||||
<field name="report_docx_template" type="base64" file="docx_report/static/src/docx/contract.docx"/>
|
||||
<field name="report_docx_template_filename">contract.docx</field>
|
||||
</record>
|
||||
|
||||
<!-- УПД (новый шаблон) -->
|
||||
<record id="docx_template_invoice_upd" model="docx.template">
|
||||
<field name="name">Универсальный передаточный документ(УПД) DOCX</field>
|
||||
<field name="report_type">docx-docx</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="docx_output_type">docx</field>
|
||||
<field name="docx_model_id" search="[('model','=','account.move')]" model="ir.model"/>
|
||||
<field name="global_template" eval="True"/>
|
||||
<field name="filename_pattern">object.name or 'upd'</field>
|
||||
<field name="report_docx_template" type="base64" file="docx_report/static/src/docx/report_account_invoice_upd_new_templates.docx"/>
|
||||
<field name="report_docx_template_filename">report_account_invoice_upd_new_templates.docx</field>
|
||||
</record>
|
||||
|
||||
<!-- УПД-Н (новый шаблон) -->
|
||||
<record id="docx_template_invoice_updn" model="docx.template">
|
||||
<field name="name">УПД без печатей DOCX</field>
|
||||
<field name="report_type">docx-docx</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="docx_output_type">docx</field>
|
||||
<field name="docx_model_id" search="[('model','=','account.move')]" model="ir.model"/>
|
||||
<field name="global_template" eval="True"/>
|
||||
<field name="filename_pattern">object.name or 'updn'</field>
|
||||
<field name="report_docx_template" type="base64" file="docx_report/static/src/docx/report_account_invoice_updn_templates.docx"/>
|
||||
<field name="report_docx_template_filename">report_account_invoice_updn_templates.docx</field>
|
||||
</record>
|
||||
|
||||
<!-- Заказ на продажу -->
|
||||
<record id="docx_template_saleorder" model="docx.template">
|
||||
<field name="name">Счет по форме 1С DOCX</field>
|
||||
<field name="report_type">docx-docx</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="docx_output_type">docx</field>
|
||||
<field name="docx_model_id" search="[('model','=','sale.order')]" model="ir.model"/>
|
||||
<field name="global_template" eval="True"/>
|
||||
<field name="filename_pattern">object.name or 'sale_order'</field>
|
||||
<field name="report_docx_template" type="base64" file="docx_report/static/src/docx/report_saleorder_new_template.docx"/>
|
||||
<field name="report_docx_template_filename">report_saleorder_new_template.docx</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
18
docx_report/demo/demo.xml
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- DOCX-шаблон для печати договора на модели account.move -->
|
||||
<!-- report_docx_template (файл .docx) загружается пользователем вручную -->
|
||||
<record id="demo_docx_template_contract" model="docx.template">
|
||||
<field name="name">Печать договора</field>
|
||||
<field name="report_type">docx-docx</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="docx_output_type">docx</field>
|
||||
<field name="docx_model_id" search="[('model','=','account.move')]" model="ir.model"/>
|
||||
<field name="global_template" eval="True"/>
|
||||
<field name="filename_pattern">object.name or 'contract'</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
BIN
docx_report/img_1.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
docx_report/img_2.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
docx_report/img_3.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docx_report/img_4.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
docx_report/img_5.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docx_report/img_6.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docx_report/img_7.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
docx_report/img_8.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docx_report/img_9.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
6
docx_report/models/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from . import docx_template
|
||||
from . import ir_model
|
||||
from . import ir_actions_report
|
||||
from . import docx_custom_field
|
||||
from . import ir_model_fields
|
||||
from . import docx_template_mixin
|
||||
41
docx_report/models/docx_custom_field.py
Normal file
@ -0,0 +1,41 @@
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class DocxCustomField(models.Model):
|
||||
_name = "docx.custom.field"
|
||||
_description = "Кастомная переменная для DOCX-шаблона"
|
||||
|
||||
report_id = fields.Many2one(
|
||||
"ir.actions.report",
|
||||
string="DOCX-отчёт",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
|
||||
technical_name = fields.Char(
|
||||
string="Техническое имя",
|
||||
required=True,
|
||||
help="Имя переменной, используемое в шаблоне",
|
||||
)
|
||||
|
||||
name = fields.Char(
|
||||
string="Название",
|
||||
required=True,
|
||||
help="Имя для интерфейса.",
|
||||
)
|
||||
|
||||
value_python = fields.Text(
|
||||
string="Значение (Python)",
|
||||
required=True,
|
||||
help=(
|
||||
"Python-выражение, которое будет вычислено в контексте отчёта.\n"
|
||||
),
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"uniq_report_technical_name",
|
||||
"unique(report_id, technical_name)",
|
||||
"Техническое имя кастомной переменной должно быть уникально в рамках одного отчёта.",
|
||||
)
|
||||
]
|
||||
341
docx_report/models/docx_template.py
Normal file
@ -0,0 +1,341 @@
|
||||
import re
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from base64 import b64decode
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class DocxTemplate(models.Model):
|
||||
"""
|
||||
Высокоуровневый DOCX-шаблон договора.
|
||||
|
||||
Все технические поля (name, report_type, model,
|
||||
report_docx_template, print_report_name и т.д.)
|
||||
берём напрямую из ir.actions.report через _inherits.
|
||||
"""
|
||||
_name = "docx.template"
|
||||
_description = "DOCX-шаблон договора"
|
||||
_inherits = {"ir.actions.report": "report_id"}
|
||||
|
||||
report_id = fields.Many2one(
|
||||
"ir.actions.report",
|
||||
string="Действие отчёта",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
|
||||
filename_pattern = fields.Char(
|
||||
string="Шаблон имени файла",
|
||||
help=(
|
||||
"Python-выражение, вычисляемое при скачивании DOCX.\n"
|
||||
"Должно вернуть строку БЕЗ расширения.\n"
|
||||
"Доступные переменные:\n"
|
||||
" - object: запись, для которой печатается отчёт\n"
|
||||
" - time: модуль time из Python"
|
||||
),
|
||||
)
|
||||
|
||||
docx_output_type = fields.Selection(
|
||||
selection=[
|
||||
("docx", "DOCX"),
|
||||
],
|
||||
string="Формат вывода",
|
||||
default="docx",
|
||||
required=True,
|
||||
)
|
||||
|
||||
docx_model_id = fields.Many2one(
|
||||
"ir.model",
|
||||
string="Модель документа",
|
||||
ondelete="set null"
|
||||
)
|
||||
|
||||
available_field_ids = fields.Many2many(
|
||||
"ir.model.fields",
|
||||
string="Доступные поля (подсказка)",
|
||||
compute="_compute_available_field_ids",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
global_template = fields.Boolean(
|
||||
string="Глобальный",
|
||||
default=True,
|
||||
help="Если включено, шаблон доступен глобально",
|
||||
)
|
||||
|
||||
report_docx_template_filename = fields.Char(
|
||||
string="Имя файла шаблона",
|
||||
help="Имя загруженного DOCX-шаблона. Не влияет на имя выводимого файла.",
|
||||
)
|
||||
|
||||
hint_model_id = fields.Many2one(
|
||||
"ir.model",
|
||||
string="Модель для подсказки полей",
|
||||
help=(
|
||||
"Используется только как подсказка при создании шаблона.\n"
|
||||
"На реальную модель отчёта не влияет."
|
||||
),
|
||||
)
|
||||
|
||||
def action_bind_to_actions(self):
|
||||
"""Создать contextual action (binding) для каждого шаблона."""
|
||||
for template in self:
|
||||
template.report_id.create_action()
|
||||
return True
|
||||
|
||||
def action_bind_all_to_actions(self):
|
||||
"""Добавить в печать действия все шаблоны сразу."""
|
||||
templates = self.search([])
|
||||
for template in templates:
|
||||
template.report_id.create_action()
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "display_notification",
|
||||
"params": {
|
||||
"title": _("Готово"),
|
||||
"message": _("Все шаблоны (%d) добавлены в действия печати.") % len(templates),
|
||||
"type": "success",
|
||||
"sticky": False,
|
||||
},
|
||||
}
|
||||
|
||||
def action_unbind_from_actions(self):
|
||||
"""Удалить contextual action (binding) для каждого шаблона."""
|
||||
for template in self:
|
||||
template.report_id.unlink_action()
|
||||
return True
|
||||
|
||||
|
||||
@api.depends("hint_model_id", "docx_model_id")
|
||||
def _compute_available_field_ids(self):
|
||||
"""
|
||||
Это всего лишь подсказка для конструктора шаблонов.
|
||||
|
||||
Берём поля:
|
||||
- в первую очередь для hint_model_id
|
||||
- если hint_model_id не задана — для docx_model_id
|
||||
"""
|
||||
Fields = self.env["ir.model.fields"]
|
||||
for rec in self:
|
||||
model_rec = rec.hint_model_id or rec.docx_model_id
|
||||
if model_rec:
|
||||
rec.available_field_ids = Fields.search(
|
||||
[("model_id", "=", model_rec.id)],
|
||||
order="name",
|
||||
)
|
||||
else:
|
||||
rec.available_field_ids = Fields.browse()
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
output_type = vals.get("docx_output_type")
|
||||
if output_type and not vals.get("report_type"):
|
||||
if output_type == "docx":
|
||||
vals["report_type"] = "docx-docx"
|
||||
elif output_type == "pdf":
|
||||
vals["report_type"] = "docx-pdf"
|
||||
elif output_type == "docx_pdf":
|
||||
vals["report_type"] = "docx-docx"
|
||||
|
||||
model_id = vals.get("docx_model_id")
|
||||
if model_id and not vals.get("model"):
|
||||
model_rec = self.env["ir.model"].browse(model_id)
|
||||
vals["model"] = model_rec.model or False
|
||||
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
if "docx_output_type" in vals and "report_type" not in vals:
|
||||
out = vals["docx_output_type"]
|
||||
if out == "docx":
|
||||
vals["report_type"] = "docx-docx"
|
||||
elif out == "pdf":
|
||||
vals["report_type"] = "docx-pdf"
|
||||
elif out == "docx_pdf":
|
||||
vals["report_type"] = "docx-docx"
|
||||
|
||||
if "docx_model_id" in vals and "model" not in vals:
|
||||
model_rec = self.env["ir.model"].browse(vals["docx_model_id"])
|
||||
vals["model"] = model_rec.model or False
|
||||
return super().write(vals)
|
||||
|
||||
def action_validate_docx_template(self):
|
||||
self.ensure_one()
|
||||
_logger.info("[DOCX VALIDATION] --- START ---")
|
||||
|
||||
if not self.report_docx_template:
|
||||
raise UserError(_("Не загружен DOCX-шаблон."))
|
||||
if not self.docx_model_id:
|
||||
raise UserError(_("Заполните поле 'Модель документа'."))
|
||||
|
||||
try:
|
||||
raw = b64decode(self.report_docx_template)
|
||||
zf = zipfile.ZipFile(BytesIO(raw))
|
||||
except Exception as e:
|
||||
raise UserError(_("Файл шаблона повреждён или не является DOCX: %s") % e)
|
||||
|
||||
pattern = re.compile(r"{{\s*(.*?)\s*}}", re.DOTALL)
|
||||
tag_pattern = re.compile(r"<[^>]+>")
|
||||
loop_pattern = re.compile(
|
||||
r"{%\s*for\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+in\s+.*?%}", re.DOTALL
|
||||
)
|
||||
|
||||
expressions = set()
|
||||
loop_var_names = set()
|
||||
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("word/") or not name.endswith(".xml"):
|
||||
continue
|
||||
xml = zf.read(name).decode("utf-8", errors="ignore")
|
||||
|
||||
for match in pattern.finditer(xml):
|
||||
expr = match.group(1)
|
||||
expr = tag_pattern.sub("", expr)
|
||||
expr = expr.strip()
|
||||
if expr:
|
||||
expressions.add(expr)
|
||||
|
||||
for m in loop_pattern.finditer(xml):
|
||||
var_name = m.group(1)
|
||||
loop_var_names.add(var_name)
|
||||
|
||||
if not expressions:
|
||||
return self._validation_notification(
|
||||
_("В шаблоне не найдено ни одной переменной {{ }}."), "info"
|
||||
)
|
||||
|
||||
allowed_roots = {
|
||||
"env", "model", "record", "records", "docs", "user",
|
||||
"res_company", "website", "web_base_url", "time",
|
||||
"context_timestamp", "UserError", "Warning", "format_number",
|
||||
}
|
||||
|
||||
root_model_map = {
|
||||
"record": self.docx_model_id.model,
|
||||
"records": self.docx_model_id.model,
|
||||
"docs": self.docx_model_id.model,
|
||||
"user": "res.users",
|
||||
"res_company": "res.company",
|
||||
}
|
||||
|
||||
custom_var_names = (
|
||||
set(self.docx_custom_field_ids.mapped("technical_name"))
|
||||
if hasattr(self, "docx_custom_field_ids")
|
||||
else set()
|
||||
)
|
||||
|
||||
relational_types = {"many2one", "one2many", "many2many"}
|
||||
missing = set()
|
||||
|
||||
|
||||
func_stop_patterns = ("mapped", "filtered", "sorted", "search")
|
||||
|
||||
for expr in expressions:
|
||||
core = expr.split("|", 1)[0]
|
||||
for sep in (" if ", " or ", " and ", " else "):
|
||||
core = core.split(sep, 1)[0]
|
||||
core = core.strip()
|
||||
|
||||
for func in func_stop_patterns:
|
||||
core = re.split(rf"\.{func}\s*\(", core)[0]
|
||||
|
||||
if not core:
|
||||
continue
|
||||
|
||||
match = re.search(r'[a-zA-Z_][a-zA-Z0-9_.\[\]\'\"]*', core)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
token = match.group(0)
|
||||
|
||||
token = re.sub(r"\['([^']+)']", r".\1", token)
|
||||
token = re.sub(r'\["([^"]+)"]', r".\1", token)
|
||||
|
||||
parts = token.split(".")
|
||||
root = parts[0]
|
||||
|
||||
if root in custom_var_names and root not in allowed_roots:
|
||||
continue
|
||||
if root in loop_var_names and root not in allowed_roots and root not in custom_var_names:
|
||||
continue
|
||||
if root not in allowed_roots:
|
||||
missing.add(root)
|
||||
continue
|
||||
|
||||
model_name = root_model_map.get(root)
|
||||
|
||||
if not model_name:
|
||||
continue
|
||||
|
||||
current_model = self.env[model_name]
|
||||
|
||||
path = root
|
||||
|
||||
for name in parts[1:]:
|
||||
name_stripped = name.strip()
|
||||
|
||||
if any(
|
||||
name_stripped.startswith(f"{func}(") or name_stripped == func
|
||||
for func in func_stop_patterns
|
||||
):
|
||||
break
|
||||
|
||||
if current_model is None:
|
||||
break
|
||||
|
||||
field = current_model._fields.get(name_stripped)
|
||||
if not field:
|
||||
_logger.warning(
|
||||
"[DOCX VALIDATION] MISSING field: %s.%s (model=%s). "
|
||||
"Available fields: %s",
|
||||
path,
|
||||
name_stripped,
|
||||
current_model._name,
|
||||
", ".join(sorted(current_model._fields.keys())),
|
||||
)
|
||||
missing.add(f"{path}.{name_stripped}")
|
||||
break
|
||||
|
||||
path = f"{path}.{name_stripped}"
|
||||
|
||||
if field.type in relational_types and field.comodel_name:
|
||||
_logger.info(
|
||||
"[DOCX VALIDATION] Following relation → %s", field.comodel_name
|
||||
)
|
||||
current_model = self.env[field.comodel_name]
|
||||
else:
|
||||
current_model = None
|
||||
|
||||
_logger.info("[DOCX VALIDATION] ----- END CHECK -----")
|
||||
|
||||
if missing:
|
||||
_logger.warning("[DOCX VALIDATION] Missing vars: %s", missing)
|
||||
text = "\n".join(sorted(missing))
|
||||
raise UserError(
|
||||
_("Обнаружены неизвестные переменные или поля в шаблоне для модели '%s':\n%s")
|
||||
% (self.docx_model_id.model, text)
|
||||
)
|
||||
|
||||
return self._validation_notification(
|
||||
_("Все переменные в шаблоне валидны для модели %s.") % self.docx_model_id.model,
|
||||
"success",
|
||||
)
|
||||
|
||||
def _validation_notification(self, message, type="success"):
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "display_notification",
|
||||
"params": {
|
||||
"title": _("Валидация DOCX-шаблона"),
|
||||
"message": message,
|
||||
"type": type,
|
||||
"sticky": False,
|
||||
},
|
||||
}
|
||||
13
docx_report/models/docx_template_mixin.py
Normal file
@ -0,0 +1,13 @@
|
||||
from odoo import models, fields, _
|
||||
|
||||
|
||||
class DocxTemplateMixin(models.AbstractModel):
|
||||
_name = "docx.template.mixin"
|
||||
_description = "Mixin: DOCX template link"
|
||||
|
||||
docx_template_id = fields.Many2one(
|
||||
"docx.template",
|
||||
string=_("DOCX шаблон"),
|
||||
domain=[("global_template", "=", False)],
|
||||
help="DOCX-шаблон, используемый по умолчанию для данного объекта.",
|
||||
)
|
||||
348
docx_report/models/ir_actions_report.py
Normal file
@ -0,0 +1,348 @@
|
||||
from base64 import b64decode
|
||||
from bs4 import BeautifulSoup
|
||||
from collections import OrderedDict
|
||||
from io import BytesIO
|
||||
from logging import getLogger
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
from docx import Document
|
||||
from docxcompose.composer import Composer
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
from odoo.http import request
|
||||
from odoo.tools.safe_eval import safe_eval, time
|
||||
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
class IrActionsReport(models.Model):
|
||||
_inherit = "ir.actions.report"
|
||||
|
||||
docx_custom_field_ids = fields.One2many(
|
||||
"docx.custom.field",
|
||||
"report_id",
|
||||
string="Кастомные переменные DOCX",
|
||||
)
|
||||
|
||||
report_name = fields.Char(
|
||||
compute="_compute_report_name",
|
||||
inverse="_inverse_report_name",
|
||||
store=True,
|
||||
required=False,
|
||||
)
|
||||
report_type = fields.Selection(
|
||||
selection_add=[("docx-docx", "DOCX")],
|
||||
ondelete={"docx-docx": "cascade"},
|
||||
)
|
||||
report_docx_template = fields.Binary(
|
||||
string="Файл шаблона",
|
||||
)
|
||||
|
||||
@api.depends("report_type", "model")
|
||||
def _compute_report_name(self):
|
||||
for record in self:
|
||||
if (
|
||||
record.report_type in ["docx-docx"]
|
||||
and record.model
|
||||
and record.id
|
||||
):
|
||||
record.report_name = "%s-docx_report+%s" % (record.model, record.id)
|
||||
else:
|
||||
record.report_name = False
|
||||
|
||||
def _inverse_report_name(self):
|
||||
"""TODO: write this method"""
|
||||
pass
|
||||
|
||||
def retrieve_attachment(self, record):
|
||||
"""
|
||||
Searc for existing report file in record's attachments by fields:
|
||||
1. name
|
||||
2. res_model
|
||||
3. res_id
|
||||
"""
|
||||
result = super().retrieve_attachment(record)
|
||||
if result:
|
||||
if self.report_type == "docx-docx":
|
||||
result = (
|
||||
result.filtered(
|
||||
lambda r: r.mimetype
|
||||
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
or None
|
||||
)
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _render_docx_docx(self, res_ids=None, data=None):
|
||||
"""
|
||||
Prepares the data for report file rendering, calls for the render method
|
||||
and handle rendering result.
|
||||
"""
|
||||
if not data:
|
||||
data = {}
|
||||
data.setdefault("report_type", "docx")
|
||||
|
||||
# access the report details with sudo() but evaluation context as current user
|
||||
self_sudo = self.sudo()
|
||||
|
||||
save_in_attachment = OrderedDict()
|
||||
# Maps the streams in `save_in_attachment` back to the records they came from
|
||||
stream_record = dict()
|
||||
if res_ids:
|
||||
Model = self.env[self_sudo.model]
|
||||
record_ids = Model.browse(res_ids)
|
||||
docx_record_ids = Model
|
||||
if self_sudo.attachment:
|
||||
for record_id in record_ids:
|
||||
attachment = self_sudo.retrieve_attachment(record_id)
|
||||
if attachment:
|
||||
# stream = self_sudo._retrieve_stream_from_attachment(attachment)
|
||||
stream = BytesIO(attachment.raw)
|
||||
save_in_attachment[record_id.id] = stream
|
||||
stream_record[stream] = record_id
|
||||
if not self_sudo.attachment_use or not attachment:
|
||||
docx_record_ids += record_id
|
||||
else:
|
||||
docx_record_ids = record_ids
|
||||
res_ids = docx_record_ids.ids
|
||||
|
||||
if save_in_attachment and not res_ids:
|
||||
_logger.info("The DOCS report has been generated from attachment.")
|
||||
return self_sudo._post_docx(save_in_attachment), "docx"
|
||||
|
||||
docx_contents = []
|
||||
for record_id in res_ids:
|
||||
docx_content = self._render_docx([record_id], data=data)
|
||||
docx_contents.append(docx_content)
|
||||
|
||||
if res_ids:
|
||||
_logger.info(
|
||||
"The DOCS report has been generated for model: %s, records %s."
|
||||
% (self_sudo.model, str(res_ids))
|
||||
)
|
||||
return (
|
||||
self_sudo._post_docx(
|
||||
save_in_attachment, docx_contents=docx_contents, res_ids=res_ids
|
||||
),
|
||||
"docx",
|
||||
)
|
||||
return docx_contents, "docx"
|
||||
|
||||
def _post_docx(self, save_in_attachment, docx_contents=None, res_ids=None):
|
||||
"""
|
||||
Adds generated file in attachments.
|
||||
"""
|
||||
|
||||
def close_streams(streams):
|
||||
for stream in streams:
|
||||
try:
|
||||
stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(save_in_attachment) == 1 and not docx_contents:
|
||||
return list(save_in_attachment.values())[0].getvalue()
|
||||
|
||||
streams = []
|
||||
if docx_contents:
|
||||
record_map = {
|
||||
r.id: r
|
||||
for r in self.env[self.model].browse(
|
||||
[res_id for res_id in res_ids if res_id]
|
||||
)
|
||||
}
|
||||
if not record_map or not self.attachment:
|
||||
streams.extend(docx_contents)
|
||||
else:
|
||||
for res_id, docx_content in zip(res_ids, docx_contents):
|
||||
if res_id in record_map and not res_id in save_in_attachment:
|
||||
new_stream = self._postprocess_docx_report(
|
||||
record_map[res_id], docx_content
|
||||
)
|
||||
if new_stream and new_stream != docx_content:
|
||||
close_streams([docx_content])
|
||||
docx_content = new_stream
|
||||
streams.append(docx_content)
|
||||
if self.attachment_use:
|
||||
for stream in save_in_attachment.values():
|
||||
streams.append(stream)
|
||||
if len(streams) == 1:
|
||||
result = streams[0].getvalue()
|
||||
else:
|
||||
try:
|
||||
merged_stream = self._merge_docx(streams)
|
||||
result = merged_stream.getvalue()
|
||||
except Exception as e:
|
||||
_logger.exception(e)
|
||||
raise UserError(
|
||||
_("One of the documents you try to merge caused failure.")
|
||||
)
|
||||
|
||||
close_streams(streams)
|
||||
return result
|
||||
|
||||
def _postprocess_docx_report(self, record, buffer):
|
||||
"""
|
||||
Creates the record in the "ir.attachment" model.
|
||||
"""
|
||||
attachment_name = safe_eval(self.attachment, {"object": record, "time": time})
|
||||
if not attachment_name:
|
||||
return None
|
||||
attachment_vals = {
|
||||
"name": attachment_name,
|
||||
"raw": buffer.getvalue(),
|
||||
"res_model": self.model,
|
||||
"res_id": record.id,
|
||||
"type": "binary",
|
||||
}
|
||||
try:
|
||||
self.env["ir.attachment"].create(attachment_vals)
|
||||
except AccessError:
|
||||
_logger.info(
|
||||
"Cannot save DOCX report %r as attachment", attachment_vals["name"]
|
||||
)
|
||||
else:
|
||||
_logger.info(
|
||||
"The DOCX document %s is now saved in the database",
|
||||
attachment_vals["name"],
|
||||
)
|
||||
return buffer
|
||||
|
||||
@staticmethod
|
||||
def _merge_docx(streams):
|
||||
"""
|
||||
Joins several docx files into one with page breaks between them.
|
||||
"""
|
||||
if not streams:
|
||||
return None
|
||||
|
||||
merged_document = Document()
|
||||
composer = Composer(merged_document)
|
||||
|
||||
for stream in streams:
|
||||
document = Document(stream)
|
||||
|
||||
if composer.doc.paragraphs:
|
||||
composer.doc.add_page_break()
|
||||
|
||||
composer.append(document)
|
||||
|
||||
merged_stream = BytesIO()
|
||||
merged_document.save(merged_stream)
|
||||
merged_stream.seek(0)
|
||||
|
||||
return merged_stream
|
||||
|
||||
def _render_docx(self, docids: list, data: dict = None):
|
||||
"""
|
||||
Receive the data for rendering and calls for it.
|
||||
|
||||
docids: list of record's ids for which report is generated.
|
||||
data: dict, conains "context", "report_type".
|
||||
"""
|
||||
if not data:
|
||||
data = {}
|
||||
data.setdefault("report_type", "docx")
|
||||
data = self._get_rendering_context(
|
||||
self, docids, data
|
||||
) # self contains current record of ir.actions.report model.
|
||||
return self._render_docx_template(self.report_docx_template, values=data)
|
||||
|
||||
def _render_docx_template(self, template: bytes, values: dict = None):
|
||||
"""
|
||||
docx file rendering itself.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if values is None:
|
||||
values = {}
|
||||
|
||||
context = dict(self.env.context, inherit_branding=False)
|
||||
# Browse the user instead of using the sudo self.env.user
|
||||
user = self.env["res.users"].browse(self.env.uid)
|
||||
website = None
|
||||
if request and hasattr(request, "website"):
|
||||
if request.website is not None:
|
||||
website = request.website
|
||||
context = dict(
|
||||
context,
|
||||
translatable=context.get("lang")
|
||||
!= request.env["ir.http"]._get_default_lang().code,
|
||||
)
|
||||
|
||||
# базовый контекст, который и так шёл в шаблон
|
||||
values.update(
|
||||
record=values["docs"],
|
||||
time=time,
|
||||
context_timestamp=lambda t: fields.Datetime.context_timestamp(
|
||||
self.with_context(tz=user.tz), t
|
||||
),
|
||||
user=user,
|
||||
res_company=user.company_id,
|
||||
website=website,
|
||||
web_base_url=self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param("web.base.url", default=""),
|
||||
)
|
||||
|
||||
# --------- КАСТОМНЫЕ ПЕРЕМЕННЫЕ ---------
|
||||
# record = текущая запись модели отчёта (partner.contract.customer, docx.test.contract и т.д.)
|
||||
record = values.get("record")
|
||||
# контекст для safe_eval кастомных выражений
|
||||
eval_context = {
|
||||
"env": self.env,
|
||||
"user": user,
|
||||
"record": record,
|
||||
"docs": record, # чтобы можно было писать record.* или docs.*
|
||||
"time": time,
|
||||
"context": self.env.context,
|
||||
"mode": "python", # как ты и просил
|
||||
}
|
||||
|
||||
custom_values = {}
|
||||
for custom in self.docx_custom_field_ids:
|
||||
tech = (custom.technical_name or "").strip()
|
||||
if not tech:
|
||||
continue
|
||||
try:
|
||||
result = safe_eval(custom.value_python or "", eval_context)
|
||||
except Exception as e:
|
||||
# Явная ошибка в кастомной переменной — не молчим
|
||||
raise UserError(
|
||||
_("Ошибка при вычислении кастомной переменной '%s': %s")
|
||||
% (tech, e)
|
||||
)
|
||||
custom_values[tech] = result
|
||||
_logger.info(
|
||||
"[DOCX CUSTOM] %s = %r (report id %s)",
|
||||
tech,
|
||||
result,
|
||||
self.id,
|
||||
)
|
||||
|
||||
# кладём их в общий контекст рендера:
|
||||
# в шаблоне можно писать {{ our_company }} и т.п.
|
||||
values.update(custom_values)
|
||||
# --------- / КАСТОМНЫЕ ПЕРЕМЕННЫЕ ---------
|
||||
|
||||
# Собственно рендер DOCX
|
||||
docx_content = BytesIO()
|
||||
with BytesIO(b64decode(template)) as template_file:
|
||||
doc = DocxTemplate(template_file)
|
||||
doc.render(values)
|
||||
doc.save(docx_content)
|
||||
docx_content.seek(0)
|
||||
return docx_content
|
||||
|
||||
@staticmethod
|
||||
def _parse_markup(markup_data: Markup):
|
||||
"""
|
||||
Extracts data from field of Html type and returns them in text format,
|
||||
without html tags.
|
||||
"""
|
||||
soup = BeautifulSoup(markup_data.__str__(), "html.parser")
|
||||
data_arr = list(soup.strings)
|
||||
return "\n".join(data_arr)
|
||||
13
docx_report/models/ir_model.py
Normal file
@ -0,0 +1,13 @@
|
||||
from odoo import models, api
|
||||
|
||||
|
||||
class IrModel(models.Model):
|
||||
_inherit = "ir.model"
|
||||
|
||||
@api.depends('model', 'name')
|
||||
def _compute_display_name(self):
|
||||
super()._compute_display_name()
|
||||
|
||||
if self.env.context.get('from_docx_template'):
|
||||
for record in self:
|
||||
record.display_name = record.model or record.name
|
||||
26
docx_report/models/ir_model_fields.py
Normal file
@ -0,0 +1,26 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class IrModelFields(models.Model):
|
||||
_inherit = "ir.model.fields"
|
||||
|
||||
docx_type_label = fields.Char(
|
||||
string="Тип для DOCX",
|
||||
compute="_compute_docx_type_label",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
@api.depends("ttype", "relation", "selection_ids.value")
|
||||
def _compute_docx_type_label(self):
|
||||
for field in self:
|
||||
t = field.ttype or ""
|
||||
if field.relation and field.ttype in ("many2one", "one2many", "many2many"):
|
||||
t = f"{field.ttype} ({field.relation})"
|
||||
|
||||
elif field.ttype == "selection":
|
||||
values = field.selection_ids.mapped("value")
|
||||
if values:
|
||||
t = f"selection ({', '.join(values)})"
|
||||
else:
|
||||
t = "selection"
|
||||
field.docx_type_label = t
|
||||
4
docx_report/security/ir.model.access.csv
Normal file
@ -0,0 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_docx_template_user,docx.template user,model_docx_template,base.group_user,1,0,0,0
|
||||
access_docx_template_manager,docx.template manager,model_docx_template,base.group_system,1,1,1,1
|
||||
access_docx_custom_field_admin,access_docx_custom_field_admin,model_docx_custom_field,base.group_system,1,1,1,1
|
||||
|
BIN
docx_report/static/src/docx/contract.docx
Normal file
BIN
docx_report/static/src/docx/report_saleorder_new_template.docx
Normal file
52
docx_report/static/src/js/action_manager_report.js
Normal file
@ -0,0 +1,52 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { download } from "@web/core/network/download";
|
||||
|
||||
async function docxHandler(action, options, env) {
|
||||
let reportType = null;
|
||||
if (action.report_type === "docx-docx") {
|
||||
reportType = "docx";
|
||||
} else if (action.report_type === "docx-pdf") {
|
||||
reportType = "pdf";
|
||||
}
|
||||
if (reportType) {
|
||||
let url = `/report/${reportType}/${action.report_name}`;
|
||||
const actionContext = action.context || {};
|
||||
if (action.data && JSON.stringify(action.data) !== "{}") {
|
||||
const options = encodeURIComponent(JSON.stringify(action.data));
|
||||
const context = encodeURIComponent(JSON.stringify(actionContext));
|
||||
url += `?options=${options}&context=${context}`;
|
||||
} else {
|
||||
if (actionContext.active_ids) {
|
||||
url += `/${actionContext.active_ids.join(",")}`;
|
||||
}
|
||||
}
|
||||
|
||||
env?.services?.ui?.block?.();
|
||||
try {
|
||||
const template_type = (action.report_type && action.report_type.split("-")[0]) || "docx";
|
||||
const type = template_type + "-" + url.split("/")[2];
|
||||
await download({
|
||||
url: "/report/download",
|
||||
data: {
|
||||
data: JSON.stringify([url, type]),
|
||||
context: JSON.stringify(Object.assign({}, actionContext, env?.services?.user?.context || {})),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
env?.services?.ui?.unblock?.();
|
||||
}
|
||||
|
||||
const onClose = options?.onClose;
|
||||
if (action.close_on_report_download) {
|
||||
return env?.services?.action?.doAction({ type: "ir.actions.act_window_close" }, { onClose });
|
||||
} else if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
registry.category("ir.actions.report handlers").add("docx_handler", docxHandler);
|
||||
90
docx_report/static/src/js/contract_docx_print_menu.js
Normal file
@ -0,0 +1,90 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {FormController} from "@web/views/form/form_controller";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
|
||||
let docxReportMap = null;
|
||||
let docxReportMapLoading = false;
|
||||
|
||||
async function loadDocxReportMap(env) {
|
||||
const orm = env.services.orm;
|
||||
const templates = await orm.searchRead("docx.template", [], [
|
||||
"id",
|
||||
"report_id",
|
||||
"global_template",
|
||||
"docx_model_id",
|
||||
]);
|
||||
|
||||
const byReportId = {};
|
||||
for (const t of templates) {
|
||||
const reportId = t.report_id && t.report_id[0];
|
||||
if (!reportId) continue;
|
||||
|
||||
byReportId[reportId] = {
|
||||
templateId: t.id,
|
||||
globalTemplate: !!t.global_template,
|
||||
isDocxBased: !!t.docx_model_id,
|
||||
};
|
||||
}
|
||||
|
||||
return { byReportId };
|
||||
}
|
||||
|
||||
patch(FormController.prototype, {
|
||||
get actionMenuItems() {
|
||||
const items = super.actionMenuItems;
|
||||
const record = this.model?.root;
|
||||
|
||||
if (!record || !("docx_template_id" in (record.data || {}))) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (!docxReportMap && !docxReportMapLoading) {
|
||||
docxReportMapLoading = true;
|
||||
|
||||
loadDocxReportMap(this.env)
|
||||
.then((map) => {
|
||||
docxReportMap = map;
|
||||
docxReportMapLoading = false;
|
||||
})
|
||||
.catch((e) => {
|
||||
docxReportMapLoading = false;
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
if (!docxReportMap) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const tplVal = record.data.docx_template_id;
|
||||
const currentTemplateId = tplVal && tplVal[0];
|
||||
const hasTemplate = !!currentTemplateId;
|
||||
|
||||
if (!items.print?.length) {
|
||||
return items;
|
||||
}
|
||||
|
||||
items.print = items.print.filter((entry) => {
|
||||
const reportId = entry.id;
|
||||
const info = docxReportMap.byReportId[reportId];
|
||||
|
||||
if (!info || !info.isDocxBased) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (info.globalTemplate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasTemplate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return info.templateId === currentTemplateId;
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
});
|
||||
2
docx_report/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_docx
|
||||
153
docx_report/tests/test_docx.py
Normal file
@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for docx_report — DOCX template loading.
|
||||
|
||||
Validates: Requirement 13.1
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
def _make_minimal_docx():
|
||||
"""
|
||||
Build a minimal valid DOCX (ZIP) file in memory and return it as base64.
|
||||
|
||||
A DOCX is a ZIP archive containing at minimum:
|
||||
- [Content_Types].xml
|
||||
- word/document.xml
|
||||
- _rels/.rels
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(
|
||||
"[Content_Types].xml",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
'<Override PartName="/word/document.xml"'
|
||||
' ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
||||
"</Types>",
|
||||
)
|
||||
zf.writestr(
|
||||
"_rels/.rels",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1"'
|
||||
' Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"'
|
||||
' Target="word/document.xml"/>'
|
||||
"</Relationships>",
|
||||
)
|
||||
zf.writestr(
|
||||
"word/document.xml",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
"<w:body><w:p><w:r><w:t>Test</w:t></w:r></w:p></w:body>"
|
||||
"</w:document>",
|
||||
)
|
||||
zf.writestr(
|
||||
"word/_rels/document.xml.rels",
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
"</Relationships>",
|
||||
)
|
||||
buf.seek(0)
|
||||
return base64.b64encode(buf.read()).decode("ascii")
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestDocxReport(TransactionCase):
|
||||
"""
|
||||
Tests for docx.template — DOCX template loading.
|
||||
|
||||
Validates: Requirement 13.1
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Resolve the ir.model record for res.partner (a simple, always-present model)
|
||||
self.ir_model = self.env["ir.model"].search(
|
||||
[("model", "=", "res.partner")], limit=1
|
||||
)
|
||||
self.docx_b64 = _make_minimal_docx()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Requirement 13.1 — loading a DOCX template saves without errors
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_create_docx_template_without_file_saves_ok(self):
|
||||
"""
|
||||
Req 13.1 — creating a docx.template record without a template file
|
||||
(file uploaded later by the user) saves without errors.
|
||||
"""
|
||||
template = self.env["docx.template"].create(
|
||||
{
|
||||
"name": "Test DOCX Template (no file)",
|
||||
"report_type": "docx-docx",
|
||||
"model": "res.partner",
|
||||
"docx_output_type": "docx",
|
||||
"docx_model_id": self.ir_model.id,
|
||||
"global_template": True,
|
||||
}
|
||||
)
|
||||
self.assertTrue(template.id, "docx.template record should be created with a valid id")
|
||||
self.assertEqual(template.name, "Test DOCX Template (no file)")
|
||||
self.assertEqual(template.model, "res.partner")
|
||||
|
||||
def test_load_docx_template_file_saves_ok(self):
|
||||
"""
|
||||
Req 13.1 — uploading a DOCX binary into report_docx_template and saving
|
||||
the docx.template record completes without errors.
|
||||
"""
|
||||
template = self.env["docx.template"].create(
|
||||
{
|
||||
"name": "Test DOCX Template (with file)",
|
||||
"report_type": "docx-docx",
|
||||
"model": "res.partner",
|
||||
"docx_output_type": "docx",
|
||||
"docx_model_id": self.ir_model.id,
|
||||
"global_template": True,
|
||||
"report_docx_template": self.docx_b64,
|
||||
"report_docx_template_filename": "test_template.docx",
|
||||
}
|
||||
)
|
||||
self.assertTrue(template.id, "docx.template record should be created with a valid id")
|
||||
self.assertTrue(
|
||||
template.report_docx_template,
|
||||
"report_docx_template field should contain the uploaded binary",
|
||||
)
|
||||
self.assertEqual(
|
||||
template.report_docx_template_filename,
|
||||
"test_template.docx",
|
||||
"Filename should be stored correctly",
|
||||
)
|
||||
|
||||
def test_write_docx_template_file_saves_ok(self):
|
||||
"""
|
||||
Req 13.1 — writing a DOCX binary to an existing docx.template record
|
||||
via write() completes without errors.
|
||||
"""
|
||||
template = self.env["docx.template"].create(
|
||||
{
|
||||
"name": "Test DOCX Template (write)",
|
||||
"report_type": "docx-docx",
|
||||
"model": "res.partner",
|
||||
"docx_output_type": "docx",
|
||||
"docx_model_id": self.ir_model.id,
|
||||
}
|
||||
)
|
||||
# Now upload the template file via write
|
||||
template.write(
|
||||
{
|
||||
"report_docx_template": self.docx_b64,
|
||||
"report_docx_template_filename": "updated_template.docx",
|
||||
}
|
||||
)
|
||||
self.assertTrue(
|
||||
template.report_docx_template,
|
||||
"report_docx_template should be set after write()",
|
||||
)
|
||||
76
docx_report/views/docx_report.xml
Normal file
@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<openerp>
|
||||
<data>
|
||||
<record id="paperformat_a4" model="report.paperformat">
|
||||
<field name="name">A4</field>
|
||||
<field name="default" eval="True"/>
|
||||
<field name="format">A4</field>
|
||||
<field name="page_height">0</field>
|
||||
<field name="page_width">0</field>
|
||||
<field name="orientation">Portrait</field>
|
||||
<field name="margin_top">7</field>
|
||||
<field name="margin_bottom">7</field>
|
||||
<field name="margin_left">7</field>
|
||||
<field name="margin_right">7</field>
|
||||
<field name="header_line" eval="False"/>
|
||||
<field name="header_spacing">35</field>
|
||||
<field name="dpi">75</field>
|
||||
</record>
|
||||
<record id="paperformat_a4l" model="report.paperformat">
|
||||
<field name="name">A4 Landscape</field>
|
||||
<field name="default" eval="True"/>
|
||||
<field name="format">A4</field>
|
||||
<field name="page_height">0</field>
|
||||
<field name="page_width">0</field>
|
||||
<field name="orientation">Landscape</field>
|
||||
<field name="margin_top">7</field>
|
||||
<field name="margin_bottom">7</field>
|
||||
<field name="margin_left">7</field>
|
||||
<field name="margin_right">7</field>
|
||||
<field name="header_line" eval="False"/>
|
||||
<field name="header_spacing">75</field>
|
||||
<field name="dpi">60</field>
|
||||
</record>
|
||||
|
||||
|
||||
|
||||
<!-- <record id="docx_report.action_report_saleorder_new_templates" model="ir.actions.report">-->
|
||||
<!-- <field name="name">Счет по форме 1С DOCX</field>-->
|
||||
<!-- <field name="model">sale.order</field>-->
|
||||
<!-- <field name="report_type">docx-docx</field>-->
|
||||
<!-- <field name="report_file">docx_report.report_order</field>-->
|
||||
<!-- <field name="print_report_name">'Счет - %s DOCX' % ((object.name or '')+' '+(object.partner_id.parent_id.name if object.partner_id.parent_id else object.partner_id.name))</field>-->
|
||||
<!-- <field name="binding_model_id" ref="sale.model_sale_order"/>-->
|
||||
<!-- <field name="paperformat_id" ref="docx_report.paperformat_a4"/>-->
|
||||
<!-- <field name="binding_type">report</field>-->
|
||||
<!-- <field name="report_docx_template" type="base64" file="docx_report/static/src/docx/report_saleorder_new_template.docx"/>-->
|
||||
<!-- </record>-->
|
||||
|
||||
|
||||
<!-- <record id="report_account_invoice_upd_new_templates" model="ir.actions.report">-->
|
||||
<!-- <field name="name">Универсальный передаточный документ(УПД) DOCX</field>-->
|
||||
<!-- <field name="model">account.move</field>-->
|
||||
<!-- <field name="report_type">docx-docx</field>-->
|
||||
<!-- <field name="report_file">docx_report.report_upd</field>-->
|
||||
<!-- <field name="print_report_name">'УПД DOCX'</field>-->
|
||||
<!-- <field name="binding_model_id" ref="account.model_account_move" />-->
|
||||
<!-- <field name="paperformat_id" ref="docx_report.paperformat_a4l" />-->
|
||||
<!-- <field name="binding_type">report</field>-->
|
||||
<!-- <field name="report_docx_template" type="base64" file="docx_report/static/src/docx/report_account_invoice_upd_new_templates.docx"/>-->
|
||||
<!-- </record>-->
|
||||
|
||||
|
||||
|
||||
<!-- <record id="report_account_invoice_updn_templates" model="ir.actions.report">-->
|
||||
<!-- <field name="name">УПД без печатей DOCX</field>-->
|
||||
<!-- <field name="model">account.move</field>-->
|
||||
<!-- <field name="report_type">docx-docx</field>-->
|
||||
<!-- <field name="report_file">docx_report.report_updn</field>-->
|
||||
<!-- <field name="print_report_name">'УПД без печатей DOCX'</field>-->
|
||||
<!-- <field name="binding_model_id" ref="account.model_account_move" />-->
|
||||
<!-- <field name="paperformat_id" ref="docx_report.paperformat_a4l" />-->
|
||||
<!-- <field name="binding_type">report</field>-->
|
||||
<!-- <field name="report_docx_template" type="base64" file="docx_report/static/src/docx/report_account_invoice_updn_templates.docx"/>-->
|
||||
<!-- </record>-->
|
||||
</data>
|
||||
</openerp>
|
||||
127
docx_report/views/docx_template_views.xml
Normal file
@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="view_docx_template_tree" model="ir.ui.view">
|
||||
<field name="name">docx.template.tree</field>
|
||||
<field name="model">docx.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="DOCX-шаблоны договоров">
|
||||
<field name="name"/>
|
||||
<field name="docx_output_type"/>
|
||||
<field name="docx_model_id"/>
|
||||
<control>
|
||||
<button name="action_bind_all_to_actions"
|
||||
type="object"
|
||||
string="Добавить все в печать"
|
||||
class="btn-primary"/>
|
||||
</control>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_docx_template_form" model="ir.ui.view">
|
||||
<field name="name">docx.template.form</field>
|
||||
<field name="model">docx.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="DOCX-шаблон договора">
|
||||
|
||||
<header>
|
||||
<button name="action_validate_docx_template"
|
||||
type="object"
|
||||
string="Валидация"
|
||||
class="oe_highlight"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_bind_to_actions"
|
||||
type="object"
|
||||
icon="fa-bolt"
|
||||
string="Добавить печать в действия"
|
||||
invisible="not docx_model_id or binding_model_id"/>
|
||||
|
||||
<button name="action_unbind_from_actions"
|
||||
type="object"
|
||||
icon="fa-times"
|
||||
string="Убрать печать из действий"
|
||||
invisible="not binding_model_id"/>
|
||||
</div>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="docx_output_type"/>
|
||||
<field name="docx_model_id"
|
||||
context="{'from_docx_template': True}"
|
||||
options="{'no_quick_create': True, 'no_create': True}"/>
|
||||
</group>
|
||||
<group string="DOCX-шаблон">
|
||||
<field name="report_docx_template"
|
||||
filename="print_report_name"/>
|
||||
<field name="print_report_name" invisible="1"/>
|
||||
<field name="filename_pattern"/>
|
||||
<field name="global_template"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Кастомные переменные">
|
||||
<field name="docx_custom_field_ids">
|
||||
<list editable="bottom">
|
||||
<field name="technical_name"/>
|
||||
<field name="name"/>
|
||||
<field name="value_python"/>
|
||||
</list>
|
||||
<form>
|
||||
<group>
|
||||
<field name="technical_name"/>
|
||||
<field name="name"/>
|
||||
<field name="value_python"/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Доступные переменные">
|
||||
<group>
|
||||
<field name="hint_model_id"
|
||||
string="Модель для подсказки"
|
||||
context="{'from_docx_template': True}"
|
||||
options="{'no_create': True, 'no_quick_create': True}"/>
|
||||
</group>
|
||||
|
||||
|
||||
|
||||
<field name="available_field_ids" readonly="1">
|
||||
<list>
|
||||
<field name="name"
|
||||
string="Техническое имя"/>
|
||||
<field name="field_description"
|
||||
string="Название поля"/>
|
||||
<field name="docx_type_label"
|
||||
string="Тип поля"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_contract_docx_template" model="ir.actions.act_window">
|
||||
<field name="name">Шаблоны</field>
|
||||
<field name="res_model">docx.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Серверное действие "Добавить все в печать" -->
|
||||
<record id="action_bind_all_docx_to_actions" model="ir.actions.server">
|
||||
<field name="name">Добавить все шаблоны в печать</field>
|
||||
<field name="model_id" ref="docx_report.model_docx_template"/>
|
||||
<field name="binding_model_id" ref="docx_report.model_docx_template"/>
|
||||
<field name="binding_type">action</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = model.action_bind_all_to_actions()</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_contract_docx_template"
|
||||
name="Шаблоны для отчетов"
|
||||
action="action_contract_docx_template"
|
||||
sequence="50"/>
|
||||
</data>
|
||||
</odoo>
|
||||