feat: show feishu notification status
This commit is contained in:
@@ -11,6 +11,7 @@ from review_agent.application_form_fill.workflow import (
|
|||||||
start_application_form_fill_workflow,
|
start_application_form_fill_workflow,
|
||||||
)
|
)
|
||||||
from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileSummaryBatch, WorkflowNodeRun
|
from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileSummaryBatch, WorkflowNodeRun
|
||||||
|
from review_agent.notifications.presenter import serialize_notification_records
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
@@ -75,6 +76,7 @@ def batch_status(request, batch_id: int):
|
|||||||
workflow_type="application_form_fill",
|
workflow_type="application_form_fill",
|
||||||
workflow_batch_id=batch.pk,
|
workflow_batch_id=batch.pk,
|
||||||
).order_by("id")
|
).order_by("id")
|
||||||
|
notifications = serialize_notification_records("application_form_fill", batch.pk)
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"batch": {
|
"batch": {
|
||||||
@@ -112,6 +114,8 @@ def batch_status(request, batch_id: int):
|
|||||||
}
|
}
|
||||||
for export in exports
|
for export in exports
|
||||||
],
|
],
|
||||||
|
"notifications": notifications,
|
||||||
|
"latest_notification": notifications[0] if notifications else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.views.decorators.http import require_http_methods
|
|||||||
|
|
||||||
from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileAttachment, Message
|
from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileAttachment, Message
|
||||||
from review_agent.models import FileSummaryBatch, WorkflowEvent
|
from review_agent.models import FileSummaryBatch, WorkflowEvent
|
||||||
|
from review_agent.notifications.presenter import serialize_notification_records
|
||||||
from .events import serialize_event
|
from .events import serialize_event
|
||||||
from .paths import resolve_storage_path
|
from .paths import resolve_storage_path
|
||||||
|
|
||||||
@@ -225,6 +226,7 @@ def batch_status(request, batch_id: int):
|
|||||||
batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first()
|
batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first()
|
||||||
if not batch:
|
if not batch:
|
||||||
raise Http404("批次不存在。")
|
raise Http404("批次不存在。")
|
||||||
|
notifications = serialize_notification_records("file_summary", batch.pk)
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"batch": {
|
"batch": {
|
||||||
@@ -249,6 +251,8 @@ def batch_status(request, batch_id: int):
|
|||||||
}
|
}
|
||||||
for node in batch.node_runs.order_by("id")
|
for node in batch.node_runs.order_by("id")
|
||||||
],
|
],
|
||||||
|
"notifications": notifications,
|
||||||
|
"latest_notification": notifications[0] if notifications else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
42
review_agent/notifications/presenter.py
Normal file
42
review_agent/notifications/presenter.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from review_agent.models import WorkflowNotificationRecord
|
||||||
|
|
||||||
|
|
||||||
|
def get_notification_records(workflow_type: str, batch_id: int):
|
||||||
|
return WorkflowNotificationRecord.objects.filter(
|
||||||
|
workflow_type=workflow_type,
|
||||||
|
workflow_batch_id=batch_id,
|
||||||
|
).order_by("-created_at", "-id")
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_notification_record(record: WorkflowNotificationRecord) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": record.pk,
|
||||||
|
"channel": record.channel,
|
||||||
|
"target": record.target,
|
||||||
|
"receiver": record.at_display_name or record.target,
|
||||||
|
"identifier_type": record.at_identifier_type,
|
||||||
|
"identifier_masked": record.at_identifier_masked,
|
||||||
|
"send_status": record.send_status,
|
||||||
|
"status_label": notification_status_label(record),
|
||||||
|
"sent_at": record.sent_at.isoformat() if record.sent_at else "",
|
||||||
|
"created_at": record.created_at.isoformat(),
|
||||||
|
"error_code": record.error_code,
|
||||||
|
"error_message": record.error_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_notification_records(workflow_type: str, batch_id: int) -> list[dict[str, object]]:
|
||||||
|
return [serialize_notification_record(record) for record in get_notification_records(workflow_type, batch_id)]
|
||||||
|
|
||||||
|
|
||||||
|
def notification_status_label(record: WorkflowNotificationRecord) -> str:
|
||||||
|
labels = {
|
||||||
|
WorkflowNotificationRecord.SendStatus.SUCCESS: "飞书通知已发送",
|
||||||
|
WorkflowNotificationRecord.SendStatus.FAILED: "飞书通知失败",
|
||||||
|
WorkflowNotificationRecord.SendStatus.DISABLED: "飞书通知未启用",
|
||||||
|
WorkflowNotificationRecord.SendStatus.SKIPPED_DUPLICATE: "飞书通知已跳过重复发送",
|
||||||
|
WorkflowNotificationRecord.SendStatus.PENDING: "飞书通知待发送",
|
||||||
|
}
|
||||||
|
return labels.get(record.send_status, record.send_status)
|
||||||
@@ -8,6 +8,7 @@ from django.views.decorators.http import require_http_methods
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
from review_agent.models import FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun
|
from review_agent.models import FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun
|
||||||
|
from review_agent.notifications.presenter import serialize_notification_records
|
||||||
from review_agent.regulatory_review.events import record_event
|
from review_agent.regulatory_review.events import record_event
|
||||||
from review_agent.regulatory_review.services.info_extract import ensure_regulatory_condition_candidates
|
from review_agent.regulatory_review.services.info_extract import ensure_regulatory_condition_candidates
|
||||||
from review_agent.regulatory_review.services.rectification_review import review_missing_issues
|
from review_agent.regulatory_review.services.rectification_review import review_missing_issues
|
||||||
@@ -25,6 +26,7 @@ def batch_status(request, batch_id: int):
|
|||||||
workflow_type="regulatory_review",
|
workflow_type="regulatory_review",
|
||||||
workflow_batch_id=batch.pk,
|
workflow_batch_id=batch.pk,
|
||||||
).order_by("id")
|
).order_by("id")
|
||||||
|
notifications = serialize_notification_records("regulatory_review", batch.pk)
|
||||||
payload = {
|
payload = {
|
||||||
"batch": {
|
"batch": {
|
||||||
"id": batch.pk,
|
"id": batch.pk,
|
||||||
@@ -46,6 +48,8 @@ def batch_status(request, batch_id: int):
|
|||||||
}
|
}
|
||||||
for node in nodes
|
for node in nodes
|
||||||
],
|
],
|
||||||
|
"notifications": notifications,
|
||||||
|
"latest_notification": notifications[0] if notifications else None,
|
||||||
}
|
}
|
||||||
if batch.status == RegulatoryReviewBatch.Status.WAITING_USER and condition_candidates:
|
if batch.status == RegulatoryReviewBatch.Status.WAITING_USER and condition_candidates:
|
||||||
payload["condition_confirmation"] = {
|
payload["condition_confirmation"] = {
|
||||||
|
|||||||
@@ -887,7 +887,8 @@ input:focus {
|
|||||||
.attachment-item span,
|
.attachment-item span,
|
||||||
.workflow-card em,
|
.workflow-card em,
|
||||||
.workflow-card small,
|
.workflow-card small,
|
||||||
.workflow-error {
|
.workflow-error,
|
||||||
|
.workflow-notification {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -1042,18 +1043,33 @@ input:focus {
|
|||||||
|
|
||||||
.node-status span,
|
.node-status span,
|
||||||
.node-status small,
|
.node-status small,
|
||||||
.workflow-error {
|
.workflow-error,
|
||||||
|
.workflow-notification {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-error {
|
.workflow-error,
|
||||||
|
.workflow-notification {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-error {
|
||||||
|
background: #fff1f0;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-notification {
|
||||||
|
background: #f5fbf7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-notification[data-notification-status="failed"] {
|
||||||
background: #fff1f0;
|
background: #fff1f0;
|
||||||
color: #b42318;
|
color: #b42318;
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-running,
|
.status-running,
|
||||||
|
|||||||
@@ -734,6 +734,34 @@
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notificationLabel(notification) {
|
||||||
|
if (!notification) {
|
||||||
|
return "暂无飞书通知记录";
|
||||||
|
}
|
||||||
|
return notification.status_label || notification.send_status || "飞书通知状态未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotificationSummary(card, notification) {
|
||||||
|
var panel = card.querySelector(".workflow-notification");
|
||||||
|
if (!panel) {
|
||||||
|
panel = document.createElement("p");
|
||||||
|
panel.className = "workflow-notification";
|
||||||
|
card.insertBefore(panel, card.querySelector("ol"));
|
||||||
|
}
|
||||||
|
var text = notificationLabel(notification);
|
||||||
|
if (notification && notification.receiver) {
|
||||||
|
text += " · " + notification.receiver;
|
||||||
|
}
|
||||||
|
if (notification && notification.sent_at) {
|
||||||
|
text += " · " + notification.sent_at;
|
||||||
|
}
|
||||||
|
if (notification && notification.error_message) {
|
||||||
|
text += " · " + notification.error_message;
|
||||||
|
}
|
||||||
|
panel.textContent = text;
|
||||||
|
panel.setAttribute("data-notification-status", notification ? notification.send_status || "" : "none");
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshWorkflowCard(batchId, workflow_type) {
|
async function refreshWorkflowCard(batchId, workflow_type) {
|
||||||
if (!summaryPanel || !batchId) {
|
if (!summaryPanel || !batchId) {
|
||||||
return "";
|
return "";
|
||||||
@@ -788,6 +816,7 @@
|
|||||||
} else if (riskSummary) {
|
} else if (riskSummary) {
|
||||||
riskSummary.remove();
|
riskSummary.remove();
|
||||||
}
|
}
|
||||||
|
renderNotificationSummary(card, payload.latest_notification);
|
||||||
var list = card.querySelector("ol");
|
var list = card.querySelector("ol");
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
(payload.nodes || []).forEach(function (node) {
|
(payload.nodes || []).forEach(function (node) {
|
||||||
|
|||||||
@@ -46,3 +46,32 @@ def test_frontend_selects_application_form_fill_status_url_and_terminal_status()
|
|||||||
assert 'workflow_type === "application_form_fill"' in script
|
assert 'workflow_type === "application_form_fill"' in script
|
||||||
assert "data-application-form-fill-status-url-template" in script
|
assert "data-application-form-fill-status-url-template" in script
|
||||||
assert 'status === "partial_success"' in script
|
assert 'status === "partial_success"' in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_form_fill_status_includes_no_feishu_notification(client, django_user_model):
|
||||||
|
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||||
|
conversation = Conversation.objects.create(user=user, title="会话")
|
||||||
|
summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-AFF")
|
||||||
|
batch = ApplicationFormFillBatch.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
source_summary_batch=summary,
|
||||||
|
batch_no="AFF-FEISHU",
|
||||||
|
)
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/review-agent/application-form-fill/{batch.pk}/status/")
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["latest_notification"] is None
|
||||||
|
assert payload["notifications"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_renders_feishu_notification_status():
|
||||||
|
script = open("static/js/app.js", encoding="utf-8").read()
|
||||||
|
css = open("static/css/login.css", encoding="utf-8").read()
|
||||||
|
|
||||||
|
assert "renderNotificationSummary" in script
|
||||||
|
assert "暂无飞书通知记录" in script
|
||||||
|
assert "workflow-notification" in script
|
||||||
|
assert ".workflow-notification" in css
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun
|
from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun, WorkflowNotificationRecord
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -223,6 +223,31 @@ def test_frontend_renders_workflow_error_messages():
|
|||||||
assert ".workflow-error" in css
|
assert ".workflow-error" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_summary_status_includes_feishu_notification(client, django_user_model):
|
||||||
|
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||||
|
conversation = Conversation.objects.create(user=user, title="会话")
|
||||||
|
batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-FEISHU")
|
||||||
|
WorkflowNotificationRecord.objects.create(
|
||||||
|
workflow_type="file_summary",
|
||||||
|
workflow_batch_id=batch.pk,
|
||||||
|
workflow_batch_no=batch.batch_no,
|
||||||
|
workflow_status=batch.status,
|
||||||
|
dedupe_key=f"file_summary:{batch.pk}:{batch.status}",
|
||||||
|
trigger_user=user,
|
||||||
|
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
|
||||||
|
target="负责人",
|
||||||
|
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
|
||||||
|
message_title="自动汇总完成",
|
||||||
|
)
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/review-agent/file-summary/{batch.pk}/status/")
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["latest_notification"]["status_label"] == "飞书通知已发送"
|
||||||
|
assert payload["notifications"][0]["target"] == "负责人"
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_renders_workflow_batches_as_carousel():
|
def test_frontend_renders_workflow_batches_as_carousel():
|
||||||
script = open("static/js/app.js", encoding="utf-8").read()
|
script = open("static/js/app.js", encoding="utf-8").read()
|
||||||
css = open("static/css/login.css", encoding="utf-8").read()
|
css = open("static/css/login.css", encoding="utf-8").read()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from review_agent.models import (
|
|||||||
RegulatoryArtifact,
|
RegulatoryArtifact,
|
||||||
RegulatoryNotificationRecord,
|
RegulatoryNotificationRecord,
|
||||||
RegulatoryReviewBatch,
|
RegulatoryReviewBatch,
|
||||||
|
WorkflowNotificationRecord,
|
||||||
WorkflowNodeRun,
|
WorkflowNodeRun,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,3 +231,35 @@ def test_frontend_keeps_single_condition_confirmation_prompt():
|
|||||||
assert "data-condition-confirmation-card" in script
|
assert "data-condition-confirmation-card" in script
|
||||||
assert "removeStaleConditionConfirmationCards" in script
|
assert "removeStaleConditionConfirmationCards" in script
|
||||||
assert '[data-condition-confirmation-card]' in script
|
assert '[data-condition-confirmation-card]' in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_regulatory_status_includes_failed_feishu_notification(client, django_user_model):
|
||||||
|
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||||
|
conversation = Conversation.objects.create(user=user, title="会话")
|
||||||
|
summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-RR")
|
||||||
|
batch = RegulatoryReviewBatch.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
source_summary_batch=summary,
|
||||||
|
batch_no="RR-FEISHU",
|
||||||
|
)
|
||||||
|
WorkflowNotificationRecord.objects.create(
|
||||||
|
workflow_type="regulatory_review",
|
||||||
|
workflow_batch_id=batch.pk,
|
||||||
|
workflow_batch_no=batch.batch_no,
|
||||||
|
workflow_status=batch.status,
|
||||||
|
dedupe_key=f"regulatory_review:{batch.pk}:{batch.status}",
|
||||||
|
trigger_user=user,
|
||||||
|
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
|
||||||
|
target="负责人",
|
||||||
|
send_status=WorkflowNotificationRecord.SendStatus.FAILED,
|
||||||
|
message_title="法规核查完成",
|
||||||
|
error_message="bad receive_id",
|
||||||
|
)
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"/api/review-agent/regulatory-review/{batch.pk}/status/")
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["latest_notification"]["status_label"] == "飞书通知失败"
|
||||||
|
assert payload["latest_notification"]["error_message"] == "bad receive_id"
|
||||||
|
|||||||
Reference in New Issue
Block a user