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
107
tests/views/test_trash.py
Normal file
107
tests/views/test_trash.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue