Files
yuthon_base/yuthon_brain/models/brain_source.py
T
2026-09-23 18:06:45 +08:00

359 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
import json
import logging
import os
import re
from odoo import _, models, fields, api
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval
try: # Odoo 18 推荐用 tools.misc.file_path(get_module_resource 已弃用)
from odoo.tools.misc import file_path
except ImportError: # pragma: no cover
file_path = None
_logger = logging.getLogger(__name__)
VIEW_FILE = 'yuthon_brain/static/src/brain_view.html'
_LI_RE = re.compile(r'<li[^>]*>(.*?)</li>', re.S | re.I)
_STRONG_RE = re.compile(r'<strong[^>]*>(.*?)</strong>', re.S | re.I)
_TAG_RE = re.compile(r'<[^>]+>')
_ENTITIES = (
('&nbsp;', ' '), ('&amp;', '&'), ('&lt;', '<'),
('&gt;', '>'), ('&quot;', '"'), ('&#39;', "'"),
)
def _plain(text):
"""去掉 HTML 标签与常见实体,压平空白并剥掉首尾标点。"""
s = _TAG_RE.sub('', text or '')
for ent, ch in _ENTITIES:
s = s.replace(ent, ch)
return ' '.join(s.split()).strip(' \u3000::;;,,。')
def parse_feature_html(html):
"""把 features 这类 HTML 列表解析成 [(标题, 说明), ...]。
兼容三种写法(shcool 用的是第 1、3 种):
<li><strong>师生管理</strong>:说明文字</li> → ('师生管理', '说明文字')
<li>预置<strong>三组</strong>权限</li> → ('预置三组权限', '') strong 不在行首
<li>整句说明</li> → ('整句说明', '')
"""
items = []
seen = set()
for raw in _LI_RE.findall(html or ''):
m = _STRONG_RE.search(raw)
if m and not _plain(raw[:m.start()]):
# strong 在行首:标题取 strong 内容,其余作为说明
title = _plain(m.group(1))
desc = _plain(raw[m.end():])
else:
# 无 strong,或 strong 在句中(此时整句才是语义完整的标题)
title, desc = _plain(raw), ''
if not title or title in seen:
continue
seen.add(title)
items.append((title, desc))
return items
class BrainSource(models.Model):
"""脑图数据源:一个 source 对应一张可查看的脑图。
节点数据有两种来源,互不冲突:
1) 数据继承:子模块在自己的 data/*.xml 里用 parent_id ref 挂接(推荐,零代码);
2) ORM 同步:本模型的 action_sync() 在运行时读取某个业务模型(如 shcool 的
yuthon.about.page)记录,按分组字段生成节点,用于「不改动对方模块」的测试/导入。
"""
_name = 'yuthon.brain.source'
_description = '脑图数据源'
_order = 'sequence, id'
name = fields.Char(string='名称', required=True)
key = fields.Char(
string='路由标识',
required=True,
help='前端接口路由用,如 product / org。需唯一且只含字母数字下划线。',
)
root_id = fields.Many2one('yuthon.brain.node', string='根节点', ondelete='set null')
layout = fields.Selection(
[('radial', '放射状'), ('tree', '树状')],
string='布局', default='radial',
)
theme = fields.Selection(
[('dark', '深色'), ('light', '浅色')],
string='主题', default='light',
)
max_levels = fields.Integer(string='最大展开层级', default=3)
active = fields.Boolean(string='启用', default=True)
sequence = fields.Integer(default=10)
# ---- ORM 同步配置(可选;用于从外部模块读取数据,不改对方代码)----
sync_mode = fields.Selection(
[('record', '按记录分组'), ('feature', '解析功能亮点')],
string='同步方式', default='record', required=True,
help='按记录分组:每条记录生成一个节点;'
'解析功能亮点:把每条记录的 HTML 详情字段(如模块的「功能亮点」)'
'拆成若干功能点,作为该记录节点的子节点。',
)
sync_model = fields.Char(
string='同步模型',
help='目标业务模型技术名,如 yuthon.about.page、ir.module.module。留空则不使用 ORM 同步。',
)
sync_name_field = fields.Char(
string='名称字段', default='name',
help='作为节点显示文字的字段,默认 name;读模块时可用 shortdesc(中文显示名)。',
)
sync_group_field = fields.Char(
string='分组字段',
help='仅在「按记录分组」模式生效:按该字段值分组生成父节点(如 category)。留空则全部直接挂在根下。',
)
sync_detail_field = fields.Char(
string='详情字段',
help='仅在「解析功能亮点」模式生效:要拆解的 HTML 字段技术名,如 features。',
)
sync_domain = fields.Char(
string='过滤条件', default='[]',
help='Odoo domain 字符串,如 [(\'active\',\'=\',True)]。',
)
node_ids = fields.One2many('yuthon.brain.node', 'source_id', string='节点')
edge_ids = fields.One2many('yuthon.brain.edge', 'source_id', string='连线')
_sql_constraints = [
('uniq_key', 'unique(key)', '路由标识(key)必须唯一'),
]
def action_sync(self):
"""从 sync_model 读取记录生成脑图节点(标记 auto,便于重同步清理)。
不改任何外部模块代码,仅在 yuthon_brain 内创建节点。
"""
self.ensure_one()
if not self.sync_model:
raise UserError(_('请先设置「同步模型」'))
if self.sync_model not in self.env:
raise UserError(_(
'目标模型 %s 未安装,无法同步(请确认对应模块已安装)'
) % self.sync_model)
Model = self.env[self.sync_model]
try:
domain = safe_eval(self.sync_domain or '[]')
except Exception:
domain = []
recs = Model.search(domain)
# 清掉上次自动同步的节点,保留手动维护的
self.node_ids.filtered(lambda n: n.auto).unlink()
root = self.root_id
if not root:
root = self.env['yuthon.brain.node'].create({
'source_id': self.id,
'label': self.name,
'rel_type': 'child',
'color': '#0ea5e9',
'auto': True,
})
self.root_id = root
name_field = self.sync_name_field or 'name'
group_field = self.sync_group_field
if self.sync_mode == 'feature':
created, skipped = self._sync_features(Model, recs, root, name_field)
return self._done(created, recs, skipped)
created = 0
if group_field and group_field in Model._fields:
f = Model._fields[group_field]
selection = dict(f.selection) if f.type == 'selection' else {}
groups = {}
for r in recs:
gv = r[group_field]
groups.setdefault(gv, []).append(r)
for gv, grp in groups.items():
glabel = selection.get(gv, gv) if gv else '未分类'
gnode = self.env['yuthon.brain.node'].create({
'source_id': self.id,
'parent_id': root.id,
'label': glabel,
'rel_type': 'child',
'color': '#2563eb',
'auto': True,
'extra': json.dumps({'group': True, 'value': gv},
ensure_ascii=False),
})
for r in grp:
self.env['yuthon.brain.node'].create({
'source_id': self.id,
'parent_id': gnode.id,
'label': r[name_field] or ('#%d' % r.id),
'rel_type': 'child',
'color': '#1E6FFF',
'auto': True,
'extra': json.dumps(
{'model': self.sync_model, 'res_id': r.id},
ensure_ascii=False),
})
created += 1
else:
for r in recs:
self.env['yuthon.brain.node'].create({
'source_id': self.id,
'parent_id': root.id,
'label': r[name_field] or ('#%d' % r.id),
'rel_type': 'child',
'color': '#1E6FFF',
'auto': True,
'extra': json.dumps(
{'model': self.sync_model, 'res_id': r.id},
ensure_ascii=False),
})
created += 1
return self._done(created, recs)
@api.model
def action_sync_all(self):
"""安装/升级时自动同步所有已配置 ORM 的数据源,让脑图开箱即有内容。
单个数据源失败(例如目标模块未安装)只记日志、不阻断安装。
"""
for src in self.search([('sync_model', '!=', False)]):
try:
src.action_sync()
_logger.info('yuthon_brain: 数据源 %s 自动同步完成', src.key)
except Exception:
_logger.warning(
'yuthon_brain: 数据源 %s 自动同步失败(已跳过)', src.key,
exc_info=True)
return True
def _done(self, created, recs, skipped=None):
"""统一的同步结果提示。"""
msg = _('已同步 %(rec)d 条记录、生成 %(n)d 个节点') % {
'rec': len(recs), 'n': created,
}
if skipped:
msg += _(';%d 条记录无功能亮点已跳过') % len(skipped)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'message': msg,
'type': 'success',
'sticky': False,
},
}
def _sync_features(self, Model, recs, root, name_field):
"""解析功能亮点模式:每条记录 → 一个父节点,其详情 HTML 的每个 <li> → 一个子节点。
用于把 ir.module.module 的「功能亮点」这类富文本清单摊平成脑图分支,
全程只读外部模型,不修改对方任何代码或数据。
"""
detail_field = self.sync_detail_field
if not detail_field:
raise UserError(_('「解析功能亮点」模式需要先设置「详情字段」,例如 features'))
if detail_field not in Model._fields:
raise UserError(_(
'模型 %s 上不存在字段 %s,请检查「详情字段」设置'
) % (self.sync_model, detail_field))
Node = self.env['yuthon.brain.node']
created = 0
skipped = []
for r in recs:
items = parse_feature_html(r[detail_field])
if not items:
skipped.append(r[name_field] or r.display_name)
continue
label = (r[name_field] if name_field in Model._fields else '') \
or r.display_name or ('#%d' % r.id)
rec_node = Node.create({
'source_id': self.id,
'parent_id': root.id,
'label': label,
'rel_type': 'child',
'color': '#2563eb',
'auto': True,
'extra': json.dumps(
{'model': self.sync_model, 'res_id': r.id,
'count': len(items)},
ensure_ascii=False),
})
created += 1
for title, desc in items:
Node.create({
'source_id': self.id,
'parent_id': rec_node.id,
'label': title,
'rel_type': 'child',
'color': '#1E6FFF',
'auto': True,
'extra': json.dumps(
{'model': self.sync_model, 'res_id': r.id, 'desc': desc},
ensure_ascii=False),
})
created += 1
return created, skipped
# ---- 脑图可视化嵌入后端(客户端动作)----
def view_asset_version(self):
"""前端静态页的版本号(取文件 mtime)。
brain_view.html 的响应头是 Cache-Control: max-age=604800(7 天),
浏览器可能一直用旧副本。拿它拼 ?v=... 可打破缓存。
"""
if not file_path:
return 0
try:
return int(os.path.getmtime(file_path(VIEW_FILE, filter_ext=('.html',))))
except (OSError, ValueError):
return 0
@api.model
def refresh_visual_action_params(self):
"""升级模块时把最新版本号写进 client action 的 params(供菜单入口用)。
数据 XML 里用 <function> 调用,故不受 noupdate 影响,每次升级都会刷新。
"""
action = self.env.ref('yuthon_brain.yuthon_brain_action_visual',
raise_if_not_found=False)
if action:
action.params = {'v': self.view_asset_version()}
def action_open_visual(self):
"""后台表单按钮:在 Odoo 内部打开该数据源的脑图(不再跳走)。
返回客户端动作 yuthon_brain.visual,由前端 OWL 组件用 iframe 承载
brain_view.html 并撑满内容区;顶部面包屑可返回原表单。
前端按 ?key=<路由标识> 读取对应数据源。
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'yuthon_brain.visual',
'name': self.name,
'params': {'key': self.key, 'v': self.view_asset_version()},
'target': 'current',
'context': dict(self.env.context, active_id=self.id,
active_model=self._name),
}
def action_open_visual_url(self):
"""后台表单按钮:在新标签打开独立脑图页(多屏对照时用)。"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': '/yuthon_brain/static/src/brain_view.html?key=%s' % self.key,
'target': 'new',
}