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:
parent
203f9b578e
commit
37cd96fa7a
33 changed files with 1614 additions and 367 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue