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
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue