Public release from ruodoo-project: 19.0 - 2026-05-31 21:19:12 UTC

This commit is contained in:
CI Publish Bot
2026-05-31 21:19:21 +00:00
commit aa4214c195
1213 changed files with 183945 additions and 0 deletions

2
docx_report/__init__.py Normal file
View File

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

View File

@ -0,0 +1,26 @@
{
"name": "DOCX Templates",
"summary": "DOCX-шаблоны",
"version": "19.0.1.0.0",
"license": "LGPL-3",
"category": "Accounting/Localizations",
"depends": [
"base",
],
"data": [
"security/ir.model.access.csv",
"views/docx_template_views.xml",
"views/docx_report.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,
}

View File

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

View File

@ -0,0 +1,219 @@
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_content = report.with_context(context)._render_docx_docx(
docids_list, data=payload
)
headers = [
(
"Content-Type",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
),
]
return request.make_response(docx_content, 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, token, context=None):
"""
Обрабатывает запрос на скачивание файла отчета.
Расширен для:
- 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
)
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)))

18
docx_report/demo/demo.xml Normal file
View 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>

View 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

View 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)",
"Техническое имя кастомной переменной должно быть уникально в рамках одного отчёта.",
)
]

View File

@ -0,0 +1,325 @@
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_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,
},
}

View 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-шаблон, используемый по умолчанию для данного объекта.",
)

View File

@ -0,0 +1,364 @@
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)
# --------- / КАСТОМНЫЕ ПЕРЕМЕННЫЕ ---------
# Преобразование Html/Markup полей в plain text для docs.*
record_to_render = values["record"]
docs = {
key: record_to_render[key]
for key in record_to_render._fields.keys()
if not isinstance(record_to_render[key], fields.Markup)
}
docs.update(
{
key: self._parse_markup(record_to_render[key])
for key in record_to_render._fields.keys()
if isinstance(record_to_render[key], fields.Markup)
}
)
values["docs"] = docs
# Собственно рендер 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__())
data_arr = list(soup.strings)
return "\n".join(data_arr)

View 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

View 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

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_docx_template_user docx.template user model_docx_template base.group_user 1 0 0 0
3 access_docx_template_manager docx.template manager model_docx_template base.group_system 1 1 1 1
4 access_docx_custom_field_admin access_docx_custom_field_admin model_docx_custom_field base.group_system 1 1 1 1

View 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);

View 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;
},
});

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_docx

View 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()",
)

View 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>

View File

@ -0,0 +1,111 @@
<?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"/>
</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>
<menuitem id="menu_contract_docx_template"
name="Шаблоны для отчетов"
action="action_contract_docx_template"
sequence="50"/>
</data>
</odoo>