298 lines
12 KiB
Python
298 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
import json
|
||
import logging
|
||
import re
|
||
|
||
from odoo import _, models, fields, api
|
||
from odoo.exceptions import UserError
|
||
from odoo.tools.safe_eval import safe_eval
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
_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 = (
|
||
(' ', ' '), ('&', '&'), ('<', '<'),
|
||
('>', '>'), ('"', '"'), (''', "'"),
|
||
)
|
||
|
||
|
||
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
|