feat(phase8-task201): EditTemplate + EditPlan 数据模型、仓储、迁移与测试 #141
@@ -0,0 +1,115 @@
|
||||
"""phase8 edit template plan
|
||||
|
||||
Revision ID: 016
|
||||
Revises: 015
|
||||
Create Date: 2026-07-01
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "016"
|
||||
down_revision = "015"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- edit_templates: 替换为 Phase 8 新 schema ---
|
||||
# 删除旧列
|
||||
op.drop_column("edit_templates", "project_id")
|
||||
op.drop_column("edit_templates", "target_duration")
|
||||
op.drop_column("edit_templates", "clip_count")
|
||||
op.drop_column("edit_templates", "is_active")
|
||||
op.drop_column("edit_templates", "created_by_user_id")
|
||||
op.drop_column("edit_templates", "metadata")
|
||||
|
||||
# 添加新列
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("template_type", sa.String(50), nullable=False, server_default="default"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("preview_url", sa.String(1000), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("sort_weight", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||
)
|
||||
|
||||
# 添加索引
|
||||
op.create_index("ix_edit_templates_template_type", "edit_templates", ["template_type"])
|
||||
op.create_index("ix_edit_templates_sort_weight", "edit_templates", ["sort_weight"])
|
||||
op.create_index("ix_edit_templates_status", "edit_templates", ["status"])
|
||||
|
||||
# --- edit_plans: 重建表(在 011 中被删除) ---
|
||||
op.create_table(
|
||||
"edit_plans",
|
||||
sa.Column("id", sa.String(32), primary_key=True),
|
||||
sa.Column("template_id", sa.String(32), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft", index=True),
|
||||
sa.Column("total_duration", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("edit_plans")
|
||||
|
||||
op.drop_index("ix_edit_templates_status", "edit_templates")
|
||||
op.drop_index("ix_edit_templates_sort_weight", "edit_templates")
|
||||
op.drop_index("ix_edit_templates_template_type", "edit_templates")
|
||||
|
||||
op.drop_column("edit_templates", "status")
|
||||
op.drop_column("edit_templates", "sort_weight")
|
||||
op.drop_column("edit_templates", "preview_url")
|
||||
op.drop_column("edit_templates", "config")
|
||||
op.drop_column("edit_templates", "template_type")
|
||||
|
||||
# 恢复旧列
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("project_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("target_duration", sa.Float(), nullable=False, server_default="30"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("clip_count", sa.Integer(), nullable=False, server_default="3"),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"edit_templates",
|
||||
sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"),
|
||||
)
|
||||
@@ -3,6 +3,8 @@
|
||||
from .asset_library_repository import SQLAlchemyAssetLibraryRepository
|
||||
from .asset_repository import SQLAlchemyAssetRepository
|
||||
from .classification_job_repository import SQLAlchemyClassificationJobRepository
|
||||
from .edit_plan_repository import SQLAlchemyEditPlanRepository
|
||||
from .edit_template_repository import SQLAlchemyEditTemplateRepository
|
||||
from .generated_video_repository import SQLAlchemyGeneratedVideoRepository
|
||||
from .generation_task_repository import SQLAlchemyGenerationTaskRepository
|
||||
from .ingest_job_repository import SQLAlchemyIngestJobRepository
|
||||
@@ -20,6 +22,8 @@ __all__ = [
|
||||
"SQLAlchemyAssetLibraryRepository",
|
||||
"SQLAlchemyAssetRepository",
|
||||
"SQLAlchemyClassificationJobRepository",
|
||||
"SQLAlchemyEditPlanRepository",
|
||||
"SQLAlchemyEditTemplateRepository",
|
||||
"SQLAlchemyGeneratedVideoRepository",
|
||||
"SQLAlchemyGenerationTaskRepository",
|
||||
"SQLAlchemyIngestJobRepository",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""SQLAlchemy implementation of EditPlanRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
|
||||
class SQLAlchemyEditPlanRepository:
|
||||
"""SQLAlchemy 剪辑计划仓储"""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list_by_template(
|
||||
self,
|
||||
template_id: str,
|
||||
*,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[EditPlan]:
|
||||
"""按模板列出剪辑计划"""
|
||||
query = self.session.query(EditPlanModel).filter(
|
||||
EditPlanModel.template_id == template_id,
|
||||
)
|
||||
if status:
|
||||
query = query.filter(EditPlanModel.status == status)
|
||||
query = query.order_by(EditPlanModel.created_at.desc())
|
||||
models = query.offset(skip).limit(limit).all()
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
*,
|
||||
status: Optional[EditPlanStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[EditPlan]:
|
||||
"""列出所有剪辑计划"""
|
||||
query = self.session.query(EditPlanModel)
|
||||
if status:
|
||||
query = query.filter(EditPlanModel.status == status)
|
||||
query = query.order_by(EditPlanModel.created_at.desc())
|
||||
models = query.offset(skip).limit(limit).all()
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
"""根据 ID 获取计划"""
|
||||
model = (
|
||||
self.session.query(EditPlanModel)
|
||||
.filter(EditPlanModel.id == plan_id)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
"""创建计划"""
|
||||
model = EditPlanModel(
|
||||
id=plan.id,
|
||||
template_id=plan.template_id,
|
||||
name=plan.name,
|
||||
status=plan.status,
|
||||
total_duration=plan.total_duration,
|
||||
config=plan.config,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
self.session.refresh(model)
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
"""更新计划"""
|
||||
model = (
|
||||
self.session.query(EditPlanModel)
|
||||
.filter(EditPlanModel.id == plan.id)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
raise ValueError(f"EditPlan {plan.id} not found")
|
||||
model.template_id = plan.template_id
|
||||
model.name = plan.name
|
||||
model.status = plan.status
|
||||
model.total_duration = plan.total_duration
|
||||
model.config = plan.config
|
||||
model.updated_at = plan.updated_at
|
||||
self.session.commit()
|
||||
self.session.refresh(model)
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
"""删除计划"""
|
||||
model = (
|
||||
self.session.query(EditPlanModel)
|
||||
.filter(EditPlanModel.id == plan_id)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def count(self, *, status: Optional[EditPlanStatus] = None) -> int:
|
||||
"""统计计划数量"""
|
||||
query = self.session.query(EditPlanModel)
|
||||
if status:
|
||||
query = query.filter(EditPlanModel.status == status)
|
||||
return query.count()
|
||||
|
||||
@staticmethod
|
||||
def _model_to_entity(model: EditPlanModel) -> EditPlan:
|
||||
return EditPlan(
|
||||
id=model.id,
|
||||
template_id=model.template_id,
|
||||
name=model.name,
|
||||
status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT,
|
||||
total_duration=model.total_duration or 0.0,
|
||||
config=model.config or {},
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""SQLAlchemy implementation of EditTemplateRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import EditTemplateModel
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
|
||||
|
||||
class SQLAlchemyEditTemplateRepository:
|
||||
"""SQLAlchemy 剪辑模板仓储"""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list_active(
|
||||
self,
|
||||
*,
|
||||
template_type: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[EditTemplate]:
|
||||
"""列出所有激活的模板"""
|
||||
query = self.session.query(EditTemplateModel).filter(
|
||||
EditTemplateModel.status == EditTemplateStatus.ACTIVE,
|
||||
)
|
||||
if template_type:
|
||||
query = query.filter(EditTemplateModel.template_type == template_type)
|
||||
query = query.order_by(
|
||||
EditTemplateModel.sort_weight.desc(),
|
||||
EditTemplateModel.created_at.desc(),
|
||||
)
|
||||
models = query.offset(skip).limit(limit).all()
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def list_all(
|
||||
self,
|
||||
*,
|
||||
template_type: Optional[str] = None,
|
||||
status: Optional[EditTemplateStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[EditTemplate]:
|
||||
"""列出所有模板(含非激活)"""
|
||||
query = self.session.query(EditTemplateModel)
|
||||
if template_type:
|
||||
query = query.filter(EditTemplateModel.template_type == template_type)
|
||||
if status:
|
||||
query = query.filter(EditTemplateModel.status == status)
|
||||
query = query.order_by(
|
||||
EditTemplateModel.sort_weight.desc(),
|
||||
EditTemplateModel.created_at.desc(),
|
||||
)
|
||||
models = query.offset(skip).limit(limit).all()
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def get(self, template_id: str) -> Optional[EditTemplate]:
|
||||
"""根据 ID 获取模板"""
|
||||
model = (
|
||||
self.session.query(EditTemplateModel)
|
||||
.filter(EditTemplateModel.id == template_id)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def create(self, template: EditTemplate) -> EditTemplate:
|
||||
"""创建模板"""
|
||||
model = EditTemplateModel(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
template_type=template.template_type,
|
||||
config=template.config,
|
||||
preview_url=template.preview_url,
|
||||
sort_weight=template.sort_weight,
|
||||
status=template.status,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
self.session.refresh(model)
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def update(self, template: EditTemplate) -> EditTemplate:
|
||||
"""更新模板"""
|
||||
model = (
|
||||
self.session.query(EditTemplateModel)
|
||||
.filter(EditTemplateModel.id == template.id)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
raise ValueError(f"EditTemplate {template.id} not found")
|
||||
model.name = template.name
|
||||
model.description = template.description
|
||||
model.template_type = template.template_type
|
||||
model.config = template.config
|
||||
model.preview_url = template.preview_url
|
||||
model.sort_weight = template.sort_weight
|
||||
model.status = template.status
|
||||
model.updated_at = template.updated_at
|
||||
self.session.commit()
|
||||
self.session.refresh(model)
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def delete(self, template_id: str) -> bool:
|
||||
"""删除模板"""
|
||||
model = (
|
||||
self.session.query(EditTemplateModel)
|
||||
.filter(EditTemplateModel.id == template_id)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def count(self, *, status: Optional[EditTemplateStatus] = None) -> int:
|
||||
"""统计模板数量"""
|
||||
query = self.session.query(EditTemplateModel)
|
||||
if status:
|
||||
query = query.filter(EditTemplateModel.status == status)
|
||||
return query.count()
|
||||
|
||||
@staticmethod
|
||||
def _model_to_entity(model: EditTemplateModel) -> EditTemplate:
|
||||
return EditTemplate(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
description=model.description or "",
|
||||
template_type=model.template_type or "default",
|
||||
config=model.config or {},
|
||||
preview_url=model.preview_url or "",
|
||||
sort_weight=model.sort_weight or 0,
|
||||
status=EditTemplateStatus(model.status) if model.status else EditTemplateStatus.ACTIVE,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
@@ -86,17 +86,39 @@ class AssetModel(Base):
|
||||
|
||||
|
||||
class EditTemplateModel(Base):
|
||||
"""Phase 8 剪辑模板 ORM 模型
|
||||
|
||||
全局模板库中的模板,定义剪辑风格、配置参数和预览信息。
|
||||
"""
|
||||
|
||||
__tablename__ = "edit_templates"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
name = Column(String(120), nullable=False)
|
||||
description = Column(Text, nullable=False, default="")
|
||||
target_duration = Column(Float, nullable=False, default=30)
|
||||
clip_count = Column(Integer, nullable=False, default=3)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_by_user_id = Column(String(32), nullable=False, default="")
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
template_type = Column(String(50), nullable=False, default="default", index=True)
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
preview_url = Column(String(1000), nullable=False, default="")
|
||||
sort_weight = Column(Integer, nullable=False, default=0, index=True)
|
||||
status = Column(String(20), nullable=False, default="active", index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class EditPlanModel(Base):
|
||||
"""Phase 8 剪辑计划 ORM 模型
|
||||
|
||||
基于某个 EditTemplate 创建的剪辑计划,包含具体的配置和状态追踪。
|
||||
"""
|
||||
|
||||
__tablename__ = "edit_plans"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
template_id = Column(String(32), nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="draft", index=True)
|
||||
total_duration = Column(Float, nullable=False, default=0.0)
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ from .classification import (
|
||||
)
|
||||
from .duplication import DuplicateSegment, DuplicationRecord
|
||||
from .editing_mode import EditingMode
|
||||
from .edit_plan import EditPlan, EditPlanStatus
|
||||
from .edit_template import EditTemplate, EditTemplateStatus
|
||||
from .entities import (
|
||||
Asset,
|
||||
AssetLibrary,
|
||||
@@ -35,6 +37,10 @@ __all__ = [
|
||||
"DuplicateSegment",
|
||||
"DuplicationRecord",
|
||||
"EditingMode",
|
||||
"EditPlan",
|
||||
"EditPlanStatus",
|
||||
"EditTemplate",
|
||||
"EditTemplateStatus",
|
||||
"GeneratedVideo",
|
||||
"GenerationTask",
|
||||
"GenerationTaskStatus",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""EditPlan domain entity for Phase 8 模板编排引擎."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from enum import StrEnum
|
||||
else:
|
||||
from enum import Enum
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
pass
|
||||
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class EditPlanStatus(StrEnum):
|
||||
"""剪辑计划状态"""
|
||||
|
||||
DRAFT = "draft"
|
||||
EDITING = "editing"
|
||||
RENDERING = "rendering"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EditPlan:
|
||||
"""Phase 8 剪辑计划实体
|
||||
|
||||
基于某个 EditTemplate 创建的剪辑计划,包含具体的配置和状态追踪。
|
||||
状态流转:draft → editing → rendering → completed / failed
|
||||
"""
|
||||
|
||||
id: str
|
||||
template_id: str
|
||||
name: str
|
||||
status: EditPlanStatus = EditPlanStatus.DRAFT
|
||||
total_duration: float = 0.0
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
template_id: str,
|
||||
name: str,
|
||||
*,
|
||||
config: dict[str, Any] | None = None,
|
||||
total_duration: float = 0.0,
|
||||
) -> EditPlan:
|
||||
"""创建新剪辑计划实例"""
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("计划名称不能为空")
|
||||
if not template_id.strip():
|
||||
raise ValueError("template_id 不能为空")
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
template_id=template_id.strip(),
|
||||
name=clean_name,
|
||||
status=EditPlanStatus.DRAFT,
|
||||
total_duration=total_duration,
|
||||
config=config or {},
|
||||
)
|
||||
|
||||
def start_editing(self) -> None:
|
||||
"""开始编辑"""
|
||||
if self.status != EditPlanStatus.DRAFT:
|
||||
raise ValueError(f"只有 draft 状态的计划可以开始编辑,当前状态: {self.status}")
|
||||
self.status = EditPlanStatus.EDITING
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def start_rendering(self) -> None:
|
||||
"""开始渲染"""
|
||||
if self.status != EditPlanStatus.EDITING:
|
||||
raise ValueError(f"只有 editing 状态的计划可以开始渲染,当前状态: {self.status}")
|
||||
self.status = EditPlanStatus.RENDERING
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_completed(self) -> None:
|
||||
"""标记为完成"""
|
||||
if self.status != EditPlanStatus.RENDERING:
|
||||
raise ValueError(f"只有 rendering 状态的计划可以标记完成,当前状态: {self.status}")
|
||||
self.status = EditPlanStatus.COMPLETED
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_failed(self) -> None:
|
||||
"""标记为失败"""
|
||||
if self.status != EditPlanStatus.RENDERING:
|
||||
raise ValueError(f"只有 rendering 状态的计划可以标记失败,当前状态: {self.status}")
|
||||
self.status = EditPlanStatus.FAILED
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def reset_to_draft(self) -> None:
|
||||
"""重置为草稿状态(仅从 failed 状态可重置)"""
|
||||
if self.status != EditPlanStatus.FAILED:
|
||||
raise ValueError(f"只有 failed 状态的计划可以重置,当前状态: {self.status}")
|
||||
self.status = EditPlanStatus.DRAFT
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""EditTemplate domain entity for Phase 8 模板编排引擎."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from enum import StrEnum
|
||||
else:
|
||||
from enum import Enum
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
pass
|
||||
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class EditTemplateStatus(StrEnum):
|
||||
"""模板状态"""
|
||||
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EditTemplate:
|
||||
"""Phase 8 剪辑模板实体
|
||||
|
||||
全局模板库中的模板,定义剪辑风格、配置参数和预览信息。
|
||||
不绑定到具体项目,可被多个 EditPlan 引用。
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
template_type: str = "default"
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
preview_url: str = ""
|
||||
sort_weight: int = 0
|
||||
status: EditTemplateStatus = EditTemplateStatus.ACTIVE
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
name: str,
|
||||
*,
|
||||
description: str = "",
|
||||
template_type: str = "default",
|
||||
config: dict[str, Any] | None = None,
|
||||
preview_url: str = "",
|
||||
sort_weight: int = 0,
|
||||
status: EditTemplateStatus = EditTemplateStatus.ACTIVE,
|
||||
) -> EditTemplate:
|
||||
"""创建新模板实例"""
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("模板名称不能为空")
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
name=clean_name,
|
||||
description=description.strip(),
|
||||
template_type=template_type.strip() or "default",
|
||||
config=config or {},
|
||||
preview_url=preview_url.strip(),
|
||||
sort_weight=sort_weight,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def activate(self) -> None:
|
||||
"""激活模板"""
|
||||
self.status = EditTemplateStatus.ACTIVE
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def deactivate(self) -> None:
|
||||
"""停用模板"""
|
||||
self.status = EditTemplateStatus.INACTIVE
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""模板是否处于激活状态"""
|
||||
return self.status == EditTemplateStatus.ACTIVE
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Unit tests for Phase 8 EditTemplate and EditPlan domain entities + repositories."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.edit_template_repository import (
|
||||
SQLAlchemyEditTemplateRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
|
||||
|
||||
# ── EditTemplate 领域实体测试 ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditTemplate:
|
||||
def test_create_success(self):
|
||||
t = EditTemplate.create("vlog模板", description="日常vlog", template_type="vlog")
|
||||
assert t.name == "vlog模板"
|
||||
assert t.description == "日常vlog"
|
||||
assert t.template_type == "vlog"
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
assert t.config == {}
|
||||
assert t.sort_weight == 0
|
||||
assert t.id # 自动生成
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
t = EditTemplate.create(" 模板 ")
|
||||
assert t.name == "模板"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
try:
|
||||
EditTemplate.create(" ")
|
||||
assert False, "应该抛出 ValueError"
|
||||
except ValueError as e:
|
||||
assert "模板名称不能为空" in str(e)
|
||||
|
||||
def test_activate_deactivate(self):
|
||||
t = EditTemplate.create("test")
|
||||
assert t.is_active is True
|
||||
t.deactivate()
|
||||
assert t.is_active is False
|
||||
assert t.status == EditTemplateStatus.INACTIVE
|
||||
t.activate()
|
||||
assert t.is_active is True
|
||||
assert t.status == EditTemplateStatus.ACTIVE
|
||||
|
||||
|
||||
# ── EditPlan 领域实体测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEditPlan:
|
||||
def test_create_success(self):
|
||||
p = EditPlan.create("tpl-1", "我的计划", config={"bgm": "happy"})
|
||||
assert p.template_id == "tpl-1"
|
||||
assert p.name == "我的计划"
|
||||
assert p.status == EditPlanStatus.DRAFT
|
||||
assert p.config == {"bgm": "happy"}
|
||||
assert p.total_duration == 0.0
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
try:
|
||||
EditPlan.create("tpl-1", " ")
|
||||
assert False, "应该抛出 ValueError"
|
||||
except ValueError as e:
|
||||
assert "计划名称不能为空" in str(e)
|
||||
|
||||
def test_create_empty_template_id_raises(self):
|
||||
try:
|
||||
EditPlan.create(" ", "test")
|
||||
assert False, "应该抛出 ValueError"
|
||||
except ValueError as e:
|
||||
assert "template_id 不能为空" in str(e)
|
||||
|
||||
def test_status_transitions_happy_path(self):
|
||||
p = EditPlan.create("tpl-1", "test")
|
||||
assert p.status == EditPlanStatus.DRAFT
|
||||
|
||||
p.start_editing()
|
||||
assert p.status == EditPlanStatus.EDITING
|
||||
|
||||
p.start_rendering()
|
||||
assert p.status == EditPlanStatus.RENDERING
|
||||
|
||||
p.mark_completed()
|
||||
assert p.status == EditPlanStatus.COMPLETED
|
||||
|
||||
def test_status_transitions_failure_path(self):
|
||||
p = EditPlan.create("tpl-1", "test")
|
||||
p.start_editing()
|
||||
p.start_rendering()
|
||||
p.mark_failed()
|
||||
assert p.status == EditPlanStatus.FAILED
|
||||
|
||||
p.reset_to_draft()
|
||||
assert p.status == EditPlanStatus.DRAFT
|
||||
|
||||
def test_invalid_transition_raises(self):
|
||||
p = EditPlan.create("tpl-1", "test")
|
||||
try:
|
||||
p.start_rendering() # draft → rendering 不合法
|
||||
assert False, "应该抛出 ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def test_mark_completed_from_non_rendering_raises(self):
|
||||
p = EditPlan.create("tpl-1", "test")
|
||||
try:
|
||||
p.mark_completed() # draft → completed 不合法
|
||||
assert False, "应该抛出 ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def test_reset_from_non_failed_raises(self):
|
||||
p = EditPlan.create("tpl-1", "test")
|
||||
try:
|
||||
p.reset_to_draft() # draft → draft 不合法
|
||||
assert False, "应该抛出 ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Repository 集成测试(内存 SQLite) ─────────────────────────────────
|
||||
|
||||
|
||||
class TestRepositories:
|
||||
def _make_session(self):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
return SessionLocal()
|
||||
|
||||
def test_edit_template_repository_crud(self):
|
||||
session = self._make_session()
|
||||
try:
|
||||
repo = SQLAlchemyEditTemplateRepository(session)
|
||||
|
||||
t = EditTemplate.create("模板A", description="测试模板")
|
||||
repo.create(t)
|
||||
|
||||
fetched = repo.get(t.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name == "模板A"
|
||||
assert fetched.description == "测试模板"
|
||||
|
||||
# list
|
||||
active = repo.list_active()
|
||||
assert len(active) == 1
|
||||
assert active[0].id == t.id
|
||||
|
||||
# update
|
||||
fetched.deactivate()
|
||||
repo.update(fetched)
|
||||
assert repo.get(t.id).status == EditTemplateStatus.INACTIVE
|
||||
assert len(repo.list_active()) == 0
|
||||
|
||||
# count
|
||||
assert repo.count() == 1
|
||||
|
||||
# delete
|
||||
assert repo.delete(t.id) is True
|
||||
assert repo.get(t.id) is None
|
||||
assert repo.count() == 0
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def test_edit_plan_repository_crud(self):
|
||||
session = self._make_session()
|
||||
try:
|
||||
repo = SQLAlchemyEditPlanRepository(session)
|
||||
|
||||
p = EditPlan.create("tpl-1", "计划A", config={"key": "val"})
|
||||
repo.create(p)
|
||||
|
||||
fetched = repo.get(p.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name == "计划A"
|
||||
assert fetched.config == {"key": "val"}
|
||||
assert fetched.status == EditPlanStatus.DRAFT
|
||||
|
||||
# list_by_template
|
||||
plans = repo.list_by_template("tpl-1")
|
||||
assert len(plans) == 1
|
||||
|
||||
# update status
|
||||
fetched.start_editing()
|
||||
repo.update(fetched)
|
||||
editing = repo.list_by_template("tpl-1", status=EditPlanStatus.EDITING)
|
||||
assert len(editing) == 1
|
||||
|
||||
# count
|
||||
assert repo.count() == 1
|
||||
assert repo.count(status=EditPlanStatus.EDITING) == 1
|
||||
assert repo.count(status=EditPlanStatus.DRAFT) == 0
|
||||
|
||||
# delete
|
||||
assert repo.delete(p.id) is True
|
||||
assert repo.get(p.id) is None
|
||||
finally:
|
||||
session.close()
|
||||
Reference in New Issue
Block a user