v0.5.0: Trash (soft-delete) + Keep folder

Delete now moves an artifact to a recoverable Trash instead of hard-removing.

- Model: Artifact.deleted_at (nullable, indexed) = in Trash.
- Soft-delete everywhere: DELETE /api/artifacts/{id} sets deleted_at
  (idempotent 204); session POST /a/{id}/delete for the feed's hover Delete
  icon; POST /a/{id}/undelete restores; POST /feed/empty-trash purges.
- Trashed artifacts are off-quota (deleting frees quota) and eviction-protected
  (never evicted early); hidden from feed, sources, counts, and all API reads
  (list/detail/raw).
- Sidebar: "Deleted" and "Keep" system folders at the bottom (counts, hidden
  when empty); ?folder=deleted / ?folder=keep views reuse the row rendering.
  Deleted view has hover Undelete + an Empty Trash button. Keep = view over
  is_kept. Live-poll disabled in folder views.
- Docs (api.md delete section, CLAUDE.md), 10 new/updated tests. 150 passing;
  API soft-delete lifecycle verified live.

Purge is manual (Empty Trash) this release; auto purge-on-delete (background,
~3-day retention) is the planned fast-follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-07 12:48:43 +01:00
parent 6c1823e65e
commit 4254164bef
14 changed files with 438 additions and 43 deletions

View file

@ -44,6 +44,7 @@ from .storage import (
delete_artifact_files,
disk_free_bytes,
make_room_for,
purge_trashed,
sweep_orphan_assets,
total_allocated_bytes,
user_quota_bytes,
@ -90,7 +91,7 @@ class ArtifactListCreateView(APIView):
user = resolve_request_user(request)
if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
artifacts = Artifact.objects.filter(owner=user)
artifacts = Artifact.objects.filter(owner=user, deleted_at__isnull=True)
if source := request.query_params.get("source"):
artifacts = artifacts.filter(source=source)
if label := request.query_params.get("label"):
@ -231,7 +232,7 @@ class ArtifactDetailView(APIView):
user = resolve_request_user(request)
if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id):
@ -239,7 +240,7 @@ class ArtifactDetailView(APIView):
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
data = request.data
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
if not_editable:
@ -298,9 +299,12 @@ class ArtifactDetailView(APIView):
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
# Soft-delete: move to Trash (recoverable, off-quota) rather than
# permanently removing. Purged later via Empty Trash. Idempotent.
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
delete_artifact_files(artifact)
artifact.delete()
if artifact.deleted_at is None:
artifact.deleted_at = timezone.now()
artifact.save(update_fields=["deleted_at"])
return Response(status=204)
@ -318,7 +322,7 @@ class ArtifactRawView(APIView):
user = resolve_request_user(request)
if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
raw_path = raw_artifact_path(artifact)
if raw_path and raw_path.exists():
@ -516,21 +520,40 @@ def feed(request):
selected_artifact_id = request.GET.get("artifact", "")
preview_none = request.GET.get("preview") == "none"
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
selected_folder = request.GET.get("folder", "")
if selected_folder not in ("deleted", "keep"):
selected_folder = ""
all_artifacts = Artifact.objects.filter(owner=request.user)
# Live (non-trashed) artifacts drive the normal feed and the source sidebar.
live = Artifact.objects.filter(owner=request.user, deleted_at__isnull=True)
if selected_folder == "deleted":
# System folder: all trashed items, not source-scoped.
selected_source = ""
base = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False)
elif selected_folder == "keep":
selected_source = ""
base = live.filter(is_kept=True)
else:
base = live
artifacts = apply_feed_filters(
all_artifacts, selected_source, selected_label, selected_kind, title_query
base, selected_source, selected_label, selected_kind, title_query
)
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days that
# do match, so selecting a source is never met with a blank list.
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days:
window_start = recent_days[-1]
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
older_count = artifacts.filter(day__lt=window_start).count()
if selected_folder:
# System folders show everything (no 7-day window / infinite scroll).
window_start = today
window_artifacts = list(artifacts.order_by("-created_at"))
older_count = 0
else:
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days
# that do match, so selecting a source is never met with a blank list.
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days:
window_start = recent_days[-1]
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
older_count = artifacts.filter(day__lt=window_start).count()
overrides = tag_color_overrides()
day_groups = group_artifacts_by_day(window_artifacts, overrides)
@ -540,6 +563,8 @@ def feed(request):
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
toggle_query = {"view": "rendered"}
if selected_folder:
toggle_query["folder"] = selected_folder
if selected_source:
toggle_query["source"] = selected_source
if selected_label:
@ -554,18 +579,20 @@ def feed(request):
toggle_query["view"] = "raw"
raw_url = f"?{urlencode(toggle_query)}"
# Source counts come from the other filters only, so the sidebar still shows
# every source available under the current label/type/name filters.
# Source counts + labels always come from live artifacts, so the sidebar is
# the same whichever folder you're viewing.
source_scoped = apply_feed_filters(
all_artifacts, "", selected_label, selected_kind, title_query
live, "", selected_label, selected_kind, title_query
)
source_counts = (
source_scoped.values("source")
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
.order_by("source")
)
keep_count = live.filter(is_kept=True).count()
trash_count = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False).count()
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
all_labels = sorted({tag for tags in live.values_list("tags", flat=True) for tag in tags})
return render(
request,
"artifacts/feed.html",
@ -578,6 +605,10 @@ def feed(request):
"feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(),
"source_counts": source_counts,
"total_count": source_scoped.filter(read_at__isnull=True).count(),
"keep_count": keep_count,
"trash_count": trash_count,
"selected_folder": selected_folder,
"in_trash": selected_folder == "deleted",
"all_labels": labels_with_colors_list(all_labels, overrides),
"raw_url": raw_url,
"rendered_url": rendered_url,
@ -630,12 +661,42 @@ def toggle_artifact_share(request, artifact_id):
return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url})
@login_required
@require_POST
def delete_artifact(request, artifact_id):
"""Soft-delete: move to Trash (recoverable, off-quota). Idempotent."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
if artifact.deleted_at is None:
artifact.deleted_at = timezone.now()
artifact.save(update_fields=["deleted_at"])
return JsonResponse({"id": artifact.id, "deleted": True})
@login_required
@require_POST
def undelete_artifact(request, artifact_id):
"""Restore a trashed artifact back to the live feed."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
if artifact.deleted_at is not None:
artifact.deleted_at = None
artifact.save(update_fields=["deleted_at"])
return JsonResponse({"id": artifact.id, "deleted": False})
@login_required
@require_POST
def empty_trash(request):
"""Permanently purge all of the user's trashed artifacts."""
count = purge_trashed(request.user)
return JsonResponse({"purged": count})
@login_required
def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
artifacts = apply_feed_filters(
Artifact.objects.filter(owner=request.user),
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
request.GET.get("source", ""),
request.GET.get("label", ""),
request.GET.get("kind", ""),
@ -683,7 +744,7 @@ def feed_updates(request):
selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "")
artifacts = apply_feed_filters(
Artifact.objects.filter(owner=request.user),
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
selected_source,
selected_label,
selected_kind,