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>
785 lines
27 KiB
Python
785 lines
27 KiB
Python
import hashlib
|
|
import html
|
|
import json
|
|
import mimetypes
|
|
import re
|
|
import secrets
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from urllib.parse import urlencode
|
|
|
|
import markdown
|
|
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, 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
|
|
from django.urls import reverse
|
|
from django.utils.dateparse import parse_date
|
|
from django.utils import timezone
|
|
from django.utils.decorators import method_decorator
|
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
|
from django.views.decorators.http import require_POST
|
|
from rest_framework.authtoken.models import Token
|
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
|
from rest_framework.response import Response
|
|
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}
|
|
|
|
REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
|
|
|
|
|
|
def filter_by_label(queryset, label):
|
|
matching_ids = [artifact.id for artifact in queryset.only("id", "tags") if label in artifact.tags]
|
|
return queryset.filter(pk__in=matching_ids)
|
|
|
|
|
|
DEFAULT_THEMES = {
|
|
Artifact.HTML_FRAGMENT: "report",
|
|
Artifact.MARKDOWN: "report",
|
|
Artifact.DECK: "deck",
|
|
}
|
|
|
|
|
|
@method_decorator(login_required, name="get")
|
|
class ArtifactListCreateView(APIView):
|
|
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
|
|
|
def get(self, request):
|
|
artifacts = Artifact.objects.filter(owner=request.user)
|
|
if source := request.query_params.get("source"):
|
|
artifacts = artifacts.filter(source=source)
|
|
if label := request.query_params.get("label"):
|
|
artifacts = filter_by_label(artifacts, label)
|
|
if day := request.query_params.get("day"):
|
|
artifacts = artifacts.filter(day=day)
|
|
if kind := request.query_params.get("kind"):
|
|
artifacts = artifacts.filter(kind=kind)
|
|
if title_query := request.query_params.get("q"):
|
|
artifacts = artifacts.filter(title__icontains=title_query)
|
|
|
|
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
|
|
|
|
def post(self, request):
|
|
user = resolve_token_user(request)
|
|
if user is None:
|
|
return HttpResponseForbidden("Invalid or missing API token")
|
|
|
|
if request.content_type and request.content_type.startswith("multipart/"):
|
|
artifact = create_binary_artifact(request.data, user)
|
|
if isinstance(artifact, Response):
|
|
return artifact
|
|
apply_tag_colors(request.data)
|
|
return Response(serialize_artifact(artifact, request), status=201)
|
|
|
|
data = request.data
|
|
kind = data.get("kind")
|
|
if kind not in TEXT_KINDS:
|
|
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
|
|
|
|
required = ["source", "title", "content"]
|
|
missing = [field for field in required if not data.get(field)]
|
|
if missing:
|
|
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
|
|
|
source_error = validate_source_fields(data)
|
|
if source_error:
|
|
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)
|
|
|
|
|
|
@method_decorator(login_required, name="get")
|
|
class ArtifactDetailView(APIView):
|
|
def get(self, request, artifact_id):
|
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
|
return Response(serialize_artifact(artifact, request))
|
|
|
|
def patch(self, request, artifact_id):
|
|
user = resolve_token_user(request)
|
|
if user is None:
|
|
return HttpResponseForbidden("Invalid or missing API token")
|
|
|
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
|
data = request.data
|
|
if "title" in data:
|
|
if not data["title"]:
|
|
return Response({"detail": "title cannot be blank"}, status=400)
|
|
artifact.title = data["title"]
|
|
if "tags" in data:
|
|
artifact.tags = normalize_tags(data["tags"])
|
|
if "source_prompt" in data:
|
|
artifact.source_prompt = data["source_prompt"] or ""
|
|
if "source_type" in data:
|
|
if data["source_type"] not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
|
|
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
|
|
artifact.source_type = data["source_type"]
|
|
if "source_name" in data:
|
|
artifact.source_name = data["source_name"] or ""
|
|
if "source_id" in data:
|
|
artifact.source_id = data["source_id"] or ""
|
|
if "metadata" in data:
|
|
if not isinstance(data["metadata"], dict):
|
|
return Response({"detail": "metadata must be an object"}, status=400)
|
|
artifact.metadata = data["metadata"]
|
|
if "tag_colors" in data:
|
|
try:
|
|
apply_tag_colors(data)
|
|
except ValueError as exc:
|
|
return Response({"detail": str(exc)}, status=400)
|
|
artifact.save(
|
|
update_fields=[
|
|
"title",
|
|
"tags",
|
|
"source_prompt",
|
|
"source_type",
|
|
"source_name",
|
|
"source_id",
|
|
"metadata",
|
|
]
|
|
)
|
|
return Response(serialize_artifact(artifact, request))
|
|
|
|
|
|
class CapabilitiesView(APIView):
|
|
"""Static capability info. Reachable by a logged-in session or a valid API token,
|
|
so agents can check connectivity before posting. Exposes no user data."""
|
|
|
|
def get(self, request):
|
|
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
|
return HttpResponseForbidden("Login or a valid API token is required")
|
|
return Response(
|
|
{
|
|
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
|
|
"filters": ["source", "label", "day", "kind", "q"],
|
|
"views": ["rendered", "raw"],
|
|
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
|
|
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
|
|
"auth": "bearer_token",
|
|
}
|
|
)
|
|
|
|
|
|
FEED_WINDOW_DAYS = 7
|
|
|
|
|
|
def apply_feed_filters(queryset, source="", label="", kind="", title_query=""):
|
|
if source:
|
|
queryset = queryset.filter(source=source)
|
|
if label:
|
|
queryset = filter_by_label(queryset, label)
|
|
if kind:
|
|
queryset = queryset.filter(kind=kind)
|
|
if title_query:
|
|
queryset = queryset.filter(title__icontains=title_query)
|
|
return queryset
|
|
|
|
|
|
def group_artifacts_by_day(artifacts, overrides):
|
|
"""Turn a flat, reverse-chronological list into [{day, artifacts}] groups."""
|
|
groups = []
|
|
for artifact in artifacts:
|
|
# Source has its own column in the row, so chips are the free-form tags only.
|
|
artifact.label_chips = tags_with_colors(artifact.tags, overrides)
|
|
if not groups or groups[-1]["day"] != artifact.day:
|
|
groups.append({"day": artifact.day, "artifacts": []})
|
|
groups[-1]["artifacts"].append(artifact)
|
|
return groups
|
|
|
|
|
|
@login_required
|
|
def feed(request):
|
|
selected_source = request.GET.get("source", "")
|
|
selected_label = request.GET.get("label", "")
|
|
selected_kind = request.GET.get("kind", "")
|
|
title_query = request.GET.get("q", "")
|
|
today = timezone.localdate()
|
|
selected_artifact_id = request.GET.get("artifact", "")
|
|
preview_none = request.GET.get("preview") == "none"
|
|
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
|
|
|
all_artifacts = Artifact.objects.filter(owner=request.user)
|
|
artifacts = apply_feed_filters(
|
|
all_artifacts, selected_source, selected_label, selected_kind, title_query
|
|
)
|
|
|
|
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
|
if not artifacts.filter(day__gte=window_start).exists():
|
|
# Nothing recent for these filters: fall back to the most recent days 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()
|
|
day_groups = group_artifacts_by_day(window_artifacts, overrides)
|
|
|
|
selected_artifact = None
|
|
if not preview_none:
|
|
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
|
|
|
|
toggle_query = {"view": "rendered"}
|
|
if selected_source:
|
|
toggle_query["source"] = selected_source
|
|
if selected_label:
|
|
toggle_query["label"] = selected_label
|
|
if selected_kind:
|
|
toggle_query["kind"] = selected_kind
|
|
if title_query:
|
|
toggle_query["q"] = title_query
|
|
if selected_artifact:
|
|
toggle_query["artifact"] = selected_artifact.id
|
|
rendered_url = f"?{urlencode(toggle_query)}"
|
|
toggle_query["view"] = "raw"
|
|
raw_url = f"?{urlencode(toggle_query)}"
|
|
|
|
# Source counts come from the other filters only, so the sidebar still shows
|
|
# every source available under the current label/type/name filters.
|
|
source_scoped = apply_feed_filters(
|
|
all_artifacts, "", selected_label, selected_kind, title_query
|
|
)
|
|
source_counts = (
|
|
source_scoped.values("source")
|
|
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
|
|
.order_by("source")
|
|
)
|
|
|
|
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
|
|
return render(
|
|
request,
|
|
"artifacts/feed.html",
|
|
{
|
|
"artifacts": window_artifacts,
|
|
"day_groups": day_groups,
|
|
"older_count": older_count,
|
|
"window_start": window_start,
|
|
"source_counts": source_counts,
|
|
"total_count": source_scoped.filter(read_at__isnull=True).count(),
|
|
"all_labels": labels_with_colors_list(all_labels, overrides),
|
|
"raw_url": raw_url,
|
|
"rendered_url": rendered_url,
|
|
"selected_artifact": selected_artifact,
|
|
"selected_kind": selected_kind,
|
|
"selected_label": selected_label,
|
|
"selected_source": selected_source,
|
|
"selected_view": selected_view,
|
|
"preview_none": preview_none,
|
|
"today": today,
|
|
"title_query": title_query,
|
|
"kind_choices": Artifact.KIND_CHOICES,
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def mark_artifact_read(request, artifact_id):
|
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
|
if artifact.read_at is None:
|
|
artifact.read_at = timezone.now()
|
|
artifact.save(update_fields=["read_at"])
|
|
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."""
|
|
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
|
|
artifacts = apply_feed_filters(
|
|
Artifact.objects.filter(owner=request.user),
|
|
request.GET.get("source", ""),
|
|
request.GET.get("label", ""),
|
|
request.GET.get("kind", ""),
|
|
request.GET.get("q", ""),
|
|
).filter(day__lt=before).order_by("-created_at")
|
|
|
|
# Take a whole page of days at a time so a day is never split across pages.
|
|
page_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
|
|
if not page_days:
|
|
return HttpResponse("")
|
|
|
|
page_artifacts = list(artifacts.filter(day__gte=page_days[-1]))
|
|
day_groups = group_artifacts_by_day(page_artifacts, tag_color_overrides())
|
|
remaining = artifacts.filter(day__lt=page_days[-1]).count()
|
|
|
|
return render(
|
|
request,
|
|
"artifacts/_day_groups.html",
|
|
{
|
|
"day_groups": day_groups,
|
|
"next_before": page_days[-1].isoformat(),
|
|
"has_more": remaining > 0,
|
|
"selected_artifact": None,
|
|
"selected_source": request.GET.get("source", ""),
|
|
"selected_label": request.GET.get("label", ""),
|
|
"selected_kind": request.GET.get("kind", ""),
|
|
"title_query": request.GET.get("q", ""),
|
|
"selected_view": "raw" if request.GET.get("view") == "raw" else "rendered",
|
|
"today": timezone.localdate(),
|
|
},
|
|
)
|
|
|
|
|
|
@login_required
|
|
@xframe_options_sameorigin
|
|
def artifact_view(request, artifact_id):
|
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
|
if request.GET.get("raw") == "1":
|
|
raw_path = raw_artifact_path(artifact)
|
|
if raw_path and raw_path.exists():
|
|
return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8")
|
|
return HttpResponse(raw_unavailable_html(artifact), content_type="text/html")
|
|
|
|
path = safe_artifact_path(artifact.path)
|
|
if not path.exists():
|
|
raise Http404("Artifact file not found")
|
|
|
|
content_type, _ = mimetypes.guess_type(path.name)
|
|
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
|
|
|
|
|
|
def create_text_artifact(data, request, user):
|
|
now = timezone.localtime()
|
|
artifact_id = generate_id()
|
|
source = slugify(data["source"]) or "unknown"
|
|
kind = data["kind"]
|
|
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
|
|
title = data["title"]
|
|
content = data["content"]
|
|
|
|
directory = Path(source) / now.date().isoformat()
|
|
target_dir = settings.ARTIFACTS_ROOT / directory
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
if kind == Artifact.HTML_FULL:
|
|
rendered = content
|
|
elif kind == Artifact.MARKDOWN:
|
|
rendered = wrap_html(markdown.markdown(content, extensions=["extra", "sane_lists"]), title, theme, request)
|
|
(target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8")
|
|
elif kind == Artifact.DECK:
|
|
rendered = wrap_deck(content, title, request)
|
|
else:
|
|
rendered = wrap_html(content, title, theme, request)
|
|
|
|
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,
|
|
source=source,
|
|
title=title,
|
|
kind=kind,
|
|
theme=theme,
|
|
day=now.date(),
|
|
path=stored_path.as_posix(),
|
|
original_kind=kind,
|
|
tags=normalize_tags(data.get("tags", [])),
|
|
source_prompt=data.get("source_prompt", ""),
|
|
size_bytes=size_bytes,
|
|
**source_fields(data, source),
|
|
)
|
|
|
|
|
|
def create_binary_artifact(data, user):
|
|
uploaded = data.get("file")
|
|
required = ["source", "title"]
|
|
missing = [field for field in required if not data.get(field)]
|
|
if not uploaded:
|
|
missing.append("file")
|
|
if missing:
|
|
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
|
source_error = validate_source_fields(data)
|
|
if source_error:
|
|
return source_error
|
|
|
|
now = timezone.localtime()
|
|
artifact_id = generate_id()
|
|
source = slugify(data["source"]) or "unknown"
|
|
title = data["title"]
|
|
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
|
kind = binary_kind(content_type)
|
|
|
|
directory = Path(source) / now.date().isoformat()
|
|
target_dir = settings.ARTIFACTS_ROOT / directory
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
suffix = safe_suffix(uploaded.name, content_type)
|
|
stored_path = directory / f"{artifact_id}{suffix}"
|
|
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
|
|
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,
|
|
source=source,
|
|
title=title,
|
|
kind=kind,
|
|
theme="",
|
|
day=now.date(),
|
|
path=stored_path.as_posix(),
|
|
original_kind=kind,
|
|
tags=normalize_tags(data.get("tags", [])),
|
|
source_prompt=data.get("source_prompt", ""),
|
|
size_bytes=size_bytes,
|
|
**source_fields(data, source),
|
|
)
|
|
|
|
|
|
def wrap_html(body, title, theme, request):
|
|
stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css")
|
|
return f"""<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{html.escape(title)}</title>
|
|
<link rel="stylesheet" href="{stylesheet}">
|
|
</head>
|
|
<body class="artifact">
|
|
{body}
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def wrap_deck(body, title, request):
|
|
stylesheet = request.build_absolute_uri("/static/themes/deck.css")
|
|
return f"""<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{html.escape(title)}</title>
|
|
<link rel="stylesheet" href="{stylesheet}">
|
|
</head>
|
|
<body class="deck">
|
|
<main class="slides">{body}</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def generate_id():
|
|
while True:
|
|
artifact_id = secrets.token_hex(4)
|
|
if not Artifact.objects.filter(pk=artifact_id).exists():
|
|
return artifact_id
|
|
|
|
|
|
def resolve_token_user(request):
|
|
auth = request.headers.get("Authorization", "")
|
|
if not auth.startswith("Bearer "):
|
|
return None
|
|
key = auth[len("Bearer "):].strip()
|
|
if not key:
|
|
return None
|
|
try:
|
|
return Token.objects.select_related("user").get(key=key).user
|
|
except Token.DoesNotExist:
|
|
return None
|
|
|
|
|
|
def normalize_tags(tags):
|
|
if isinstance(tags, str):
|
|
return [tag.strip() for tag in tags.split(",") if tag.strip()]
|
|
if isinstance(tags, list):
|
|
return [str(tag).strip() for tag in tags if str(tag).strip()]
|
|
return []
|
|
|
|
|
|
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
|
|
TAG_PALETTE = [
|
|
"#4f6df5", "#e0578c", "#2fa88f", "#d98b2b",
|
|
"#8c5fd6", "#3fa1d8", "#c14f4f", "#5aa552",
|
|
]
|
|
|
|
|
|
def tag_color(name, overrides):
|
|
if override := overrides.get(name):
|
|
return override
|
|
index = int(hashlib.sha256(name.encode("utf-8")).hexdigest(), 16) % len(TAG_PALETTE)
|
|
return TAG_PALETTE[index]
|
|
|
|
|
|
def tag_color_overrides():
|
|
return dict(TagColor.objects.values_list("name", "color"))
|
|
|
|
|
|
def tags_with_colors(tags, overrides=None):
|
|
overrides = overrides if overrides is not None else tag_color_overrides()
|
|
return [{"name": tag, "color": tag_color(tag, overrides)} for tag in tags]
|
|
|
|
|
|
def labels_with_colors_list(labels, overrides=None):
|
|
overrides = overrides if overrides is not None else tag_color_overrides()
|
|
return [{"name": label, "color": tag_color(label, overrides)} for label in labels]
|
|
|
|
|
|
def labels_with_colors(artifact, overrides=None):
|
|
return labels_with_colors_list([artifact.source, *artifact.tags], overrides)
|
|
|
|
|
|
def validate_source_fields(data):
|
|
if source_type := data.get("source_type"):
|
|
if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
|
|
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
|
|
try:
|
|
parse_metadata_value(data.get("metadata", {}))
|
|
except ValueError as exc:
|
|
return Response({"detail": str(exc)}, status=400)
|
|
try:
|
|
parse_tag_colors_value(data.get("tag_colors", {}))
|
|
except ValueError as exc:
|
|
return Response({"detail": str(exc)}, status=400)
|
|
return None
|
|
|
|
|
|
def parse_tag_colors_value(value):
|
|
if value in (None, ""):
|
|
return {}
|
|
if isinstance(value, str):
|
|
try:
|
|
value = json.loads(value)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("tag_colors must be an object") from exc
|
|
if not isinstance(value, dict):
|
|
raise ValueError("tag_colors must be an object")
|
|
for tag, color in value.items():
|
|
if not HEX_COLOR_RE.match(str(color)):
|
|
raise ValueError(f"tag_colors[{tag}] must be a hex color like #RRGGBB")
|
|
return value
|
|
|
|
|
|
def apply_tag_colors(data):
|
|
tag_colors = parse_tag_colors_value(data.get("tag_colors", {}))
|
|
for tag, color in tag_colors.items():
|
|
TagColor.objects.update_or_create(name=tag, defaults={"color": color})
|
|
|
|
|
|
def source_fields(data, source):
|
|
return {
|
|
"source_type": data.get("source_type") or Artifact.AGENT,
|
|
"source_name": data.get("source_name") or source,
|
|
"source_id": data.get("source_id") or "",
|
|
"metadata": parse_metadata_value(data.get("metadata", {})),
|
|
}
|
|
|
|
|
|
def parse_metadata_value(value):
|
|
if value in (None, ""):
|
|
return {}
|
|
if isinstance(value, dict):
|
|
return value
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = json.loads(value)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("metadata must be an object") from exc
|
|
if isinstance(parsed, dict):
|
|
return parsed
|
|
raise ValueError("metadata must be an object")
|
|
|
|
|
|
def binary_kind(content_type):
|
|
if content_type.startswith("image/"):
|
|
return Artifact.IMAGE
|
|
if content_type == "application/pdf":
|
|
return Artifact.PDF
|
|
return Artifact.BINARY
|
|
|
|
|
|
def safe_suffix(filename, content_type):
|
|
suffix = Path(filename).suffix.lower()
|
|
if suffix and suffix[1:].isalnum() and len(suffix) <= 10:
|
|
return suffix
|
|
return mimetypes.guess_extension(content_type) or ".bin"
|
|
|
|
|
|
def serialize_artifact(artifact, request):
|
|
return {
|
|
"id": artifact.id,
|
|
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
|
"api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])),
|
|
"raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"),
|
|
"raw_available": raw_artifact_path(artifact) is not None,
|
|
"source": artifact.source,
|
|
"title": artifact.title,
|
|
"kind": artifact.kind,
|
|
"theme": artifact.theme,
|
|
"day": artifact.day.isoformat(),
|
|
"created_at": artifact.created_at.isoformat(),
|
|
"tags": tags_with_colors(artifact.tags),
|
|
"labels": labels_with_colors(artifact),
|
|
"source_prompt": artifact.source_prompt,
|
|
"source_type": artifact.source_type,
|
|
"source_name": artifact.source_name,
|
|
"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,
|
|
}
|
|
|
|
|
|
def safe_artifact_path(relative_path):
|
|
root = settings.ARTIFACTS_ROOT.resolve()
|
|
path = (root / relative_path).resolve()
|
|
if root not in path.parents and path != root:
|
|
raise Http404("Invalid artifact path")
|
|
return path
|
|
|
|
|
|
def raw_artifact_path(artifact):
|
|
path = safe_artifact_path(artifact.path)
|
|
if artifact.kind == Artifact.MARKDOWN:
|
|
return path.with_suffix(".md")
|
|
if artifact.kind == Artifact.HTML_FULL:
|
|
return path
|
|
return None
|
|
|
|
|
|
class DaybookLoginView(auth_views.LoginView):
|
|
template_name = "artifacts/login.html"
|
|
authentication_form = DaybookLoginForm
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
if form.cleaned_data.get("remember_me"):
|
|
self.request.session.set_expiry(REMEMBER_ME_SESSION_AGE)
|
|
else:
|
|
self.request.session.set_expiry(0)
|
|
return response
|
|
|
|
|
|
@login_required
|
|
def settings_home(request):
|
|
return redirect("settings-account")
|
|
|
|
|
|
@login_required
|
|
def settings_account(request):
|
|
token, _ = Token.objects.get_or_create(user=request.user)
|
|
if request.method == "POST" and request.POST.get("action") == "regenerate":
|
|
token.delete()
|
|
token = Token.objects.create(user=request.user)
|
|
messages.success(request, "API token regenerated.")
|
|
return redirect("settings-account")
|
|
return render(
|
|
request,
|
|
"artifacts/settings_account.html",
|
|
{"token": token, "active_section": "account"},
|
|
)
|
|
|
|
|
|
@login_required
|
|
def settings_demo_data(request):
|
|
if request.method == "POST":
|
|
count = create_demo_artifacts(request.user, request)
|
|
messages.success(request, f"Created {count} demo artifacts.")
|
|
return redirect("settings-demo-data")
|
|
return render(
|
|
request,
|
|
"artifacts/settings_demo_data.html",
|
|
{"active_section": "demo-data"},
|
|
)
|
|
|
|
|
|
@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>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Raw source unavailable</title>
|
|
<style>
|
|
body {{ background: #f4f1ea; color: #1d2528; font-family: system-ui, sans-serif; margin: 0; padding: 2rem; }}
|
|
main {{ background: #fffaf0; border-radius: 16px; max-width: 42rem; padding: 1.4rem; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Raw source unavailable</h1>
|
|
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|