Files
Azmat@qq.com 0bd1db6bf9 模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
2026-09-21 16:24:37 +08:00

1027 lines
51 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from io import BytesIO, StringIO
from types import SimpleNamespace
from unittest.mock import patch
from django.core.management import call_command
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase, TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from . import assets_client
from .models import Asset, AssetFile, FreeAsset, FreeAssetGroup, Model
def _mk_team(username, team_name):
user = User.objects.create_user(username=username, password="x")
team = Team.objects.create(name=team_name, owner=user)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
return user, team
class ModelLibraryApiTests(TestCase):
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
def setUp(self):
self.user, self.teamA = _mk_team("ua", "TeamA")
self.other, self.teamB = _mk_team("ub", "TeamB")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.mine = Model.objects.create(team=self.teamA, name="我的模特")
self.official = Model.objects.create(team=self.teamB, name="官方小美", is_official=True)
self.bs_other = Model.objects.create(team=self.teamB, name="别队私有") # B 的私有,A 看不到
def test_list_returns_mine_plus_official(self):
ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]}
self.assertIn(str(self.mine.id), ids)
self.assertIn(str(self.official.id), ids) # 官方跨团队可见
self.assertNotIn(str(self.bs_other.id), ids) # 别队私有不可见
def test_tab_official_and_mine(self):
off = {m["id"] for m in self.client.get("/api/models/?tab=official").json()["results"]}
self.assertEqual(off, {str(self.official.id)})
mine = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]}
self.assertEqual(mine, {str(self.mine.id)})
def test_filter_by_portrait_asset(self):
portrait = Asset.objects.create(team=self.teamA, name="角色立绘", asset_type="image", source="ai_generated")
self.mine.portrait_asset = portrait
self.mine.save(update_fields=["portrait_asset"])
rows = self.client.get(f"/api/models/?portrait_asset={portrait.id}").json()["results"]
self.assertEqual([row["id"] for row in rows], [str(self.mine.id)])
missing = self.client.get("/api/models/?portrait_asset=00000000-0000-0000-0000-000000000000").json()["results"]
self.assertEqual(missing, [])
def test_enroll_asset_creates_model_once_and_reuses_it(self):
portrait = Asset.objects.create(
team=self.teamA, name="AI 模特候选", asset_type="image", source="ai_generated", category=Asset.Category.PERSON,
)
first = self.client.post("/api/models/enroll-asset/", {"asset_id": str(portrait.id), "name": "通勤模特"}, format="json")
self.assertEqual(first.status_code, 201)
self.assertEqual(first.json()["name"], "通勤模特")
self.assertEqual(first.json()["portrait_asset"], str(portrait.id))
second = self.client.post("/api/models/enroll-asset/", {"asset_id": str(portrait.id), "name": "重复名称不应覆盖"}, format="json")
self.assertEqual(second.status_code, 200)
self.assertEqual(second.json()["id"], first.json()["id"])
self.assertEqual(Model.objects.filter(team=self.teamA, portrait_asset=portrait, is_deleted=False).count(), 1)
def test_official_flag_in_payload(self):
row = next(m for m in self.client.get("/api/models/").json()["results"] if m["id"] == str(self.official.id))
self.assertTrue(row["is_official"])
def test_update_own_model_profile_does_not_rewrite_portrait_asset(self):
portrait = Asset.objects.create(team=self.teamA, name="原始形象资产名", asset_type="image", source="upload")
self.mine.portrait_asset = portrait
self.mine.save(update_fields=["portrait_asset"])
res = self.client.patch(
f"/api/models/{self.mine.id}/",
{"name": "更新后的模特名", "description": "通勤风格"},
format="json",
)
self.assertEqual(res.status_code, 200)
self.mine.refresh_from_db()
portrait.refresh_from_db()
self.assertEqual(self.mine.name, "更新后的模特名")
self.assertEqual(self.mine.description, "通勤风格")
self.assertEqual(portrait.name, "原始形象资产名")
def test_official_model_cannot_be_edited(self):
res = self.client.patch(f"/api/models/{self.official.id}/", {"name": "不应更新"}, format="json")
self.assertEqual(res.status_code, 403)
self.official.refresh_from_db()
self.assertEqual(self.official.name, "官方小美")
def test_delete_is_soft(self):
res = self.client.delete(f"/api/models/{self.mine.id}/")
self.assertEqual(res.status_code, 204)
self.mine.refresh_from_db()
self.assertTrue(self.mine.is_deleted)
ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]}
self.assertNotIn(str(self.mine.id), ids)
def test_trash_restore_and_purge_model_keeps_record_and_asset(self):
portrait = Asset.objects.create(team=self.teamA, name="模特形象", asset_type="image", source="upload")
portrait_file = AssetFile.objects.create(
asset=portrait,
object_key="models/portrait.png",
bucket="test",
content_type="image/png",
is_primary=True,
)
self.mine.portrait_asset = portrait
self.mine.save(update_fields=["portrait_asset"])
self.client.delete(f"/api/models/{self.mine.id}/")
trash_ids = {m["id"] for m in self.client.get("/api/models/trash/").json()["results"]}
self.assertIn(str(self.mine.id), trash_ids)
portrait.refresh_from_db()
self.assertFalse(portrait.is_deleted)
self.assertIsNone(portrait.purged_at)
restore = self.client.post(f"/api/models/{self.mine.id}/restore/")
self.assertEqual(restore.status_code, 200)
self.mine.refresh_from_db()
self.assertFalse(self.mine.is_deleted)
self.assertIsNone(self.mine.purged_at)
ids = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]}
self.assertIn(str(self.mine.id), ids)
self.client.delete(f"/api/models/{self.mine.id}/")
purge = self.client.delete(f"/api/models/{self.mine.id}/purge/")
self.assertEqual(purge.status_code, 204)
self.mine.refresh_from_db()
self.assertTrue(self.mine.is_deleted)
self.assertIsNotNone(self.mine.purged_at)
self.assertTrue(Model.objects.filter(id=self.mine.id).exists())
self.assertTrue(Asset.objects.filter(id=portrait.id).exists())
portrait.refresh_from_db()
self.assertFalse(portrait.is_deleted)
self.assertIsNone(portrait.purged_at)
self.assertTrue(AssetFile.objects.filter(id=portrait_file.id).exists())
trash_ids = {m["id"] for m in self.client.get("/api/models/trash/").json()["results"]}
self.assertNotIn(str(self.mine.id), trash_ids)
def test_official_cannot_be_deleted(self):
res = self.client.delete(f"/api/models/{self.official.id}/")
self.assertEqual(res.status_code, 403)
self.official.refresh_from_db()
self.assertFalse(self.official.is_deleted)
def test_cannot_delete_other_team_model(self):
res = self.client.delete(f"/api/models/{self.bs_other.id}/")
self.assertIn(res.status_code, (403, 404)) # 别队私有:不在可见 qs → 404,或命中守卫 → 403
self.bs_other.refresh_from_db()
self.assertFalse(self.bs_other.is_deleted)
def test_create_rejects_cross_team_asset(self):
b_asset = Asset.objects.create(team=self.teamB, name="别队图", asset_type="image", source="upload")
res = self.client.post("/api/models/", {"name": "盗用", "portrait_asset": str(b_asset.id)}, format="json")
self.assertEqual(res.status_code, 400)
def test_upload_real_person_creates_asset_and_model(self):
stored = SimpleNamespace(object_key="teams/x/models/a.png", bucket="b", content_type="image/png", size_bytes=10)
with patch("apps.assets.views.TosStorage") as Tos:
Tos.return_value.upload_fileobj.return_value = stored
f = BytesIO(b"img")
f.name = "lady.png"
res = self.client.post("/api/models/upload/", {"name": "上传妹", "file": f}, format="multipart")
self.assertEqual(res.status_code, 201)
body = res.json()
self.assertEqual(body["source"], "upload")
model = Model.objects.get(id=body["id"])
self.assertIsNotNone(model.portrait_asset)
self.assertEqual(model.portrait_asset.category, Asset.Category.MODEL_PORTRAIT)
def test_upload_portrait_replaces_current_model_only_and_stays_out_of_library(self):
old_portrait = Asset.objects.create(
team=self.teamA, name="旧形象", asset_type="image", source="upload", category=Asset.Category.MODEL_PORTRAIT,
)
self.mine.portrait_asset = old_portrait
self.mine.save(update_fields=["portrait_asset"])
stored = SimpleNamespace(object_key="teams/x/models/new.png", bucket="b", content_type="image/png", size_bytes=10)
with patch("apps.assets.views.TosStorage") as Tos:
Tos.return_value.upload_fileobj.return_value = stored
image = SimpleUploadedFile("new.png", b"img", content_type="image/png")
res = self.client.post(
f"/api/models/{self.mine.id}/upload-portrait/",
{"file": image, "name": "一次保存的新模特名", "description": "一次保存的新描述"},
format="multipart",
)
self.assertEqual(res.status_code, 200)
self.mine.refresh_from_db()
self.assertNotEqual(self.mine.portrait_asset_id, old_portrait.id)
self.assertEqual(self.mine.name, "一次保存的新模特名")
self.assertEqual(self.mine.description, "一次保存的新描述")
new_portrait = self.mine.portrait_asset
self.assertEqual(new_portrait.category, Asset.Category.MODEL_PORTRAIT)
self.assertFalse(new_portrait.in_library)
self.assertTrue(Asset.objects.filter(id=old_portrait.id).exists())
self.assertEqual(Model.objects.filter(team=self.teamA).count(), 1)
self.assertEqual(
self.mine.metadata["portrait_versions"],
[str(old_portrait.id), str(new_portrait.id)],
)
library_ids = {row["id"] for row in self.client.get("/api/assets/?page_size=200").json()["results"]}
self.assertNotIn(str(new_portrait.id), library_ids)
def test_official_model_cannot_replace_portrait(self):
with patch("apps.assets.views.TosStorage") as Tos:
image = SimpleUploadedFile("new.png", b"img", content_type="image/png")
res = self.client.post(f"/api/models/{self.official.id}/upload-portrait/", {"file": image}, format="multipart")
self.assertEqual(res.status_code, 403)
Tos.assert_not_called()
class ActorModelMigrationAuditTests(TestCase):
"""审计命令只能分类和输出,绝不能改写旧资产、项目或模特记录。"""
def setUp(self):
from apps.products.models import Product
from apps.projects.models import BaseAssetGroup, Project
self.user, self.team = _mk_team("audit-u", "Audit")
self.eligible = Asset.objects.create(
team=self.team, name="旧保存演员", asset_type="image", source="upload", category=Asset.Category.PERSON, in_library=True,
)
existing = Asset.objects.create(
team=self.team, name="已有模特形象", asset_type="image", source="upload", category=Asset.Category.PERSON, in_library=True,
)
Model.objects.create(team=self.team, name="已有模特", portrait_asset=existing)
role = Asset.objects.create(
team=self.team, name="项目角色", asset_type="image", source="ai_generated", category=Asset.Category.PERSON, in_library=True,
)
product = Product.objects.create(team=self.team, title="测试商品")
project = Project.objects.create(team=self.team, name="测试项目", product=product)
BaseAssetGroup.objects.create(project=project, kind=BaseAssetGroup.Kind.PERSON, adopted_asset=role)
Asset.objects.create(
team=self.team, name="垃圾桶演员", asset_type="image", source="upload", category=Asset.Category.PERSON, is_deleted=True,
)
def test_audit_is_read_only_and_excludes_project_and_existing_models(self):
before_assets = Asset.objects.count()
before_models = Model.objects.count()
out = StringIO()
call_command("audit_actor_model_migration", "--team-id", str(self.team.id), stdout=out)
report = out.getvalue()
self.assertIn("旧已保存人物候选: 1", report)
self.assertIn(str(self.eligible.id), report)
self.assertIn("垃圾桶 person(不迁移): 1", report)
self.assertIn("未创建、更新、删除任何记录或文件", report)
self.assertEqual(Asset.objects.count(), before_assets)
self.assertEqual(Model.objects.count(), before_models)
def test_apply_migrates_only_eligible_saved_actor_and_is_idempotent(self):
call_command("migrate_saved_actors_to_models", "--team-id", str(self.team.id), "--apply")
model = Model.objects.get(team=self.team, portrait_asset=self.eligible)
self.assertEqual(model.name, self.eligible.name)
self.assertEqual(model.metadata.get("migrated_from_legacy_actor"), True)
self.eligible.refresh_from_db()
self.assertEqual(self.eligible.category, Asset.Category.PERSON) # 历史分类不改
self.assertFalse(self.eligible.is_deleted)
self.assertEqual(Model.objects.filter(team=self.team, portrait_asset=self.eligible).count(), 1)
self.assertFalse(Model.objects.filter(team=self.team, portrait_asset__name="项目角色").exists())
call_command("migrate_saved_actors_to_models", "--team-id", str(self.team.id), "--apply")
self.assertEqual(Model.objects.filter(team=self.team, portrait_asset=self.eligible).count(), 1)
class AssetSoftDeleteTests(TestCase):
"""资产库不展示软删资产(is_deleted=True):列表 + tab 计数都排除。"""
def setUp(self):
self.user, self.team = _mk_team("usd", "TeamSD")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.live = Asset.objects.create(team=self.team, name="留存图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON)
self.dead = Asset.objects.create(team=self.team, name="废图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON, is_deleted=True)
def test_list_excludes_soft_deleted(self):
ids = {a["id"] for a in self.client.get("/api/assets/?page_size=50").json()["results"]}
self.assertIn(str(self.live.id), ids)
self.assertNotIn(str(self.dead.id), ids)
def test_summary_excludes_soft_deleted(self):
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 1)
def test_functional_model_and_project_assets_do_not_appear_in_others(self):
model_portrait = Asset.objects.create(
team=self.team, name="模特形象", asset_type="image", source="upload", category=Asset.Category.MODEL_PORTRAIT,
)
tri_view = Asset.objects.create(
team=self.team, name="角色三视图", asset_type="image", source="ai_generated", category=Asset.Category.TRI_VIEW,
)
other = Asset.objects.create(
team=self.team, name="其他图片", asset_type="image", source="upload", category=Asset.Category.UNCATEGORIZED,
)
ids = {row["id"] for row in self.client.get("/api/assets/?tab=others&page_size=50").json()["results"]}
self.assertIn(str(other.id), ids)
self.assertNotIn(str(model_portrait.id), ids)
self.assertNotIn(str(tri_view.id), ids)
class AssetTrashApiTests(TestCase):
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 二级软删。"""
def setUp(self):
self.user, self.team = _mk_team("utr", "TeamTR")
_, self.other_team = _mk_team("utr2", "TeamTR2")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.live = Asset.objects.create(team=self.team, name="活图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON)
self.dead = Asset.objects.create(team=self.team, name="删掉的图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON, is_deleted=True)
self.dead_file = AssetFile.objects.create(
asset=self.dead,
object_key="assets/deleted.png",
bucket="test",
content_type="image/png",
is_primary=True,
)
# 别队软删资产:不得出现在本队垃圾桶(team 隔离)
self.other_dead = Asset.objects.create(team=self.other_team, name="别队删图", asset_type="image", source="ai_generated", is_deleted=True)
def test_trash_lists_only_own_deleted(self):
ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
self.assertEqual(ids, {str(self.dead.id)}) # 只含本队软删;不含活图 / 别队删图
def test_restore_makes_asset_visible_in_library_again(self):
res = self.client.post(f"/api/assets/{self.dead.id}/restore/")
self.assertEqual(res.status_code, 200)
self.dead.refresh_from_db()
self.assertFalse(self.dead.is_deleted)
ids = {a["id"] for a in self.client.get("/api/assets/?page_size=50").json()["results"]}
self.assertIn(str(self.dead.id), ids) # 资产库重新可见
# 恢复后不再在垃圾桶
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
def test_purge_soft_hides_from_trash(self):
res = self.client.delete(f"/api/assets/{self.dead.id}/purge/")
self.assertEqual(res.status_code, 204)
self.dead.refresh_from_db()
self.assertIsNotNone(self.dead.purged_at)
self.assertTrue(Asset.objects.filter(id=self.dead.id).exists())
self.assertTrue(AssetFile.objects.filter(id=self.dead_file.id).exists())
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
def test_purge_referenced_asset_keeps_record(self):
from apps.products.models import Product, ProductImage
product = Product.objects.create(team=self.team, created_by=self.user, title="P")
ProductImage.objects.create(product=product, asset=self.dead) # PROTECT 引用
res = self.client.delete(f"/api/assets/{self.dead.id}/purge/")
self.assertEqual(res.status_code, 204)
self.dead.refresh_from_db()
self.assertTrue(self.dead.is_deleted)
self.assertIsNotNone(self.dead.purged_at)
self.assertTrue(Asset.objects.filter(id=self.dead.id).exists())
def test_restore_rejects_live_asset(self):
# 未删资产不在 trash 态 queryset → 404(与商品垃圾桶口径一致)
self.assertEqual(self.client.post(f"/api/assets/{self.live.id}/restore/").status_code, 404)
class AssetsClientErrorCodeTests(SimpleTestCase):
def test_not_found_helpers_cover_volcano_variants(self):
self.assertTrue(assets_client.is_not_found_code("NotFound"))
self.assertTrue(assets_client.is_not_found_code("NotFound.asset_id"))
self.assertTrue(assets_client.is_not_found_code("NotFound.group_id"))
self.assertFalse(assets_client.is_not_found_code("Forbidden"))
self.assertFalse(assets_client.is_not_found_code(None))
self.assertTrue(assets_client.is_asset_not_found_code("NotFound"))
self.assertTrue(assets_client.is_asset_not_found_code("NotFound.asset_id"))
self.assertFalse(assets_client.is_asset_not_found_code("NotFound.group_id"))
self.assertTrue(assets_client.is_group_not_found_code("NotFound"))
self.assertTrue(assets_client.is_group_not_found_code("NotFound.group_id"))
self.assertFalse(assets_client.is_group_not_found_code("NotFound.asset_id"))
def test_not_found_variant_messages_are_not_generic(self):
asset_error = assets_client.AssetsAPIError("NotFound.asset_id", "remote asset missing")
group_error = assets_client.AssetsAPIError("NotFound.group_id", "remote group missing")
unknown_error = assets_client.AssetsAPIError("Unknown", "unknown")
self.assertNotEqual(asset_error.user_message, unknown_error.user_message)
self.assertNotEqual(group_error.user_message, unknown_error.user_message)
self.assertNotEqual(asset_error.user_message, group_error.user_message)
class FreeAssetQuickUploadTests(TestCase):
"""人物素材库快捷上传:自动默认组,但底层仍走 FreeAssetGroup / FreeAsset。"""
def setUp(self):
self.user, self.team = _mk_team("ufq", "TeamFQ")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.stored = SimpleNamespace(object_key="teams/x/free-create/library/a.png", bucket="b", content_type="image/png", size_bytes=10)
def _image(self, name="a.png"):
return SimpleUploadedFile(name, b"img", content_type="image/png")
@patch("apps.assets.free_library.assets_client.is_enabled", return_value=True)
@patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1")
@patch("apps.assets.free_library.assets_client.create_asset", return_value="Asset-1")
@patch("apps.assets.free_library.TosStorage")
def test_quick_upload_creates_default_group(self, storage_cls, create_asset, create_group, _enabled):
storage = storage_cls.return_value
storage.upload_fileobj.return_value = self.stored
storage.public_url.return_value = "http://tos/a.png"
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image()}, format="multipart")
self.assertEqual(res.status_code, 201)
group = FreeAssetGroup.objects.get(team=self.team)
self.assertEqual(group.name, "默认素材")
self.assertEqual(group.remote_group_id, "Group-1")
self.assertEqual(group.assets.count(), 1)
asset = group.assets.get()
self.assertEqual(asset.asset_type, FreeAsset.Type.IMAGE)
self.assertEqual(asset.status, FreeAsset.Status.PROCESSING)
create_group.assert_called_once()
create_asset.assert_called_once_with("Group-1", "http://tos/a.png", name="a.png", asset_type=FreeAsset.Type.IMAGE)
self.assertEqual(res.json()["group"]["id"], str(group.id))
@patch("apps.assets.free_library.assets_client.is_enabled", return_value=True)
@patch("apps.assets.free_library.assets_client.get_asset_group", return_value={"Id": "Group-1"})
@patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1")
@patch("apps.assets.free_library.assets_client.create_asset", side_effect=["Asset-1", "Asset-2"])
@patch("apps.assets.free_library.TosStorage")
def test_quick_upload_reuses_default_group(self, storage_cls, create_asset, create_group, get_group, _enabled):
storage = storage_cls.return_value
storage.upload_fileobj.return_value = self.stored
storage.public_url.return_value = "http://tos/a.png"
first = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("a.png")}, format="multipart")
second = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("b.png")}, format="multipart")
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 201)
self.assertEqual(FreeAssetGroup.objects.filter(team=self.team).count(), 1)
self.assertEqual(FreeAsset.objects.filter(group__team=self.team).count(), 2)
create_group.assert_called_once()
get_group.assert_called_once_with("Group-1")
self.assertEqual(create_asset.call_count, 2)
@patch("apps.assets.free_library.assets_client.is_enabled", return_value=True)
@patch(
"apps.assets.free_library.assets_client.get_asset_group",
side_effect=assets_client.AssetsAPIError("NotFound.group_id", "old group not visible"),
)
@patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-2")
@patch("apps.assets.free_library.assets_client.create_asset", return_value="Asset-2")
@patch("apps.assets.free_library.TosStorage")
def test_quick_upload_replaces_old_default_group(self, storage_cls, create_asset, create_group, get_group, _enabled):
old_group = FreeAssetGroup.objects.create(
team=self.team,
created_by=self.user,
name="默认素材",
description="__airshelf_free_asset_default__",
remote_group_id="Old-Group",
)
storage = storage_cls.return_value
storage.upload_fileobj.return_value = self.stored
storage.public_url.return_value = "http://tos/a.png"
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("new.png")}, format="multipart")
self.assertEqual(res.status_code, 201)
self.assertFalse(FreeAsset.objects.filter(group=old_group).exists())
new_group = FreeAssetGroup.objects.exclude(id=old_group.id).get(team=self.team)
self.assertEqual(new_group.remote_group_id, "Group-2")
self.assertEqual(new_group.assets.count(), 1)
get_group.assert_called_once_with("Old-Group")
create_group.assert_called_once()
create_asset.assert_called_once_with("Group-2", "http://tos/a.png", name="new.png", asset_type=FreeAsset.Type.IMAGE)
@patch("apps.assets.free_library.assets_client.is_enabled", return_value=True)
@patch("apps.assets.free_library.assets_client.create_asset_group", return_value="Group-1")
@patch("apps.assets.free_library.TosStorage")
def test_quick_upload_rejects_non_image(self, storage_cls, create_group, _enabled):
res = self.client.post(
"/api/assets/free-assets/quick-upload/",
{"file": SimpleUploadedFile("v.mp4", b"video", content_type="video/mp4")},
format="multipart",
)
self.assertEqual(res.status_code, 400)
self.assertIn("仅支持图片", res.json()["detail"])
self.assertFalse(storage_cls.return_value.upload_fileobj.called)
create_group.assert_called_once()
class FreeAssetGroupUploadTests(TestCase):
def setUp(self):
self.user, self.team = _mk_team("fgu", "TeamFGU")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.group = FreeAssetGroup.objects.create(
team=self.team,
created_by=self.user,
name="旧素材组",
description="",
remote_group_id="Old-Group",
)
self.stored = SimpleNamespace(object_key="teams/x/free-create/library/a.png", bucket="b", content_type="image/png", size_bytes=10)
def _image(self, name="a.png"):
return SimpleUploadedFile(name, b"img", content_type="image/png")
@patch("apps.assets.free_library.assets_client.is_enabled", return_value=True)
@patch(
"apps.assets.free_library.assets_client.create_asset",
side_effect=assets_client.AssetsAPIError("NotFound.group_id", "old group not visible"),
)
@patch("apps.assets.free_library.TosStorage")
def test_upload_to_old_remote_group_returns_clear_message(self, storage_cls, create_asset, _enabled):
storage = storage_cls.return_value
storage.upload_fileobj.return_value = self.stored
storage.public_url.return_value = "http://tos/a.png"
res = self.client.post(f"/api/assets/free-groups/{self.group.id}/assets/", {"file": self._image()}, format="multipart")
self.assertEqual(res.status_code, 400)
self.assertEqual(res.json()["detail"], "该素材组属于其他火山账号,请新建素材组后重新上传")
self.assertFalse(FreeAsset.objects.filter(group=self.group).exists())
create_asset.assert_called_once_with("Old-Group", "http://tos/a.png", name="a.png", asset_type=FreeAsset.Type.IMAGE)
class FreeAssetDeleteCompatibilityTests(TestCase):
def setUp(self):
self.user, self.team = _mk_team("fdel", "TeamFDEL")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.group = FreeAssetGroup.objects.create(
team=self.team,
created_by=self.user,
name="旧素材组",
description="",
remote_group_id="Old-Group",
)
self.asset = FreeAsset.objects.create(
group=self.group,
remote_asset_id="Old-Asset",
name="旧素材.png",
url="http://tos/a.png",
asset_type=FreeAsset.Type.IMAGE,
thumbnail_url="http://tos/a.png",
status=FreeAsset.Status.ACTIVE,
)
@patch(
"apps.assets.free_library.assets_client.delete_asset",
side_effect=assets_client.AssetsAPIError("NotFound.asset_id", "old asset not visible"),
)
def test_delete_old_remote_asset_clears_local_asset(self, delete_asset):
res = self.client.delete(f"/api/assets/free-assets/{self.asset.id}/")
self.assertEqual(res.status_code, 204)
self.assertFalse(FreeAsset.objects.filter(id=self.asset.id).exists())
delete_asset.assert_called_once_with("Old-Asset")
@patch(
"apps.assets.free_library.assets_client.delete_asset_group",
side_effect=assets_client.AssetsAPIError("NotFound.group_id", "old group not visible"),
)
def test_delete_old_remote_group_marks_local_group_deleted(self, delete_group):
res = self.client.delete(f"/api/assets/free-groups/{self.group.id}/")
self.assertEqual(res.status_code, 204)
self.group.refresh_from_db()
self.assertTrue(self.group.is_deleted)
self.assertTrue(FreeAsset.objects.filter(id=self.asset.id).exists())
delete_group.assert_called_once_with("Old-Group")
class FreeAssetRenameCompatibilityTests(TestCase):
def setUp(self):
self.user, self.team = _mk_team("fren", "TeamFREN")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.group = FreeAssetGroup.objects.create(
team=self.team,
created_by=self.user,
name="旧素材组",
description="old description",
remote_group_id="Old-Group",
)
self.asset = FreeAsset.objects.create(
group=self.group,
remote_asset_id="Old-Asset",
name="旧素材.png",
url="http://tos/a.png",
asset_type=FreeAsset.Type.IMAGE,
thumbnail_url="http://tos/a.png",
status=FreeAsset.Status.ACTIVE,
)
@patch(
"apps.assets.free_library.assets_client.update_asset",
side_effect=assets_client.AssetsAPIError("NotFound.asset_id", "old asset not visible"),
)
def test_rename_old_remote_asset_returns_clear_message_without_saving(self, update_asset):
res = self.client.patch(f"/api/assets/free-assets/{self.asset.id}/", {"name": "新名字.png"}, format="json")
self.assertEqual(res.status_code, 400)
self.assertEqual(res.json()["detail"], "该素材属于其他火山账号,请重新上传后再编辑")
self.asset.refresh_from_db()
self.assertEqual(self.asset.name, "旧素材.png")
update_asset.assert_called_once_with("Old-Asset", name="新名字.png")
@patch(
"apps.assets.free_library.assets_client.update_asset_group",
side_effect=assets_client.AssetsAPIError("NotFound.group_id", "old group not visible"),
)
def test_rename_old_remote_group_returns_clear_message_without_saving(self, update_group):
res = self.client.patch(
f"/api/assets/free-groups/{self.group.id}/",
{"name": "新素材组", "description": "new description"},
format="json",
)
self.assertEqual(res.status_code, 400)
self.assertEqual(res.json()["detail"], "该素材组属于其他火山账号,请新建素材组后重新上传")
self.group.refresh_from_db()
self.assertEqual(self.group.name, "旧素材组")
self.assertEqual(self.group.description, "old description")
update_group.assert_called_once_with("Old-Group", name="新素材组", description="new description")
class VideoPacksTests(TestCase):
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
def setUp(self):
from apps.products.models import Product
from apps.projects.models import Project, VideoSegment, VideoSegmentVersion
from .models import AssetFile
self.user, self.team = _mk_team("uvp", "TeamVP")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
self.proj = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
self.assets = []
for i in range(2):
a = Asset.objects.create(team=self.team, name=f"clip{i}", asset_type="video", source="ai_generated", category=Asset.Category.VIDEO_CLIP)
AssetFile.objects.create(asset=a, object_key=f"k{i}.mp4", bucket="b", content_type="video/mp4", is_primary=True)
seg = VideoSegment.objects.create(project=self.proj, sort_order=i)
ver = VideoSegmentVersion.objects.create(video_segment=seg, asset=a, is_adopted=True)
seg.adopted_version = ver
seg.save(update_fields=["adopted_version"])
self.assets.append(a)
def test_packs_grouped_by_project(self):
data = self.client.get("/api/assets/video-packs/").json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["project_name"], "项目甲")
self.assertEqual(len(data[0]["clips"]), 2)
self.assertIn("url", data[0]["clips"][0])
def test_soft_deleted_clip_excluded(self):
Asset.objects.filter(id=self.assets[0].id).update(is_deleted=True)
data = self.client.get("/api/assets/video-packs/").json()
self.assertEqual(len(data[0]["clips"]), 1)
class AssetBatchesTests(TestCase):
"""图片趴(模特上身图)按生成批次成组:同 metadata.batch_id 的多张图 = 一批;
无 batch_id 的旧图各自成单图批。summary 也按批次计数。"""
def setUp(self):
self.user, self.team = _mk_team("ubt", "TeamBT")
self.client = APIClient()
self.client.force_authenticate(self.user)
# 一批 3 张(同 batch_id)
for i in range(3):
Asset.objects.create(
team=self.team, name=f"AI 生成 · 模特上身图 · {i + 1}", asset_type="image",
source="ai_generated", category=Asset.Category.MODEL_TRYON, metadata={"batch_id": "B1"},
)
# 一张旧图(无 batch_id)→ 自己成一批
Asset.objects.create(
team=self.team, name="AI 生成 · 模特上身图 · 1", asset_type="image",
source="ai_generated", category=Asset.Category.MODEL_TRYON,
)
def test_batches_grouped(self):
data = self.client.get("/api/assets/batches/?tab=tryon&page_size=10").json()
self.assertEqual(data["count"], 2) # 一批 3 张 + 一张单图批
by_count = sorted(b["count"] for b in data["results"])
self.assertEqual(by_count, [1, 3])
big = next(b for b in data["results"] if b["count"] == 3)
self.assertEqual(big["name"], "AI 生成 · 模特上身图") # 去掉「· 序号」
self.assertEqual(len(big["items"]), 3)
def test_summary_counts_batches(self):
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 2)
class FreeCreateSplitTabTests(TestCase):
"""Free create assets share category=free_create; tabs split by asset_type."""
def setUp(self):
self.user, self.team = _mk_team("ufc", "TeamFC")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.image1 = Asset.objects.create(
team=self.team,
name="free image 1",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.FREE_CREATE,
metadata={"batch_id": "IMG-B1"},
)
self.image2 = Asset.objects.create(
team=self.team,
name="free image 2",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.FREE_CREATE,
metadata={"batch_id": "IMG-B1"},
)
self.video = Asset.objects.create(
team=self.team,
name="free video",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.FREE_CREATE,
metadata={"feature": "free_video"},
)
def test_image_creations_batches_exclude_videos(self):
data = self.client.get("/api/assets/batches/?tab=image_creations&page_size=10").json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["count"], 2)
self.assertEqual({a["asset_type"] for a in data["results"][0]["items"]}, {"image"})
def test_video_creations_list_excludes_images(self):
data = self.client.get("/api/assets/?tab=video_creations&page_size=10").json()
self.assertEqual(data["count"], 1)
self.assertEqual(data["results"][0]["id"], str(self.video.id))
self.assertEqual(data["results"][0]["asset_type"], "video")
def test_summary_exposes_split_counts_and_keeps_legacy_creations(self):
data = self.client.get("/api/assets/summary/").json()
self.assertEqual(data["image_creations"], 1)
self.assertEqual(data["video_creations"], 1)
self.assertEqual(data["creations"], 2)
def test_delete_and_restore_video_returns_to_video_creations_tab(self):
res = self.client.delete(f"/api/assets/{self.video.id}/")
self.assertEqual(res.status_code, 204)
self.video.refresh_from_db()
self.assertTrue(self.video.is_deleted)
video_tab = self.client.get("/api/assets/?tab=video_creations&page_size=10").json()
self.assertEqual(video_tab["count"], 0)
summary = self.client.get("/api/assets/summary/").json()
self.assertEqual(summary["video_creations"], 0)
self.assertEqual(summary["image_creations"], 1)
trash_ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
self.assertIn(str(self.video.id), trash_ids)
restore = self.client.post(f"/api/assets/{self.video.id}/restore/")
self.assertEqual(restore.status_code, 200)
self.video.refresh_from_db()
self.assertFalse(self.video.is_deleted)
self.assertEqual(self.video.category, Asset.Category.FREE_CREATE)
self.assertEqual(self.video.asset_type, Asset.Type.VIDEO)
video_tab = self.client.get("/api/assets/?tab=video_creations&page_size=10").json()
self.assertEqual(video_tab["count"], 1)
self.assertEqual(video_tab["results"][0]["id"], str(self.video.id))
image_batches = self.client.get("/api/assets/batches/?tab=image_creations&page_size=10").json()
self.assertEqual({a["asset_type"] for a in image_batches["results"][0]["items"]}, {"image"})
class FacetsProductsTests(TestCase):
"""facets 返回本 tab 下真实关联的商品(?product= 三路之一:metadata.product_id 等),
并按 tab 隔离、team 隔离、只回 id/title。"""
def setUp(self):
from apps.products.models import Product
self.user, self.team = _mk_team("ufp", "TeamFP")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.p1 = Product.objects.create(team=self.team, created_by=self.user, title="商品甲")
self.p2 = Product.objects.create(team=self.team, created_by=self.user, title="商品乙")
# tryon 图经 metadata.product_id 关联 p1
Asset.objects.create(
team=self.team, name="上身图", asset_type="image", source="ai_generated",
category=Asset.Category.MODEL_TRYON, metadata={"product_id": str(self.p1.id)},
)
# kits 图关联 p2(验证 tab 隔离:tryon 下不应出现 p2)
Asset.objects.create(
team=self.team, name="套图", asset_type="image", source="ai_generated",
category=Asset.Category.PLATFORM_KIT, metadata={"product_id": str(self.p2.id)},
)
def test_facets_products_scoped_by_tab(self):
data = self.client.get("/api/assets/facets/?tab=tryon").json()
self.assertIn("products", data)
self.assertEqual({p["id"] for p in data["products"]}, {str(self.p1.id)})
self.assertEqual({p["title"] for p in data["products"]}, {"商品甲"})
# kits tab 只见 p2
kits = self.client.get("/api/assets/facets/?tab=kits").json()
self.assertEqual({p["id"] for p in kits["products"]}, {str(self.p2.id)})
def test_facets_products_empty_when_none(self):
data = self.client.get("/api/assets/facets/?tab=others").json()
self.assertEqual(data["products"], [])
def test_facets_products_newest_first(self):
from datetime import timedelta
from django.utils import timezone
from apps.products.models import Product
Asset.objects.create(
team=self.team, name="上身图2", asset_type="image", source="ai_generated",
category=Asset.Category.MODEL_TRYON, metadata={"product_id": str(self.p2.id)},
)
Product.objects.filter(id=self.p1.id).update(created_at=timezone.now() - timedelta(days=1))
data = self.client.get("/api/assets/facets/?tab=tryon").json()
self.assertEqual([p["id"] for p in data["products"]], [str(self.p2.id), str(self.p1.id)])
class SubmitReviewTests(TestCase):
"""手动兜底:灰盾点击 → 提交审核;只对送审类放行,非送审类 400。"""
def setUp(self):
self.user, self.team = _mk_team("usr", "TeamSR")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.person = Asset.objects.create(team=self.team, name="角色", asset_type="image", source="ai_generated", category=Asset.Category.PERSON)
# 图片趴(模特上身图)= 非送审类,用于校验 submit-review 对其拒绝(product_image/scene 现已属送审类)
self.tryon = Asset.objects.create(team=self.team, name="上身图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON)
def test_submit_review_person_ok(self):
with patch("apps.assets.review.submit_asset_for_review") as sub:
res = self.client.post(f"/api/assets/{self.person.id}/submit-review/")
self.assertEqual(res.status_code, 200)
sub.assert_called_once()
self.assertIn("review_status", res.json())
def test_submit_review_rejects_non_review_category(self):
res = self.client.post(f"/api/assets/{self.tryon.id}/submit-review/")
self.assertEqual(res.status_code, 400)
def test_submit_review_503_when_submission_did_not_start(self):
"""送审没真正发出去(服务未启用/未配 SDK/火山未回 Id),且素材非终态 → 503,
review_status 仍为空。这样前端不会把「没送出去」误显示成「审核中」(刷新即打回原形)。"""
with patch("apps.assets.review.submit_asset_for_review", return_value=False) as sub:
res = self.client.post(f"/api/assets/{self.person.id}/submit-review/")
sub.assert_called_once()
self.assertEqual(res.status_code, 503)
self.assertEqual(res.json().get("review_status"), "")
def test_submit_review_200_when_started(self):
"""真正进入审核(processing)→ 200 + 真态,供前端落 processing。"""
def _mark_processing(asset, *, force=False):
asset.review_status = "processing"
asset.save(update_fields=["review_status"])
return True
with patch("apps.assets.review.submit_asset_for_review", side_effect=_mark_processing):
res = self.client.post(f"/api/assets/{self.person.id}/submit-review/")
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json().get("review_status"), "processing")
class ReviewScopeTests(TestCase):
"""送审范围 = 含人脸资产:角色定妆照(person)/ 模特形象图(model_portrait)/ 三视图(tri_view)/
分镜图(storyboard)/ 场景(scene)/ 商品图(product_image);图片趴(上身图/套图/创作)不送审。"""
def test_review_categories_constant(self):
self.assertEqual(
Asset.REVIEW_CATEGORIES,
("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"),
)
def test_poll_only_covers_assets_that_really_reached_volcano(self):
"""轮询的判据是「拿到了火山远端 Id」,不是类别。
force 送审的上传视频、临时图也会是 processingworker 必须盯到绿/红;
反过来,没有远端 Id 的 processing 是没真正送出去的,轮它只会白打请求。
送审范围仍然只挑 REVIEW_CATEGORIES(见下一条)。
"""
from apps.assets import review
_, team = _mk_team("uc", "TeamC")
polled = {}
for cat in ("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"):
a = Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="processing",
review_remote_id=f"remote-{cat}")
polled[cat] = str(a.id)
# 图片趴不在送审范围里,但已经 force 送出去的同样要盯到终态。
forced = Asset.objects.create(
team=team, name="force", asset_type="image", source="ai_generated",
category="free_create", review_status="processing", review_remote_id="remote-force",
)
# 没有远端 Id = 没真送出去,不该被轮询。
Asset.objects.create(team=team, name="没送出去", asset_type="image", source="ai_generated",
category="person", review_status="processing")
with patch("apps.assets.review.assets_client.is_enabled", return_value=False):
out = review.poll_team_reviews(team)
self.assertEqual(set(out.keys()), set(polled.values()) | {str(forced.id)})
def test_submission_still_only_covers_review_categories(self):
from apps.assets import review
_, team = _mk_team("uc-sub", "TeamCSub")
for cat in ("person", "product_image"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="")
for cat in ("model_tryon", "platform_kit", "free_create"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="")
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub:
review.submit_unsubmitted_reviews(team=team)
submitted = {asset.category for (asset,), _ in (call for call in sub.call_args_list)}
self.assertEqual(submitted, {"person", "product_image"})
def test_poll_team_reviews_auto_submits_unsubmitted(self):
from apps.assets import review
_, team = _mk_team("usub", "TeamUSub")
empty = Asset.objects.create(
team=team, name="待审角色", asset_type="image", source="ai_generated",
category=Asset.Category.PERSON, review_status="",
)
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch(
"apps.assets.review.ensure_review_drain_loop"
):
review.poll_team_reviews(team)
sub.assert_called()
self.assertEqual(sub.call_args.args[0].id, empty.id)
def test_drain_platform_reviews_submits_then_polls(self):
from apps.assets import review
_, team = _mk_team("drain", "TeamDrain")
Asset.objects.create(
team=team, name="未送", asset_type="image", source="ai_generated",
category=Asset.Category.PERSON, review_status="",
)
proc = Asset.objects.create(
team=team, name="审中", asset_type="image", source="ai_generated",
category=Asset.Category.PERSON, review_status="processing",
review_remote_id="remote-proc", # 只有真的送到火山、拿到 Id 的才会被轮询
)
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch(
"apps.assets.review.poll_asset_review", return_value="active"
) as polled:
out = review.drain_platform_reviews()
self.assertEqual(out["submitted"], 1)
self.assertEqual(out["polled"], 1)
self.assertEqual(out["statuses"][str(proc.id)], "active")
sub.assert_called_once()
polled.assert_called_once()
class ImageSizeGuardTests(TestCase):
"""火山素材审核要求图片 300–6000 px。小图以前能一路存进商品库,
直到生成视频送审那一刻才炸([InvalidParameter.WidthTooSmall]) —— 报错离上传很远,
用户对不上号。改成入库前就挡住。"""
def _png(self, width, height):
from io import BytesIO
from PIL import Image
buf = BytesIO()
Image.new("RGB", (width, height), "white").save(buf, format="PNG")
return buf.getvalue()
def test_rejects_image_below_300px(self):
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
with self.assertRaises(ImageTooSmallError) as ctx:
ensure_reviewable_image(self._png(120, 800), label="商品图")
self.assertIn("商品图", str(ctx.exception))
self.assertIn("300", str(ctx.exception))
self.assertIn("120×800", str(ctx.exception)) # 把实际尺寸写给用户,免得反复试
def test_rejects_image_above_6000px(self):
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
with self.assertRaises(ImageTooSmallError):
ensure_reviewable_image(self._png(6400, 900))
def test_accepts_image_in_range_and_returns_size(self):
from apps.assets.image_guard import ensure_reviewable_image
self.assertEqual(ensure_reviewable_image(self._png(800, 1200)), (800, 1200))
def test_broken_file_is_rejected(self):
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
with self.assertRaises(ImageTooSmallError):
ensure_reviewable_image(b"not-an-image")
def test_volcano_error_codes_are_humanized(self):
from apps.assets.review import humanize_submit_error
self.assertIn(
"图片太小",
humanize_submit_error("[InvalidParameter.WidthTooSmall] Width must be between 300px and 6000px."),
)
self.assertIn("格式不支持", humanize_submit_error("[InvalidParameter.InvalidImageFormat] bad"))
# 认不出的原样带出,不要吞掉
self.assertEqual(humanize_submit_error("Some brand new error"), "Some brand new error")