Compare commits
No commits in common. "v0.5.0" and "v0.4.1" have entirely different histories.
14 changed files with 43 additions and 438 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -9,27 +9,7 @@ former name, "Daybook".)
|
||||||
|
|
||||||
_Nothing yet._
|
_Nothing yet._
|
||||||
|
|
||||||
## [0.5.0] — 2026-09-07
|
## [0.4.1] — 2026-09-05
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Trash (soft-delete).** Deleting an artifact now moves it to a recoverable
|
|
||||||
**Trash** instead of removing it. Feed rows get a hover **Delete** icon; a
|
|
||||||
**"Deleted"** folder at the bottom of the sidebar lists trashed items with a
|
|
||||||
hover **Undelete**, plus an **Empty Trash** button to purge permanently.
|
|
||||||
Trashed items are **off-quota** (deleting frees quota immediately) and
|
|
||||||
eviction-protected. `DELETE /api/artifacts/{id}` is now a soft-delete too
|
|
||||||
(idempotent); trashed items are hidden from all API reads.
|
|
||||||
- **Keep folder.** A sidebar folder listing kept artifacts (a view over the
|
|
||||||
existing `is_kept` flag), mirroring Trash. Both hide when empty.
|
|
||||||
- Session endpoints `POST /a/{id}/delete`, `/a/{id}/undelete`,
|
|
||||||
`/feed/empty-trash`; feed `?folder=deleted` / `?folder=keep` views.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- `DELETE /api/artifacts/{id}` no longer hard-deletes — see above.
|
|
||||||
|
|
||||||
### Note
|
|
||||||
- Permanent purge is manual (**Empty Trash**) in this release; automatic
|
|
||||||
purge-on-delete (background, ~3-day retention) is the planned fast-follow.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Live feed updates now also keep the **source sidebar** current: as new posts
|
- Live feed updates now also keep the **source sidebar** current: as new posts
|
||||||
|
|
@ -185,8 +165,7 @@ First tagged release.
|
||||||
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
||||||
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
||||||
|
|
||||||
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...HEAD
|
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...HEAD
|
||||||
[0.5.0]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...v0.5.0
|
|
||||||
[0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1
|
[0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1
|
||||||
[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0
|
[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0
|
||||||
[0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2
|
[0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ The core product principle is: agents publish content, not files. The server own
|
||||||
- Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient.
|
- Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient.
|
||||||
- User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once.
|
- User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once.
|
||||||
- `TagColor` (label color overrides) stays global/shared across all users.
|
- `TagColor` (label color overrides) stays global/shared across all users.
|
||||||
- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both folders hide when empty. (Auto purge-on-delete is a planned fast-follow; currently purge is manual.)
|
|
||||||
- Use random 8-character artifact IDs.
|
- Use random 8-character artifact IDs.
|
||||||
- Start as a plain Django project; add Docker after core ingest and viewer work.
|
- Start as a plain Django project; add Docker after core ingest and viewer work.
|
||||||
- Preserve original markdown as `.md` alongside rendered `.html`.
|
- Preserve original markdown as `.md` alongside rendered `.html`.
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -60,9 +60,6 @@ class Artifact(models.Model):
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
read_at = models.DateTimeField(null=True, blank=True)
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
is_kept = models.BooleanField(default=False)
|
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)
|
size_bytes = models.BigIntegerField(default=0)
|
||||||
# NULL = not shared. Many rows share NULL (SQL treats each NULL as
|
# NULL = not shared. Many rows share NULL (SQL treats each NULL as
|
||||||
# distinct, so `unique=True` still holds); a real value must be unique
|
# distinct, so `unique=True` still holds); a real value must be unique
|
||||||
|
|
@ -77,10 +74,6 @@ class Artifact(models.Model):
|
||||||
def is_shared(self):
|
def is_shared(self):
|
||||||
return bool(self.share_token)
|
return bool(self.share_token)
|
||||||
|
|
||||||
@property
|
|
||||||
def is_deleted(self):
|
|
||||||
return self.deleted_at is not None
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,8 @@ def artifact_total_bytes(artifact):
|
||||||
|
|
||||||
|
|
||||||
def user_usage_bytes(user):
|
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_bytes = (
|
||||||
Artifact.objects.filter(owner=user, deleted_at__isnull=True)
|
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||||
.aggregate(total=Sum("size_bytes"))
|
|
||||||
.get("total")
|
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
asset_bytes = (
|
asset_bytes = (
|
||||||
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||||
|
|
@ -118,10 +113,8 @@ def make_room_for(user, incoming_bytes):
|
||||||
if used + incoming_bytes <= quota:
|
if used + incoming_bytes <= quota:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Trashed artifacts are eviction-protected (recovery window is guaranteed)
|
|
||||||
# and already off-quota, so they're never eviction candidates.
|
|
||||||
unkept = list(
|
unkept = list(
|
||||||
Artifact.objects.filter(owner=user, is_kept=False, deleted_at__isnull=True).order_by("created_at")
|
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
|
||||||
)
|
)
|
||||||
# Check whether eviction can possibly succeed before deleting anything -
|
# Check whether eviction can possibly succeed before deleting anything -
|
||||||
# otherwise a doomed request would still destroy every unkept artifact on
|
# otherwise a doomed request would still destroy every unkept artifact on
|
||||||
|
|
@ -144,16 +137,6 @@ def make_room_for(user, incoming_bytes):
|
||||||
return evicted
|
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)
|
ASSET_ORPHAN_GRACE = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ urlpatterns = [
|
||||||
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
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>/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>/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("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||||
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
|
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
|
||||||
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ from .storage import (
|
||||||
delete_artifact_files,
|
delete_artifact_files,
|
||||||
disk_free_bytes,
|
disk_free_bytes,
|
||||||
make_room_for,
|
make_room_for,
|
||||||
purge_trashed,
|
|
||||||
sweep_orphan_assets,
|
sweep_orphan_assets,
|
||||||
total_allocated_bytes,
|
total_allocated_bytes,
|
||||||
user_quota_bytes,
|
user_quota_bytes,
|
||||||
|
|
@ -91,7 +90,7 @@ class ArtifactListCreateView(APIView):
|
||||||
user = resolve_request_user(request)
|
user = resolve_request_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
||||||
artifacts = Artifact.objects.filter(owner=user, deleted_at__isnull=True)
|
artifacts = Artifact.objects.filter(owner=user)
|
||||||
if source := request.query_params.get("source"):
|
if source := request.query_params.get("source"):
|
||||||
artifacts = artifacts.filter(source=source)
|
artifacts = artifacts.filter(source=source)
|
||||||
if label := request.query_params.get("label"):
|
if label := request.query_params.get("label"):
|
||||||
|
|
@ -232,7 +231,7 @@ class ArtifactDetailView(APIView):
|
||||||
user = resolve_request_user(request)
|
user = resolve_request_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
||||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||||
return Response(serialize_artifact(artifact, request))
|
return Response(serialize_artifact(artifact, request))
|
||||||
|
|
||||||
def patch(self, request, artifact_id):
|
def patch(self, request, artifact_id):
|
||||||
|
|
@ -240,7 +239,7 @@ class ArtifactDetailView(APIView):
|
||||||
if user is None:
|
if user is None:
|
||||||
return HttpResponseForbidden("Invalid or missing API token")
|
return HttpResponseForbidden("Invalid or missing API token")
|
||||||
|
|
||||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||||
data = request.data
|
data = request.data
|
||||||
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
|
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
|
||||||
if not_editable:
|
if not_editable:
|
||||||
|
|
@ -299,12 +298,9 @@ class ArtifactDetailView(APIView):
|
||||||
if user is None:
|
if user is None:
|
||||||
return HttpResponseForbidden("Invalid or missing API token")
|
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)
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||||
if artifact.deleted_at is None:
|
delete_artifact_files(artifact)
|
||||||
artifact.deleted_at = timezone.now()
|
artifact.delete()
|
||||||
artifact.save(update_fields=["deleted_at"])
|
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -322,7 +318,7 @@ class ArtifactRawView(APIView):
|
||||||
user = resolve_request_user(request)
|
user = resolve_request_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
||||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||||
|
|
||||||
raw_path = raw_artifact_path(artifact)
|
raw_path = raw_artifact_path(artifact)
|
||||||
if raw_path and raw_path.exists():
|
if raw_path and raw_path.exists():
|
||||||
|
|
@ -520,40 +516,21 @@ def feed(request):
|
||||||
selected_artifact_id = request.GET.get("artifact", "")
|
selected_artifact_id = request.GET.get("artifact", "")
|
||||||
preview_none = request.GET.get("preview") == "none"
|
preview_none = request.GET.get("preview") == "none"
|
||||||
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
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 = ""
|
|
||||||
|
|
||||||
# Live (non-trashed) artifacts drive the normal feed and the source sidebar.
|
all_artifacts = Artifact.objects.filter(owner=request.user)
|
||||||
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(
|
artifacts = apply_feed_filters(
|
||||||
base, selected_source, selected_label, selected_kind, title_query
|
all_artifacts, selected_source, selected_label, selected_kind, title_query
|
||||||
)
|
)
|
||||||
|
|
||||||
if selected_folder:
|
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
||||||
# System folders show everything (no 7-day window / infinite scroll).
|
if not artifacts.filter(day__gte=window_start).exists():
|
||||||
window_start = today
|
# Nothing recent for these filters: fall back to the most recent days that
|
||||||
window_artifacts = list(artifacts.order_by("-created_at"))
|
# do match, so selecting a source is never met with a blank list.
|
||||||
older_count = 0
|
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
|
||||||
else:
|
if recent_days:
|
||||||
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
window_start = recent_days[-1]
|
||||||
if not artifacts.filter(day__gte=window_start).exists():
|
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
|
||||||
# Nothing recent for these filters: fall back to the most recent days
|
older_count = artifacts.filter(day__lt=window_start).count()
|
||||||
# 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()
|
overrides = tag_color_overrides()
|
||||||
day_groups = group_artifacts_by_day(window_artifacts, overrides)
|
day_groups = group_artifacts_by_day(window_artifacts, overrides)
|
||||||
|
|
@ -563,8 +540,6 @@ def feed(request):
|
||||||
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
|
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
|
||||||
|
|
||||||
toggle_query = {"view": "rendered"}
|
toggle_query = {"view": "rendered"}
|
||||||
if selected_folder:
|
|
||||||
toggle_query["folder"] = selected_folder
|
|
||||||
if selected_source:
|
if selected_source:
|
||||||
toggle_query["source"] = selected_source
|
toggle_query["source"] = selected_source
|
||||||
if selected_label:
|
if selected_label:
|
||||||
|
|
@ -579,20 +554,18 @@ def feed(request):
|
||||||
toggle_query["view"] = "raw"
|
toggle_query["view"] = "raw"
|
||||||
raw_url = f"?{urlencode(toggle_query)}"
|
raw_url = f"?{urlencode(toggle_query)}"
|
||||||
|
|
||||||
# Source counts + labels always come from live artifacts, so the sidebar is
|
# Source counts come from the other filters only, so the sidebar still shows
|
||||||
# the same whichever folder you're viewing.
|
# every source available under the current label/type/name filters.
|
||||||
source_scoped = apply_feed_filters(
|
source_scoped = apply_feed_filters(
|
||||||
live, "", selected_label, selected_kind, title_query
|
all_artifacts, "", selected_label, selected_kind, title_query
|
||||||
)
|
)
|
||||||
source_counts = (
|
source_counts = (
|
||||||
source_scoped.values("source")
|
source_scoped.values("source")
|
||||||
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
|
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
|
||||||
.order_by("source")
|
.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 live.values_list("tags", flat=True) for tag in tags})
|
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"artifacts/feed.html",
|
"artifacts/feed.html",
|
||||||
|
|
@ -605,10 +578,6 @@ def feed(request):
|
||||||
"feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(),
|
"feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(),
|
||||||
"source_counts": source_counts,
|
"source_counts": source_counts,
|
||||||
"total_count": source_scoped.filter(read_at__isnull=True).count(),
|
"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),
|
"all_labels": labels_with_colors_list(all_labels, overrides),
|
||||||
"raw_url": raw_url,
|
"raw_url": raw_url,
|
||||||
"rendered_url": rendered_url,
|
"rendered_url": rendered_url,
|
||||||
|
|
@ -661,42 +630,12 @@ def toggle_artifact_share(request, artifact_id):
|
||||||
return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url})
|
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
|
@login_required
|
||||||
def feed_older(request):
|
def feed_older(request):
|
||||||
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
|
"""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()
|
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
|
||||||
artifacts = apply_feed_filters(
|
artifacts = apply_feed_filters(
|
||||||
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
|
Artifact.objects.filter(owner=request.user),
|
||||||
request.GET.get("source", ""),
|
request.GET.get("source", ""),
|
||||||
request.GET.get("label", ""),
|
request.GET.get("label", ""),
|
||||||
request.GET.get("kind", ""),
|
request.GET.get("kind", ""),
|
||||||
|
|
@ -744,7 +683,7 @@ def feed_updates(request):
|
||||||
selected_kind = request.GET.get("kind", "")
|
selected_kind = request.GET.get("kind", "")
|
||||||
title_query = request.GET.get("q", "")
|
title_query = request.GET.get("q", "")
|
||||||
artifacts = apply_feed_filters(
|
artifacts = apply_feed_filters(
|
||||||
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
|
Artifact.objects.filter(owner=request.user),
|
||||||
selected_source,
|
selected_source,
|
||||||
selected_label,
|
selected_label,
|
||||||
selected_kind,
|
selected_kind,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Single source of truth for the app version. Bump this, commit, then tag the
|
# Single source of truth for the app version. Bump this, commit, then tag the
|
||||||
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
|
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
|
||||||
# GET /api/capabilities ("version").
|
# GET /api/capabilities ("version").
|
||||||
__version__ = "0.5.0"
|
__version__ = "0.4.1"
|
||||||
|
|
|
||||||
12
docs/api.md
12
docs/api.md
|
|
@ -452,15 +452,11 @@ DELETE /api/artifacts/{id}
|
||||||
Authorization: Bearer <your-personal-token>
|
Authorization: Bearer <your-personal-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Soft-delete:** moves the artifact to Trash rather than removing it — it's
|
Removes the artifact's database row and its files (primary file, markdown
|
||||||
recoverable (from the app's "Deleted" folder), becomes off-quota, and is hidden
|
sidecar, and any attached assets). Returns `204` with no body on success.
|
||||||
from all API reads (`GET /api/artifacts`, the detail, and `…/raw` all treat it
|
|
||||||
as gone). Files stay on disk until the trash is purged (Empty Trash in the app).
|
|
||||||
Returns `204` with no body.
|
|
||||||
|
|
||||||
**Idempotent:** deleting an already-trashed id returns `204` again (not `404`).
|
You can only delete artifacts your own token owns; others return `404`, as
|
||||||
You can only delete artifacts your own token owns; another user's returns `404`.
|
does a second delete of the same id.
|
||||||
There is no API endpoint to undelete or purge yet — that's app-UI-only.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
|
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
|
||||||
|
|
|
||||||
|
|
@ -1055,57 +1055,6 @@ kbd {
|
||||||
animation: agentfeed-row-arrive 1.5s ease-out;
|
animation: agentfeed-row-arrive 1.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover/focus row actions (delete in the feed, undelete in Trash) */
|
|
||||||
.artifact-row {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-action {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.5rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
opacity: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.15rem 0.3rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.artifact-row:hover .row-action,
|
|
||||||
.artifact-row:focus-within .row-action {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-action:hover {
|
|
||||||
background: var(--surface-alt);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-delete:hover {
|
|
||||||
color: var(--danger-text-plain);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* System folders (Keep / Deleted) at the bottom of the sidebar */
|
|
||||||
.source-folders {
|
|
||||||
border-top: 1px solid var(--grid-line);
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
padding-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-trash-button {
|
|
||||||
background: var(--danger-bg);
|
|
||||||
border: 1px solid var(--danger-text);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--danger-text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
padding: 0.2rem 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-key-create {
|
.api-key-create {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,5 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="artifact-source">{{ artifact.source }}</span>
|
<span class="artifact-source">{{ artifact.source }}</span>
|
||||||
{% if in_trash %}
|
|
||||||
<span class="row-action row-undelete" role="button" tabindex="0" data-undelete="{{ artifact.id }}" title="Restore from Trash" aria-label="Restore {{ artifact.title }}">↩</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="row-action row-delete" role="button" tabindex="0" data-delete="{{ artifact.id }}" title="Delete" aria-label="Delete {{ artifact.title }}">🗑</span>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@
|
||||||
data-selected-kind="{{ selected_kind }}"
|
data-selected-kind="{{ selected_kind }}"
|
||||||
data-title-query="{{ title_query }}"
|
data-title-query="{{ title_query }}"
|
||||||
data-selected-view="{{ selected_view }}"
|
data-selected-view="{{ selected_view }}"
|
||||||
data-folder="{{ selected_folder }}"
|
|
||||||
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
||||||
data-older-count="{{ older_count }}">
|
data-older-count="{{ older_count }}">
|
||||||
<aside class="source-sidebar" aria-label="Sources">
|
<aside class="source-sidebar" aria-label="Sources">
|
||||||
|
|
@ -35,33 +34,16 @@
|
||||||
<h2>Sources</h2>
|
<h2>Sources</h2>
|
||||||
</div>
|
</div>
|
||||||
<nav class="source-list">
|
<nav class="source-list">
|
||||||
<a class="source-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||||
<span class="source-name">All sources</span>
|
<span class="source-name">All sources</span>
|
||||||
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
|
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% for entry in source_counts %}
|
{% for entry in source_counts %}
|
||||||
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
||||||
<span class="source-name">{{ entry.source }}</span>
|
<span class="source-name">{{ entry.source }}</span>
|
||||||
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if keep_count or trash_count %}
|
|
||||||
<div class="source-folders">
|
|
||||||
{% if keep_count %}
|
|
||||||
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&view={{ selected_view }}&preview=none" data-folder-link="keep">
|
|
||||||
<span class="source-name">★ Keep</span>
|
|
||||||
<span class="source-count">{{ keep_count }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if trash_count %}
|
|
||||||
<a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&view={{ selected_view }}&preview=none" data-folder-link="deleted">
|
|
||||||
<span class="source-name">🗑 Deleted</span>
|
|
||||||
<span class="source-count">{{ trash_count }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -69,12 +51,8 @@
|
||||||
|
|
||||||
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||||
<div class="pane-bar pane-heading">
|
<div class="pane-bar pane-heading">
|
||||||
<h2>{% if selected_folder == 'deleted' %}Deleted{% elif selected_folder == 'keep' %}Keep{% elif selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||||
{% if selected_folder == 'deleted' %}
|
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||||
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
|
|
||||||
{% else %}
|
|
||||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
|
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
|
||||||
|
|
@ -466,68 +444,6 @@
|
||||||
if (allLink && total) bumpSourceBadge(allLink, total);
|
if (allLink && total) bumpSourceBadge(allLink, total);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Delete / undelete / empty trash ---
|
|
||||||
const updateArtifactCount = (delta) => {
|
|
||||||
const header = document.querySelector(".artifact-sidebar .pane-heading span");
|
|
||||||
if (!header) return;
|
|
||||||
const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta);
|
|
||||||
header.textContent = `${total} artifact${total === 1 ? "" : "s"}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const bumpFolderCount = (folder, delta) => {
|
|
||||||
const link = document.querySelector(`[data-folder-link="${folder}"]`);
|
|
||||||
if (link) bumpSourceBadge(link, delta);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRowFromList = (row) => {
|
|
||||||
const group = row.closest(".day-group");
|
|
||||||
row.remove();
|
|
||||||
if (group) {
|
|
||||||
const remaining = group.querySelectorAll(".artifact-row").length;
|
|
||||||
const count = group.querySelector(".day-separator-count");
|
|
||||||
if (count) count.textContent = String(remaining);
|
|
||||||
if (remaining === 0) group.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const softDeleteRow = async (id) => {
|
|
||||||
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/a/${id}/delete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
|
||||||
if (!response.ok) return;
|
|
||||||
} catch { return; }
|
|
||||||
if (!row) return;
|
|
||||||
if (row.classList.contains("selected")) clearPreview();
|
|
||||||
if (row.classList.contains("unread")) decrementSourceCount(row.dataset.source);
|
|
||||||
removeRowFromList(row);
|
|
||||||
updateArtifactCount(-1);
|
|
||||||
bumpFolderCount("deleted", 1); // best-effort; the folder appears on next nav if it's the first delete
|
|
||||||
};
|
|
||||||
|
|
||||||
const undeleteRow = async (id) => {
|
|
||||||
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/a/${id}/undelete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
|
||||||
if (!response.ok) return;
|
|
||||||
} catch { return; }
|
|
||||||
if (!row) return;
|
|
||||||
removeRowFromList(row);
|
|
||||||
bumpFolderCount("deleted", -1);
|
|
||||||
// The restored item reappears in the feed on the next navigation (full render).
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyTrashButton = document.querySelector("[data-empty-trash]");
|
|
||||||
if (emptyTrashButton) {
|
|
||||||
emptyTrashButton.addEventListener("click", async () => {
|
|
||||||
if (!window.confirm("Permanently delete everything in Trash? This can't be undone.")) return;
|
|
||||||
try {
|
|
||||||
const response = await fetch("{% url 'feed-empty-trash' %}", {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
|
||||||
if (!response.ok) return;
|
|
||||||
} catch { return; }
|
|
||||||
window.location.href = window.location.pathname; // trash empty; folder gone
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderPreview = (row, pushState = true) => {
|
const renderPreview = (row, pushState = true) => {
|
||||||
navigationMode = "artifacts";
|
navigationMode = "artifacts";
|
||||||
markRead(row);
|
markRead(row);
|
||||||
|
|
@ -563,15 +479,6 @@
|
||||||
|
|
||||||
// Delegated so rows appended by infinite scroll work without rebinding.
|
// Delegated so rows appended by infinite scroll work without rebinding.
|
||||||
artifactList.addEventListener("click", (event) => {
|
artifactList.addEventListener("click", (event) => {
|
||||||
// Row action icons (delete/undelete) intercept before selection.
|
|
||||||
const action = event.target.closest("[data-delete], [data-undelete]");
|
|
||||||
if (action) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (action.hasAttribute("data-delete")) softDeleteRow(action.getAttribute("data-delete"));
|
|
||||||
else undeleteRow(action.getAttribute("data-undelete"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const row = event.target.closest(".artifact-row");
|
const row = event.target.closest(".artifact-row");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -760,9 +667,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollUpdates = async () => {
|
const pollUpdates = async () => {
|
||||||
// Don't poll while viewing a system folder (Deleted/Keep) — new live
|
if (livePolling || document.hidden || !liveSince) return;
|
||||||
// posts don't belong in those views.
|
|
||||||
if (livePolling || document.hidden || !liveSince || feed.dataset.folder) return;
|
|
||||||
livePolling = true;
|
livePolling = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}});
|
const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}});
|
||||||
|
|
|
||||||
|
|
@ -334,8 +334,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
|
|
||||||
# -- delete (DELETE) --------------------------------------------------
|
# -- delete (DELETE) --------------------------------------------------
|
||||||
|
|
||||||
def test_delete_soft_deletes_to_trash(self):
|
def test_delete_removes_artifact_and_files(self):
|
||||||
# DELETE moves the artifact to Trash (recoverable), not a hard removal.
|
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
artifact = Artifact.objects.get(pk=artifact_id)
|
artifact = Artifact.objects.get(pk=artifact_id)
|
||||||
stored_path = self.artifacts_root / artifact.path
|
stored_path = self.artifacts_root / artifact.path
|
||||||
|
|
@ -345,12 +344,9 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 204)
|
self.assertEqual(response.status_code, 204)
|
||||||
# Row still exists, marked deleted; files kept on disk until purge.
|
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
|
||||||
artifact.refresh_from_db()
|
self.assertFalse(stored_path.exists())
|
||||||
self.assertIsNotNone(artifact.deleted_at)
|
self.assertFalse(stored_path.with_suffix(".md").exists())
|
||||||
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):
|
def test_delete_requires_token(self):
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
|
@ -366,8 +362,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
|
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
|
||||||
|
|
||||||
def test_delete_is_idempotent(self):
|
def test_delete_twice_second_call_404s(self):
|
||||||
# Soft-delete is idempotent: deleting an already-trashed artifact is a no-op 204.
|
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
first = self.client.delete(
|
first = self.client.delete(
|
||||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
|
|
@ -377,7 +372,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
second = self.client.delete(
|
second = self.client.delete(
|
||||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
)
|
)
|
||||||
self.assertEqual(second.status_code, 204)
|
self.assertEqual(second.status_code, 404)
|
||||||
|
|
||||||
def test_deleted_artifact_is_gone_from_list_and_detail(self):
|
def test_deleted_artifact_is_gone_from_list_and_detail(self):
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
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