documents.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. """主案、子方案和共享附件只读 HTTP 接口。"""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. from flask import current_app, request
  6. from dms.api.v1.blueprint import api_v1
  7. from dms.common.errors import (
  8. BatchManifestMismatchError,
  9. DmsError,
  10. InvalidArgumentError,
  11. PayloadTooLargeError,
  12. )
  13. from dms.common.response import success_response
  14. from dms.extensions import db
  15. from dms.security.decorators import admin_required, bearer_auth_required
  16. from dms.services.attachment_query_service import (
  17. get_attachment,
  18. list_attachment_main_plans,
  19. list_attachments,
  20. list_main_plan_attachments,
  21. )
  22. from dms.services.attachment_binding_service import (
  23. bind_attachments,
  24. reorder_main_plan_attachments,
  25. unbind_attachment,
  26. )
  27. from dms.services.document_query_service import (
  28. get_document,
  29. list_documents,
  30. list_main_plan_tags,
  31. list_sub_plans,
  32. parse_string_id,
  33. )
  34. from dms.services.document_relation_service import (
  35. list_document_relations,
  36. save_document_relations,
  37. )
  38. from dms.services.document_mutation_service import (
  39. create_attachment,
  40. create_document,
  41. delete_attachment,
  42. delete_document,
  43. reorder_documents,
  44. update_attachment,
  45. update_document,
  46. update_document_description,
  47. )
  48. from dms.services.document_password_service import save_document_password
  49. from dms.services.file_read_service import download_document, preview_document
  50. from dms.services.permission_service import get_permissions, save_permissions
  51. logger = logging.getLogger(__name__)
  52. def _metadata() -> dict:
  53. if set(request.files) != {"file"} or set(request.form) != {"metadata"}:
  54. raise InvalidArgumentError("请求必须且只能包含file和metadata")
  55. try:
  56. payload = json.loads(request.form["metadata"])
  57. except (json.JSONDecodeError, TypeError) as exc:
  58. raise InvalidArgumentError("metadata必须是JSON对象字符串") from exc
  59. if not isinstance(payload, dict):
  60. raise InvalidArgumentError("metadata必须是JSON对象字符串")
  61. return payload
  62. def _json_body() -> dict:
  63. if not request.is_json:
  64. raise InvalidArgumentError("请求体必须是JSON对象")
  65. payload = request.get_json(silent=True)
  66. if not isinstance(payload, dict):
  67. raise InvalidArgumentError("请求体必须是JSON对象")
  68. return payload
  69. def _row_version() -> int:
  70. value = request.args.get("rowVersion")
  71. if value is None or not value.isdecimal():
  72. raise InvalidArgumentError("rowVersion必须是非负整数")
  73. return int(value)
  74. def _main_plan_row_version() -> int:
  75. if set(request.args) != {"mainPlanRowVersion"}:
  76. raise InvalidArgumentError(
  77. "查询参数必须且只能包含mainPlanRowVersion"
  78. )
  79. value = request.args.get("mainPlanRowVersion")
  80. if value is None or not value.isdecimal():
  81. raise InvalidArgumentError("mainPlanRowVersion必须是非负整数")
  82. return int(value)
  83. def _batch(*, attachment: bool):
  84. if set(request.files) != {"files"} or set(request.form) != {"items"}:
  85. raise InvalidArgumentError("批量请求必须且只能包含files和items")
  86. files = request.files.getlist("files")
  87. try:
  88. items = json.loads(request.form["items"])
  89. except (json.JSONDecodeError, TypeError) as exc:
  90. raise InvalidArgumentError("items必须是JSON数组字符串") from exc
  91. if not isinstance(items, list):
  92. raise InvalidArgumentError("items必须是JSON数组字符串")
  93. if len(files) != len(items):
  94. raise BatchManifestMismatchError()
  95. if not files:
  96. raise InvalidArgumentError("批量文件不能为空")
  97. if len(files) > current_app.config["DMS_BATCH_MAX_FILES"]:
  98. raise PayloadTooLargeError("批量文件数量超过允许上限")
  99. results = []
  100. success_count = 0
  101. for index, (file, payload) in enumerate(zip(files, items, strict=True)):
  102. original_name = file.filename or ""
  103. try:
  104. document = (
  105. create_attachment(file, payload, batch=True)
  106. if attachment
  107. else create_document(file, payload, batch=True)
  108. )
  109. results.append(
  110. {
  111. "index": index,
  112. "originalFileName": original_name,
  113. "success": True,
  114. "document": document,
  115. }
  116. )
  117. success_count += 1
  118. except DmsError as exc:
  119. db.session.rollback()
  120. results.append(
  121. {
  122. "index": index,
  123. "originalFileName": original_name,
  124. "success": False,
  125. "errorCode": exc.code,
  126. "errorMessage": exc.message,
  127. }
  128. )
  129. except Exception:
  130. db.session.rollback()
  131. logger.exception("批量导入单项发生未处理异常:index=%s", index)
  132. results.append(
  133. {
  134. "index": index,
  135. "originalFileName": original_name,
  136. "success": False,
  137. "errorCode": "INTERNAL_ERROR",
  138. "errorMessage": "服务器内部错误",
  139. }
  140. )
  141. return {
  142. "total": len(files),
  143. "successCount": success_count,
  144. "failureCount": len(files) - success_count,
  145. "items": results,
  146. }
  147. @api_v1.get("/documents")
  148. @bearer_auth_required
  149. def documents_list_route():
  150. return success_response(list_documents(request.args))
  151. @api_v1.get("/documents/tags")
  152. @bearer_auth_required
  153. def documents_tags_route():
  154. """聚合当前用户可见的主案标签,供前端左侧标签筛选条使用。"""
  155. return success_response(list_main_plan_tags())
  156. @api_v1.get("/documents/relation-presets")
  157. @bearer_auth_required
  158. def document_relation_presets_route():
  159. """方案关联关系类型预置清单(启动期加载,编辑弹窗下拉用)。"""
  160. presets = current_app.extensions["dms_relation_presets"]
  161. response, status = success_response(
  162. {"presets": [preset.to_dict() for preset in presets]}
  163. )
  164. response.headers["Cache-Control"] = "private, no-store"
  165. return response, status
  166. @api_v1.get("/documents/<id>/relations")
  167. @bearer_auth_required
  168. def document_relations_route(id: str):
  169. """查看方案声明的关联清单(与文档详情同套访问控制)。"""
  170. return success_response(list_document_relations(parse_string_id(id)))
  171. @api_v1.put("/documents/<id>/relations")
  172. @bearer_auth_required
  173. @admin_required
  174. def save_document_relations_route(id: str):
  175. """整体替换方案的关联清单(管理员;设密主案需通过EDIT密码锁)。"""
  176. return success_response(
  177. save_document_relations(parse_string_id(id), _json_body())
  178. )
  179. @api_v1.put("/documents/reorder")
  180. @bearer_auth_required
  181. @admin_required
  182. def documents_reorder_route():
  183. """管理员拖动重排主案/子方案(主案全局排序,子方案在所属主案内排序)。"""
  184. return success_response(reorder_documents(_json_body()))
  185. @api_v1.post("/documents")
  186. @bearer_auth_required
  187. @admin_required
  188. def create_document_route():
  189. payload = _metadata()
  190. return success_response(
  191. create_document(request.files["file"], payload),
  192. status=201,
  193. )
  194. @api_v1.post("/documents/batch-import")
  195. @bearer_auth_required
  196. @admin_required
  197. def batch_import_documents_route():
  198. return success_response(_batch(attachment=False))
  199. @api_v1.get("/documents/<id>")
  200. @bearer_auth_required
  201. def document_detail_route(id: str):
  202. return success_response(get_document(parse_string_id(id)))
  203. @api_v1.put("/documents/<id>")
  204. @bearer_auth_required
  205. @admin_required
  206. def update_document_route(id: str):
  207. return success_response(
  208. update_document(parse_string_id(id), _json_body())
  209. )
  210. @api_v1.put("/documents/<id>/description")
  211. @bearer_auth_required
  212. @admin_required
  213. def update_document_description_route(id: str):
  214. """维护文档详细说明("查看"对话框右侧备注区)。"""
  215. return success_response(
  216. update_document_description(parse_string_id(id), _json_body())
  217. )
  218. @api_v1.delete("/documents/<id>")
  219. @bearer_auth_required
  220. @admin_required
  221. def delete_document_route(id: str):
  222. return success_response(
  223. delete_document(parse_string_id(id), _row_version())
  224. )
  225. @api_v1.get("/documents/<id>/preview")
  226. @bearer_auth_required
  227. def preview_document_route(id: str):
  228. return preview_document(parse_string_id(id))
  229. @api_v1.get("/documents/<id>/download")
  230. @bearer_auth_required
  231. def download_document_route(id: str):
  232. return download_document(parse_string_id(id))
  233. @api_v1.get("/main-plans/<id>/sub-plans")
  234. @bearer_auth_required
  235. def sub_plans_list_route(id: str):
  236. return success_response(list_sub_plans(parse_string_id(id), request.args))
  237. @api_v1.get("/attachments")
  238. @bearer_auth_required
  239. @admin_required
  240. def attachments_list_route():
  241. return success_response(list_attachments(request.args))
  242. @api_v1.post("/attachments")
  243. @bearer_auth_required
  244. @admin_required
  245. def create_attachment_route():
  246. payload = _metadata()
  247. return success_response(
  248. create_attachment(request.files["file"], payload),
  249. status=201,
  250. )
  251. @api_v1.post("/attachments/batch-import")
  252. @bearer_auth_required
  253. @admin_required
  254. def batch_import_attachments_route():
  255. return success_response(_batch(attachment=True))
  256. @api_v1.get("/attachments/<id>")
  257. @bearer_auth_required
  258. def attachment_detail_route(id: str):
  259. return success_response(get_attachment(parse_string_id(id)))
  260. @api_v1.put("/attachments/<id>")
  261. @bearer_auth_required
  262. @admin_required
  263. def update_attachment_route(id: str):
  264. return success_response(
  265. update_attachment(parse_string_id(id), _json_body())
  266. )
  267. @api_v1.delete("/attachments/<id>")
  268. @bearer_auth_required
  269. @admin_required
  270. def delete_attachment_route(id: str):
  271. return success_response(
  272. delete_attachment(parse_string_id(id), _row_version())
  273. )
  274. @api_v1.get("/attachments/<id>/main-plans")
  275. @bearer_auth_required
  276. def attachment_main_plans_route(id: str):
  277. return success_response(
  278. list_attachment_main_plans(parse_string_id(id))
  279. )
  280. @api_v1.get("/main-plans/<id>/attachments")
  281. @bearer_auth_required
  282. def main_plan_attachments_route(id: str):
  283. return success_response(
  284. list_main_plan_attachments(parse_string_id(id), request.args)
  285. )
  286. @api_v1.post("/main-plans/<id>/attachments/bind")
  287. @bearer_auth_required
  288. @admin_required
  289. def bind_main_plan_attachments_route(id: str):
  290. return success_response(
  291. bind_attachments(parse_string_id(id), _json_body())
  292. )
  293. @api_v1.put("/main-plans/<id>/attachments/reorder")
  294. @bearer_auth_required
  295. @admin_required
  296. def reorder_main_plan_attachments_route(id: str):
  297. """调整主案下挂载附件的显示顺序(顺序存于挂载关系,各主案独立)。"""
  298. return success_response(
  299. reorder_main_plan_attachments(parse_string_id(id), _json_body())
  300. )
  301. @api_v1.delete("/main-plans/<id>/attachments/<attachmentId>")
  302. @bearer_auth_required
  303. @admin_required
  304. def unbind_main_plan_attachment_route(id: str, attachmentId: str):
  305. return success_response(
  306. unbind_attachment(
  307. parse_string_id(id),
  308. parse_string_id(attachmentId, field="attachmentId"),
  309. _main_plan_row_version(),
  310. )
  311. )
  312. @api_v1.get("/documents/<id>/permissions")
  313. @bearer_auth_required
  314. @admin_required
  315. def document_permissions_route(id: str):
  316. return success_response(get_permissions(parse_string_id(id)))
  317. @api_v1.put("/documents/<id>/permissions")
  318. @bearer_auth_required
  319. @admin_required
  320. def save_document_permissions_route(id: str):
  321. return success_response(
  322. save_permissions(parse_string_id(id), _json_body())
  323. )
  324. @api_v1.put("/documents/<id>/password")
  325. @bearer_auth_required
  326. @admin_required
  327. def save_document_password_route(id: str):
  328. return success_response(
  329. save_document_password(parse_string_id(id), _json_body())
  330. )