326 lines
11 KiB
Python
326 lines
11 KiB
Python
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,
|
||
},
|
||
}
|