feat: 解耦角色与模特并完善模特库
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
from io import BytesIO
|
||||
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
|
||||
@@ -44,10 +45,60 @@ class ModelLibraryApiTests(TestCase):
|
||||
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)
|
||||
@@ -129,6 +180,103 @@ class ModelLibraryApiTests(TestCase):
|
||||
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 计数都排除。"""
|
||||
@@ -148,6 +296,22 @@ class AssetSoftDeleteTests(TestCase):
|
||||
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 二级软删。"""
|
||||
|
||||
Reference in New Issue
Block a user