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

@ -334,7 +334,8 @@ class ArtifactAPITestCase(APITestCase):
# -- delete (DELETE) --------------------------------------------------
def test_delete_removes_artifact_and_files(self):
def test_delete_soft_deletes_to_trash(self):
# DELETE moves the artifact to Trash (recoverable), not a hard removal.
artifact_id = self.create_artifact().data["id"]
artifact = Artifact.objects.get(pk=artifact_id)
stored_path = self.artifacts_root / artifact.path
@ -344,9 +345,12 @@ class ArtifactAPITestCase(APITestCase):
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 204)
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
self.assertFalse(stored_path.exists())
self.assertFalse(stored_path.with_suffix(".md").exists())
# Row still exists, marked deleted; files kept on disk until purge.
artifact.refresh_from_db()
self.assertIsNotNone(artifact.deleted_at)
self.assertTrue(stored_path.exists())
# But it's gone from the API's list/detail (hidden like the feed).
self.assertEqual(self.client.get(reverse("artifact-api"), **self.auth_headers(self.token)).data, [])
def test_delete_requires_token(self):
artifact_id = self.create_artifact().data["id"]
@ -362,7 +366,8 @@ class ArtifactAPITestCase(APITestCase):
self.assertEqual(response.status_code, 404)
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
def test_delete_twice_second_call_404s(self):
def test_delete_is_idempotent(self):
# Soft-delete is idempotent: deleting an already-trashed artifact is a no-op 204.
artifact_id = self.create_artifact().data["id"]
first = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
@ -372,7 +377,7 @@ class ArtifactAPITestCase(APITestCase):
second = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(second.status_code, 404)
self.assertEqual(second.status_code, 204)
def test_deleted_artifact_is_gone_from_list_and_detail(self):
artifact_id = self.create_artifact().data["id"]

107
tests/views/test_trash.py Normal file
View file

@ -0,0 +1,107 @@
import shutil
import tempfile
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from artifacts.models import Artifact
from artifacts.storage import user_usage_bytes
User = get_user_model()
class TrashKeepViewTestCase(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-trash-"))
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
cls.settings_override.enable()
@classmethod
def tearDownClass(cls):
cls.settings_override.disable()
shutil.rmtree(cls.artifacts_root, ignore_errors=True)
super().tearDownClass()
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.other = User.objects.create_user(username="bob", password="pw")
self.client.force_login(self.user)
self._n = 0
def make(self, owner=None, title="Note", source="agent", is_kept=False, size_bytes=1):
self._n += 1
return Artifact.objects.create(
id=f"trs{self._n:05d}",
owner=owner or self.user,
source=source,
title=title,
kind=Artifact.MARKDOWN,
day=timezone.localdate(),
path=f"{source}/trs{self._n:05d}.html",
original_kind=Artifact.MARKDOWN,
size_bytes=size_bytes,
is_kept=is_kept,
)
def test_delete_soft_deletes(self):
a = self.make()
r = self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertEqual(r.status_code, 200)
a.refresh_from_db()
self.assertIsNotNone(a.deleted_at)
def test_delete_requires_login(self):
self.client.logout()
a = self.make()
r = self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertEqual(r.status_code, 302)
def test_delete_scoped_to_owner(self):
a = self.make(owner=self.other)
r = self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertEqual(r.status_code, 404)
a.refresh_from_db()
self.assertIsNone(a.deleted_at)
def test_deleted_hidden_from_feed_shown_in_trash(self):
a = self.make(title="ThrowawayThing")
self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertNotIn("ThrowawayThing", self.client.get("/").content.decode())
self.assertIn("ThrowawayThing", self.client.get("/?folder=deleted").content.decode())
def test_undelete_restores(self):
a = self.make(title="ComeBack")
self.client.post(reverse("artifact-delete", args=[a.id]))
r = self.client.post(reverse("artifact-undelete", args=[a.id]))
self.assertEqual(r.status_code, 200)
a.refresh_from_db()
self.assertIsNone(a.deleted_at)
self.assertIn("ComeBack", self.client.get("/").content.decode())
def test_empty_trash_purges(self):
a = self.make()
b = self.make()
self.client.post(reverse("artifact-delete", args=[a.id]))
self.client.post(reverse("artifact-delete", args=[b.id]))
r = self.client.post(reverse("feed-empty-trash"))
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["purged"], 2)
self.assertFalse(Artifact.objects.filter(pk__in=[a.id, b.id]).exists())
def test_deleted_is_off_quota(self):
a = self.make(size_bytes=1000)
self.assertEqual(user_usage_bytes(self.user), 1000)
self.client.post(reverse("artifact-delete", args=[a.id]))
self.assertEqual(user_usage_bytes(self.user), 0)
def test_keep_folder_shows_only_kept(self):
self.make(title="KeptOne", is_kept=True)
self.make(title="NormalOne")
page = self.client.get("/?folder=keep").content.decode()
self.assertIn("KeptOne", page)
self.assertNotIn("NormalOne", page)