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 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 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) 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 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") 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", ""), **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) 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", ""), **source_fields(data, source), ) def wrap_html(body, title, theme, request): stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css") return f"""
Daybook does not have preserved raw source for {html.escape(artifact.title)}.