seed_documents.py 13 KB

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