- P0: 新增 products.ui 产品架构页面 - P1: 实现钉钉审批回调→cms_content状态同步(approved→published, rejected→draft) - P2: 修复 docs/architecture.md 路径(/entcms/* → /*),补充cms_sections表 - P2: 重命名 init_superuser.py → init_superuser_permissions.py 避免与scripts/冲突 - build.sh 更新构建后步骤说明
437 lines
15 KiB
Python
437 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
dingdingflow module initialization.
|
|
Registers all module functions with ServerEnv for use in .ui and .dspy files.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import datetime
|
|
|
|
from ahserver.serverenv import ServerEnv
|
|
from appPublic.uniqueID import getID
|
|
from dingdingflow.dingtalk_client import get_dingtalk_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MODULE_NAME = "dingdingflow"
|
|
MODULE_VERSION = "1.0.0"
|
|
|
|
|
|
def _get_dbname():
|
|
"""Get the database name for this module."""
|
|
env = ServerEnv()
|
|
return env.get_module_dbname(MODULE_NAME)
|
|
|
|
|
|
# ─── CRUD: dd_approvals ───────────────────────────────────────────────────────
|
|
|
|
async def create_dd_approval(data):
|
|
"""Create a new approval record."""
|
|
new_id = getID()
|
|
data["id"] = new_id
|
|
if "org_id" not in data:
|
|
data["org_id"] = "0"
|
|
if "status" not in data:
|
|
data["status"] = "pending"
|
|
if "created_at" not in data:
|
|
data["created_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
await sor.C("dd_approvals", data)
|
|
return {"id": new_id}
|
|
|
|
|
|
async def update_dd_approval(data):
|
|
"""Update an existing approval record."""
|
|
record_id = data.get("id")
|
|
if not record_id:
|
|
raise ValueError("id is required for update")
|
|
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
await sor.U("dd_approvals", {"id": record_id}, data)
|
|
return {"id": record_id}
|
|
|
|
|
|
async def delete_dd_approval(record_id):
|
|
"""Delete an approval record by ID."""
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
await sor.D("dd_approvals", {"id": record_id})
|
|
return True
|
|
|
|
|
|
async def get_dd_approval(record_id):
|
|
"""Get a single approval record by ID."""
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
rows = await sor.R("dd_approvals", {"id": record_id})
|
|
if rows:
|
|
return rows[0]
|
|
return None
|
|
|
|
|
|
async def list_dd_approvals(filters=None, page=1, rows=20, sort="created_at desc"):
|
|
"""List approval records with optional filters."""
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
query_filters = filters or {}
|
|
ns = {"page": page, "rows": rows, "sort": sort}
|
|
ns.update(query_filters)
|
|
result = await sor.R("dd_approvals", query_filters, page=page, rows=rows, sort=sort)
|
|
return result
|
|
|
|
|
|
# ─── CRUD: dd_approval_configs ────────────────────────────────────────────────
|
|
|
|
async def create_dd_approval_config(data):
|
|
"""Create a new approval config record."""
|
|
new_id = getID()
|
|
data["id"] = new_id
|
|
if "org_id" not in data:
|
|
data["org_id"] = "0"
|
|
if "is_active" not in data:
|
|
data["is_active"] = "1"
|
|
if "created_at" not in data:
|
|
data["created_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
data["updated_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
await sor.C("dd_approval_configs", data)
|
|
return {"id": new_id}
|
|
|
|
|
|
async def update_dd_approval_config(data):
|
|
"""Update an existing approval config record."""
|
|
record_id = data.get("id")
|
|
if not record_id:
|
|
raise ValueError("id is required for update")
|
|
|
|
data["updated_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
await sor.U("dd_approval_configs", {"id": record_id}, data)
|
|
return {"id": record_id}
|
|
|
|
|
|
async def delete_dd_approval_config(record_id):
|
|
"""Delete an approval config record by ID."""
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
await sor.D("dd_approval_configs", {"id": record_id})
|
|
return True
|
|
|
|
|
|
async def get_dd_approval_config(record_id):
|
|
"""Get a single approval config record by ID."""
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
rows = await sor.R("dd_approval_configs", {"id": record_id})
|
|
if rows:
|
|
return rows[0]
|
|
return None
|
|
|
|
|
|
async def get_approval_config_by_type(org_id, biz_type):
|
|
"""Get approval config by org_id and biz_type (unique constraint)."""
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
rows = await sor.R("dd_approval_configs", {"org_id": org_id, "biz_type": biz_type})
|
|
if rows:
|
|
return rows[0]
|
|
return None
|
|
|
|
|
|
# ─── Business Logic: Approval Workflow ────────────────────────────────────────
|
|
|
|
async def submit_approval(biz_type, biz_id, title, applicant_id, org_id="0"):
|
|
"""
|
|
Submit a new approval request.
|
|
|
|
1. Look up the approval config for this biz_type
|
|
2. Create a dd_approvals record
|
|
3. Call DingTalk API to create the approval instance
|
|
4. Store the DingTalk instance_id back in the record
|
|
|
|
Returns: dict with approval record details
|
|
"""
|
|
client = get_dingtalk_client()
|
|
|
|
# Look up config
|
|
config = await get_approval_config_by_type(org_id, biz_type)
|
|
if not config:
|
|
logger.error("No approval config found for org_id=%s, biz_type=%s", org_id, biz_type)
|
|
return {"success": False, "message": f"No approval config found for biz_type={biz_type}"}
|
|
|
|
process_code = getattr(config, "process_code", "") or ""
|
|
agent_id = getattr(config, "agent_id", "") or ""
|
|
form_config_raw = getattr(config, "form_config", "") or ""
|
|
|
|
# Build form data from form_config
|
|
form_data = []
|
|
if form_config_raw:
|
|
try:
|
|
form_config = json.loads(form_config_raw) if isinstance(form_config_raw, str) else form_config_raw
|
|
if isinstance(form_config, list):
|
|
form_data = form_config
|
|
except (json.JSONDecodeError, TypeError) as e:
|
|
logger.warning("Failed to parse form_config: %s", str(e))
|
|
|
|
# If no form_data, create minimal form with title
|
|
if not form_data:
|
|
form_data = [
|
|
{"name": "审批标题", "value": title},
|
|
{"name": "业务类型", "value": biz_type},
|
|
]
|
|
|
|
# Call DingTalk API
|
|
result = client.create_approval_instance(process_code, form_data, applicant_id)
|
|
|
|
if not result["success"]:
|
|
# Still create the record with failed status
|
|
approval_data = {
|
|
"biz_type": biz_type,
|
|
"biz_id": biz_id,
|
|
"title": title,
|
|
"applicant_id": applicant_id,
|
|
"org_id": org_id,
|
|
"status": "pending",
|
|
"dingtalk_instance_id": "",
|
|
"comment": f"DingTalk API error: {result.get('errmsg', '')}",
|
|
}
|
|
approval = await create_dd_approval(approval_data)
|
|
return {
|
|
"success": False,
|
|
"message": f"DingTalk API failed: {result.get('errmsg', '')}",
|
|
"approval_id": approval["id"],
|
|
}
|
|
|
|
# Create approval record with instance_id
|
|
approval_data = {
|
|
"biz_type": biz_type,
|
|
"biz_id": biz_id,
|
|
"title": title,
|
|
"applicant_id": applicant_id,
|
|
"org_id": org_id,
|
|
"status": "pending",
|
|
"dingtalk_instance_id": result["instance_id"],
|
|
}
|
|
approval = await create_dd_approval(approval_data)
|
|
|
|
logger.info(
|
|
"Approval submitted: id=%s, instance=%s, biz=%s/%s",
|
|
approval["id"],
|
|
result["instance_id"],
|
|
biz_type,
|
|
biz_id,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Approval submitted successfully",
|
|
"approval_id": approval["id"],
|
|
"instance_id": result["instance_id"],
|
|
}
|
|
|
|
|
|
async def get_approval_status(approval_id):
|
|
"""
|
|
Query DingTalk for the latest approval status and sync to local DB.
|
|
|
|
Returns: dict with current status info
|
|
"""
|
|
# Get local record
|
|
record = await get_dd_approval(approval_id)
|
|
if not record:
|
|
return {"success": False, "message": "Approval record not found"}
|
|
|
|
instance_id = getattr(record, "dingtalk_instance_id", "")
|
|
current_status = getattr(record, "status", "")
|
|
|
|
# If already completed, no need to check DingTalk
|
|
if current_status in ("approved", "rejected", "cancelled"):
|
|
return {
|
|
"success": True,
|
|
"status": current_status,
|
|
"approval_id": approval_id,
|
|
"instance_id": instance_id,
|
|
}
|
|
|
|
if not instance_id:
|
|
return {
|
|
"success": True,
|
|
"status": current_status,
|
|
"approval_id": approval_id,
|
|
"instance_id": "",
|
|
"message": "No DingTalk instance ID, cannot sync",
|
|
}
|
|
|
|
# Query DingTalk
|
|
client = get_dingtalk_client()
|
|
dt_result = client.get_approval_instance(instance_id)
|
|
|
|
if not dt_result["success"]:
|
|
return {
|
|
"success": False,
|
|
"message": f"DingTalk query failed: {dt_result.get('errmsg', '')}",
|
|
"status": current_status,
|
|
}
|
|
|
|
# Map DingTalk status to local status
|
|
dt_status = dt_result.get("status", "")
|
|
dt_result_val = dt_result.get("result", "")
|
|
|
|
new_status = current_status
|
|
if dt_status == "COMPLETED":
|
|
if dt_result_val == "agree":
|
|
new_status = "approved"
|
|
elif dt_result_val == "refuse":
|
|
new_status = "rejected"
|
|
elif dt_status == "TERMINATED":
|
|
new_status = "cancelled"
|
|
|
|
# Update local record if status changed
|
|
if new_status != current_status:
|
|
update_data = {"status": new_status}
|
|
if new_status in ("approved", "rejected", "cancelled"):
|
|
update_data["completed_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
await update_dd_approval({"id": approval_id, **update_data})
|
|
logger.info("Approval %s status synced: %s -> %s", approval_id, current_status, new_status)
|
|
|
|
return {
|
|
"success": True,
|
|
"status": new_status,
|
|
"approval_id": approval_id,
|
|
"instance_id": instance_id,
|
|
}
|
|
|
|
|
|
async def handle_dingtalk_callback(data):
|
|
"""
|
|
Process DingTalk webhook callback.
|
|
|
|
DingTalk sends callbacks when approval status changes.
|
|
Expected data format:
|
|
{
|
|
"processInstanceId": "xxx",
|
|
"processCode": "xxx",
|
|
"type": "bpms_instance_change",
|
|
"result": "agree" / "refuse",
|
|
"staffId": "xxx",
|
|
...
|
|
}
|
|
"""
|
|
logger.info("DingTalk callback received: %s", json.dumps(data, ensure_ascii=False))
|
|
|
|
instance_id = data.get("processInstanceId", "")
|
|
if not instance_id:
|
|
return {"success": False, "message": "Missing processInstanceId"}
|
|
|
|
callback_type = data.get("type", "")
|
|
if callback_type != "bpms_instance_change":
|
|
logger.info("Ignoring callback type: %s", callback_type)
|
|
return {"success": True, "message": f"Ignored callback type: {callback_type}"}
|
|
|
|
# Find local approval record by DingTalk instance ID
|
|
dbname = _get_dbname()
|
|
db = ServerEnv().db
|
|
async with db.sqlorContext(dbname) as sor:
|
|
rows = await sor.R("dd_approvals", {"dingtalk_instance_id": instance_id})
|
|
|
|
if not rows:
|
|
logger.warning("No local approval found for instance_id=%s", instance_id)
|
|
return {"success": False, "message": f"No approval found for instance {instance_id}"}
|
|
|
|
record = rows[0]
|
|
record_id = getattr(record, "id", "")
|
|
current_status = getattr(record, "status", "")
|
|
|
|
# Map callback to status
|
|
dt_result = data.get("result", "")
|
|
new_status = current_status
|
|
if dt_result == "agree":
|
|
new_status = "approved"
|
|
elif dt_result == "refuse":
|
|
new_status = "rejected"
|
|
elif callback_type == "terminate":
|
|
new_status = "cancelled"
|
|
|
|
# Update record
|
|
if new_status != current_status:
|
|
update_data = {
|
|
"id": record_id,
|
|
"status": new_status,
|
|
"comment": data.get("remark", ""),
|
|
}
|
|
if new_status in ("approved", "rejected", "cancelled"):
|
|
update_data["completed_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
await update_dd_approval(update_data)
|
|
logger.info("Callback: approval %s updated to %s", record_id, new_status)
|
|
|
|
# Notify entcms module about status change
|
|
biz_type = getattr(record, "biz_type", "")
|
|
biz_id = getattr(record, "biz_id", "")
|
|
if biz_type == "content_publish" and biz_id:
|
|
content_status = "published" if new_status == "approved" else "draft" if new_status == "rejected" else ""
|
|
if content_status:
|
|
content_update = {"id": biz_id, "status": content_status}
|
|
if content_status == "published":
|
|
content_update["published_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
async with db.sqlorContext(dbname) as sor:
|
|
await sor.U("cms_content", content_update)
|
|
logger.info("Callback: cms_content %s updated to %s", biz_id, content_status)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Approval {record_id} updated to {new_status}",
|
|
"approval_id": record_id,
|
|
"status": new_status,
|
|
}
|
|
|
|
|
|
# ─── Module Loader ─────────────────────────────────────────────────────────────
|
|
|
|
def load_dingdingflow():
|
|
"""Register all dingdingflow functions with ServerEnv."""
|
|
env = ServerEnv()
|
|
|
|
# CRUD functions for dd_approvals
|
|
env.create_dd_approval = create_dd_approval
|
|
env.update_dd_approval = update_dd_approval
|
|
env.delete_dd_approval = delete_dd_approval
|
|
env.get_dd_approval = get_dd_approval
|
|
env.list_dd_approvals = list_dd_approvals
|
|
|
|
# CRUD functions for dd_approval_configs
|
|
env.create_dd_approval_config = create_dd_approval_config
|
|
env.update_dd_approval_config = update_dd_approval_config
|
|
env.delete_dd_approval_config = delete_dd_approval_config
|
|
env.get_dd_approval_config = get_dd_approval_config
|
|
env.get_approval_config_by_type = get_approval_config_by_type
|
|
|
|
# Business logic functions
|
|
env.submit_approval = submit_approval
|
|
env.get_approval_status = get_approval_status
|
|
env.handle_dingtalk_callback = handle_dingtalk_callback
|
|
|
|
# DingTalk client accessor
|
|
env.get_dingtalk_client = get_dingtalk_client
|
|
|
|
logger.info("dingdingflow module loaded (v%s)", MODULE_VERSION)
|
|
return True
|