Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89e9e24a5e | |||
| c243c4dd58 | |||
| a314a42405 | |||
| 2d83b9385c |
@@ -6,6 +6,7 @@ from app.api.routes.assets import router as assets_router
|
||||
from app.api.routes.auth import router as auth_router
|
||||
from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||
from app.api.routes.clips_standalone import router as clips_standalone_router
|
||||
from app.api.routes.cover_templates import router as cover_templates_router
|
||||
from app.api.routes.duplication import router as duplication_router
|
||||
from app.api.routes.feature_flags import router as feature_flags_router
|
||||
@@ -156,6 +157,10 @@ api_router.include_router(
|
||||
prefix="/templates",
|
||||
tags=["Template"],
|
||||
)
|
||||
api_router.include_router(
|
||||
clips_standalone_router,
|
||||
tags=["Clips"],
|
||||
)
|
||||
api_router.include_router(
|
||||
templates_editor_router,
|
||||
prefix="/templates/{template_id}/editor",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""P0 fix: 共享的默认模板自动兜底逻辑。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_or_create_default_template_id(db: Session, user_id: str) -> Optional[str]:
|
||||
"""为用户查找一个有效模板;若不存在则自动创建默认配音模板。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel, TemplateModel
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
||||
from packages.application.template.commands import CreateTemplateCommand, SegmentCommand
|
||||
from packages.application.template.use_cases import CreateTemplateUseCase
|
||||
|
||||
existing = (
|
||||
db.query(TemplateModel)
|
||||
.filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True))
|
||||
.order_by(TemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
has_seg = (
|
||||
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first()
|
||||
)
|
||||
if has_seg:
|
||||
return existing.id
|
||||
|
||||
try:
|
||||
repo = SQLAlchemyTemplateRepository(db)
|
||||
cmd = CreateTemplateCommand(
|
||||
user_id=user_id,
|
||||
name="默认配音模板",
|
||||
mode="voice_over",
|
||||
category="default",
|
||||
tags=[],
|
||||
title_config={},
|
||||
subtitle_config={},
|
||||
bgm_config={},
|
||||
estimated_duration=0.0,
|
||||
segments=[SegmentCommand(segment_order=0, duration_min=1.0, duration_max=30.0)],
|
||||
)
|
||||
tpl = CreateTemplateUseCase(repo).execute(cmd)
|
||||
db.commit()
|
||||
logger.info("[default-template] 自动创建默认模板: user=%s tpl=%s", user_id, tpl.id)
|
||||
return tpl.id
|
||||
except Exception:
|
||||
db.rollback()
|
||||
existing2 = (
|
||||
db.query(TemplateModel)
|
||||
.filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True))
|
||||
.order_by(TemplateModel.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
if existing2 is not None:
|
||||
has_seg2 = (
|
||||
db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing2.id).first()
|
||||
)
|
||||
if has_seg2:
|
||||
return existing2.id
|
||||
logger.exception("[default-template] 自动创建默认模板失败: user=%s", user_id)
|
||||
return None
|
||||
@@ -0,0 +1,93 @@
|
||||
"""独立的从素材创建片段端点(不依赖template_id路径参数).
|
||||
|
||||
POST /api/v1/clips/from-assets
|
||||
- 与 /api/v1/templates/{template_id}/editor/clips/from-assets 功能一致
|
||||
- 区别:template_id 从 body 传入(可选),为空时后端自动创建/查找默认模板
|
||||
- 解决前端首次加载时 templateId 为空导致双斜杠 404 的问题
|
||||
- 内部复用 resolve_draft_plan_id 和 create_clips_from_assets_editor 的核心逻辑
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_asset_repository, get_db_session
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
|
||||
from ._default_template import get_or_create_default_template_id
|
||||
from .templates_editor.clips import create_clips_from_assets_editor
|
||||
from .templates_editor.dependencies import resolve_draft_plan_id
|
||||
from .templates_editor.schemas import ClipsFromAssetsRequest, ClipsFromAssetsResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["Clips"])
|
||||
|
||||
|
||||
class StandaloneClipsRequest(ClipsFromAssetsRequest):
|
||||
"""扩展请求:template_id 可选(不传则后端自动兜底默认模板)。"""
|
||||
|
||||
template_id: str | None = None
|
||||
|
||||
|
||||
def _get_editor_services_direct(db: Session) -> tuple[EditTemplateService, EditPlanService]:
|
||||
"""直接构造服务实例(非 Depends 版本,供独立端点内部调用)。"""
|
||||
return EditTemplateService(db), EditPlanService(db)
|
||||
|
||||
|
||||
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
|
||||
def create_clips_from_assets(
|
||||
body: StandaloneClipsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
|
||||
) -> ClipsFromAssetsResponse:
|
||||
"""从素材批量创建片段(template_id 可选,为空自动兜底)。"""
|
||||
user_id = str(current_user.user.id)
|
||||
services = _get_editor_services_direct(db)
|
||||
|
||||
# 1. 解析/兜底 template_id,拿到 plan_id
|
||||
template_id = (body.template_id or "").strip()
|
||||
if not template_id:
|
||||
template_id = get_or_create_default_template_id(db, user_id)
|
||||
if not template_id:
|
||||
from fastapi import HTTPException
|
||||
from fastapi import status as http_status
|
||||
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id=template_id,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False, # 上面已兜底过
|
||||
)
|
||||
|
||||
# 2. 构造标准化请求(去除独立端扩展字段),复用原端点核心逻辑
|
||||
core_body = ClipsFromAssetsRequest(
|
||||
asset_ids=body.asset_ids,
|
||||
clip_type=body.clip_type,
|
||||
clip_count=body.clip_count,
|
||||
required_clips_count=body.required_clips_count,
|
||||
)
|
||||
|
||||
# 3. 直接调用原端点函数(此时所有 Depends 依赖已手动传入)
|
||||
return create_clips_from_assets_editor(
|
||||
template_id=template_id,
|
||||
body=core_body,
|
||||
background_tasks=background_tasks,
|
||||
plan_id=plan_id,
|
||||
services=services,
|
||||
asset_repo=asset_repo,
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
)
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
保留:
|
||||
- GET /templates:列表查询(生成页使用)
|
||||
- 默认模板自动创建兜底逻辑(``_get_or_create_default_template_id`` 位于
|
||||
generation_variant_plans.py)继续通过 service 层 CreateTemplateUseCase 工作,
|
||||
但不再暴露模板 CRUD / 分类 / 标签 / 收藏 / 复制 / 校验 / 使用统计等 HTTP 端点
|
||||
(前端 PR#1911 已删除 my-templates / editing-planner / templates 管理页面)。
|
||||
- 默认模板自动创建兜底逻辑(复用 _default_template.get_or_create_default_template_id)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,10 +11,7 @@ import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.template import (
|
||||
ListTemplatesResponse,
|
||||
TemplateResponse,
|
||||
)
|
||||
from app.schemas.template import ListTemplatesResponse, TemplateResponse
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -27,6 +21,8 @@ from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemp
|
||||
from packages.application.template.commands import ListTemplatesFilter
|
||||
from packages.application.template.use_cases import CountTemplatesUseCase, ListTemplatesUseCase
|
||||
|
||||
from ._default_template import get_or_create_default_template_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -43,16 +39,20 @@ def list_templates(
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页条数,默认 20"),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repo: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""获取用户可用的模板列表(仅返回 active 状态)。"""
|
||||
user_id = str(current_user.user.id)
|
||||
# P0 兜底:无有效模板时自动创建默认配音模板
|
||||
get_or_create_default_template_id(db, user_id)
|
||||
|
||||
list_uc = ListTemplatesUseCase(repo)
|
||||
count_uc = CountTemplatesUseCase(repo)
|
||||
filters = ListTemplatesFilter(
|
||||
category=category,
|
||||
tag=tag,
|
||||
mode=mode,
|
||||
valid_only=True, # 仅返回 active + 有片段配置
|
||||
valid_only=True,
|
||||
)
|
||||
skip = (page - 1) * page_size
|
||||
templates = list_uc.execute(user_id, skip=skip, limit=page_size, filter=filters)
|
||||
|
||||
@@ -28,7 +28,7 @@ from .adjustments import router as adjustments_router
|
||||
from .ai_features import router as ai_features_router
|
||||
from .bgm import router as bgm_router
|
||||
from .clips import router as clips_router
|
||||
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
||||
from .dependencies import get_draft_plan_id, get_editor_services, resolve_draft_plan_id # noqa: F401
|
||||
from .draft import router as draft_router
|
||||
from .effects import router as effects_router
|
||||
from .export import router as export_router
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
核心依赖:
|
||||
- get_editor_services: 获取模板+计划服务
|
||||
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
|
||||
- resolve_draft_plan_id: 纯函数版本(可在非依赖场景复用)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
@@ -20,6 +22,8 @@ from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
|
||||
from .._default_template import get_or_create_default_template_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -30,27 +34,37 @@ def get_editor_services(
|
||||
return EditTemplateService(db), EditPlanService(db)
|
||||
|
||||
|
||||
def get_draft_plan_id(
|
||||
template_id: str,
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
def resolve_draft_plan_id(
|
||||
template_id: Optional[str],
|
||||
services: tuple[EditTemplateService, EditPlanService],
|
||||
current_user: AuthenticatedUser,
|
||||
db: Session,
|
||||
auto_create_default: bool = True,
|
||||
) -> str:
|
||||
"""路径依赖:根据 template_id 获取或创建草稿,返回 plan_id.
|
||||
"""纯函数:根据 template_id 获取或创建草稿 plan_id(可在独立端点复用)。
|
||||
|
||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
||||
确保 template_id → plan_id 的映射始终存在。
|
||||
|
||||
模板读取遵循单一数据源、显式判定(不使用异常降级):
|
||||
- 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True);
|
||||
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
|
||||
模板不存在、已删除或不归属于当前用户时,一律返回 404。
|
||||
- template_id 为空且 auto_create_default=True 时,自动兜底创建/查找默认模板
|
||||
- 返回有效 plan_id;模板不存在/无权限时抛 404
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
|
||||
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
|
||||
# P0 兜底:空 template_id 时自动创建/查找默认模板
|
||||
if not template_id and auto_create_default:
|
||||
template_id = get_or_create_default_template_id(db, user_id)
|
||||
if not template_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="无法自动创建默认模板,请刷新页面重试",
|
||||
)
|
||||
|
||||
if not template_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="缺少 template_id 参数",
|
||||
)
|
||||
|
||||
# 0. 门禁:校验模板存在且可访问
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get_active(template_id, user_id)
|
||||
is_global_template = tpl_svc.get_template(template_id) is not None
|
||||
@@ -73,7 +87,6 @@ def get_draft_plan_id(
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
# 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段)
|
||||
pseudo_template = EditTemplate(
|
||||
id=old_template.id,
|
||||
name=old_template.name,
|
||||
@@ -81,7 +94,6 @@ def get_draft_plan_id(
|
||||
status=EditTemplateStatus.ACTIVE,
|
||||
)
|
||||
|
||||
# 将旧模板 segments 转换为 clip_configs
|
||||
clip_configs: list[TemplateClipConfig] = []
|
||||
for seg in old_template.segments or []:
|
||||
clip_configs.append(
|
||||
@@ -105,7 +117,6 @@ def get_draft_plan_id(
|
||||
)
|
||||
plan = result["plan"]
|
||||
|
||||
# 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑)
|
||||
plan_svc.update_plan_config(plan.id, {"is_template_draft": True})
|
||||
|
||||
logger.info(
|
||||
@@ -115,3 +126,19 @@ def get_draft_plan_id(
|
||||
user_id,
|
||||
)
|
||||
return plan.id
|
||||
|
||||
|
||||
def get_draft_plan_id(
|
||||
template_id: str,
|
||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> str:
|
||||
"""FastAPI 依赖:路径参数 {template_id} 下获取/创建草稿 plan_id。"""
|
||||
return resolve_draft_plan_id(
|
||||
template_id=template_id,
|
||||
services=services,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
auto_create_default=False, # 路径参数路由不兜底(路径里本就应有值)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""P0 #1922 默认模板自动兜底单元测试。
|
||||
|
||||
覆盖 resolve_draft_plan_id 的核心路径(使用可mock的外部依赖):
|
||||
- 空 tid 时调用 get_or_create_default_template_id 兜底
|
||||
- auto_create_default=False 时空tid抛400
|
||||
- 模板不存在/无权访问抛404
|
||||
- 已有草稿直接返回 plan_id
|
||||
- 旧模板(templates表)走 generate_from_template 创建草稿
|
||||
|
||||
_get_or_create_default_template_id 是薄SQL封装,其逻辑分支通过依赖注入路径间接覆盖;
|
||||
直接SQL分支通过integration test/容器验证,不做单元级mock(避免内部import脆弱mock)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from app.api.routes.templates_editor.dependencies import resolve_draft_plan_id
|
||||
|
||||
|
||||
class TestResolveDraftPlanId:
|
||||
"""resolve_draft_plan_id 行为。"""
|
||||
|
||||
def _make_services(self, global_tpl=None, draft=None):
|
||||
tpl_svc = MagicMock()
|
||||
plan_svc = MagicMock()
|
||||
tpl_svc.get_template.return_value = global_tpl
|
||||
tpl_svc.get_template_draft.return_value = draft
|
||||
return (tpl_svc, plan_svc)
|
||||
|
||||
def _make_user(self, uid: str = "user-1"):
|
||||
u = MagicMock()
|
||||
u.user.id = uid
|
||||
return u
|
||||
|
||||
def test_empty_tid_auto_fallback_creates_default_then_draft(self):
|
||||
"""template_id 为空 + auto_create_default=True:先兜底拿到tid,再走旧模板草稿创建。"""
|
||||
db = MagicMock()
|
||||
services = self._make_services()
|
||||
user = self._make_user()
|
||||
old_tpl = MagicMock()
|
||||
old_tpl.id = "tpl-auto"
|
||||
old_tpl.name = "默认配音模板"
|
||||
old_tpl.mode = "voice_over"
|
||||
old_tpl.segments = [MagicMock(id="seg-1", segment_order=0, duration_min=1.0, duration_max=30.0)]
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_active.return_value = old_tpl
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.id = "plan-new"
|
||||
mock_generator = MagicMock()
|
||||
mock_generator.generate_from_template.return_value = {"plan": mock_plan}
|
||||
|
||||
with (
|
||||
patch("app.api.routes.templates_editor.dependencies.SQLAlchemyTemplateRepository", return_value=mock_repo),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.dependencies.get_or_create_default_template_id",
|
||||
return_value="tpl-auto",
|
||||
) as mock_fb,
|
||||
patch("app.services.plan_generator_service.PlanGeneratorService", return_value=mock_generator),
|
||||
):
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id="",
|
||||
services=services,
|
||||
current_user=user,
|
||||
db=db,
|
||||
auto_create_default=True,
|
||||
)
|
||||
|
||||
assert plan_id == "plan-new"
|
||||
mock_fb.assert_called_once_with(db, "user-1")
|
||||
|
||||
def test_empty_tid_without_auto_raises_400(self):
|
||||
"""auto_create_default=False 且 tid 为空 → 400。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
db = MagicMock()
|
||||
services = self._make_services()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
resolve_draft_plan_id(
|
||||
template_id="",
|
||||
services=services,
|
||||
current_user=self._make_user(),
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_missing_template_raises_404(self):
|
||||
"""tid 存在但模板找不到/无权访问 → 404。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
db = MagicMock()
|
||||
services = self._make_services() # get_template returns None
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_active.return_value = None
|
||||
with patch("app.api.routes.templates_editor.dependencies.SQLAlchemyTemplateRepository", return_value=mock_repo):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
resolve_draft_plan_id(
|
||||
template_id="tpl-ghost",
|
||||
services=services,
|
||||
current_user=self._make_user(),
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
def test_existing_draft_returned_directly(self):
|
||||
"""已有模板草稿直接返回 plan_id,不重复创建。"""
|
||||
db = MagicMock()
|
||||
draft = MagicMock()
|
||||
draft.id = "plan-existing"
|
||||
services = self._make_services(draft=draft)
|
||||
old_tpl = MagicMock()
|
||||
old_tpl.id = "tpl-1"
|
||||
old_tpl.segments = []
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_active.return_value = old_tpl
|
||||
with patch("app.api.routes.templates_editor.dependencies.SQLAlchemyTemplateRepository", return_value=mock_repo):
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id="tpl-1",
|
||||
services=services,
|
||||
current_user=self._make_user(),
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
assert plan_id == "plan-existing"
|
||||
# 没触发generate
|
||||
services[1].update_plan_config.assert_not_called()
|
||||
|
||||
def test_global_template_creates_draft_via_new_service(self):
|
||||
"""全局模板(新系统edit_templates表存在)走tpl_svc.create_template_draft。"""
|
||||
db = MagicMock()
|
||||
services = self._make_services(global_tpl=MagicMock(), draft=None)
|
||||
new_draft = MagicMock()
|
||||
new_draft.id = "plan-global"
|
||||
services[0].create_template_draft.return_value = new_draft
|
||||
plan_id = resolve_draft_plan_id(
|
||||
template_id="tpl-global",
|
||||
services=services,
|
||||
current_user=self._make_user(),
|
||||
db=db,
|
||||
auto_create_default=False,
|
||||
)
|
||||
assert plan_id == "plan-global"
|
||||
services[0].create_template_draft.assert_called_once_with("tpl-global", user_id="user-1")
|
||||
Reference in New Issue
Block a user