fix: 完善商品—模特库-视频项目-自由创作等删除先进垃圾桶及可恢复等流程
This commit is contained in:
@@ -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"),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:读结构化全脚本,只重写目标镜、保留其余镜,落新版本,计费一次。"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user