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:
parent
6c1823e65e
commit
4254164bef
14 changed files with 438 additions and 43 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue