fix: 完善商品—模特库-视频项目-自由创作等删除先进垃圾桶及可恢复等流程

This commit is contained in:
hh
2026-07-10 15:06:07 +08:00
parent 3e52942547
commit 564215dc53
28 changed files with 1086 additions and 190 deletions
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("projects", "0006_migrate_storyboard_to_shots"),
]
operations = [
migrations.AddField(
model_name="project",
name="is_deleted",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="project",
name="purged_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddIndex(
model_name="project",
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="projects_pr_team_id_9bc32b_idx"),
),
]
+3
View File
@@ -23,6 +23,8 @@ class Project(TeamOwnedModel):
budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
failure_reason = 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 = [
@@ -30,6 +32,7 @@ class Project(TeamOwnedModel):
models.Index(fields=["team", "current_stage"]),
# 项目列表默认按团队 + 更新时间倒序
models.Index(fields=["team", "-updated_at"]),
models.Index(fields=["team", "is_deleted", "purged_at"]),
]
def __str__(self) -> str:
+4 -2
View File
@@ -327,7 +327,7 @@ class ProjectListSerializer(serializers.ModelSerializer):
fields = [
"id", "name", "product", "product_title", "cover_preview_url",
"status", "current_stage", "script_version_count", "video_segment_count",
"created_at", "updated_at",
"is_deleted", "purged_at", "created_at", "updated_at",
]
def get_cover_preview_url(self, obj) -> str:
@@ -354,6 +354,8 @@ class ProjectSerializer(serializers.ModelSerializer):
"budget_limit",
"failure_reason",
"metadata",
"is_deleted",
"purged_at",
"stages",
"script_versions",
"base_asset_groups",
@@ -364,4 +366,4 @@ class ProjectSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
]
read_only_fields = ["id", "status", "current_stage", "failure_reason", "created_at", "updated_at"]
read_only_fields = ["id", "status", "current_stage", "failure_reason", "is_deleted", "purged_at", "created_at", "updated_at"]
+61 -1
View File
@@ -5,7 +5,7 @@ from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.assets.models import Asset
from apps.assets.models import Asset, AssetFile
from apps.billing.models import CreditAccount, CreditLedger
from apps.products.models import Product
from apps.projects.models import (
@@ -88,6 +88,66 @@ class ProjectApiTests(TestCase):
)
self.assertTrue(VideoSegment.objects.filter(project=project).exists())
def test_delete_restore_and_purge_project_trash(self):
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="Trash Project")
segment = VideoSegment.objects.create(project=project, sort_order=0, target_duration_seconds=15)
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="项目视频片段",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.VIDEO_CLIP,
)
asset_file = AssetFile.objects.create(
asset=asset,
object_key="projects/clip.mp4",
bucket="test",
content_type="video/mp4",
is_primary=True,
)
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
segment.adopted_version = version
segment.save(update_fields=["adopted_version"])
delete = self.client.delete(f"/api/projects/{project.id}/")
self.assertEqual(delete.status_code, 204)
project.refresh_from_db()
self.assertTrue(project.is_deleted)
self.assertIsNone(project.purged_at)
list_ids = [p["id"] for p in self.client.get("/api/projects/").json()["results"]]
self.assertNotIn(str(project.id), list_ids)
trash_ids = [p["id"] for p in self.client.get("/api/projects/trash/").json()["results"]]
self.assertIn(str(project.id), trash_ids)
asset.refresh_from_db()
self.assertFalse(asset.is_deleted)
self.assertIsNone(asset.purged_at)
restore = self.client.post(f"/api/projects/{project.id}/restore/")
self.assertEqual(restore.status_code, 200)
project.refresh_from_db()
self.assertFalse(project.is_deleted)
self.assertIsNone(project.purged_at)
list_ids = [p["id"] for p in self.client.get("/api/projects/").json()["results"]]
self.assertIn(str(project.id), list_ids)
self.assertTrue(VideoSegmentVersion.objects.filter(id=version.id).exists())
self.client.delete(f"/api/projects/{project.id}/")
purge = self.client.delete(f"/api/projects/{project.id}/purge/")
self.assertEqual(purge.status_code, 204)
project.refresh_from_db()
self.assertTrue(project.is_deleted)
self.assertIsNotNone(project.purged_at)
self.assertTrue(Project.objects.filter(id=project.id).exists())
self.assertTrue(VideoSegment.objects.filter(project=project).exists())
self.assertTrue(VideoSegmentVersion.objects.filter(id=version.id).exists())
asset.refresh_from_db()
self.assertFalse(asset.is_deleted)
self.assertIsNone(asset.purged_at)
self.assertTrue(AssetFile.objects.filter(id=asset_file.id).exists())
trash_ids = [p["id"] for p in self.client.get("/api/projects/trash/").json()["results"]]
self.assertNotIn(str(project.id), trash_ids)
@patch("apps.ai.services.VolcanoArkProvider")
def test_rerun_script_segment_via_agent_rewrites_one_and_charges_once(self, provider_cls):
"""单镜重跑改走 agent:读结构化全脚本,只重写目标镜、保留其余镜,落新版本,计费一次。"""
+55 -7
View File
@@ -5,6 +5,7 @@ import uuid
from django.db import transaction
from django.db.models import Count, Q
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
@@ -246,7 +247,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
return # 项目内已有商品三视图,不覆盖
tri = (
Asset.objects.filter(
team=team, is_deleted=False,
team=team, is_deleted=False, purged_at__isnull=True,
metadata__product_id=str(product_id), metadata__view="three_view",
)
.order_by("-created_at")
@@ -267,7 +268,12 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
# 按采用版分镜数收口一次(幂等;只裁从未生成过的尾段,生成中/已出片的段绝不动)。
# 用轻量查询判定 + 同步,再交给 super 做重 prefetch 序列化(避免重查询跑两遍)。
proj = (
Project.objects.filter(pk=kwargs.get("pk"), **{self.team_field: self.get_team()})
Project.objects.filter(
pk=kwargs.get("pk"),
is_deleted=False,
purged_at__isnull=True,
**{self.team_field: self.get_team()},
)
.only("id", "current_stage", "status", "product_id")
.first()
)
@@ -289,6 +295,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
return super().retrieve(request, *args, **kwargs)
def get_queryset(self):
if self.action in ("trash", "restore", "purge"):
return (
Project.objects.select_related("product", "product__cover_asset")
.prefetch_related("product__cover_asset__files")
.filter(is_deleted=True, purged_at__isnull=True, **{self.team_field: self.get_team()})
.order_by("-updated_at")
)
# 列表:轻查询(只 select_related product + 计数注解),不做详情那串 12 个重 prefetch
# ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。
if self.action == "list":
@@ -300,8 +313,43 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
video_segment_count=Count("video_segments", distinct=True),
)
)
return qs.filter(**{self.team_field: self.get_team()}).order_by("-updated_at")
return super().get_queryset()
return qs.filter(
is_deleted=False,
purged_at__isnull=True,
**{self.team_field: self.get_team()},
).order_by("-updated_at")
return super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
def perform_destroy(self, instance):
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)
serializer_cls = ProjectListSerializer
if page is not None:
return self.get_paginated_response(serializer_cls(page, many=True).data)
return Response(serializer_cls(qs, many=True).data)
@action(detail=True, methods=["post"], url_path="restore")
def restore(self, request, pk=None):
"""从垃圾桶恢复视频项目。"""
project = self.get_object()
project.is_deleted = False
project.purged_at = None
project.save(update_fields=["is_deleted", "purged_at", "updated_at"])
return Response(ProjectSerializer(project).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除 = 二级软删除:从垃圾桶隐藏,项目 DB 记录保留。"""
project = self.get_object()
project.purged_at = timezone.now()
project.save(update_fields=["purged_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get"], url_path="export-clips")
def export_clips(self, request, pk=None):
@@ -359,7 +407,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=False, methods=["get"])
def summary(self, request):
"""项目统计(总数 + 各状态计数),供仪表盘/侧栏徽标——只跑 COUNT,不拉项目对象。"""
base = Project.objects.filter(team=self.get_team())
base = Project.objects.filter(team=self.get_team(), is_deleted=False, purged_at__isnull=True)
by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))}
return Response({"total": base.count(), "by_status": by_status})
@@ -594,7 +642,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if asset is None and asset_id:
# 跨团队可见的官方模特形象图 / 三视图 → 克隆进本团队再挂
official = (
Asset.objects.filter(id=asset_id, is_deleted=False)
Asset.objects.filter(id=asset_id, is_deleted=False, purged_at__isnull=True)
.filter(Q(model_as_portrait__is_official=True) | Q(model_as_triview__is_official=True))
.first()
)
@@ -659,7 +707,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if source_portrait_id:
candidate_ids.add(str(source_portrait_id))
model = (
ModelEntity.objects.filter(portrait_asset_id__in=candidate_ids, is_deleted=False)
ModelEntity.objects.filter(portrait_asset_id__in=candidate_ids, is_deleted=False, purged_at__isnull=True)
.exclude(triview_asset__isnull=True)
.select_related("triview_asset")
.first()