Add multi-user auth, settings area, and unified label feed

Turns Daybook into a multi-user tool and reworks the feed UI:

- Multi-user: Django session login (username/password + remember-me),
  per-user personal API tokens (DRF authtoken), whole app gated behind
  login. Each artifact has an owner; feeds are private per user.
- Rename persona -> source (free-text label for whatever posted an
  artifact: agent-profile, script, or person). source + tags form one
  unified label system; single label filter matches tags, source has
  its own filter/column/sidebar.
- Settings area (gear/user menu, top-right): Account (view/regenerate
  token) and Demo Data (in-app button seeding one artifact per kind).
  User account management via Django admin (superusers only).
- Feed rework: rolling 7-day window with sticky inline date separators
  and infinite scroll; left sidebar is now a Sources list with per-source
  unread counts; artifact type shown as text after title; source in its
  own column.
- Unread tracking: read_at on Artifact, marked read when previewed,
  bold titles for unread, sidebar unread badges, old banner removed.
- Tag colours: global shared registry, hash-based default with optional
  per-tag override via tag_colors on POST/PATCH.
- Ingest: token resolves owner; same source name under two users' tokens
  stays isolated. daybook_post.py renamed persona->source, requires a
  real token, --check accepts a bearer token. Docs updated to match.

Migrations 0004-0008 rename persona, add owner (nullable -> backfill to
first superuser -> required), and add read_at. Fresh installs no-op the
backfill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ritte 2026-07-24 09:06:46 +02:00
parent 203f9b578e
commit 37cd96fa7a
33 changed files with 1614 additions and 367 deletions

View file

