Initial public commit
Daybook: a self-hosted, multi-user Django + DRF app where agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed. This squashes prior local development history (kept privately on a separate branch) into a single clean starting point for the public repository. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
940a703cd7
62 changed files with 5755 additions and 0 deletions
135
artifacts/storage.py
Normal file
135
artifacts/storage.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Disk-usage accounting, per-user quota, and automatic eviction.
|
||||
|
||||
An artifact's on-disk footprint is the primary stored file plus, for markdown,
|
||||
the preserved `.md` sidecar. We record it on the row (`size_bytes`) at ingest so
|
||||
usage is a cheap SUM rather than a stat of every file.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Artifact, Asset, UserProfile
|
||||
|
||||
|
||||
class QuotaExceeded(Exception):
|
||||
"""Raised when a post cannot fit even after evicting every unkept artifact."""
|
||||
|
||||
|
||||
def artifact_disk_size(relative_path, kind):
|
||||
"""Bytes an artifact occupies on disk, including the markdown sidecar."""
|
||||
root = settings.ARTIFACTS_ROOT
|
||||
total = 0
|
||||
primary = root / relative_path
|
||||
if primary.exists():
|
||||
total += primary.stat().st_size
|
||||
if kind == Artifact.MARKDOWN:
|
||||
sidecar = primary.with_suffix(".md")
|
||||
if sidecar.exists():
|
||||
total += sidecar.stat().st_size
|
||||
return total
|
||||
|
||||
|
||||
def delete_artifact_files(artifact):
|
||||
"""Remove an artifact's files from disk (primary, markdown sidecar, assets).
|
||||
|
||||
Assets are cascade-deleted from the DB by the FK, but their files must be
|
||||
removed here explicitly.
|
||||
"""
|
||||
root = settings.ARTIFACTS_ROOT
|
||||
primary = root / artifact.path
|
||||
primary.unlink(missing_ok=True)
|
||||
if artifact.kind == Artifact.MARKDOWN:
|
||||
primary.with_suffix(".md").unlink(missing_ok=True)
|
||||
for asset in artifact.assets.all():
|
||||
delete_asset_file(asset)
|
||||
|
||||
|
||||
def delete_asset_file(asset):
|
||||
(settings.ARTIFACTS_ROOT / asset.path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def artifact_total_bytes(artifact):
|
||||
"""An artifact's own bytes plus those of the assets embedded in it."""
|
||||
asset_bytes = artifact.assets.aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||
return artifact.size_bytes + asset_bytes
|
||||
|
||||
|
||||
def user_usage_bytes(user):
|
||||
artifact_bytes = (
|
||||
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||
)
|
||||
asset_bytes = (
|
||||
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||
)
|
||||
return artifact_bytes + asset_bytes
|
||||
|
||||
|
||||
def user_quota_bytes(user):
|
||||
profile, _ = UserProfile.objects.get_or_create(user=user)
|
||||
return profile.quota_bytes
|
||||
|
||||
|
||||
def make_room_for(user, incoming_bytes):
|
||||
"""Evict oldest unkept artifacts until `incoming_bytes` fits under quota.
|
||||
|
||||
Returns the list of evicted artifacts (already deleted). Raises QuotaExceeded
|
||||
if even removing every unkept artifact leaves no room.
|
||||
"""
|
||||
quota = user_quota_bytes(user)
|
||||
if incoming_bytes > quota:
|
||||
raise QuotaExceeded("Artifact is larger than the entire quota.")
|
||||
|
||||
used = user_usage_bytes(user)
|
||||
if used + incoming_bytes <= quota:
|
||||
return []
|
||||
|
||||
evicted = []
|
||||
unkept = (
|
||||
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
|
||||
)
|
||||
for artifact in unkept:
|
||||
if used + incoming_bytes <= quota:
|
||||
break
|
||||
# Freeing an artifact also frees the assets embedded in it.
|
||||
used -= artifact_total_bytes(artifact)
|
||||
delete_artifact_files(artifact)
|
||||
artifact.delete()
|
||||
evicted.append(artifact)
|
||||
|
||||
if used + incoming_bytes > quota:
|
||||
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
|
||||
return evicted
|
||||
|
||||
|
||||
ASSET_ORPHAN_GRACE = timedelta(hours=24)
|
||||
|
||||
|
||||
def sweep_orphan_assets(user=None):
|
||||
"""Delete assets uploaded but never referenced by an artifact.
|
||||
|
||||
Runs opportunistically on upload. The grace period gives an agent time to
|
||||
upload images and then post the artifact that references them.
|
||||
"""
|
||||
cutoff = timezone.now() - ASSET_ORPHAN_GRACE
|
||||
orphans = Asset.objects.filter(artifact__isnull=True, created_at__lt=cutoff)
|
||||
if user is not None:
|
||||
orphans = orphans.filter(owner=user)
|
||||
count = 0
|
||||
for asset in orphans:
|
||||
delete_asset_file(asset)
|
||||
asset.delete()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def disk_free_bytes():
|
||||
"""Free space on the volume that holds the artifact store."""
|
||||
return shutil.disk_usage(settings.ARTIFACTS_ROOT).free
|
||||
|
||||
|
||||
def total_allocated_bytes():
|
||||
return UserProfile.objects.aggregate(total=Sum("quota_bytes")).get("total") or 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue