fix: 完善商品—模特库-视频项目-自由创作等删除先进垃圾桶及可恢复等流程
This commit is contained in:
+163
-18
@@ -1,4 +1,5 @@
|
||||
from django.db.models import Count
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, OuterRef
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@@ -7,6 +8,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.serializers import AssetSerializer
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
from apps.common.celery_health import require_worker
|
||||
@@ -53,7 +55,9 @@ class GenerateImageView(APIView):
|
||||
# 对话归属:传了 id 就用现成对话(限本团队);没传则自动开一条新对话,标题取 prompt 前 24 字。
|
||||
conversation = None
|
||||
if conversation_id:
|
||||
conversation = ImageConversation.objects.filter(team=team, id=conversation_id, is_deleted=False).first()
|
||||
conversation = ImageConversation.objects.filter(
|
||||
team=team, id=conversation_id, is_deleted=False, purged_at__isnull=True
|
||||
).first()
|
||||
if conversation is None:
|
||||
conversation = ImageConversation.objects.create(
|
||||
team=team,
|
||||
@@ -92,7 +96,7 @@ class GenerateImageView(APIView):
|
||||
"status": t.status,
|
||||
"error_message": t.error_message,
|
||||
"assets": AssetSerializer(
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
|
||||
).data,
|
||||
}
|
||||
for t in tasks
|
||||
@@ -114,7 +118,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
def get_queryset(self):
|
||||
# 可选 ?task_type=a,b,c 过滤:生图工作室的任务中心只想看生图任务(模特上身图/平台套图/
|
||||
# 图片创作 = person_image / product_image),不掺脚本/实体抽取/故事板等流水线内部任务。
|
||||
queryset = super().get_queryset()
|
||||
queryset = super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
|
||||
# 从 request_payload(已 defer)里只抽 batch_id / mode 两个 JSON 标量供前端「按批分组 + 标签」用:
|
||||
# KeyTextTransform 在 SQL 层 JSON_EXTRACT,不会把几 MB 的 payload 整列拉回(避开 payload 性能坑)。
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
@@ -211,7 +215,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
"created_at": t.created_at,
|
||||
# 软删的图不再出现在工作台记录里(R109:删除资产库图片 → 任务记录联动)
|
||||
"assets": AssetSerializer(
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
|
||||
).data,
|
||||
}
|
||||
for t in tasks
|
||||
@@ -247,20 +251,63 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""
|
||||
|
||||
serializer_class = ImageConversationSerializer
|
||||
queryset = ImageConversation.objects.filter(is_deleted=False).select_related("product").order_by("-last_active_at")
|
||||
queryset = ImageConversation.objects.select_related("product").order_by("-last_active_at")
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().annotate(_task_count=Count("tasks"))
|
||||
if self.action in ("trash", "restore", "purge"):
|
||||
queryset = queryset.filter(is_deleted=True, purged_at__isnull=True)
|
||||
else:
|
||||
queryset = queryset.filter(is_deleted=False, purged_at__isnull=True)
|
||||
mode = self.request.query_params.get("mode", "").strip()
|
||||
if mode:
|
||||
queryset = queryset.filter(mode=mode)
|
||||
return queryset
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
# 软删:对话从列表消失,但其 AITask.conversation 置空由 DB on_delete=SET_NULL 不触发(我们没真删),
|
||||
# 成图始终留在资产库。仅打标记。
|
||||
instance.is_deleted = True
|
||||
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||
# 只联动该会话生成任务产出的 Asset,不碰用户上传的参考素材。
|
||||
with transaction.atomic():
|
||||
Asset.objects.filter(team=instance.team, origin_task__conversation=instance, purged_at__isnull=True).update(is_deleted=True)
|
||||
AITask.objects.filter(team=instance.team, conversation=instance, purged_at__isnull=True).update(is_deleted=True)
|
||||
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):
|
||||
"""从垃圾桶恢复自由创作图片会话。"""
|
||||
conversation = self.get_object()
|
||||
with transaction.atomic():
|
||||
conversation.is_deleted = False
|
||||
conversation.purged_at = None
|
||||
conversation.save(update_fields=["is_deleted", "purged_at", "updated_at"])
|
||||
AITask.objects.filter(team=conversation.team, conversation=conversation, purged_at__isnull=True).update(is_deleted=False)
|
||||
Asset.objects.filter(team=conversation.team, origin_task__conversation=conversation, purged_at__isnull=True).update(is_deleted=False)
|
||||
return Response(self.get_serializer(conversation).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["delete"], url_path="purge")
|
||||
def purge(self, request, pk=None):
|
||||
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录保留。"""
|
||||
conversation = self.get_object()
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
conversation.purged_at = now
|
||||
conversation.save(update_fields=["purged_at", "updated_at"])
|
||||
AITask.objects.filter(team=conversation.team, conversation=conversation, purged_at__isnull=True).update(
|
||||
is_deleted=True, purged_at=now
|
||||
)
|
||||
Asset.objects.filter(team=conversation.team, origin_task__conversation=conversation, purged_at__isnull=True).update(
|
||||
is_deleted=True, purged_at=now
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def tasks(self, request, pk=None):
|
||||
@@ -281,7 +328,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
for rid in ids or []:
|
||||
rid = str(rid)
|
||||
if rid not in ref_cache:
|
||||
a = Asset.objects.filter(id=rid).prefetch_related("files").first()
|
||||
a = Asset.objects.filter(id=rid, is_deleted=False, purged_at__isnull=True).prefetch_related("files").first()
|
||||
# 带上 id:前端重跑时凭它原样复用参考图(否则刷新恢复的批次只有 {name,url},重跑丢参考)
|
||||
ref_cache[rid] = {"id": rid, "name": a.name, "url": _asset_preview(a)} if a else None
|
||||
if ref_cache[rid]:
|
||||
@@ -301,7 +348,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"reference_images": resolve_refs((t.request_payload or {}).get("reference_image_ids")),
|
||||
"created_at": t.created_at,
|
||||
"assets": AssetSerializer(
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
|
||||
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
|
||||
).data,
|
||||
}
|
||||
for t in tasks
|
||||
@@ -311,12 +358,42 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
def _free_video_task_queryset(team):
|
||||
return (
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False)
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False, purged_at__isnull=True)
|
||||
.select_related("model_config")
|
||||
.prefetch_related("generated_assets", "generated_assets__files")
|
||||
)
|
||||
|
||||
|
||||
def _free_video_list_queryset(team):
|
||||
"""正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。"""
|
||||
video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO)
|
||||
active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True)
|
||||
return (
|
||||
_free_video_task_queryset(team)
|
||||
.annotate(
|
||||
_has_video_asset=Exists(video_assets),
|
||||
_has_active_video_asset=Exists(active_video_assets),
|
||||
)
|
||||
.exclude(
|
||||
status=AITask.Status.SUCCEEDED,
|
||||
_has_video_asset=True,
|
||||
_has_active_video_asset=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _free_video_trash_queryset(team):
|
||||
return (
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=True, purged_at__isnull=True)
|
||||
.select_related("model_config")
|
||||
.prefetch_related("generated_assets", "generated_assets__files")
|
||||
)
|
||||
|
||||
|
||||
def _set_free_video_generated_assets_deleted(task, deleted):
|
||||
Asset.objects.filter(team=task.team, origin_task=task, purged_at__isnull=True).update(is_deleted=deleted)
|
||||
|
||||
|
||||
class FreeVideoView(APIView):
|
||||
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
|
||||
|
||||
@@ -349,7 +426,7 @@ class FreeVideoView(APIView):
|
||||
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
qs = _free_video_task_queryset(team).order_by("-created_at")
|
||||
qs = _free_video_list_queryset(team).order_by("-created_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
return Response(
|
||||
@@ -389,7 +466,9 @@ class FreeVideoFavoriteView(APIView):
|
||||
|
||||
def post(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
task = AITask.objects.filter(
|
||||
team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False, purged_at__isnull=True
|
||||
).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
task.is_favorited = not task.is_favorited
|
||||
@@ -402,13 +481,79 @@ class FreeVideoDetailView(APIView):
|
||||
|
||||
def delete(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
task = AITask.objects.filter(
|
||||
team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False, purged_at__isnull=True
|
||||
).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING):
|
||||
return Response({"detail": "任务生成中,请等待完成后再删除"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
task.is_deleted = True
|
||||
task.save(update_fields=["is_deleted", "updated_at"])
|
||||
with transaction.atomic():
|
||||
_set_free_video_generated_assets_deleted(task, True)
|
||||
task.is_deleted = True
|
||||
task.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class FreeVideoTrashView(APIView):
|
||||
"""GET /api/ai/free-video/trash/ —— 自由创作视频垃圾桶。"""
|
||||
|
||||
def get(self, request):
|
||||
from .free_video import serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
offset = max(0, int(request.query_params.get("offset") or 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
try:
|
||||
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
qs = _free_video_trash_queryset(team).order_by("-updated_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
return Response(
|
||||
{
|
||||
"results": [serialize_free_video_task(t, include_deleted_assets=True) for t in tasks],
|
||||
"total": total,
|
||||
"has_more": offset + page_size < total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FreeVideoRestoreView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/restore/ —— 从垃圾桶恢复自由创作视频。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
from .free_video import serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
task = _free_video_trash_queryset(team).filter(id=task_id).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
with transaction.atomic():
|
||||
task.is_deleted = False
|
||||
task.purged_at = None
|
||||
task.save(update_fields=["is_deleted", "purged_at", "updated_at"])
|
||||
_set_free_video_generated_assets_deleted(task, False)
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class FreeVideoPurgeView(APIView):
|
||||
"""DELETE /api/ai/free-video/<id>/purge/ —— 二级软删除,从垃圾桶隐藏。"""
|
||||
|
||||
def delete(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = _free_video_trash_queryset(team).filter(id=task_id).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
task.purged_at = now
|
||||
task.save(update_fields=["purged_at", "updated_at"])
|
||||
Asset.objects.filter(team=task.team, origin_task=task, purged_at__isnull=True).update(is_deleted=True, purged_at=now)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user