日志比较
This commit is contained in:
1 parent
aa3742b103
commit
c555b92089
13 files changed
+1702
-1
No files matched your search
@@ -57,7 +57,8 @@ from app.gateway.routers import (
|
||||
menu_overrides,
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
notification_templates,
|
||||
notifications,
|
||||
open_3qfx,
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
@@ -1609,6 +1610,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
||||
app.include_router(task_buttons.router)
|
||||
|
||||
# Notification template management (通知模板管理) at /api/notification-templates
|
||||
app.include_router(notification_templates.router)
|
||||
|
||||
# Microsoft Agent Framework 多智能体会商实验 (independent of the legacy
|
||||
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
|
||||
app.include_router(maf_roundtable.router)
|
||||
|
||||
@@ -358,6 +358,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.task_button_store = make_task_button_store(sf)
|
||||
|
||||
from deerflow.persistence.notification_templates import make_notification_template_store
|
||||
|
||||
app.state.notification_template_store = make_notification_template_store(sf)
|
||||
|
||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||
|
||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Notification template management API (通知模板管理).
|
||||
|
||||
Admin-only CRUD for message notification templates with variable validation,
|
||||
optimistic locking, and live preview.
|
||||
|
||||
Routes (prefix ``/api/notification-templates``):
|
||||
GET "" list templates (admin, paginated)
|
||||
GET "/{id}" get single template (admin)
|
||||
POST "" create template (admin)
|
||||
PUT "/{id}" update template (admin, optimistic lock)
|
||||
DELETE "/{id}" delete template (admin)
|
||||
POST "/{id}/duplicate" duplicate template (admin)
|
||||
POST "/preview" preview template with variables (admin)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_current_user, get_optional_user_from_request
|
||||
from deerflow.persistence.notification_templates import NotificationTemplateStore
|
||||
|
||||
router = APIRouter(prefix="/api/notification-templates", tags=["notification-templates"])
|
||||
|
||||
ALLOWED_VARIABLES = {"userName", "taskName", "taskId", "dueDate"}
|
||||
|
||||
_VAR_PATTERN = re.compile(r"\{\{([^}]*)\}\}")
|
||||
|
||||
|
||||
def _validate_template_variables(text: str, field_name: str) -> None:
|
||||
for m in _VAR_PATTERN.finditer(text):
|
||||
inner = m.group(1)
|
||||
if not inner or inner != inner.strip():
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"{field_name}: 变量格式错误 '{m.group(0)}',变量名不能为空或包含空格",
|
||||
)
|
||||
var_name = inner.strip()
|
||||
if var_name not in ALLOWED_VARIABLES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"{field_name}: 不支持的变量 '{{{{{var_name}}}}}',"
|
||||
f"仅支持: {', '.join(sorted(ALLOWED_VARIABLES))}",
|
||||
)
|
||||
|
||||
|
||||
def _validate_tags(tags: list[str]) -> list[str]:
|
||||
cleaned: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for t in tags:
|
||||
t = t.strip()
|
||||
if not t:
|
||||
continue
|
||||
if len(t) > 20:
|
||||
raise HTTPException(status_code=422, detail=f"标签长度超过 20: '{t}'")
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
cleaned.append(t)
|
||||
if len(cleaned) > 5:
|
||||
raise HTTPException(status_code=422, detail="标签数量不能超过 5 个")
|
||||
return cleaned
|
||||
|
||||
|
||||
# ── schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TemplateCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=50)
|
||||
title_template: str = Field(..., min_length=1, max_length=120)
|
||||
content_template: str = Field(..., min_length=1, max_length=5000)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class TemplateUpdateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=50)
|
||||
title_template: str = Field(..., min_length=1, max_length=120)
|
||||
content_template: str = Field(..., min_length=1, max_length=5000)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
expected_version: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
title_template: str
|
||||
content_template: str
|
||||
tags: list[str]
|
||||
enabled: bool
|
||||
version: int
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
|
||||
class TemplateListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[TemplateResponse]
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
title_template: str = Field(default="", max_length=120)
|
||||
content_template: str = Field(default="", max_length=5000)
|
||||
variables: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PreviewResponse(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
missing_variables: list[str]
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_store(request: Request) -> NotificationTemplateStore:
|
||||
store = getattr(request.app.state, "notification_template_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Notification template store not available")
|
||||
return store
|
||||
|
||||
|
||||
async def _require_admin(request: Request) -> str | None:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is not None and getattr(user, "system_role", None) != "admin":
|
||||
raise HTTPException(status_code=403, detail="通知模板管理仅限管理员")
|
||||
return await get_current_user(request)
|
||||
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=TemplateListResponse)
|
||||
async def list_templates(
|
||||
request: Request,
|
||||
keyword: str | None = Query(default=None),
|
||||
enabled: bool | None = Query(default=None),
|
||||
tag: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> TemplateListResponse:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
result = await store.list_templates(
|
||||
keyword=keyword, enabled=enabled, tag=tag, page=page, page_size=page_size
|
||||
)
|
||||
return TemplateListResponse(
|
||||
total=result["total"],
|
||||
items=[TemplateResponse(**r) for r in result["items"]],
|
||||
page=result["page"],
|
||||
page_size=result["page_size"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateResponse)
|
||||
async def get_template(request: Request, template_id: str) -> TemplateResponse:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
row = await store.get_template(template_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
return TemplateResponse(**row)
|
||||
|
||||
|
||||
@router.post("", response_model=TemplateResponse, status_code=201)
|
||||
async def create_template(request: Request, body: TemplateCreateRequest) -> TemplateResponse:
|
||||
user_id = await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
|
||||
name = body.name.strip()
|
||||
if len(name) < 2:
|
||||
raise HTTPException(status_code=422, detail="name 去除首尾空格后长度不得少于 2")
|
||||
|
||||
if await store.name_exists(name):
|
||||
raise HTTPException(status_code=409, detail=f"模板名称已存在: {name}")
|
||||
|
||||
_validate_template_variables(body.title_template, "title_template")
|
||||
_validate_template_variables(body.content_template, "content_template")
|
||||
tags = _validate_tags(body.tags)
|
||||
|
||||
row = await store.create_template(
|
||||
{
|
||||
"name": name,
|
||||
"title_template": body.title_template,
|
||||
"content_template": body.content_template,
|
||||
"tags": tags,
|
||||
"enabled": body.enabled,
|
||||
},
|
||||
created_by=user_id,
|
||||
)
|
||||
return TemplateResponse(**row)
|
||||
|
||||
|
||||
@router.put("/{template_id}", response_model=TemplateResponse)
|
||||
async def update_template(request: Request, template_id: str, body: TemplateUpdateRequest) -> TemplateResponse:
|
||||
user_id = await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
|
||||
name = body.name.strip()
|
||||
if len(name) < 2:
|
||||
raise HTTPException(status_code=422, detail="name 去除首尾空格后长度不得少于 2")
|
||||
|
||||
if await store.name_exists(name, exclude_id=template_id):
|
||||
raise HTTPException(status_code=409, detail=f"模板名称已存在: {name}")
|
||||
|
||||
_validate_template_variables(body.title_template, "title_template")
|
||||
_validate_template_variables(body.content_template, "content_template")
|
||||
tags = _validate_tags(body.tags)
|
||||
|
||||
try:
|
||||
row = await store.update_template(
|
||||
template_id,
|
||||
{
|
||||
"name": name,
|
||||
"title_template": body.title_template,
|
||||
"content_template": body.content_template,
|
||||
"tags": tags,
|
||||
"enabled": body.enabled,
|
||||
},
|
||||
expected_version=body.expected_version,
|
||||
updated_by=user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
if msg == "NOT_FOUND":
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
if msg == "VERSION_CONFLICT":
|
||||
latest = await store.get_template(template_id)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "版本冲突,服务器版本已更新",
|
||||
"server": TemplateResponse(**latest).model_dump() if latest else None,
|
||||
},
|
||||
)
|
||||
raise
|
||||
return TemplateResponse(**row)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=204)
|
||||
async def delete_template(request: Request, template_id: str) -> None:
|
||||
await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
deleted = await store.delete_template(template_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
|
||||
|
||||
@router.post("/{template_id}/duplicate", response_model=TemplateResponse, status_code=201)
|
||||
async def duplicate_template(request: Request, template_id: str) -> TemplateResponse:
|
||||
user_id = await _require_admin(request)
|
||||
store = _get_store(request)
|
||||
try:
|
||||
row = await store.duplicate_template(template_id, created_by=user_id)
|
||||
except ValueError as exc:
|
||||
if str(exc) == "NOT_FOUND":
|
||||
raise HTTPException(status_code=404, detail="模板不存在")
|
||||
raise
|
||||
return TemplateResponse(**row)
|
||||
|
||||
|
||||
@router.post("/preview", response_model=PreviewResponse)
|
||||
async def preview_template(request: Request, body: PreviewRequest) -> PreviewResponse:
|
||||
await _require_admin(request)
|
||||
|
||||
_validate_template_variables(body.title_template, "title_template")
|
||||
_validate_template_variables(body.content_template, "content_template")
|
||||
|
||||
all_vars = set()
|
||||
for text in (body.title_template, body.content_template):
|
||||
for m in _VAR_PATTERN.finditer(text):
|
||||
all_vars.add(m.group(1).strip())
|
||||
|
||||
missing = sorted(all_vars - set(body.variables.keys()))
|
||||
|
||||
def _replace(text: str) -> str:
|
||||
def _sub(m: re.Match) -> str:
|
||||
var_name = m.group(1).strip()
|
||||
return body.variables.get(var_name, m.group(0))
|
||||
return _VAR_PATTERN.sub(_sub, text)
|
||||
|
||||
return PreviewResponse(
|
||||
title=_replace(body.title_template),
|
||||
content=_replace(body.content_template),
|
||||
missing_variables=missing,
|
||||
)
|
||||
+2
@@ -53,6 +53,7 @@ from deerflow.persistence.maf_roundtable.model import (
|
||||
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
|
||||
from deerflow.persistence.models.run_event import RunEventRow
|
||||
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||
from deerflow.persistence.notifications.model import NotificationRow
|
||||
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
|
||||
from deerflow.persistence.position_roles.model import PositionRoleRow
|
||||
@@ -124,6 +125,7 @@ __all__ = [
|
||||
"MafRoundtableSessionRow",
|
||||
"MenuOverrideRow",
|
||||
"NotificationRow",
|
||||
"NotificationTemplateRow",
|
||||
"ParallelAgentTaskRow",
|
||||
"PositionRoundtableNodeRow",
|
||||
"PositionRoundtableSessionRow",
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Notification template persistence (通知模板管理)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
|
||||
from deerflow.persistence.notification_templates.memory import MemoryNotificationTemplateStore
|
||||
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||
from deerflow.persistence.notification_templates.sql import NotificationTemplateRepository
|
||||
|
||||
__all__ = [
|
||||
"MemoryNotificationTemplateStore",
|
||||
"NotificationTemplateRepository",
|
||||
"NotificationTemplateRow",
|
||||
"NotificationTemplateStore",
|
||||
"make_notification_template_store",
|
||||
]
|
||||
|
||||
|
||||
def make_notification_template_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryNotificationTemplateStore()
|
||||
return NotificationTemplateRepository(session_factory)
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"""Abstract notification template persistence API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NotificationTemplateStore(ABC):
|
||||
@abstractmethod
|
||||
async def list_templates(
|
||||
self,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
tag: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_template(
|
||||
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_template(self, template_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
|
||||
raise NotImplementedError
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
"""In-memory notification template store (used when no DB is configured / tests)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
|
||||
|
||||
|
||||
def _normalize(data: dict[str, Any], *, created_by: str | None = None, updated_by: str | None = None) -> dict[str, Any]:
|
||||
now = datetime.now(UTC).isoformat()
|
||||
return {
|
||||
"id": str(data.get("id") or uuid.uuid4().hex),
|
||||
"name": str(data["name"]),
|
||||
"title_template": str(data.get("title_template") or ""),
|
||||
"content_template": str(data.get("content_template") or ""),
|
||||
"tags": list(data.get("tags") or []),
|
||||
"enabled": bool(data.get("enabled", True)),
|
||||
"version": int(data.get("version", 1)),
|
||||
"created_by": data.get("created_by") or created_by,
|
||||
"updated_by": updated_by or data.get("updated_by") or created_by,
|
||||
"created_at": data.get("created_at") or now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
|
||||
class MemoryNotificationTemplateStore(NotificationTemplateStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def list_templates(
|
||||
self,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
tag: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
items = list(self._rows.values())
|
||||
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
items = [
|
||||
r for r in items
|
||||
if kw in r["name"].lower() or kw in r["title_template"].lower() or kw in r["content_template"].lower()
|
||||
]
|
||||
|
||||
if enabled is not None:
|
||||
items = [r for r in items if r["enabled"] == enabled]
|
||||
|
||||
if tag:
|
||||
items = [r for r in items if tag in r.get("tags", [])]
|
||||
|
||||
items.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
total = len(items)
|
||||
offset = (page - 1) * page_size
|
||||
page_items = items[offset : offset + page_size]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [dict(r) for r in page_items],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
row = self._rows.get(template_id)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||
row = _normalize(data, created_by=created_by)
|
||||
self._rows[row["id"]] = row
|
||||
return dict(row)
|
||||
|
||||
async def update_template(
|
||||
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
row = self._rows.get(template_id)
|
||||
if row is None:
|
||||
raise ValueError("NOT_FOUND")
|
||||
if row["version"] != expected_version:
|
||||
raise ValueError("VERSION_CONFLICT")
|
||||
for key in ("name", "title_template", "content_template", "tags", "enabled"):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
row["version"] = row["version"] + 1
|
||||
row["updated_by"] = updated_by
|
||||
row["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return dict(row)
|
||||
|
||||
async def delete_template(self, template_id: str) -> bool:
|
||||
if template_id in self._rows:
|
||||
del self._rows[template_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
|
||||
row = self._rows.get(template_id)
|
||||
if row is None:
|
||||
raise ValueError("NOT_FOUND")
|
||||
base_name = f"{row['name']} - 副本"
|
||||
new_name = base_name
|
||||
counter = 1
|
||||
existing_names = {r["name"] for r in self._rows.values()}
|
||||
while new_name in existing_names:
|
||||
counter += 1
|
||||
new_name = f"{base_name} {counter}"
|
||||
|
||||
new_row = _normalize(
|
||||
{
|
||||
"name": new_name,
|
||||
"title_template": row["title_template"],
|
||||
"content_template": row["content_template"],
|
||||
"tags": list(row.get("tags", [])),
|
||||
"enabled": row["enabled"],
|
||||
},
|
||||
created_by=created_by,
|
||||
)
|
||||
self._rows[new_row["id"]] = new_row
|
||||
return dict(new_row)
|
||||
|
||||
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
|
||||
for rid, r in self._rows.items():
|
||||
if r["name"] == name and (exclude_id is None or rid != exclude_id):
|
||||
return True
|
||||
return False
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"""ORM model for notification templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableJSON
|
||||
|
||||
|
||||
class NotificationTemplateRow(Base):
|
||||
__tablename__ = "notification_templates"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
title_template: Mapped[str] = mapped_column(String(240), default="")
|
||||
content_template: Mapped[str] = mapped_column(Text, default="")
|
||||
tags: Mapped[list] = mapped_column(PortableJSON(), default=list)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64))
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||
)
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
"""SQLAlchemy-backed notification template store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
|
||||
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
|
||||
|
||||
|
||||
def _to_dict(row: NotificationTemplateRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("created_at", "updated_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
data["tags"] = data.get("tags") or []
|
||||
data["enabled"] = bool(data.get("enabled", True))
|
||||
data["version"] = int(data.get("version", 1))
|
||||
return data
|
||||
|
||||
|
||||
class NotificationTemplateRepository(NotificationTemplateStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_templates(
|
||||
self,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
tag: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
stmt = select(NotificationTemplateRow)
|
||||
count_stmt = select(func.count()).select_from(NotificationTemplateRow)
|
||||
|
||||
if keyword:
|
||||
kw = f"%{keyword}%"
|
||||
filt = or_(
|
||||
NotificationTemplateRow.name.ilike(kw),
|
||||
NotificationTemplateRow.title_template.ilike(kw),
|
||||
NotificationTemplateRow.content_template.ilike(kw),
|
||||
)
|
||||
stmt = stmt.where(filt)
|
||||
count_stmt = count_stmt.where(filt)
|
||||
|
||||
if enabled is not None:
|
||||
stmt = stmt.where(NotificationTemplateRow.enabled == enabled)
|
||||
count_stmt = count_stmt.where(NotificationTemplateRow.enabled == enabled)
|
||||
|
||||
if tag:
|
||||
all_rows_stmt = stmt
|
||||
all_rows_count_stmt = count_stmt
|
||||
# Filter by tag in JSON array — load all and filter in Python for portability
|
||||
stmt = select(NotificationTemplateRow)
|
||||
count_stmt = select(func.count()).select_from(NotificationTemplateRow)
|
||||
if keyword:
|
||||
kw = f"%{keyword}%"
|
||||
filt = or_(
|
||||
NotificationTemplateRow.name.ilike(kw),
|
||||
NotificationTemplateRow.title_template.ilike(kw),
|
||||
NotificationTemplateRow.content_template.ilike(kw),
|
||||
)
|
||||
stmt = stmt.where(filt)
|
||||
count_stmt = count_stmt.where(filt)
|
||||
if enabled is not None:
|
||||
stmt = stmt.where(NotificationTemplateRow.enabled == enabled)
|
||||
count_stmt = count_stmt.where(enabled == NotificationTemplateRow.enabled)
|
||||
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt.order_by(NotificationTemplateRow.created_at.desc()))
|
||||
all_rows = list(result.scalars())
|
||||
filtered = [r for r in all_rows if tag in (r.tags or [])]
|
||||
total = len(filtered)
|
||||
offset = (page - 1) * page_size
|
||||
page_rows = filtered[offset : offset + page_size]
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_to_dict(r) for r in page_rows],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
async with self._sf() as session:
|
||||
total_result = await session.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
stmt = stmt.order_by(NotificationTemplateRow.created_at.desc()).offset(offset).limit(page_size)
|
||||
result = await session.execute(stmt)
|
||||
rows = list(result.scalars())
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [_to_dict(r) for r in rows],
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
async def get_template(self, template_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(NotificationTemplateRow, template_id)
|
||||
return _to_dict(row) if row else None
|
||||
|
||||
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
|
||||
row = NotificationTemplateRow(
|
||||
id=str(data.get("id") or uuid.uuid4().hex),
|
||||
name=str(data["name"]),
|
||||
title_template=str(data.get("title_template") or ""),
|
||||
content_template=str(data.get("content_template") or ""),
|
||||
tags=list(data.get("tags") or []),
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
version=1,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_template(
|
||||
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(NotificationTemplateRow, template_id)
|
||||
if row is None:
|
||||
raise ValueError("NOT_FOUND")
|
||||
if row.version != expected_version:
|
||||
raise ValueError("VERSION_CONFLICT")
|
||||
if "name" in data:
|
||||
row.name = str(data["name"])
|
||||
if "title_template" in data:
|
||||
row.title_template = str(data["title_template"])
|
||||
if "content_template" in data:
|
||||
row.content_template = str(data["content_template"])
|
||||
if "tags" in data:
|
||||
row.tags = list(data["tags"])
|
||||
if "enabled" in data:
|
||||
row.enabled = bool(data["enabled"])
|
||||
row.version = row.version + 1
|
||||
row.updated_by = updated_by
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_template(self, template_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(NotificationTemplateRow, template_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(NotificationTemplateRow, template_id)
|
||||
if row is None:
|
||||
raise ValueError("NOT_FOUND")
|
||||
base_name = f"{row.name} - 副本"
|
||||
new_name = base_name
|
||||
counter = 1
|
||||
while True:
|
||||
existing = await session.execute(
|
||||
select(NotificationTemplateRow).where(NotificationTemplateRow.name == new_name)
|
||||
)
|
||||
if existing.scalar_one_or_none() is None:
|
||||
break
|
||||
counter += 1
|
||||
new_name = f"{base_name} {counter}"
|
||||
|
||||
new_row = NotificationTemplateRow(
|
||||
id=uuid.uuid4().hex,
|
||||
name=new_name,
|
||||
title_template=row.title_template,
|
||||
content_template=row.content_template,
|
||||
tags=list(row.tags or []),
|
||||
enabled=row.enabled,
|
||||
version=1,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
)
|
||||
session.add(new_row)
|
||||
await session.commit()
|
||||
await session.refresh(new_row)
|
||||
return _to_dict(new_row)
|
||||
|
||||
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
|
||||
async with self._sf() as session:
|
||||
stmt = select(NotificationTemplateRow).where(NotificationTemplateRow.name == name)
|
||||
if exclude_id:
|
||||
stmt = stmt.where(NotificationTemplateRow.id != exclude_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
Reference in new issue
Block a user