737 lines
23 KiB
Python
737 lines
23 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
合同管理服务(异步版本)
|
|
"""
|
|
import secrets
|
|
import uuid
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from sqlalchemy import select, func, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from online_dev.contract.model import (
|
|
ContractInstance,
|
|
ContractLog,
|
|
ContractSignature,
|
|
ContractSignToken,
|
|
ContractTemplate,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ContractTemplateService:
|
|
"""合同模板服务"""
|
|
|
|
@staticmethod
|
|
async def get_list(
|
|
db: AsyncSession,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
name: str = None,
|
|
code: str = None,
|
|
category: str = None,
|
|
status: str = None,
|
|
) -> Dict[str, Any]:
|
|
"""获取模板列表"""
|
|
conditions = [ContractTemplate.is_deleted == False]
|
|
|
|
if name:
|
|
conditions.append(ContractTemplate.name.ilike(f"%{name}%"))
|
|
if code:
|
|
conditions.append(ContractTemplate.code.ilike(f"%{code}%"))
|
|
if category:
|
|
conditions.append(ContractTemplate.category == category)
|
|
if status:
|
|
conditions.append(ContractTemplate.status == status)
|
|
|
|
# 获取总数
|
|
count_stmt = select(func.count(ContractTemplate.id)).where(and_(*conditions))
|
|
total_result = await db.execute(count_stmt)
|
|
total = total_result.scalar() or 0
|
|
|
|
# 获取列表
|
|
offset = (page - 1) * page_size
|
|
stmt = select(ContractTemplate).where(and_(*conditions)).order_by(
|
|
ContractTemplate.sort, ContractTemplate.sys_create_datetime.desc()
|
|
).offset(offset).limit(page_size)
|
|
|
|
result = await db.execute(stmt)
|
|
items = list(result.scalars().all())
|
|
|
|
return {
|
|
"items": items,
|
|
"total": total,
|
|
"page": page,
|
|
"pageSize": page_size,
|
|
}
|
|
|
|
@staticmethod
|
|
async def get_by_id(db: AsyncSession, template_id: str) -> Optional[ContractTemplate]:
|
|
"""根据ID获取模板"""
|
|
stmt = select(ContractTemplate).where(
|
|
ContractTemplate.id == template_id,
|
|
ContractTemplate.is_deleted == False
|
|
)
|
|
result = await db.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
@staticmethod
|
|
async def get_by_code(db: AsyncSession, code: str) -> Optional[ContractTemplate]:
|
|
"""根据编码获取模板"""
|
|
stmt = select(ContractTemplate).where(
|
|
ContractTemplate.code == code,
|
|
ContractTemplate.is_deleted == False
|
|
)
|
|
result = await db.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
@staticmethod
|
|
async def create(
|
|
db: AsyncSession,
|
|
name: str,
|
|
code: str,
|
|
category: str = "",
|
|
description: str = "",
|
|
template_config: Dict = None,
|
|
creator_id: str = None,
|
|
) -> ContractTemplate:
|
|
"""创建模板"""
|
|
template = ContractTemplate(
|
|
name=name,
|
|
code=code,
|
|
category=category,
|
|
description=description,
|
|
template_config=template_config or {},
|
|
sys_creator_id=creator_id,
|
|
sys_modifier_id=creator_id,
|
|
)
|
|
db.add(template)
|
|
await db.commit()
|
|
await db.refresh(template)
|
|
return template
|
|
|
|
@staticmethod
|
|
async def update(
|
|
db: AsyncSession,
|
|
template: ContractTemplate,
|
|
name: str = None,
|
|
category: str = None,
|
|
description: str = None,
|
|
template_config: Dict = None,
|
|
thumbnail: str = None,
|
|
) -> ContractTemplate:
|
|
"""更新模板"""
|
|
if name is not None:
|
|
template.name = name
|
|
if category is not None:
|
|
template.category = category
|
|
if description is not None:
|
|
template.description = description
|
|
if template_config is not None:
|
|
template.template_config = template_config
|
|
if thumbnail is not None:
|
|
template.thumbnail = thumbnail
|
|
|
|
await db.commit()
|
|
await db.refresh(template)
|
|
return template
|
|
|
|
@staticmethod
|
|
async def delete(db: AsyncSession, template: ContractTemplate):
|
|
"""删除模板(软删除)"""
|
|
template.is_deleted = True
|
|
await db.commit()
|
|
|
|
@staticmethod
|
|
async def publish(db: AsyncSession, template: ContractTemplate) -> ContractTemplate:
|
|
"""发布模板"""
|
|
template.status = "published"
|
|
template.version += 1
|
|
await db.commit()
|
|
await db.refresh(template)
|
|
return template
|
|
|
|
@staticmethod
|
|
async def disable(db: AsyncSession, template: ContractTemplate) -> ContractTemplate:
|
|
"""停用模板"""
|
|
template.status = "disabled"
|
|
await db.commit()
|
|
await db.refresh(template)
|
|
return template
|
|
|
|
@staticmethod
|
|
async def copy(
|
|
db: AsyncSession,
|
|
template: ContractTemplate,
|
|
new_code: str,
|
|
new_name: str = None,
|
|
creator_id: str = None,
|
|
) -> ContractTemplate:
|
|
"""复制模板"""
|
|
new_template = ContractTemplate(
|
|
name=new_name or f"{template.name}_副本",
|
|
code=new_code,
|
|
category=template.category,
|
|
description=template.description,
|
|
template_config=template.template_config,
|
|
thumbnail=template.thumbnail,
|
|
status="draft",
|
|
version=1,
|
|
sys_creator_id=creator_id,
|
|
sys_modifier_id=creator_id,
|
|
)
|
|
db.add(new_template)
|
|
await db.commit()
|
|
await db.refresh(new_template)
|
|
return new_template
|
|
|
|
@staticmethod
|
|
async def get_categories(db: AsyncSession) -> List[str]:
|
|
"""获取所有分类"""
|
|
stmt = select(ContractTemplate.category).where(
|
|
ContractTemplate.is_deleted == False,
|
|
ContractTemplate.category != ""
|
|
).distinct()
|
|
|
|
result = await db.execute(stmt)
|
|
return [row[0] for row in result.fetchall()]
|
|
|
|
|
|
class ContractInstanceService:
|
|
"""合同实例服务"""
|
|
|
|
@staticmethod
|
|
def generate_contract_no() -> str:
|
|
"""生成合同编号"""
|
|
now = datetime.now()
|
|
prefix = now.strftime("JZ%Y%m%d")
|
|
suffix = uuid.uuid4().hex[:6].upper()
|
|
return f"{prefix}{suffix}"
|
|
|
|
@staticmethod
|
|
async def check_contract_no_exists(
|
|
db: AsyncSession,
|
|
contract_no: str,
|
|
exclude_id: str = None
|
|
) -> bool:
|
|
"""检查合同编号是否已存在"""
|
|
conditions = [
|
|
ContractInstance.contract_no == contract_no,
|
|
ContractInstance.is_deleted == False
|
|
]
|
|
if exclude_id:
|
|
conditions.append(ContractInstance.id != exclude_id)
|
|
|
|
stmt = select(func.count(ContractInstance.id)).where(and_(*conditions))
|
|
result = await db.execute(stmt)
|
|
count = result.scalar() or 0
|
|
return count > 0
|
|
|
|
@staticmethod
|
|
async def get_list(
|
|
db: AsyncSession,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
title: str = None,
|
|
contract_no: str = None,
|
|
status: str = None,
|
|
template_id: str = None,
|
|
creator_id: str = None,
|
|
) -> Dict[str, Any]:
|
|
"""获取合同列表"""
|
|
conditions = [ContractInstance.is_deleted == False]
|
|
|
|
if title:
|
|
conditions.append(ContractInstance.title.ilike(f"%{title}%"))
|
|
if contract_no:
|
|
conditions.append(ContractInstance.contract_no.ilike(f"%{contract_no}%"))
|
|
if status:
|
|
conditions.append(ContractInstance.status == status)
|
|
if template_id:
|
|
conditions.append(ContractInstance.template_id == template_id)
|
|
if creator_id:
|
|
conditions.append(ContractInstance.creator_id == creator_id)
|
|
|
|
# 获取总数
|
|
count_stmt = select(func.count(ContractInstance.id)).where(and_(*conditions))
|
|
total_result = await db.execute(count_stmt)
|
|
total = total_result.scalar() or 0
|
|
|
|
# 获取列表
|
|
offset = (page - 1) * page_size
|
|
stmt = select(ContractInstance).where(and_(*conditions)).order_by(
|
|
ContractInstance.sys_create_datetime.desc()
|
|
).offset(offset).limit(page_size)
|
|
|
|
result = await db.execute(stmt)
|
|
items = list(result.scalars().all())
|
|
|
|
return {
|
|
"items": items,
|
|
"total": total,
|
|
"page": page,
|
|
"pageSize": page_size,
|
|
}
|
|
|
|
@staticmethod
|
|
async def get_by_id(db: AsyncSession, instance_id: str) -> Optional[ContractInstance]:
|
|
"""根据ID获取合同"""
|
|
stmt = select(ContractInstance).where(
|
|
ContractInstance.id == instance_id,
|
|
ContractInstance.is_deleted == False
|
|
)
|
|
result = await db.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
@staticmethod
|
|
async def create(
|
|
db: AsyncSession,
|
|
template_id: str,
|
|
title: str,
|
|
creator_id: str,
|
|
variable_data: Dict = None,
|
|
contract_no: str = None,
|
|
) -> ContractInstance:
|
|
"""创建合同实例"""
|
|
# 获取模板
|
|
template = await ContractTemplateService.get_by_id(db, template_id)
|
|
if not template:
|
|
raise ValueError(f"模板不存在: {template_id}")
|
|
|
|
# 如果没有提供合同编号,自动生成
|
|
if not contract_no:
|
|
contract_no = ContractInstanceService.generate_contract_no()
|
|
|
|
# 检查合同编号唯一性
|
|
if await ContractInstanceService.check_contract_no_exists(db, contract_no):
|
|
raise ValueError(f"合同编号 {contract_no} 已存在")
|
|
|
|
instance = ContractInstance(
|
|
template_id=template_id,
|
|
contract_no=contract_no,
|
|
title=title,
|
|
creator_id=creator_id,
|
|
contract_config=template.template_config,
|
|
variable_data=variable_data or {},
|
|
created_at=datetime.now(),
|
|
sys_creator_id=creator_id,
|
|
sys_modifier_id=creator_id,
|
|
)
|
|
db.add(instance)
|
|
await db.flush()
|
|
|
|
# 记录日志
|
|
log = ContractLog(
|
|
contract_id=instance.id,
|
|
action="create",
|
|
operator_id=creator_id,
|
|
sys_creator_id=creator_id,
|
|
)
|
|
db.add(log)
|
|
|
|
await db.commit()
|
|
await db.refresh(instance)
|
|
return instance
|
|
|
|
@staticmethod
|
|
async def update(
|
|
db: AsyncSession,
|
|
instance: ContractInstance,
|
|
title: str = None,
|
|
variable_data: Dict = None,
|
|
signature_data: Dict = None,
|
|
operator_id: str = None,
|
|
) -> ContractInstance:
|
|
"""更新合同"""
|
|
if title is not None:
|
|
instance.title = title
|
|
if variable_data is not None:
|
|
instance.variable_data = variable_data
|
|
if signature_data is not None:
|
|
instance.signature_data = signature_data
|
|
|
|
instance.sys_modifier_id = operator_id
|
|
|
|
# 记录日志
|
|
if operator_id:
|
|
log = ContractLog(
|
|
contract_id=instance.id,
|
|
action="update",
|
|
operator_id=operator_id,
|
|
sys_creator_id=operator_id,
|
|
)
|
|
db.add(log)
|
|
|
|
await db.commit()
|
|
await db.refresh(instance)
|
|
return instance
|
|
|
|
@staticmethod
|
|
async def submit(
|
|
db: AsyncSession,
|
|
instance: ContractInstance,
|
|
operator_id: str
|
|
) -> ContractInstance:
|
|
"""提交合同(进入待签署状态)"""
|
|
instance.status = "pending"
|
|
instance.sys_modifier_id = operator_id
|
|
|
|
log = ContractLog(
|
|
contract_id=instance.id,
|
|
action="submit",
|
|
operator_id=operator_id,
|
|
sys_creator_id=operator_id,
|
|
)
|
|
db.add(log)
|
|
|
|
await db.commit()
|
|
await db.refresh(instance)
|
|
return instance
|
|
|
|
@staticmethod
|
|
async def complete(
|
|
db: AsyncSession,
|
|
instance: ContractInstance,
|
|
operator_id: str
|
|
) -> ContractInstance:
|
|
"""完成合同"""
|
|
instance.status = "completed"
|
|
instance.completed_at = datetime.now()
|
|
instance.sys_modifier_id = operator_id
|
|
|
|
log = ContractLog(
|
|
contract_id=instance.id,
|
|
action="complete",
|
|
operator_id=operator_id,
|
|
sys_creator_id=operator_id,
|
|
)
|
|
db.add(log)
|
|
|
|
await db.commit()
|
|
await db.refresh(instance)
|
|
return instance
|
|
|
|
@staticmethod
|
|
async def cancel(
|
|
db: AsyncSession,
|
|
instance: ContractInstance,
|
|
operator_id: str,
|
|
comment: str = ""
|
|
) -> ContractInstance:
|
|
"""取消合同"""
|
|
instance.status = "canceled"
|
|
instance.sys_modifier_id = operator_id
|
|
|
|
log = ContractLog(
|
|
contract_id=instance.id,
|
|
action="cancel",
|
|
operator_id=operator_id,
|
|
comment=comment,
|
|
sys_creator_id=operator_id,
|
|
)
|
|
db.add(log)
|
|
|
|
await db.commit()
|
|
await db.refresh(instance)
|
|
return instance
|
|
|
|
@staticmethod
|
|
async def delete(db: AsyncSession, instance: ContractInstance):
|
|
"""删除合同(软删除)"""
|
|
instance.is_deleted = True
|
|
await db.commit()
|
|
|
|
@staticmethod
|
|
async def get_logs(db: AsyncSession, instance_id: str) -> List[ContractLog]:
|
|
"""获取合同日志"""
|
|
stmt = select(ContractLog).where(
|
|
ContractLog.contract_id == instance_id
|
|
).order_by(ContractLog.sys_create_datetime)
|
|
|
|
result = await db.execute(stmt)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
class ContractSignatureService:
|
|
"""合同签署服务"""
|
|
|
|
@staticmethod
|
|
async def sign(
|
|
db: AsyncSession,
|
|
instance: ContractInstance,
|
|
element_id: str,
|
|
signature_image: str,
|
|
signer_id: str,
|
|
signer_name: str = "",
|
|
sign_ip: str = "",
|
|
sign_device: str = "",
|
|
) -> ContractSignature:
|
|
"""签署合同"""
|
|
# 查找元素信息
|
|
element = None
|
|
for el in instance.contract_config.get("elements", []):
|
|
if el.get("id") == element_id:
|
|
element = el
|
|
break
|
|
|
|
if not element:
|
|
raise ValueError("签署区域不存在")
|
|
|
|
# 获取签署方信息
|
|
signature_config = element.get("signature", {})
|
|
party_type = signature_config.get("partyType", "party_a")
|
|
party_label = signature_config.get("partyLabel", "")
|
|
sign_type = "seal" if element.get("type") == "seal-zone" else "signature"
|
|
|
|
# 查找是否已存在签署记录
|
|
stmt = select(ContractSignature).where(
|
|
ContractSignature.contract_id == instance.id,
|
|
ContractSignature.element_id == element_id
|
|
)
|
|
result = await db.execute(stmt)
|
|
existing = result.scalar_one_or_none()
|
|
|
|
if existing:
|
|
# 更新现有记录
|
|
existing.party_type = party_type
|
|
existing.party_label = party_label
|
|
existing.sign_type = sign_type
|
|
existing.signer_id = signer_id
|
|
existing.signer_name = signer_name
|
|
existing.signature_image = signature_image
|
|
existing.status = "signed"
|
|
existing.signed_at = datetime.now()
|
|
existing.sign_ip = sign_ip
|
|
existing.sign_device = sign_device
|
|
signature = existing
|
|
else:
|
|
# 创建新记录
|
|
signature = ContractSignature(
|
|
contract_id=instance.id,
|
|
element_id=element_id,
|
|
party_type=party_type,
|
|
party_label=party_label,
|
|
sign_type=sign_type,
|
|
signer_id=signer_id,
|
|
signer_name=signer_name,
|
|
signature_image=signature_image,
|
|
status="signed",
|
|
signed_at=datetime.now(),
|
|
sign_ip=sign_ip,
|
|
sign_device=sign_device,
|
|
sys_creator_id=signer_id,
|
|
)
|
|
db.add(signature)
|
|
|
|
# 更新合同签署数据
|
|
signature_data = instance.signature_data or {}
|
|
signature_data[element_id] = signature_image
|
|
instance.signature_data = signature_data
|
|
|
|
# 检查是否所有必须签署区域都已签署
|
|
all_signed = await ContractSignatureService.check_all_signed(instance)
|
|
if all_signed and instance.status == "pending":
|
|
instance.status = "signing"
|
|
|
|
# 记录日志
|
|
log = ContractLog(
|
|
contract_id=instance.id,
|
|
action="sign",
|
|
operator_id=signer_id,
|
|
comment=f"{party_label}签署",
|
|
extra_data={"element_id": element_id},
|
|
ip_address=sign_ip,
|
|
sys_creator_id=signer_id,
|
|
)
|
|
db.add(log)
|
|
|
|
await db.commit()
|
|
await db.refresh(signature)
|
|
return signature
|
|
|
|
@staticmethod
|
|
async def check_all_signed(instance: ContractInstance) -> bool:
|
|
"""检查是否所有必须签署区域都已签署"""
|
|
elements = instance.contract_config.get("elements", [])
|
|
signature_data = instance.signature_data or {}
|
|
|
|
for element in elements:
|
|
if element.get("type") in ["signature-zone", "seal-zone"]:
|
|
signature_config = element.get("signature", {})
|
|
if signature_config.get("required", True):
|
|
if element.get("id") not in signature_data:
|
|
return False
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
async def get_signatures(db: AsyncSession, instance_id: str) -> List[ContractSignature]:
|
|
"""获取合同签署记录"""
|
|
stmt = select(ContractSignature).where(
|
|
ContractSignature.contract_id == instance_id
|
|
).order_by(ContractSignature.sys_create_datetime)
|
|
|
|
result = await db.execute(stmt)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
class MobileSignService:
|
|
"""移动端签署服务"""
|
|
|
|
@staticmethod
|
|
async def create_sign_token(
|
|
db: AsyncSession,
|
|
instance: ContractInstance,
|
|
party_type: str = "party_a",
|
|
signer_name: str = "",
|
|
expire_minutes: int = 30,
|
|
) -> ContractSignToken:
|
|
"""创建签署令牌"""
|
|
# 生成随机令牌
|
|
token = secrets.token_urlsafe(32)
|
|
|
|
# 计算过期时间
|
|
expired_at = datetime.now() + timedelta(minutes=expire_minutes)
|
|
|
|
# 创建令牌记录
|
|
sign_token = ContractSignToken(
|
|
contract_id=instance.id,
|
|
token=token,
|
|
party_type=party_type,
|
|
signer_name=signer_name,
|
|
expired_at=expired_at,
|
|
)
|
|
db.add(sign_token)
|
|
await db.commit()
|
|
await db.refresh(sign_token)
|
|
|
|
return sign_token
|
|
|
|
@staticmethod
|
|
async def get_by_token(db: AsyncSession, token: str) -> Optional[ContractSignToken]:
|
|
"""根据令牌获取签署令牌"""
|
|
stmt = select(ContractSignToken).where(
|
|
ContractSignToken.token == token,
|
|
ContractSignToken.is_used == False
|
|
)
|
|
result = await db.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
@staticmethod
|
|
def validate_token(sign_token: ContractSignToken) -> Tuple[bool, str]:
|
|
"""验证令牌有效性"""
|
|
if sign_token.is_used:
|
|
return False, "该签署链接已被使用"
|
|
|
|
if sign_token.expired_at < datetime.now():
|
|
return False, "该签署链接已过期"
|
|
|
|
return True, ""
|
|
|
|
@staticmethod
|
|
async def mobile_sign(
|
|
db: AsyncSession,
|
|
sign_token: ContractSignToken,
|
|
instance: ContractInstance,
|
|
element_id: str,
|
|
signature_image: str,
|
|
signer_name: str = "",
|
|
sign_ip: str = "",
|
|
sign_device: str = "",
|
|
) -> ContractSignature:
|
|
"""移动端签署"""
|
|
# 查找元素信息
|
|
element = None
|
|
for el in instance.contract_config.get("elements", []):
|
|
if el.get("id") == element_id:
|
|
element = el
|
|
break
|
|
|
|
if not element:
|
|
raise ValueError("签署区域不存在")
|
|
|
|
# 获取签署方信息
|
|
signature_config = element.get("signature", {})
|
|
party_type = signature_config.get("partyType", "party_a")
|
|
party_label = signature_config.get("partyLabel", "")
|
|
sign_type = "seal" if element.get("type") == "seal-zone" else "signature"
|
|
|
|
# 使用令牌中的签署人姓名(如果提供)
|
|
final_signer_name = signer_name or sign_token.signer_name
|
|
|
|
# 截断设备信息,避免超出数据库字段长度
|
|
device_info = (sign_device + " (Mobile)")[:200] if sign_device else "Mobile"
|
|
|
|
# 查找是否已存在签署记录
|
|
stmt = select(ContractSignature).where(
|
|
ContractSignature.contract_id == instance.id,
|
|
ContractSignature.element_id == element_id
|
|
)
|
|
result = await db.execute(stmt)
|
|
existing = result.scalar_one_or_none()
|
|
|
|
if existing:
|
|
# 更新现有记录
|
|
existing.party_type = party_type
|
|
existing.party_label = party_label
|
|
existing.sign_type = sign_type
|
|
existing.signer_id = None
|
|
existing.signer_name = final_signer_name
|
|
existing.signature_image = signature_image
|
|
existing.status = "signed"
|
|
existing.signed_at = datetime.now()
|
|
existing.sign_ip = sign_ip
|
|
existing.sign_device = device_info
|
|
signature = existing
|
|
else:
|
|
# 创建新记录
|
|
signature = ContractSignature(
|
|
contract_id=instance.id,
|
|
element_id=element_id,
|
|
party_type=party_type,
|
|
party_label=party_label,
|
|
sign_type=sign_type,
|
|
signer_id=None,
|
|
signer_name=final_signer_name,
|
|
signature_image=signature_image,
|
|
status="signed",
|
|
signed_at=datetime.now(),
|
|
sign_ip=sign_ip,
|
|
sign_device=device_info,
|
|
)
|
|
db.add(signature)
|
|
|
|
# 更新合同签署数据
|
|
signature_data = instance.signature_data or {}
|
|
signature_data[element_id] = signature_image
|
|
instance.signature_data = signature_data
|
|
|
|
# 检查是否所有必须签署区域都已签署
|
|
all_signed = await ContractSignatureService.check_all_signed(instance)
|
|
if all_signed and instance.status == "pending":
|
|
instance.status = "signing"
|
|
|
|
# 标记令牌已使用
|
|
sign_token.is_used = True
|
|
sign_token.used_at = datetime.now()
|
|
|
|
# 记录日志
|
|
log = ContractLog(
|
|
contract_id=instance.id,
|
|
action="sign",
|
|
operator_id=instance.creator_id,
|
|
comment=f"{party_label}通过手机签署 (签署人: {final_signer_name})",
|
|
extra_data={"element_id": element_id, "mobile": True},
|
|
ip_address=sign_ip,
|
|
sys_creator_id=instance.creator_id,
|
|
)
|
|
db.add(log)
|
|
|
|
await db.commit()
|
|
await db.refresh(signature)
|
|
return signature
|