from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Any, ClassVar
from django import forms
from django.contrib import admin
from django.http import HttpRequest, JsonResponse
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
from apps.contracts.admin.mixins.action_mixin import ContractActionMixin
from apps.contracts.admin.mixins.display_mixin import ContractDisplayMixin
from apps.contracts.admin.mixins.save_mixin import ContractSaveMixin
from apps.contracts.models import (
Contract,
ContractAssignment,
ContractParty,
ContractStatus,
FinalizedMaterial,
SupplementaryAgreement,
SupplementaryAgreementParty,
)
from apps.core.admin.mixins import AdminImportExportMixin
from apps.core.models.enums import CaseStage
if TYPE_CHECKING:
BaseModelAdmin = admin.ModelAdmin
BaseStackedInline = admin.StackedInline
BaseTabularInline = admin.TabularInline
from django.db.models import QuerySet
else:
try:
import nested_admin
BaseModelAdmin = nested_admin.NestedModelAdmin
BaseStackedInline = nested_admin.NestedStackedInline
BaseTabularInline = nested_admin.NestedTabularInline
except ImportError:
BaseModelAdmin = admin.ModelAdmin
BaseStackedInline = admin.StackedInline
BaseTabularInline = admin.TabularInline
class FinalizedMaterialAdminForm(forms.ModelForm[FinalizedMaterial]):
file = forms.FileField(
required=False,
label=_("上传文件"),
help_text=_("仅支持 PDF,最大 20MB"),
)
class Meta:
model = FinalizedMaterial
fields = ("file", "category")
def save(self, commit: bool = True) -> FinalizedMaterial:
instance = super().save(commit=False)
uploaded_file = self.cleaned_data.get("file")
if uploaded_file:
from apps.contracts.admin.wiring_admin import get_material_service
svc = get_material_service()
contract_id: int = instance.contract_id or self.instance.contract_id
rel_path, original_name = svc.save_material_file(uploaded_file, contract_id)
instance.file_path = rel_path
instance.original_filename = original_name
if commit:
instance.save()
return instance
class FinalizedMaterialInline(BaseTabularInline):
model = FinalizedMaterial
form = FinalizedMaterialAdminForm
extra = 1
fields: ClassVar = ("file", "category", "filename_link", "uploaded_at")
readonly_fields: ClassVar = ("filename_link", "uploaded_at")
@admin.display(description=_("原始文件名"))
def filename_link(self, obj: FinalizedMaterial) -> str:
from django.utils.html import format_html
if obj.file_path and obj.original_filename:
url = f"/media/{obj.file_path}"
return format_html('{}', url, obj.original_filename)
return obj.original_filename or "-"
def delete_model(self, request: HttpRequest, obj: FinalizedMaterial) -> None:
from apps.contracts.admin.wiring_admin import get_material_service
get_material_service().delete_material_file(obj.file_path)
obj.delete()
class Media:
css = {"all": ("contracts/css/finalized_material_inline.css",)}
class ContractPartyInline(BaseTabularInline):
model = ContractParty
extra = 1
fields = ("client", "role")
autocomplete_fields: ClassVar = ["client"]
show_change_link = True
class Media:
js = ("contracts/js/party_role_auto.js",)
class ContractAssignmentInline(BaseTabularInline):
model = ContractAssignment
extra = 1
fields = ("lawyer", "is_primary", "order")
autocomplete_fields: ClassVar = ["lawyer"]
class SupplementaryAgreementPartyInline(BaseTabularInline):
"""补充协议当事人内联(嵌套在补充协议中)"""
model = SupplementaryAgreementParty
extra = 1
fields = ("client", "role")
autocomplete_fields: ClassVar = ["client"]
class SupplementaryAgreementInline(BaseStackedInline):
"""补充协议内联(在合同中)"""
model = SupplementaryAgreement
extra = 0
fields = ("name",)
show_change_link = True
# 如果支持嵌套 Admin,添加当事人内联到补充协议
if BaseModelAdmin is not admin.ModelAdmin:
SupplementaryAgreementInline.inlines = [SupplementaryAgreementPartyInline] # type: ignore[attr-defined]
def serialize_contract_obj(obj: Any) -> dict[str, Any]:
"""将单个 Contract 实例序列化为 dict(供 ContractAdmin 和 CaseAdmin 共用)。"""
from apps.contracts.services.contract.integrations import serialize_contract_obj as serialize_contract_obj_service
result: dict[str, Any] = serialize_contract_obj_service(obj)
return result
@admin.register(Contract)
class ContractAdmin(
ContractDisplayMixin, ContractSaveMixin, ContractActionMixin, AdminImportExportMixin, BaseModelAdmin
):
class ContractAdminForm(forms.ModelForm[Contract]):
representation_stages = forms.MultipleChoiceField(
choices=CaseStage.choices,
required=False,
widget=forms.SelectMultiple,
label=_("代理阶段"),
)
class Meta:
model = Contract
fields = "__all__"
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
if not getattr(self.instance, "pk", None):
self.fields["status"].initial = ContractStatus.ACTIVE
self.fields["specified_date"].initial = timezone.localdate()
self.fields["representation_stages"].initial = list(
getattr(self.instance, "representation_stages", []) or []
)
def clean(self) -> dict[str, Any]:
cleaned = super().clean() or {}
try:
from apps.contracts.validators import normalize_representation_stages
ctype = cleaned.get("case_type")
rep = cleaned.get("representation_stages") or []
cleaned["representation_stages"] = normalize_representation_stages(ctype, rep, strict=False)
except Exception:
logger.exception("操作失败")
return cleaned
form = ContractAdminForm
list_display = (
"id",
"name_link",
"case_type",
"status",
"start_date",
"end_date",
"get_primary_lawyer",
"fee_mode",
"fixed_amount",
"risk_rate",
"is_archived",
)
list_filter = ("case_type", "status", "fee_mode", "is_archived")
search_fields = ("name",)
readonly_fields = ("get_primary_lawyer_display", "filing_number")
export_model_name = "contract"
import_required_fields = ("name",)
actions = ["export_selected_as_json", "export_all_as_json"]
inlines: ClassVar = [
ContractPartyInline,
ContractAssignmentInline,
SupplementaryAgreementInline,
FinalizedMaterialInline,
]
class Media:
js = ("cases/admin_case_form.js",)
change_form_template = "admin/contracts/contract/change_form.html"
change_list_template = "admin/contracts/contract/change_list.html"
def get_queryset(self, request: HttpRequest) -> Any:
return super().get_queryset(request).prefetch_related("assignments__lawyer", "contract_parties__client")
def get_urls(self) -> list[Any]:
from django.urls import path as urlpath
urls = super().get_urls()
custom = [
urlpath(
"batch-folder-binding/",
self.admin_site.admin_view(self.batch_folder_binding_view),
name="contracts_contract_batch_folder_binding",
),
urlpath(
"batch-folder-binding/preview/",
self.admin_site.admin_view(self.batch_folder_binding_preview_view),
name="contracts_contract_batch_folder_binding_preview",
),
urlpath(
"batch-folder-binding/save/",
self.admin_site.admin_view(self.batch_folder_binding_save_view),
name="contracts_contract_batch_folder_binding_save",
),
urlpath(
"batch-folder-binding/open-folder/",
self.admin_site.admin_view(self.batch_folder_binding_open_folder_view),
name="contracts_contract_batch_folder_binding_open_folder",
),
urlpath(
"/reorder-materials/",
self.admin_site.admin_view(self.reorder_materials_view),
name="contracts_contract_reorder_materials",
),
urlpath(
"oa-sync/",
self.admin_site.admin_view(self.oa_sync_view),
name="contracts_contract_oa_sync",
),
urlpath(
"oa-sync/start/",
self.admin_site.admin_view(self.oa_sync_start_view),
name="contracts_contract_oa_sync_start",
),
urlpath(
"oa-sync/status//",
self.admin_site.admin_view(self.oa_sync_status_view),
name="contracts_contract_oa_sync_status",
),
urlpath(
"oa-sync/save/",
self.admin_site.admin_view(self.oa_sync_save_view),
name="contracts_contract_oa_sync_save",
),
]
return custom + urls
def reorder_materials_view(self, request: HttpRequest, contract_id: int) -> Any:
if request.method != "POST":
return JsonResponse({"error": "Method not allowed"}, status=405)
if not self.has_change_permission(request):
return JsonResponse({"error": "Permission denied"}, status=403)
try:
data = json.loads(request.body)
ids: list[int] = data.get("ids", [])
for i, pk in enumerate(ids):
FinalizedMaterial.objects.filter(pk=pk, contract_id=contract_id).update(order=i)
return JsonResponse({"ok": True})
except Exception as e:
return JsonResponse({"error": str(e)}, status=400)
def batch_folder_binding_view(self, request: HttpRequest) -> TemplateResponse:
if not self.has_view_permission(request):
from django.core.exceptions import PermissionDenied
raise PermissionDenied
from apps.contracts.admin.wiring_admin import get_contract_batch_folder_binding_service
service = get_contract_batch_folder_binding_service()
context = self.admin_site.each_context(request)
context.update(
{
"title": _("合同批量绑定文件夹"),
"opts": self.model._meta,
"cards": service.list_unbound_case_type_cards(),
"batch_folder_binding_config": {
"previewUrl": "/admin/contracts/contract/batch-folder-binding/preview/",
"saveUrl": "/admin/contracts/contract/batch-folder-binding/save/",
"openFolderUrl": "/admin/contracts/contract/batch-folder-binding/open-folder/",
"changeListUrl": "/admin/contracts/contract/",
},
}
)
return TemplateResponse(request, "admin/contracts/contract/batch_folder_binding.html", context)
def oa_sync_view(self, request: HttpRequest) -> TemplateResponse:
if not self.has_view_permission(request):
from django.core.exceptions import PermissionDenied
raise PermissionDenied
from apps.contracts.admin.wiring_admin import get_contract_oa_sync_service
service = get_contract_oa_sync_service()
context = self.admin_site.each_context(request)
context.update(
{
"title": _("合同OA信息同步"),
"opts": self.model._meta,
"oa_sync_initial_contracts": service.list_missing_oa_contracts(),
"oa_sync_config": {
"startUrl": "/admin/contracts/contract/oa-sync/start/",
"statusUrl": "/admin/contracts/contract/oa-sync/status/__SESSION_ID__/",
"saveUrl": "/admin/contracts/contract/oa-sync/save/",
"changeListUrl": "/admin/contracts/contract/",
},
}
)
return TemplateResponse(request, "admin/contracts/contract/oa_sync.html", context)
def oa_sync_start_view(self, request: HttpRequest) -> JsonResponse:
if request.method != "POST":
return JsonResponse({"success": False, "message": _("Method not allowed")}, status=405)
if not self.has_change_permission(request):
return JsonResponse({"success": False, "message": _("Permission denied")}, status=403)
from apps.contracts.admin.wiring_admin import get_contract_oa_sync_service
try:
service = get_contract_oa_sync_service()
session = service.create_or_get_active_session(started_by=request.user)
session = service.submit_session_task(session=session)
return JsonResponse(
{
"success": True,
"message": _("同步任务已启动"),
"session_id": int(session.id),
}
)
except Exception as exc:
logger.exception("contract_oa_sync_start_failed")
return JsonResponse({"success": False, "message": str(exc)}, status=400)
def oa_sync_save_view(self, request: HttpRequest) -> JsonResponse:
if request.method != "POST":
return JsonResponse({"success": False, "message": _("Method not allowed")}, status=405)
if not self.has_change_permission(request):
return JsonResponse({"success": False, "message": _("Permission denied")}, status=403)
from apps.contracts.admin.wiring_admin import get_contract_oa_sync_service
payload = self._parse_json_payload(request)
entries = payload.get("entries")
if not isinstance(entries, list):
return JsonResponse({"success": False, "message": _("参数格式错误")}, status=400)
try:
result = get_contract_oa_sync_service().save_manual_contract_oa_fields(updates=entries)
message = _("保存成功") if result.get("error_count", 0) == 0 else _("部分保存成功,请检查错误项")
return JsonResponse({"success": True, "message": message, **result})
except Exception as exc:
logger.exception("contract_oa_sync_save_failed")
return JsonResponse({"success": False, "message": str(exc)}, status=400)
def oa_sync_status_view(self, request: HttpRequest, session_id: int) -> JsonResponse:
if request.method != "GET":
return JsonResponse({"success": False, "message": _("Method not allowed")}, status=405)
if not self.has_view_permission(request):
return JsonResponse({"success": False, "message": _("Permission denied")}, status=403)
from apps.contracts.admin.wiring_admin import get_contract_oa_sync_service
service = get_contract_oa_sync_service()
session = service.get_session(session_id=session_id)
if session is None:
return JsonResponse({"success": False, "message": _("会话不存在")}, status=404)
return JsonResponse({"success": True, **service.build_status_payload(session=session)})
def batch_folder_binding_preview_view(self, request: HttpRequest) -> JsonResponse:
if request.method != "POST":
return JsonResponse({"success": False, "message": _("Method not allowed")}, status=405)
if not self.has_change_permission(request):
return JsonResponse({"success": False, "message": _("Permission denied")}, status=403)
from apps.contracts.admin.wiring_admin import get_contract_batch_folder_binding_service
payload = self._parse_json_payload(request)
case_type_roots = payload.get("case_type_roots")
if not isinstance(case_type_roots, list):
return JsonResponse({"success": False, "message": _("参数格式错误")}, status=400)
try:
data = get_contract_batch_folder_binding_service().preview(case_type_roots=case_type_roots)
return JsonResponse({"success": True, **data})
except Exception as exc:
logger.exception("contract_batch_folder_binding_preview_failed")
return JsonResponse({"success": False, "message": str(exc)}, status=400)
def batch_folder_binding_save_view(self, request: HttpRequest) -> JsonResponse:
if request.method != "POST":
return JsonResponse({"success": False, "message": _("Method not allowed")}, status=405)
if not self.has_change_permission(request):
return JsonResponse({"success": False, "message": _("Permission denied")}, status=403)
from apps.contracts.admin.wiring_admin import get_contract_batch_folder_binding_service
payload = self._parse_json_payload(request)
case_type_roots = payload.get("case_type_roots")
contract_selections = payload.get("contract_selections")
if not isinstance(case_type_roots, list) or not isinstance(contract_selections, list):
return JsonResponse({"success": False, "message": _("参数格式错误")}, status=400)
try:
result = get_contract_batch_folder_binding_service().save(
case_type_roots=case_type_roots,
contract_selections=contract_selections,
)
return JsonResponse({"success": True, **result})
except Exception as exc:
logger.exception("contract_batch_folder_binding_save_failed")
return JsonResponse({"success": False, "message": str(exc)}, status=400)
def batch_folder_binding_open_folder_view(self, request: HttpRequest) -> JsonResponse:
if request.method != "POST":
return JsonResponse({"success": False, "message": _("Method not allowed")}, status=405)
if not self.has_change_permission(request):
return JsonResponse({"success": False, "message": _("Permission denied")}, status=403)
from apps.contracts.admin.wiring_admin import get_contract_batch_folder_binding_service
payload = self._parse_json_payload(request)
root_path = str(payload.get("root_path") or "").strip()
folder_path = str(payload.get("folder_path") or "").strip()
if not root_path or not folder_path:
return JsonResponse({"success": False, "message": _("缺少路径参数")}, status=400)
try:
get_contract_batch_folder_binding_service().open_folder(root_path=root_path, folder_path=folder_path)
return JsonResponse({"success": True, "message": _("已打开文件夹")})
except Exception as exc:
logger.exception("contract_batch_folder_binding_open_folder_failed")
return JsonResponse({"success": False, "message": str(exc)}, status=400)
def _parse_json_payload(self, request: HttpRequest) -> dict[str, Any]:
try:
data = json.loads(request.body.decode("utf-8"))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def handle_json_import(
self, data_list: list[dict[str, Any]], user: str, zip_file: Any
) -> tuple[int, int, list[str]]:
from apps.contracts.services.contract_import_service import build_contract_import_service_for_admin
contract_svc = build_contract_import_service_for_admin()
success = skipped = 0
errors: list[str] = []
for i, item in enumerate(data_list, 1):
try:
filing_number = item.get("filing_number")
before = Contract.objects.filter(filing_number=filing_number).exists() if filing_number else False
contract_svc.resolve(item)
if before:
skipped += 1
else:
success += 1
except Exception as exc:
logger.exception("导入合同失败", extra={"index": i, "contract_name": item.get("name", "?")})
errors.append(f"[{i}] {item.get('name', '?')} ({type(exc).__name__}): {exc}")
return success, skipped, errors
def serialize_queryset(self, queryset: QuerySet[Contract]) -> list[dict[str, Any]]:
result = []
for obj in queryset.prefetch_related(
"contract_parties__client__identity_docs",
"contract_parties__client__property_clues__attachments",
"assignments__lawyer",
"finalized_materials",
"supplementary_agreements__parties__client",
"payments__invoices",
"finance_logs__actor",
"client_payment_records",
"cases__parties__client__identity_docs",
"cases__parties__client__property_clues__attachments",
"cases__assignments__lawyer",
"cases__supervising_authorities",
"cases__case_numbers",
"cases__chats",
"cases__logs__actor",
"cases__logs__attachments",
):
result.append(serialize_contract_obj(obj))
return result
def get_file_paths(self, queryset: QuerySet[Contract]) -> list[str]:
seen: set[str] = set()
paths: list[str] = []
def _add(p: str) -> None:
if p and p not in seen:
seen.add(p)
paths.append(p)
for obj in queryset.prefetch_related(
"finalized_materials",
"client_payment_records",
"payments__invoices",
"contract_parties__client__identity_docs",
"contract_parties__client__property_clues__attachments",
):
for m in obj.finalized_materials.all():
_add(m.file_path)
for r in obj.client_payment_records.all():
_add(r.image_path)
for p in obj.payments.all():
for inv in p.invoices.all():
_add(inv.file_path)
for p in obj.contract_parties.all():
for d in p.client.identity_docs.all():
_add(d.file_path)
for c in p.client.property_clues.all():
for a in c.attachments.all():
_add(a.file_path)
return paths