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

@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-09-07 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0015_migrate_tokens_to_apikeys'),
]
operations = [
migrations.AddField(
model_name='artifact',
name='deleted_at',
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
]

View file

@ -60,6 +60,9 @@ class Artifact(models.Model):
metadata = models.JSONField(default=dict, blank=True)
read_at = models.DateTimeField(null=True, blank=True)
is_kept = models.BooleanField(default=False)
# Soft-delete: non-null = in Trash (hidden from feed/sources, off-quota,
# recoverable via undelete, purged by Empty Trash). NULL = live.
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
size_bytes = models.BigIntegerField(default=0)
# NULL = not shared. Many rows share NULL (SQL treats each NULL as
# distinct, so `unique=True` still holds); a real value must be unique
@ -74,6 +77,10 @@ class Artifact(models.Model):
def is_shared(self):
return bool(self.share_token)
@property
def is_deleted(self):
return self.deleted_at is not None
class Meta:
ordering = ["-created_at"]

View file

@ -70,8 +70,13 @@ def artifact_total_bytes(artifact):
def user_usage_bytes(user):
# Trashed artifacts are off-quota (deleting relieves quota immediately);
# their files linger on disk until Empty Trash / purge, but don't count.
artifact_bytes = (
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
Artifact.objects.filter(owner=user, deleted_at__isnull=True)
.aggregate(total=Sum("size_bytes"))
.get("total")
or 0
)
asset_bytes = (
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
@ -113,8 +118,10 @@ def make_room_for(user, incoming_bytes):
if used + incoming_bytes <= quota:
return []
# Trashed artifacts are eviction-protected (recovery window is guaranteed)
# and already off-quota, so they're never eviction candidates.
unkept = list(
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
Artifact.objects.filter(owner=user, is_kept=False, deleted_at__isnull=True).order_by("created_at")
)
# Check whether eviction can possibly succeed before deleting anything -
# otherwise a doomed request would still destroy every unkept artifact on
@ -137,6 +144,16 @@ def make_room_for(user, incoming_bytes):
return evicted
def purge_trashed(user):
"""Permanently remove all of a user's trashed artifacts (files + rows).
Backs the "Empty Trash" action."""
trashed = list(Artifact.objects.filter(owner=user, deleted_at__isnull=False))
for artifact in trashed:
delete_artifact_files(artifact)
artifact.delete()
return len(trashed)
ASSET_ORPHAN_GRACE = timedelta(hours=24)

View file

@ -10,6 +10,9 @@ urlpatterns = [
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"),
path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
path("assets/<str:asset_id>", views.asset_view, name="asset"),

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,