Add per-user disk quotas, auto-eviction, and Keep flag
Bounds each user's disk footprint and makes throwaway artifacts self-clean:
- Per-user quota (default 1 GB) stored on a new UserProfile model,
adjustable per user in Django admin. Artifact.size_bytes records each
artifact's on-disk footprint (primary file + markdown sidecar) at
ingest so usage is a cheap SUM.
- Automatic eviction: on each post, oldest unkept artifacts are deleted
(files and rows) until the new one fits under quota. If the quota is
full of kept artifacts, the post is rejected with HTTP 413 rather than
evicting anything protected.
- Keep flag (Artifact.is_kept): a Keep button in the preview control bar
toggles protection via POST /a/{id}/keep; kept items are never evicted.
Kept rows show a green "K" badge in the listing.
- Details panel: a Details toggle in the control bar expands a panel
under it with full source, type, created time, size, and tags.
- Settings -> Storage: usage bar (green/amber/red), used/quota summary,
artifact and kept counts; superusers also see total allocated quota vs
free disk with an oversubscription warning.
- Type indicator in the listing is now a small grey pill instead of
plain text.
Storage accounting/eviction logic lives in artifacts/storage.py.
Migrations 0009 (fields + UserProfile) and 0010 (backfill sizes and
create profiles for existing users). Fresh installs no-op the backfill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
37cd96fa7a
commit
e0f1cce626
12 changed files with 517 additions and 12 deletions
|
|
@ -13,7 +13,7 @@ from django.conf import settings
|
|||
from django.contrib import messages
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count, Q, Sum
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.defaultfilters import slugify
|
||||
|
|
@ -31,6 +31,16 @@ from rest_framework.views import APIView
|
|||
from .demo_data import create_demo_artifacts
|
||||
from .forms import DaybookLoginForm
|
||||
from .models import Artifact, TagColor
|
||||
from .storage import (
|
||||
QuotaExceeded,
|
||||
artifact_disk_size,
|
||||
delete_artifact_files,
|
||||
disk_free_bytes,
|
||||
make_room_for,
|
||||
total_allocated_bytes,
|
||||
user_quota_bytes,
|
||||
user_usage_bytes,
|
||||
)
|
||||
|
||||
|
||||
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
|
||||
|
|
@ -96,6 +106,8 @@ class ArtifactListCreateView(APIView):
|
|||
return source_error
|
||||
|
||||
artifact = create_text_artifact(data, request, user)
|
||||
if isinstance(artifact, Response):
|
||||
return artifact
|
||||
apply_tag_colors(data)
|
||||
return Response(serialize_artifact(artifact, request), status=201)
|
||||
|
||||
|
|
@ -294,6 +306,15 @@ def mark_artifact_read(request, artifact_id):
|
|||
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle_artifact_keep(request, artifact_id):
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
artifact.is_kept = not artifact.is_kept
|
||||
artifact.save(update_fields=["is_kept"])
|
||||
return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept})
|
||||
|
||||
|
||||
@login_required
|
||||
def feed_older(request):
|
||||
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
|
||||
|
|
@ -377,6 +398,15 @@ def create_text_artifact(data, request, user):
|
|||
stored_path = directory / f"{artifact_id}.html"
|
||||
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
|
||||
|
||||
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
|
||||
try:
|
||||
make_room_for(user, size_bytes)
|
||||
except QuotaExceeded as exc:
|
||||
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
|
||||
if kind == Artifact.MARKDOWN:
|
||||
(target_dir / f"{artifact_id}.md").unlink(missing_ok=True)
|
||||
return Response({"detail": str(exc)}, status=413)
|
||||
|
||||
return Artifact.objects.create(
|
||||
id=artifact_id,
|
||||
owner=user,
|
||||
|
|
@ -389,6 +419,7 @@ def create_text_artifact(data, request, user):
|
|||
original_kind=kind,
|
||||
tags=normalize_tags(data.get("tags", [])),
|
||||
source_prompt=data.get("source_prompt", ""),
|
||||
size_bytes=size_bytes,
|
||||
**source_fields(data, source),
|
||||
)
|
||||
|
||||
|
|
@ -422,6 +453,13 @@ def create_binary_artifact(data, user):
|
|||
for chunk in uploaded.chunks():
|
||||
target.write(chunk)
|
||||
|
||||
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
|
||||
try:
|
||||
make_room_for(user, size_bytes)
|
||||
except QuotaExceeded as exc:
|
||||
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
|
||||
return Response({"detail": str(exc)}, status=413)
|
||||
|
||||
return Artifact.objects.create(
|
||||
id=artifact_id,
|
||||
owner=user,
|
||||
|
|
@ -434,6 +472,7 @@ def create_binary_artifact(data, user):
|
|||
original_kind=kind,
|
||||
tags=normalize_tags(data.get("tags", [])),
|
||||
source_prompt=data.get("source_prompt", ""),
|
||||
size_bytes=size_bytes,
|
||||
**source_fields(data, source),
|
||||
)
|
||||
|
||||
|
|
@ -630,6 +669,8 @@ def serialize_artifact(artifact, request):
|
|||
"source_id": artifact.source_id,
|
||||
"metadata": artifact.metadata,
|
||||
"read_at": artifact.read_at.isoformat() if artifact.read_at else None,
|
||||
"is_kept": artifact.is_kept,
|
||||
"size_bytes": artifact.size_bytes,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -696,6 +737,32 @@ def settings_demo_data(request):
|
|||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_storage(request):
|
||||
used = user_usage_bytes(request.user)
|
||||
quota = user_quota_bytes(request.user)
|
||||
kept_bytes = (
|
||||
Artifact.objects.filter(owner=request.user, is_kept=True)
|
||||
.aggregate(total=Sum("size_bytes"))
|
||||
.get("total")
|
||||
or 0
|
||||
)
|
||||
context = {
|
||||
"active_section": "storage",
|
||||
"used_bytes": used,
|
||||
"quota_bytes": quota,
|
||||
"kept_bytes": kept_bytes,
|
||||
"percent_used": min(100, round(used / quota * 100)) if quota else 0,
|
||||
"artifact_count": Artifact.objects.filter(owner=request.user).count(),
|
||||
"kept_count": Artifact.objects.filter(owner=request.user, is_kept=True).count(),
|
||||
}
|
||||
if request.user.is_superuser:
|
||||
context["is_superuser"] = True
|
||||
context["total_allocated"] = total_allocated_bytes()
|
||||
context["disk_free"] = disk_free_bytes()
|
||||
return render(request, "artifacts/settings_storage.html", context)
|
||||
|
||||
|
||||
def raw_unavailable_html(artifact):
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue