fix: 完善商品—模特库-视频项目-自由创作等删除先进垃圾桶及可恢复等流程
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("assets", "0009_freeassetgroup_freeasset_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="asset",
|
||||
name="purged_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="model",
|
||||
name="purged_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="asset",
|
||||
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="assets_asse_team_id_d7db74_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="model",
|
||||
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="assets_mode_team_id_21e90d_idx"),
|
||||
),
|
||||
]
|
||||
@@ -55,6 +55,7 @@ class Asset(TeamOwnedModel):
|
||||
)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
purged_at = models.DateTimeField(null=True, blank=True)
|
||||
# 是否进「资产库」列表:工作台生成的图(模特上身图/平台套图/自由创作)默认 False(只在工作台展示),
|
||||
# 用户「加入资产库」后才置 True 出现在资产库列表;「取消加入」置回 False 即从列表移出。
|
||||
# 字段默认 True 是为了让历史数据 / 上传 / 项目内资产保持原样可见(只对新生成的工作台图显式置 False)。
|
||||
@@ -69,6 +70,7 @@ class Asset(TeamOwnedModel):
|
||||
# 资产库按团队 + 分类倒序时间翻页(products 详情素材趴 / 资产库)
|
||||
models.Index(fields=["team", "category", "-created_at"]),
|
||||
models.Index(fields=["team", "asset_type"]),
|
||||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -102,11 +104,13 @@ class Model(TeamOwnedModel):
|
||||
description = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
purged_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
# 模特库默认按团队 + 新→旧;官方模板优先靠前由序列化/查询控制
|
||||
models.Index(fields=["team", "is_official", "-created_at"]),
|
||||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ class ModelLibrarySerializer(serializers.ModelSerializer):
|
||||
"source",
|
||||
"description",
|
||||
"metadata",
|
||||
"is_deleted",
|
||||
"purged_at",
|
||||
"portrait_asset",
|
||||
"triview_asset",
|
||||
"voice_asset",
|
||||
@@ -150,7 +152,7 @@ class ModelLibrarySerializer(serializers.ModelSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "is_official", "source", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "is_official", "source", "is_deleted", "purged_at", "created_at", "updated_at"]
|
||||
|
||||
def get_portrait(self, obj):
|
||||
return _asset_preview(obj.portrait_asset)
|
||||
|
||||
@@ -9,7 +9,7 @@ from rest_framework.test import APIClient
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
|
||||
from . import assets_client
|
||||
from .models import Asset, FreeAsset, FreeAssetGroup, Model
|
||||
from .models import Asset, AssetFile, FreeAsset, FreeAssetGroup, Model
|
||||
|
||||
|
||||
def _mk_team(username, team_name):
|
||||
@@ -56,6 +56,48 @@ class ModelLibraryApiTests(TestCase):
|
||||
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)
|
||||
@@ -108,7 +150,7 @@ class AssetSoftDeleteTests(TestCase):
|
||||
|
||||
|
||||
class AssetTrashApiTests(TestCase):
|
||||
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 硬删且被引用时 409。"""
|
||||
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 二级软删。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user, self.team = _mk_team("utr", "TeamTR")
|
||||
@@ -117,6 +159,13 @@ class AssetTrashApiTests(TestCase):
|
||||
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)
|
||||
|
||||
@@ -134,19 +183,26 @@ class AssetTrashApiTests(TestCase):
|
||||
# 恢复后不再在垃圾桶
|
||||
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
|
||||
|
||||
def test_purge_hard_deletes(self):
|
||||
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.assertFalse(Asset.objects.filter(id=self.dead.id).exists())
|
||||
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_returns_409(self):
|
||||
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, 409)
|
||||
self.assertTrue(Asset.objects.filter(id=self.dead.id, is_deleted=True).exists()) # 留在垃圾桶可恢复
|
||||
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(与商品垃圾桶口径一致)
|
||||
|
||||
@@ -5,8 +5,8 @@ import uuid
|
||||
import requests
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models.deletion import ProtectedError
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
@@ -120,8 +120,15 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# perform_destroy 软删时 update updated_at → -updated_at ≈ 删除时间倒序;不过滤 in_library
|
||||
# (未入库的工作台图删了也得能恢复)。基类 queryset 的 defer(AITask 巨型 payload)保持生效。
|
||||
if self.action in ("trash", "restore", "purge"):
|
||||
return qs.filter(is_deleted=True).order_by("-updated_at")
|
||||
qs = qs.filter(is_deleted=False) # 软删资产不出现在资产库
|
||||
trash_qs = qs.filter(is_deleted=True, purged_at__isnull=True)
|
||||
# 自由创作父记录已进垃圾桶时,生成 Asset 由父记录统一恢复/彻底隐藏,
|
||||
# 不在“资产”分类重复展示。单独从资产库删除时父记录仍为正常态,仍会出现在这里。
|
||||
owned_by_deleted_free_creation = (
|
||||
Q(origin_task__task_type="free_video", origin_task__is_deleted=True)
|
||||
| Q(origin_task__conversation__is_deleted=True)
|
||||
)
|
||||
return trash_qs.exclude(owned_by_deleted_free_creation).order_by("-updated_at")
|
||||
qs = qs.filter(is_deleted=False, purged_at__isnull=True) # 软删资产不出现在资产库
|
||||
p = self.request.query_params
|
||||
# 资产库列表/批次默认只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
|
||||
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
|
||||
@@ -176,7 +183,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""资产库 tab 计数,供 tab 徽标——不必取全量。
|
||||
图片趴三类(模特上身图/平台套图/自由创作)按「生成批次」计数(与卡片成组展示一致);
|
||||
其他(我的上传 + 兜底)仍按资产张数计。"""
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, purged_at__isnull=True, in_library=True)
|
||||
out = {}
|
||||
for t in BATCH_TABS: # 图片趴:数批次
|
||||
seen = set()
|
||||
@@ -279,23 +286,16 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
(均过滤 is_deleted),恢复后各处即联动复现;in_library 保持删除前原样,不额外置位。"""
|
||||
asset = self.get_object() # trash 态 queryset:只有软删资产可恢复
|
||||
asset.is_deleted = False
|
||||
asset.save(update_fields=["is_deleted", "updated_at"])
|
||||
asset.purged_at = None
|
||||
asset.save(update_fields=["is_deleted", "purged_at", "updated_at"])
|
||||
return Response(self.get_serializer(asset).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["delete"], url_path="purge")
|
||||
def purge(self, request, pk=None):
|
||||
"""彻底删除(仅垃圾桶内软删资产可彻底删,不可恢复)。与商品 purge 语义对齐:
|
||||
硬删 DB 行(AssetFile 记录级联删除),TOS 对象不动(商品 purge 同样不触碰对象存储)。
|
||||
分镜/视频片段/商品图等以 PROTECT 引用资产:被引用的资产硬删会炸 ProtectedError,
|
||||
如实回 409(留在垃圾桶,仍可恢复),别让它 500。"""
|
||||
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录和 TOS 文件保留。"""
|
||||
asset = self.get_object()
|
||||
try:
|
||||
asset.delete()
|
||||
except ProtectedError:
|
||||
return Response(
|
||||
{"detail": "该资产仍被商品图/分镜/视频片段等引用,无法彻底删除;可恢复后先解除引用"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
asset.purged_at = timezone.now()
|
||||
asset.save(update_fields=["purged_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="video-packs")
|
||||
@@ -323,7 +323,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
clips = []
|
||||
for seg in segs:
|
||||
a = getattr(seg.adopted_version, "asset", None)
|
||||
if a is None or a.is_deleted:
|
||||
if a is None or a.is_deleted or a.purged_at is not None:
|
||||
continue
|
||||
clips.append({"id": str(a.id), "name": a.name, "url": _asset_preview(a)})
|
||||
if not clips:
|
||||
@@ -342,7 +342,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def facets(self, request):
|
||||
"""某 tab 下真实存在的筛选项(来源/类型/关联商品/指定 metadata 键的取值),供下拉「只列真有的」。
|
||||
参数:tab、meta_keys=gender,age,role,...(逗号分隔)。"""
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
|
||||
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, purged_at__isnull=True, in_library=True)
|
||||
if request.query_params.get("tab"):
|
||||
base = base.filter(_tab_q(request.query_params["tab"]))
|
||||
sources = sorted(s for s in base.values_list("source", flat=True).distinct() if s)
|
||||
@@ -430,8 +430,14 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
team = self.get_team()
|
||||
if self.action in ("trash", "restore", "purge"):
|
||||
return (
|
||||
Model.objects.filter(team=team, is_official=False, is_deleted=True, purged_at__isnull=True)
|
||||
.prefetch_related("portrait_asset__files", "triview_asset__files")
|
||||
.order_by("-updated_at")
|
||||
)
|
||||
qs = (
|
||||
Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False)
|
||||
Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False, purged_at__isnull=True)
|
||||
.prefetch_related("portrait_asset__files", "triview_asset__files")
|
||||
)
|
||||
tab = self.request.query_params.get("tab")
|
||||
@@ -456,6 +462,32 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
instance.is_deleted = True
|
||||
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="trash")
|
||||
def trash(self, request):
|
||||
"""垃圾桶:列出本团队已软删且未彻底隐藏的自建模特。"""
|
||||
qs = self.filter_queryset(self.get_queryset())
|
||||
page = self.paginate_queryset(qs)
|
||||
if page is not None:
|
||||
return self.get_paginated_response(self.get_serializer(page, many=True).data)
|
||||
return Response(self.get_serializer(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="restore")
|
||||
def restore(self, request, pk=None):
|
||||
"""从垃圾桶恢复模特。"""
|
||||
model = self.get_object()
|
||||
model.is_deleted = False
|
||||
model.purged_at = None
|
||||
model.save(update_fields=["is_deleted", "purged_at", "updated_at"])
|
||||
return Response(self.get_serializer(model).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["delete"], url_path="purge")
|
||||
def purge(self, request, pk=None):
|
||||
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录和关联资产保留。"""
|
||||
model = self.get_object()
|
||||
model.purged_at = timezone.now()
|
||||
model.save(update_fields=["purged_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser])
|
||||
def upload(self, request):
|
||||
"""真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。"""
|
||||
@@ -507,10 +539,10 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
资产生成时已落库扣过费,此处只引用、不重新上传/不二次扣费。同一资产已建过则幂等返回。"""
|
||||
team = self.get_team()
|
||||
asset_id = request.data.get("asset_id")
|
||||
asset = Asset.objects.filter(id=asset_id, team=team, is_deleted=False).first() if asset_id else None
|
||||
asset = Asset.objects.filter(id=asset_id, team=team, is_deleted=False, purged_at__isnull=True).first() if asset_id else None
|
||||
if asset is None:
|
||||
raise ValidationError({"asset_id": "资产不存在或不属于本团队"})
|
||||
existing = Model.objects.filter(team=team, portrait_asset=asset, is_deleted=False).first()
|
||||
existing = Model.objects.filter(team=team, portrait_asset=asset, is_deleted=False, purged_at__isnull=True).first()
|
||||
if existing is not None:
|
||||
return Response(ModelLibrarySerializer(existing).data, status=status.HTTP_200_OK)
|
||||
name = (request.data.get("name") or (asset.name or "模特").split("·")[0].strip() or "模特")[:255]
|
||||
|
||||
Reference in New Issue
Block a user