attachment_query_service.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. """共享附件及其有效挂载关系只读查询。"""
  2. from __future__ import annotations
  3. from typing import Mapping
  4. from sqlalchemy import func, select
  5. from dms.common.enums import AttachmentType, DocumentType
  6. from dms.common.errors import ResourceNotFoundError
  7. from dms.common.pagination import page_result
  8. from dms.common.response import serialize_id
  9. from dms.common.time_range import parse_updated_range
  10. from dms.extensions import db
  11. from dms.models import AttachmentBinding, Document
  12. from dms.security.auth_context import AuthContext, get_auth_context
  13. from dms.services.authorization_service import (
  14. attachment_allowed_actions,
  15. evaluate_plan_access,
  16. require_attachment_access,
  17. )
  18. from dms.services.document_query_service import (
  19. DOCUMENT_SORTS,
  20. _advanced_filters,
  21. _content_snippet,
  22. _enum,
  23. _escape_like,
  24. _iso,
  25. _page,
  26. _record_view,
  27. _sort,
  28. parse_string_id,
  29. )
  30. def mounted_plan_count(attachment_id: int) -> int:
  31. return (
  32. db.session.scalar(
  33. select(func.count(AttachmentBinding.id)).where(
  34. AttachmentBinding.attachment_document_id == attachment_id,
  35. AttachmentBinding.is_deleted.is_(False),
  36. )
  37. )
  38. or 0
  39. )
  40. def attachment_summary(
  41. attachment: Document,
  42. context: AuthContext,
  43. *,
  44. plan_access=None,
  45. keyword: str | None = None,
  46. ) -> dict[str, object]:
  47. result: dict[str, object] = {
  48. "id": serialize_id(attachment.id),
  49. "documentName": attachment.document_name,
  50. "summary": attachment.summary,
  51. "description": attachment.description,
  52. "documentType": attachment.document_type,
  53. "status": attachment.document_status,
  54. "securityLevel": attachment.security_level,
  55. "visibilityType": attachment.visibility_type,
  56. "visibilitySummary": "全部已登录用户",
  57. "attachmentType": attachment.attachment_type,
  58. "categoryId": None,
  59. "categoryName": None,
  60. "categoryPath": None,
  61. "organizationId": serialize_id(attachment.organization_id),
  62. "organizationName": attachment.organization_name,
  63. "parentDocumentId": None,
  64. "rootDocumentId": None,
  65. "tags": attachment.tags or [],
  66. "fileExtension": attachment.file_extension,
  67. "childCount": attachment.child_count,
  68. "attachmentCount": attachment.attachment_count,
  69. "viewCount": attachment.view_count,
  70. "downloadCount": attachment.download_count,
  71. "createdByName": attachment.created_by_name,
  72. "createdAt": _iso(attachment.created_at),
  73. "updatedAt": _iso(attachment.updated_at),
  74. "rowVersion": attachment.row_version,
  75. "allowedActions": attachment_allowed_actions(context, plan_access),
  76. "mountedPlanCount": mounted_plan_count(attachment.id),
  77. }
  78. snippet = _content_snippet(attachment.content_text, keyword)
  79. if snippet is not None:
  80. result["contentSnippet"] = snippet
  81. return result
  82. def attachment_detail(
  83. attachment: Document,
  84. context: AuthContext,
  85. ) -> dict[str, object]:
  86. result = attachment_summary(attachment, context)
  87. result.update(
  88. {
  89. "originalFileName": attachment.original_file_name,
  90. "mimeType": attachment.mime_type,
  91. "fileSize": attachment.file_size,
  92. "fileHash": attachment.file_hash,
  93. "permissionSummary": {
  94. "organizationCount": 0,
  95. "userCount": 0,
  96. "inheritedFromMainPlan": False,
  97. },
  98. "contentText": attachment.content_text,
  99. }
  100. )
  101. return result
  102. def _attachment(attachment_id: int) -> Document:
  103. attachment = db.session.scalar(
  104. select(Document).where(
  105. Document.id == attachment_id,
  106. Document.document_type == DocumentType.ATTACHMENT.value,
  107. Document.is_deleted.is_(False),
  108. )
  109. )
  110. if attachment is None:
  111. raise ResourceNotFoundError("共享附件不存在")
  112. return attachment
  113. def _filtered_attachments(params: Mapping[str, str]):
  114. statement = select(Document).where(
  115. Document.document_type == DocumentType.ATTACHMENT.value,
  116. Document.is_deleted.is_(False),
  117. )
  118. keyword = params.get("keyword")
  119. if keyword and keyword.strip():
  120. pattern = f"%{_escape_like(keyword)}%"
  121. statement = statement.where(
  122. Document.document_name.like(pattern, escape="\\")
  123. )
  124. statement = _advanced_filters(statement, params)
  125. attachment_type = _enum(
  126. params.get("attachmentType"), AttachmentType, "attachmentType"
  127. )
  128. if attachment_type:
  129. statement = statement.where(Document.attachment_type == attachment_type)
  130. extension = params.get("fileExtension")
  131. if extension:
  132. statement = statement.where(
  133. Document.file_extension == extension.strip().lower().lstrip(".")
  134. )
  135. updated_from, updated_to = parse_updated_range(params)
  136. if updated_from:
  137. statement = statement.where(Document.updated_at >= updated_from)
  138. if updated_to:
  139. statement = statement.where(Document.updated_at <= updated_to)
  140. order, _ = _sort(params, allowed=DOCUMENT_SORTS)
  141. return statement.order_by(order, Document.id.asc())
  142. def list_attachments(params: Mapping[str, str]) -> dict[str, object]:
  143. context = get_auth_context()
  144. page = _page(params)
  145. attachments = db.session.scalars(_filtered_attachments(params)).all()
  146. total = len(attachments)
  147. selected = attachments[page.offset : page.offset + page.page_size]
  148. return page_result(
  149. [
  150. attachment_summary(item, context, keyword=params.get("contentKeyword"))
  151. for item in selected
  152. ],
  153. page=page.page,
  154. page_size=page.page_size,
  155. total=total,
  156. )
  157. def get_attachment(attachment_id: int) -> dict[str, object]:
  158. context = get_auth_context()
  159. attachment = _attachment(attachment_id)
  160. can_view, can_download = require_attachment_access(
  161. attachment, context, download=False
  162. )
  163. result = attachment_detail(attachment, context)
  164. if context.role_code.value == "USER":
  165. result["allowedActions"] = ["VIEW"] + (
  166. ["DOWNLOAD"] if can_download else []
  167. )
  168. result["viewCount"] = _record_view(attachment)
  169. return result
  170. def list_attachment_main_plans(attachment_id: int) -> dict[str, object]:
  171. context = get_auth_context()
  172. _attachment(attachment_id)
  173. rows = db.session.execute(
  174. select(AttachmentBinding, Document)
  175. .join(Document, Document.id == AttachmentBinding.main_document_id)
  176. .where(
  177. AttachmentBinding.attachment_document_id == attachment_id,
  178. AttachmentBinding.is_deleted.is_(False),
  179. Document.document_type == DocumentType.MAIN.value,
  180. Document.is_deleted.is_(False),
  181. )
  182. .order_by(Document.document_name.asc(), Document.id.asc())
  183. ).all()
  184. items: list[dict[str, object]] = []
  185. for _, main in rows:
  186. if not evaluate_plan_access(main, context).allowed:
  187. continue
  188. items.append(
  189. {
  190. "id": serialize_id(main.id),
  191. "documentName": main.document_name,
  192. "categoryName": main.category_name,
  193. "securityLevel": main.security_level,
  194. }
  195. )
  196. return {"items": items, "total": len(items)}
  197. def list_main_plan_attachments(
  198. main_document_id: int,
  199. params: Mapping[str, str],
  200. ) -> dict[str, object]:
  201. from dms.common.errors import MainPlanNotFoundError
  202. context = get_auth_context()
  203. main = db.session.scalar(
  204. select(Document).where(
  205. Document.id == main_document_id,
  206. Document.document_type == DocumentType.MAIN.value,
  207. Document.is_deleted.is_(False),
  208. )
  209. )
  210. if main is None:
  211. raise MainPlanNotFoundError()
  212. # 主案无权限时也返回挂载列表(列表全量可见);allowedActions 为空集,
  213. # 附件正文片段同步抑制,预览/下载接口仍独立拦截。
  214. main_access = evaluate_plan_access(main, context)
  215. page = _page(params)
  216. statement = (
  217. select(AttachmentBinding, Document)
  218. .join(Document, Document.id == AttachmentBinding.attachment_document_id)
  219. .where(
  220. AttachmentBinding.main_document_id == main.id,
  221. AttachmentBinding.is_deleted.is_(False),
  222. Document.document_type == DocumentType.ATTACHMENT.value,
  223. Document.is_deleted.is_(False),
  224. )
  225. )
  226. keyword = params.get("keyword")
  227. if keyword and keyword.strip():
  228. pattern = f"%{_escape_like(keyword)}%"
  229. statement = statement.where(
  230. Document.document_name.like(pattern, escape="\\")
  231. )
  232. statement = _advanced_filters(statement, params)
  233. attachment_type = _enum(
  234. params.get("attachmentType"), AttachmentType, "attachmentType"
  235. )
  236. if attachment_type:
  237. statement = statement.where(Document.attachment_type == attachment_type)
  238. updated_from, updated_to = parse_updated_range(params)
  239. if updated_from:
  240. statement = statement.where(Document.updated_at >= updated_from)
  241. if updated_to:
  242. statement = statement.where(Document.updated_at <= updated_to)
  243. rows = db.session.execute(
  244. statement.order_by(
  245. AttachmentBinding.sort_no.asc(),
  246. AttachmentBinding.id.asc(),
  247. )
  248. ).all()
  249. total = len(rows)
  250. selected = rows[page.offset : page.offset + page.page_size]
  251. items: list[dict[str, object]] = []
  252. for binding, attachment in selected:
  253. item = attachment_summary(
  254. attachment,
  255. context,
  256. plan_access=main_access,
  257. keyword=params.get("contentKeyword") if main_access.allowed else None,
  258. )
  259. item.update(
  260. {
  261. "bindingId": serialize_id(binding.id),
  262. "bindingSortNo": binding.sort_no,
  263. }
  264. )
  265. items.append(item)
  266. return page_result(
  267. items,
  268. page=page.page,
  269. page_size=page.page_size,
  270. total=total,
  271. )
  272. __all__ = [
  273. "get_attachment",
  274. "list_attachment_main_plans",
  275. "list_attachments",
  276. "list_main_plan_attachments",
  277. "parse_string_id",
  278. ]