seed_documents.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. """显式、幂等的B4开发文档、附件及挂载关系初始化。
  2. 执行:python -m dms.seed_documents
  3. 只创建缺失的约定演示数据,不覆盖有效同名文档,不恢复逻辑删除数据。
  4. """
  5. from __future__ import annotations
  6. import hashlib
  7. import mimetypes
  8. import uuid
  9. from pathlib import Path
  10. from docx import Document as WordDocument
  11. from flask import Flask, current_app
  12. from sqlalchemy import func, select
  13. from dms import init_dms
  14. from dms.common.enums import (
  15. AttachmentType,
  16. DocumentStatus,
  17. DocumentType,
  18. SecurityLevel,
  19. SubjectType,
  20. VisibilityType,
  21. )
  22. from dms.extensions import db
  23. from dms.models import (
  24. AttachmentBinding,
  25. Category,
  26. Document,
  27. Organization,
  28. Permission,
  29. User,
  30. )
  31. from dms.storage.paths import ensure_storage_directories
  32. DOCX_MIME = (
  33. "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  34. )
  35. def _required(model, *criteria, description: str):
  36. value = db.session.scalar(select(model).where(*criteria))
  37. if value is None:
  38. raise RuntimeError(f"缺少{description},请先执行B2和B3初始化工具")
  39. return value
  40. def _create_docx(
  41. root: Path,
  42. *,
  43. title: str,
  44. paragraphs: list[str],
  45. ) -> tuple[str, int, str, Path]:
  46. relative_path = f"original/{uuid.uuid4()}.docx"
  47. target = root / Path(relative_path)
  48. temporary = root / "temporary" / f"{uuid.uuid4()}.docx"
  49. try:
  50. word = WordDocument()
  51. word.add_heading(title, level=1)
  52. for paragraph in paragraphs:
  53. word.add_paragraph(paragraph)
  54. word.save(temporary)
  55. # 必须能被python-docx重新打开,之后才进入最终目录。
  56. verified = WordDocument(temporary)
  57. if not verified.paragraphs:
  58. raise RuntimeError(f"生成的DOCX无法通过内容复验:{title}")
  59. target.parent.mkdir(parents=True, exist_ok=True)
  60. temporary.replace(target)
  61. content = target.read_bytes()
  62. return (
  63. relative_path,
  64. len(content),
  65. hashlib.sha256(content).hexdigest(),
  66. target,
  67. )
  68. except Exception:
  69. temporary.unlink(missing_ok=True)
  70. target.unlink(missing_ok=True)
  71. raise
  72. def _document(
  73. *,
  74. root: Path,
  75. name: str,
  76. summary: str,
  77. document_type: DocumentType,
  78. status: DocumentStatus,
  79. security: SecurityLevel,
  80. visibility: VisibilityType,
  81. category: Category | None,
  82. actor: User,
  83. tags: list[str],
  84. new_files: list[Path],
  85. parent: Document | None = None,
  86. root_document: Document | None = None,
  87. attachment_type: AttachmentType | None = None,
  88. ) -> tuple[Document | None, bool, bool]:
  89. existing = db.session.scalar(
  90. select(Document).where(Document.document_name == name)
  91. )
  92. if existing is not None:
  93. if existing.is_deleted:
  94. return None, False, True
  95. if existing.document_type != document_type.value:
  96. raise RuntimeError(f"同名有效文档类型冲突:{name}")
  97. return existing, False, False
  98. relative_path, file_size, file_hash, target = _create_docx(
  99. root,
  100. title=name,
  101. paragraphs=[summary, "此文件由本地开发数据初始化工具生成。"],
  102. )
  103. new_files.append(target)
  104. document = Document(
  105. document_name=name,
  106. summary=summary,
  107. document_type=document_type.value,
  108. document_status=status.value,
  109. security_level=security.value,
  110. visibility_type=visibility.value,
  111. attachment_type=attachment_type.value if attachment_type else None,
  112. category_id=category.id if category else None,
  113. category_name=category.category_name if category else None,
  114. category_path=category.category_path if category else None,
  115. parent_document_id=parent.id if parent else None,
  116. root_document_id=root_document.id if root_document else None,
  117. tags=tags,
  118. original_file_name=f"{name}.docx",
  119. file_relative_path=relative_path,
  120. file_extension="docx",
  121. mime_type=mimetypes.guess_type(f"{name}.docx")[0] or DOCX_MIME,
  122. file_size=file_size,
  123. file_hash=file_hash,
  124. search_text=" ".join([name, summary, *tags]),
  125. created_by=actor.id,
  126. updated_by=actor.id,
  127. created_by_name=actor.real_name,
  128. updated_by_name=actor.real_name,
  129. )
  130. db.session.add(document)
  131. db.session.flush()
  132. return document, True, False
  133. def _permission(
  134. document: Document,
  135. *,
  136. subject_type: SubjectType,
  137. subject_id: int,
  138. subject_name: str,
  139. actor_id: int,
  140. ) -> None:
  141. existing = db.session.scalar(
  142. select(Permission).where(
  143. Permission.document_id == document.id,
  144. Permission.subject_type == subject_type.value,
  145. Permission.subject_id == subject_id,
  146. )
  147. )
  148. if existing is not None:
  149. return
  150. db.session.add(
  151. Permission(
  152. document_id=document.id,
  153. subject_type=subject_type.value,
  154. subject_id=subject_id,
  155. subject_name=subject_name,
  156. can_view=True,
  157. can_download=True,
  158. can_edit=False,
  159. can_manage_permission=False,
  160. can_delete=False,
  161. created_by=actor_id,
  162. updated_by=actor_id,
  163. )
  164. )
  165. def _binding(
  166. main: Document,
  167. attachment: Document,
  168. *,
  169. sort_no: int,
  170. actor_id: int,
  171. ) -> None:
  172. existing = db.session.scalar(
  173. select(AttachmentBinding).where(
  174. AttachmentBinding.main_document_id == main.id,
  175. AttachmentBinding.attachment_document_id == attachment.id,
  176. )
  177. )
  178. if existing is not None:
  179. return
  180. db.session.add(
  181. AttachmentBinding(
  182. main_document_id=main.id,
  183. attachment_document_id=attachment.id,
  184. sort_no=sort_no,
  185. created_by=actor_id,
  186. updated_by=actor_id,
  187. )
  188. )
  189. def seed_documents(
  190. storage_root: str | Path | None = None,
  191. ) -> tuple[int, int, int]:
  192. root = ensure_storage_directories(
  193. storage_root or current_app.config["DMS_STORAGE_ROOT"]
  194. )
  195. actor = _required(
  196. User,
  197. User.username == "admin",
  198. User.is_deleted.is_(False),
  199. description="有效admin用户",
  200. )
  201. ordinary_user = _required(
  202. User,
  203. User.username == "user",
  204. User.is_deleted.is_(False),
  205. description="有效user用户",
  206. )
  207. ops = _required(
  208. Organization,
  209. Organization.org_code == "ORG_OPS",
  210. Organization.is_deleted.is_(False),
  211. description="ORG_OPS组织",
  212. )
  213. categories = {
  214. code: _required(
  215. Category,
  216. Category.category_code == code,
  217. Category.is_deleted.is_(False),
  218. Category.status == "ENABLED",
  219. description=f"{code}分类",
  220. )
  221. for code in ("STYLE_A1", "SCENE_B", "SCENE_C")
  222. }
  223. created_count = 0
  224. existing_count = 0
  225. failed_count = 0
  226. new_files: list[Path] = []
  227. try:
  228. mains: list[Document] = []
  229. for spec in (
  230. (
  231. "2024年度综合应急预案",
  232. "年度综合应急响应和资源协调方案。",
  233. "STYLE_A1",
  234. SecurityLevel.INTERNAL,
  235. VisibilityType.ALL_AUTHENTICATED,
  236. ["应急", "年度"],
  237. ),
  238. (
  239. "战略物资管理规程",
  240. "战略物资储备、调拨和保障管理规程。",
  241. "SCENE_B",
  242. SecurityLevel.SECRET,
  243. VisibilityType.ORGANIZATION,
  244. ["物资", "保障"],
  245. ),
  246. (
  247. "通信保障方案",
  248. "重要任务通信组织和应急保障方案。",
  249. "SCENE_C",
  250. SecurityLevel.SECRET,
  251. VisibilityType.CUSTOM,
  252. ["通信", "保障"],
  253. ),
  254. ):
  255. document, created, failed = _document(
  256. root=root,
  257. name=spec[0],
  258. summary=spec[1],
  259. document_type=DocumentType.MAIN,
  260. status=DocumentStatus.PUBLISHED,
  261. security=spec[3],
  262. visibility=spec[4],
  263. category=categories[spec[2]],
  264. actor=actor,
  265. tags=spec[5],
  266. new_files=new_files,
  267. )
  268. created_count += int(created)
  269. existing_count += int(not created and not failed)
  270. failed_count += int(failed)
  271. if document is None:
  272. raise RuntimeError(f"同名文档已逻辑删除,拒绝恢复:{spec[0]}")
  273. mains.append(document)
  274. for name, summary in (
  275. ("人员组织子案", "明确人员编组、岗位职责和协同关系。"),
  276. ("资源组织子案", "明确资源清单、配置方式和调用机制。"),
  277. ("重点任务子案", "明确重点任务分解、时序和保障要求。"),
  278. ):
  279. document, created, failed = _document(
  280. root=root,
  281. name=name,
  282. summary=summary,
  283. document_type=DocumentType.SUB_PLAN,
  284. status=DocumentStatus.PUBLISHED,
  285. security=SecurityLevel.INTERNAL,
  286. visibility=VisibilityType.ALL_AUTHENTICATED,
  287. category=categories["STYLE_A1"],
  288. actor=actor,
  289. tags=["子方案"],
  290. new_files=new_files,
  291. parent=mains[0],
  292. root_document=mains[0],
  293. )
  294. created_count += int(created)
  295. existing_count += int(not created and not failed)
  296. failed_count += int(failed)
  297. if document is None:
  298. raise RuntimeError(f"同名文档已逻辑删除,拒绝恢复:{name}")
  299. attachments: list[Document] = []
  300. for name, summary, attachment_type in (
  301. (
  302. "装备保障工作规范",
  303. "装备保障作业和协同工作规范。",
  304. AttachmentType.WORK_STANDARD,
  305. ),
  306. (
  307. "应急资源清单模板",
  308. "应急资源登记与汇总表模板。",
  309. AttachmentType.TABLE,
  310. ),
  311. (
  312. "通信保障流程图",
  313. "通信保障处置流程和协同关系图。",
  314. AttachmentType.DIAGRAM,
  315. ),
  316. ):
  317. document, created, failed = _document(
  318. root=root,
  319. name=name,
  320. summary=summary,
  321. document_type=DocumentType.ATTACHMENT,
  322. status=DocumentStatus.PUBLISHED,
  323. security=SecurityLevel.PUBLIC,
  324. visibility=VisibilityType.ALL_AUTHENTICATED,
  325. category=None,
  326. actor=actor,
  327. tags=["共享附件"],
  328. new_files=new_files,
  329. attachment_type=attachment_type,
  330. )
  331. created_count += int(created)
  332. existing_count += int(not created and not failed)
  333. failed_count += int(failed)
  334. if document is None:
  335. raise RuntimeError(f"同名附件已逻辑删除,拒绝恢复:{name}")
  336. attachments.append(document)
  337. _permission(
  338. mains[1],
  339. subject_type=SubjectType.ORG,
  340. subject_id=ops.id,
  341. subject_name=ops.org_name,
  342. actor_id=actor.id,
  343. )
  344. _permission(
  345. mains[2],
  346. subject_type=SubjectType.USER,
  347. subject_id=ordinary_user.id,
  348. subject_name=ordinary_user.real_name,
  349. actor_id=actor.id,
  350. )
  351. _binding(mains[0], attachments[0], sort_no=10, actor_id=actor.id)
  352. _binding(mains[0], attachments[1], sort_no=20, actor_id=actor.id)
  353. _binding(mains[1], attachments[0], sort_no=10, actor_id=actor.id)
  354. db.session.flush()
  355. for main in mains:
  356. main.child_count = (
  357. db.session.scalar(
  358. select(func.count(Document.id)).where(
  359. Document.parent_document_id == main.id,
  360. Document.document_type == DocumentType.SUB_PLAN.value,
  361. Document.is_deleted.is_(False),
  362. )
  363. )
  364. or 0
  365. )
  366. main.attachment_count = (
  367. db.session.scalar(
  368. select(func.count(AttachmentBinding.id)).where(
  369. AttachmentBinding.main_document_id == main.id,
  370. AttachmentBinding.is_deleted.is_(False),
  371. )
  372. )
  373. or 0
  374. )
  375. for category in categories.values():
  376. category.document_count = (
  377. db.session.scalar(
  378. select(func.count(Document.id)).where(
  379. Document.category_id == category.id,
  380. Document.document_type.in_(
  381. [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
  382. ),
  383. Document.is_deleted.is_(False),
  384. )
  385. )
  386. or 0
  387. )
  388. db.session.commit()
  389. except Exception:
  390. db.session.rollback()
  391. for path in new_files:
  392. path.unlink(missing_ok=True)
  393. raise
  394. return created_count, existing_count, failed_count
  395. def main() -> None:
  396. app = Flask("dms-seed-documents")
  397. init_dms(app)
  398. with app.app_context():
  399. try:
  400. created, existing, failed = seed_documents()
  401. except Exception:
  402. db.session.rollback()
  403. print("文档初始化失败:新增0个,已存在0个,失败1个。")
  404. raise
  405. print(
  406. f"文档初始化完成:新增{created}个,已存在{existing}个,失败{failed}个。"
  407. )
  408. if __name__ == "__main__":
  409. main()