@ -1,27 +1,48 @@
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.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, render
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.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 .models import Artifact
from .demo_data import create_demo_artifacts
from .forms import DaybookLoginForm
from .models import Artifact, TagColor
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",
@ -29,17 +50,18 @@ DEFAULT_THEMES = {
}
@method_decorator(login_required, name="get")
class ArtifactListCreateView(APIView):
parser_classes = [JSONParser, FormParser, MultiPartParser]
def get(self, request):
artifacts = Artifact.objects.all()
if persona := request.query_params.get("persona"):
artifacts = artifacts.filter(persona=persona)
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 tag := request.query_params.get("tag"):
artifacts = artifacts.filter(tags__contains=[tag])
if kind := request.query_params.get("kind"):
artifacts = artifacts.filter(kind=kind)
if title_query := request.query_params.get("q"):
@ -48,13 +70,15 @@ class ArtifactListCreateView(APIView):
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
def post(self, request):
if not has_valid_token(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)
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
@ -62,7 +86,7 @@ class ArtifactListCreateView(APIView):
if kind not in TEXT_KINDS:
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
required = ["persona", "title", "content"]
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)
@ -71,20 +95,23 @@ class ArtifactListCreateView(APIView):
if source_error:
return source_error
artifact = create_text_artifact(data, request)
artifact = create_text_artifact(data, request, user)
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)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id):
if not has_valid_token(request):
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)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
data = request.data
if "title" in data:
if not data["title"]:
@ -106,6 +133,11 @@ class ArtifactDetailView(APIView):
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",
@ -121,11 +153,16 @@ class ArtifactDetailView(APIView):
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": ["persona", "day", "tag", "kind", "q"],
"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],
@ -134,35 +171,71 @@ class CapabilitiesView(APIView):
)
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):
persona = request.GET.get("persona", "")
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_day = parse_date(request.GET.get("day", "")) or today
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.all()
artifacts = all_artifacts
if persona:
artifacts = artifacts.filter(persona=persona)
if selected_kind:
artifacts = artifacts.filter(kind=selected_kind)
if title_query:
artifacts = artifacts.filter(title__icontains=title_query)
days = list(artifacts.dates("day", "day", order="DESC"))
for day in (today, selected_day):
if day not in days:
days.append(day)
days.sort(reverse=True)
all_artifacts = Artifact.objects.filter(owner=request.user)
artifacts = apply_feed_filters(
all_artifacts, selected_source, selected_label, selected_kind, title_query
)
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
selected_artifact = None if preview_none else day_artifacts.filter(pk=selected_artifact_id).first()
toggle_query = {"day": selected_day.isoformat(), "view": "rendered"}
if persona:
toggle_query["persona"] = persona
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:
@ -172,20 +245,36 @@ def feed(request):
rendered_url = f"?{urlencode(toggle_query)}"
toggle_query["view"] = "raw"
raw_url = f"?{urlencode(toggle_query)}"
personas = all_artifacts.order_by("persona").values_list("persona", flat=True).distinct()
# 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": day_artifacts,
"days": days,
"personas": personas,
"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_day": selected_day,
"selected_kind": selected_kind,
"selected_persona": persona,
"selected_label": selected_label,
"selected_source": selected_source,
"selected_view": selected_view,
"preview_none": preview_none,
"today": today,
@ -195,9 +284,59 @@ def feed(request):
)
@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
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)
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():
@ -212,16 +351,16 @@ def artifact_view(request, artifact_id):
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
def create_text_artifact(data, request):
def create_text_artifact(data, request, user):
now = timezone.localtime()
artifact_id = generate_id()
persona = slugify(data["persona"]) or "unknown"
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(persona) / now.date().isoformat()
directory = Path(source) / now.date().isoformat()
target_dir = settings.ARTIFACTS_ROOT / directory
target_dir.mkdir(parents=True, exist_ok=True)
@ -240,7 +379,8 @@ def create_text_artifact(data, request):
return Artifact.objects.create(
id=artifact_id,
persona=persona,
owner=user,
source=source,
title=title,
kind=kind,
theme=theme,
@ -249,13 +389,13 @@ def create_text_artifact(data, request):
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
**source_fields(data, persona),
**source_fields(data, source),
)
def create_binary_artifact(data):
def create_binary_artifact(data, user):
uploaded = data.get("file")
required = ["persona", "title"]
required = ["source", "title"]
missing = [field for field in required if not data.get(field)]
if not uploaded:
missing.append("file")
@ -267,12 +407,12 @@ def create_binary_artifact(data):
now = timezone.localtime()
artifact_id = generate_id()
persona = slugify(data["persona"]) or "unknown"
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(persona) / now.date().isoformat()
directory = Path(source) / now.date().isoformat()
target_dir = settings.ARTIFACTS_ROOT / directory
target_dir.mkdir(parents=True, exist_ok=True)
@ -284,7 +424,8 @@ def create_binary_artifact(data):
return Artifact.objects.create(
id=artifact_id,
persona=persona,
owner=user,
source=source,
title=title,
kind=kind,
theme="",
@ -293,7 +434,7 @@ def create_binary_artifact(data):
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
**source_fields(data, persona),
**source_fields(data, source),
)
@ -338,10 +479,17 @@ def generate_id():
return artifact_id
def has_valid_token(request):
expected = getattr(settings, "DAYBOOK_API_TOKEN", "")
def resolve_token_user(request):
auth = request.headers.get("Authorization", "")
return bool(expected) and auth == f"Bearer {expected}"
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):
@ -352,6 +500,39 @@ def normalize_tags(tags):
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}:
@ -360,13 +541,39 @@ def validate_source_fields(data):
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 source_fields(data, persona):
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 persona,
"source_name": data.get("source_name") or source,
"source_id": data.get("source_id") or "",
"metadata": parse_metadata_value(data.get("metadata", {})),
}
@ -409,18 +616,20 @@ def serialize_artifact(artifact, request):
"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,
"persona": artifact.persona,
"source": artifact.source,
"title": artifact.title,
"kind": artifact.kind,
"theme": artifact.theme,
"day": artifact.day.isoformat(),
"created_at": artifact.created_at.isoformat(),
"tags": artifact.tags,
"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,
}
@ -441,6 +650,52 @@ def raw_artifact_path(artifact):
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"},
)
def raw_unavailable_html(artifact):
return f"""<!doctype html>
<html>