Public release from ruodoo-project: 19.0 - 2026-05-10 21:19:01 UTC
This commit is contained in:
6
docx_report/models/__init__.py
Normal file
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
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)",
|
||||
"Техническое имя кастомной переменной должно быть уникально в рамках одного отчёта.",
|
||||
)
|
||||
]
|
||||
325
docx_report/models/docx_template.py
Normal file
325
docx_report/models/docx_template.py
Normal 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,
|
||||
},
|
||||
}
|
||||
13
docx_report/models/docx_template_mixin.py
Normal file
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-шаблон, используемый по умолчанию для данного объекта.",
|
||||
)
|
||||
364
docx_report/models/ir_actions_report.py
Normal file
364
docx_report/models/ir_actions_report.py
Normal 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)
|
||||
13
docx_report/models/ir_model.py
Normal file
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
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
|
||||
Reference in New Issue
Block a user