fachuan / apps /documents /models /document_template.py
jun2024's picture
Upload 2241 files
f8b03c1 verified
Raw History Blame Contribute Delete
10.8 kB
"""
法律文书生成系统 - 文件模板模型
本模块定义文件模板相关的数据模型.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.core.utils.path import Path
from apps.documents.storage import document_template_storage, resolve_docx_template_path
from .choices import (
DocumentCaseFileSubType,
DocumentCaseStage,
DocumentCaseType,
DocumentContractSubType,
DocumentContractType,
DocumentTemplateType,
LegalStatusMatchMode,
)
if TYPE_CHECKING:
from django.db.models.fields.related_descriptors import RelatedManager
from .evidence import EvidenceList
class DocumentTemplate(models.Model):
"""
文书模板
基于 docx 格式的法律文书模板文件.
支持两种模式:文件上传和路径引用.
支持两种模板类型:单个文书和全套文书.
Requirements: 2.1, 2.4, 2.6, 7.2
"""
id: int
name: Any = models.CharField(max_length=200, verbose_name=_("模板名称"))
template_type: Any = models.CharField(
max_length=20,
choices=DocumentTemplateType.choices,
default=DocumentTemplateType.CONTRACT,
verbose_name=_("模板类型"),
help_text=_("选择此模板用于合同还是案件"),
)
contract_sub_type: Any = models.CharField(
max_length=30,
choices=DocumentContractSubType.choices,
blank=True,
null=True,
verbose_name=_("合同子类型"),
help_text=_("仅在选择'合同文件模板'时有效,必须选择合同模板或补充协议模板"),
)
case_sub_type: Any = models.CharField(
max_length=50,
choices=DocumentCaseFileSubType.choices,
blank=True,
null=True,
verbose_name=_("案件文件子类型"),
help_text=_("仅在选择'案件文件模板'时有效,可选择诉状材料、证据材料、授权委托材料等"),
)
file = models.FileField(
storage=document_template_storage,
upload_to="", # 存储类会自动处理路径
blank=True,
null=True,
verbose_name=_("上传文件"),
)
file_path: Any = models.CharField(
max_length=500, blank=True, verbose_name=_("文件路径"), help_text=_("相对于模板基础目录的路径")
)
# 适用范围字段(与文件夹模板保持一致)
case_types: Any = models.JSONField(
default=list, verbose_name=_("案件类型"), help_text=_("JSON 数组,如 ['civil', 'criminal'],支持多选")
)
case_stages: Any = models.JSONField(
default=list, verbose_name=_("案件阶段"), help_text=_("JSON 数组,如 ['first_trial', 'second_trial'],支持多选")
)
contract_types: Any = models.JSONField(
default=list, verbose_name=_("合同类型"), help_text=_("JSON 数组,如 ['civil', 'criminal'],支持多选")
)
legal_statuses: Any = models.JSONField(
default=list,
blank=True,
verbose_name=_("我方诉讼地位"),
help_text=_("可单选或多选;为空表示匹配任意诉讼地位"),
)
legal_status_match_mode: Any = models.CharField(
max_length=16,
choices=LegalStatusMatchMode.choices,
default=LegalStatusMatchMode.ANY,
verbose_name=_("诉讼地位匹配模式"),
)
applicable_institutions: Any = models.JSONField(
default=list,
blank=True,
verbose_name=_("适用机构"),
help_text=_("JSON 数组,如 ['北京市第一中级人民法院'],支持多选"),
)
is_active: Any = models.BooleanField(default=True, verbose_name=_("是否启用"))
created_at: Any = models.DateTimeField(auto_now_add=True, verbose_name=_("创建时间"))
updated_at: Any = models.DateTimeField(auto_now=True, verbose_name=_("更新时间"))
if TYPE_CHECKING:
folder_bindings: RelatedManager[DocumentTemplateFolderBinding]
evidence_lists: RelatedManager[EvidenceList]
class Meta:
app_label = "documents"
verbose_name = _("文件模板")
verbose_name_plural = _("文件模板")
ordering: ClassVar = ["-updated_at"]
indexes: ClassVar = [
models.Index(fields=["template_type"]),
models.Index(fields=["is_active"]),
]
def __str__(self) -> str:
return str(self.name)
def clean(self) -> None:
file_present = bool(self.file)
file_path_present = bool((self.file_path or "").strip())
if file_present and file_path_present:
message = _("不能同时提供上传文件和文件路径")
raise ValidationError({"file": message, "file_path": message})
if not file_present and not file_path_present:
raise ValidationError(_("必须提供上传文件或文件路径"))
def get_file_location(self) -> str:
"""获取文件实际位置"""
if self.file and self.file.name:
return str(self.file.storage.path(self.file.name))
if self.file_path:
return str(resolve_docx_template_path(self.file_path))
return ""
def _get_types_display(self, types_list: list[Any], choices_class: type[models.TextChoices]) -> str:
"""获取类型列表的显示文本"""
if not types_list:
return "-"
if len(types_list) == 1:
return str(dict(choices_class.choices).get(types_list[0], types_list[0]))
return f"{len(types_list)}种类型"
@property
def template_type_display(self) -> str:
"""模板类型显示"""
base_type = str(dict(DocumentTemplateType.choices).get(self.template_type, self.template_type))
if self.template_type == DocumentTemplateType.CONTRACT and self.contract_sub_type:
sub_type = str(dict(DocumentContractSubType.choices).get(self.contract_sub_type, self.contract_sub_type))
return f"{base_type} - {sub_type}"
if self.template_type == DocumentTemplateType.CASE and self.case_sub_type:
sub_type = str(dict(DocumentCaseFileSubType.choices).get(self.case_sub_type, self.case_sub_type))
return f"{base_type} - {sub_type}"
return base_type
@property
def case_types_display(self) -> str:
"""案件类型显示"""
return self._get_types_display(self.case_types, DocumentCaseType)
@property
def case_stages_display(self) -> str:
"""案件阶段显示"""
return self._get_types_display(self.case_stages, DocumentCaseStage)
@property
def contract_types_display(self) -> str:
"""合同类型显示"""
return self._get_types_display(self.contract_types, DocumentContractType)
def get_legal_statuses_display(self) -> str:
"""获取诉讼地位显示文本"""
from apps.core.models.enums import LegalStatus
choices = dict(LegalStatus.choices)
statuses = self.legal_statuses or []
labels = [str(choices.get(code, code)) for code in statuses]
return "、".join([x for x in labels if x]) or "任意"
@property
def absolute_file_path(self) -> str:
"""文件的绝对路径"""
if not self.file_path:
return ""
return str(resolve_docx_template_path(self.file_path))
class DocumentTemplateFolderBinding(models.Model):
"""
文件模板与文件夹节点绑定
建立文件模板和文件夹模板中具体节点的多对多关系.
支持:
- 一个文件模板绑定到多个文件夹模板的不同节点
- 同一文件夹模板中,一个文件模板可放在不同位置
Requirements: 2.8
"""
id: int
document_template_id: int # 外键ID字段
folder_template_id: int # 外键ID字段
document_template: Any = models.ForeignKey(
"documents.DocumentTemplate",
on_delete=models.CASCADE,
related_name="folder_bindings",
verbose_name=_("文件模板"),
)
folder_template: Any = models.ForeignKey(
"documents.FolderTemplate",
on_delete=models.CASCADE,
related_name="document_bindings",
verbose_name=_("文件夹模板"),
)
folder_node_id: Any = models.CharField(
max_length=100,
verbose_name=_("文件夹节点ID"),
help_text=_("文件夹结构JSON中的节点ID"),
)
folder_node_path: Any = models.CharField(
max_length=500,
blank=True,
verbose_name=_("文件夹路径"),
help_text=_("自动计算的文件夹路径,如:一审/1-立案材料/1-起诉状和反诉答辩状"),
)
is_active: Any = models.BooleanField(default=True, verbose_name=_("是否启用"))
created_at: Any = models.DateTimeField(auto_now_add=True, verbose_name=_("创建时间"))
updated_at: Any = models.DateTimeField(auto_now=True, verbose_name=_("更新时间"))
class Meta:
app_label = "documents"
verbose_name = _("文件模板文件夹绑定")
verbose_name_plural = _("文件模板文件夹绑定")
ordering: ClassVar = ["folder_template", "document_template"]
# 同一文件模板在同一文件夹模板的同一节点只能绑定一次
unique_together: ClassVar = ["document_template", "folder_template", "folder_node_id"]
indexes: ClassVar = [
models.Index(fields=["folder_template", "folder_node_id"]),
models.Index(fields=["document_template"]),
models.Index(fields=["is_active"]),
]
def __str__(self) -> str:
return (
f"{self.document_template.name} → "
f"{self.folder_template.name}/{self.folder_node_path or self.folder_node_id}"
)
def save(self, *args: Any, **kwargs: Any) -> None:
"""保存时自动根据 folder_node_id 计算 folder_node_path"""
if self.folder_node_id and self.folder_template_id:
try:
structure = self.folder_template.structure or {}
path = self._find_node_path(structure.get("children", []), self.folder_node_id, [])
self.folder_node_path = "/".join(path) if path else ""
except Exception:
pass
super().save(*args, **kwargs)
def _find_node_path(self, children: list[Any], target_id: str, current_path: list[str]) -> list[str]:
for child in children:
path = current_path + [child.get("name", "")]
if child.get("id") == target_id:
return path
result = self._find_node_path(child.get("children", []), target_id, path)
if result:
return result
return []