diff --git a/.claude/commands/seed-demo.md b/.claude/commands/seed-demo.md new file mode 100644 index 0000000..ccca3ce --- /dev/null +++ b/.claude/commands/seed-demo.md @@ -0,0 +1,13 @@ +--- +description: Post one demo artifact of every kind (markdown, html_fragment, html_full, deck, image, pdf, binary), tagged, dated today. +--- + +Run the demo seeding script to populate today with one tagged sample artifact per supported kind: + +```bash +python scripts/seed_demo_artifacts.py +``` + +Requires the Daybook dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `DAYBOOK_URL=http://127.0.0.1:8001` / `DAYBOOK_TOKEN=dev-token`; no overrides needed unless the user has changed these. + +After running, report each posted artifact's kind and URL, and remind the user to reload the feed for today. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3e6ecaf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git/ +.idea/ +.venv/ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.pytest_cache/ +.coverage +htmlcov/ +staticfiles/ +db.sqlite3 +artifacts_store/ +django-runserver.log +django-runserver.err.log +.env +.env.* +deploy/.env +deploy/data/ diff --git a/.gitignore b/.gitignore index 624a5fc..7417b79 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,10 @@ htmlcov/ .vscode/ .DS_Store Thumbs.db + +# Internal-only planning/ops docs (home infrastructure details) +_archive_/ +planning/ + +# Local machine-specific tool config (MCP servers, absolute local paths) +opencode.json diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index fdc260b..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,52 +0,0 @@ -# AGENTS.md - -## Project - -Daybook is a self-hosted Django + Django REST Framework web app where AI agents publish artifacts and a human browses them in a time-ordered, persona-filterable feed. - -The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs. - -## Current MVP Decisions - -- Use a single shared ingest API token for now. -- Require `persona` in every artifact payload. -- Do not add reader authentication in the local/self-hosted MVP. -- Use random 8-character artifact IDs. -- Start as a plain Django project; add Docker after core ingest and viewer work. -- Preserve original markdown as `.md` alongside rendered `.html`. - -## Architecture Notes - -- Planning source: `planning/daybook_planning.md`. -- Django project: `daybook_project/`. -- Main app: `artifacts/`. -- Theme CSS: `static/themes/`. -- Feed template: `templates/artifacts/feed.html`. -- Stored artifact files live under `artifacts_store/{persona}/{YYYY-MM-DD}/`. - -## Implementation Guidelines - -- Prefer small, direct changes over broad abstractions. -- Keep ingest normalization server-side. -- The viewer should only need to render HTML, image, or PDF. -- Do not make agents responsible for styling, filenames, or storage paths. -- Preserve existing planning decisions unless the user explicitly changes them. -- Avoid adding backwards-compatibility code before anything has shipped. - -## Commands - -- Install dependencies: `pip install -r requirements.txt` -- Run migrations: `python manage.py migrate` -- Start dev server: `python manage.py runserver` -- Django checks: `python manage.py check` - -## API Defaults - -- MVP ingest endpoint: `POST /api/artifacts` -- MVP list endpoint: `GET /api/artifacts?persona=&day=&tag=` -- Artifact view: `GET /a/{id}` -- Development token: `Authorization: Bearer dev-token` - -## OpenCode - -This project is initialized with `opencode.json`, which loads this file as project instructions. After changing `opencode.json`, `.opencode/`, or this file, restart OpenCode so the updated instructions are loaded. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e5041c9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +## Project + +Daybook is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed. + +The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs. + +## Current MVP Decisions + +- Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account. +- Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself. +- `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either. +- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions. +- User accounts are created/managed via Django admin (superusers only — regular users are not staff and cannot access `/admin/`). +- `TagColor` (label color overrides) stays global/shared across all users. +- Use random 8-character artifact IDs. +- Start as a plain Django project; add Docker after core ingest and viewer work. +- Preserve original markdown as `.md` alongside rendered `.html`. + +## Architecture Notes + +- Planning source: `planning/daybook_planning.md`. +- Django project: `daybook_project/`. +- Main app: `artifacts/`. +- Theme CSS: `static/themes/`. +- Feed template: `templates/artifacts/feed.html`. +- Settings templates: `templates/artifacts/settings_*.html`. +- Stored artifact files live under `artifacts_store/{source}/{YYYY-MM-DD}/`. + +## Implementation Guidelines + +- Prefer small, direct changes over broad abstractions. +- Keep ingest normalization server-side. +- The viewer should only need to render HTML, image, or PDF. +- Do not make agents responsible for styling, filenames, or storage paths. +- Preserve existing planning decisions unless the user explicitly changes them. +- Avoid adding backwards-compatibility code before anything has shipped. + +## Development And Deployment Workflow + +- Develop and test locally in this workspace by default. +- Use the local Django commands for normal iteration: `python manage.py check`, `python manage.py migrate`, and `python manage.py runserver`. +- Run the local development server on `127.0.0.1:8001`. Port `8000` may be reserved or blocked on this workstation. +- Always use this same fixed port for the dev server. Never start a second instance on a different port; if `8001` is already serving, reuse it rather than starting another. +- All artifact-posting tools (`skills/daybook-posting/scripts/daybook_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `DAYBOOK_URL` to `http://127.0.0.1:8001` to match. +- Do not deploy to the Proxmox NUC during routine code iteration. +- Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build. +- Before any NUC deployment, preserve `/opt/daybook/deploy/.env` and `/opt/daybook/deploy/data`. +- After any NUC deployment or configuration change, run the documented health checks and update the NUC ops knowledge base plus `planning/daybook-container-handoff.md` if takeover facts changed. + +## Repository Notes + +- `_archive_/` exists for retired or moved project docs and experiments that should not drive current implementation decisions. +- Current deployment/takeover details live in `planning/daybook-container-handoff.md`; canonical NUC infrastructure details live in the NUC ops repo. + +## Commands + +- Install dependencies: `pip install -r requirements.txt` +- Run migrations: `python manage.py migrate` +- Start dev server: `python manage.py runserver 127.0.0.1:8001` +- Local app URL: `http://127.0.0.1:8001/` +- Local API health check: `http://127.0.0.1:8001/api/capabilities` +- Django checks: `python manage.py check` + +## API Defaults + +- MVP ingest endpoint: `POST /api/artifacts` +- MVP list endpoint: `GET /api/artifacts?label=&day=&kind=&q=` +- Artifact view: `GET /a/{id}` +- Auth: personal per-user token from Settings → Account, `Authorization: Bearer `. Get one by creating a superuser (`python manage.py createsuperuser`), logging in, and visiting Settings → Account. + +## Claude Code / OpenCode + +This project uses `CLAUDE.md` for project instructions, compatible with both Claude Code and OpenCode. The `opencode.json` configuration file loads this file. After changing `opencode.json`, `.opencode/`, or this file, restart your AI coding assistant so the updated instructions are loaded. diff --git a/artifacts/admin.py b/artifacts/admin.py index 1fcc62f..0a0d7f2 100644 --- a/artifacts/admin.py +++ b/artifacts/admin.py @@ -1,10 +1,23 @@ from django.contrib import admin -from .models import Artifact +from .models import Artifact, Asset, UserProfile @admin.register(Artifact) class ArtifactAdmin(admin.ModelAdmin): - list_display = ("id", "title", "persona", "kind", "day", "created_at") - list_filter = ("persona", "kind", "day") - search_fields = ("id", "title", "persona") + list_display = ("id", "title", "owner", "source", "kind", "is_kept", "size_bytes", "day", "created_at") + list_filter = ("owner", "source", "source_type", "kind", "is_kept", "day") + search_fields = ("id", "title", "source", "source_name", "source_id") + + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ("user", "quota_bytes") + search_fields = ("user__username",) + + +@admin.register(Asset) +class AssetAdmin(admin.ModelAdmin): + list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at") + list_filter = ("owner", "content_type") + search_fields = ("id",) diff --git a/artifacts/demo_data.py b/artifacts/demo_data.py new file mode 100644 index 0000000..2ac4f24 --- /dev/null +++ b/artifacts/demo_data.py @@ -0,0 +1,87 @@ +"""Sample artifact content for the Settings -> Demo Data button. + +Creates one artifact of every supported kind, owned by the requesting user, +tagged "demo", with source "demo". +""" + +SOURCE = "demo" + +TEXT_SAMPLES = [ + ( + "markdown", + "Seed: Markdown Sample", + "# Markdown Sample\n\nThis is a **seed** artifact demonstrating markdown rendering.\n\n- item one\n- item two\n", + "markdown", + ), + ( + "html_fragment", + "Seed: HTML Fragment Sample", + "

HTML Fragment

This is a seed HTML fragment artifact.

", + "html-fragment", + ), + ( + "html_full", + "Seed: Full HTML Sample", + "Seed Full HTML" + "

Full HTML Doc

Seed artifact.

", + "html-full", + ), + ( + "deck", + "Seed: Deck Sample", + "

Slide One

Seed deck artifact.

" + "

Slide Two

More content.

", + "deck", + ), +] + +SEED_PNG_BYTES = bytes.fromhex( + "89504e470d0a1a0a0000000d494844520000000100000001080600000" + "01f15c4890000000a49444154789c6360000002000155e59a1a0000000049454e44ae426082" +) + +SEED_PDF_BYTES = ( + b"%PDF-1.1\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"trailer<>\n" +) + +SEED_BIN_BYTES = b"\x00\x01\x02\x03seed-binary-data\xff\xfe" + +BINARY_SAMPLES = [ + ("Seed: Image Sample", "seed_pixel.png", "image/png", SEED_PNG_BYTES, "image"), + ("Seed: PDF Sample", "seed_doc.pdf", "application/pdf", SEED_PDF_BYTES, "pdf"), + ("Seed: Binary Sample", "seed_data.bin", "application/octet-stream", SEED_BIN_BYTES, "binary"), +] + + +def create_demo_artifacts(user, request): + from django.core.files.uploadedfile import SimpleUploadedFile + + from .views import create_binary_artifact, create_text_artifact + + created = 0 + for kind, title, content, tag in TEXT_SAMPLES: + data = { + "source": SOURCE, + "title": title, + "kind": kind, + "content": content, + "tags": ["demo", tag], + } + create_text_artifact(data, request, user) + created += 1 + + for title, filename, content_type, file_bytes, tag in BINARY_SAMPLES: + uploaded = SimpleUploadedFile(filename, file_bytes, content_type=content_type) + data = { + "source": SOURCE, + "title": title, + "file": uploaded, + "tags": ["demo", tag], + } + create_binary_artifact(data, user) + created += 1 + + return created diff --git a/artifacts/forms.py b/artifacts/forms.py new file mode 100644 index 0000000..44f8e98 --- /dev/null +++ b/artifacts/forms.py @@ -0,0 +1,6 @@ +from django import forms +from django.contrib.auth.forms import AuthenticationForm + + +class DaybookLoginForm(AuthenticationForm): + remember_me = forms.BooleanField(required=False, initial=True) diff --git a/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py b/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py new file mode 100644 index 0000000..5494a97 --- /dev/null +++ b/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.15 on 2026-06-10 15:14 + +from django.db import migrations, models + + +def fill_existing_source_names(apps, schema_editor): + Artifact = apps.get_model('artifacts', 'Artifact') + for artifact in Artifact.objects.filter(source_name=''): + artifact.source_name = artifact.persona + artifact.save(update_fields=['source_name']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='metadata', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='artifact', + name='source_id', + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name='artifact', + name='source_name', + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name='artifact', + name='source_type', + field=models.CharField(choices=[('agent', 'Agent'), ('person', 'Person'), ('system', 'System')], default='agent', max_length=20), + ), + migrations.RunPython(fill_existing_source_names, migrations.RunPython.noop), + ] diff --git a/artifacts/migrations/0003_tagcolor.py b/artifacts/migrations/0003_tagcolor.py new file mode 100644 index 0000000..a0c52ac --- /dev/null +++ b/artifacts/migrations/0003_tagcolor.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.15 on 2026-07-23 06:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0002_artifact_metadata_artifact_source_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='TagColor', + fields=[ + ('name', models.CharField(max_length=40, primary_key=True, serialize=False)), + ('color', models.CharField(max_length=20)), + ], + ), + ] diff --git a/artifacts/migrations/0004_rename_persona_to_source.py b/artifacts/migrations/0004_rename_persona_to_source.py new file mode 100644 index 0000000..5c2480c --- /dev/null +++ b/artifacts/migrations/0004_rename_persona_to_source.py @@ -0,0 +1,16 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("artifacts", "0003_tagcolor"), + ] + + operations = [ + migrations.RenameField( + model_name="artifact", + old_name="persona", + new_name="source", + ), + ] diff --git a/artifacts/migrations/0005_add_owner_nullable.py b/artifacts/migrations/0005_add_owner_nullable.py new file mode 100644 index 0000000..f3da6dc --- /dev/null +++ b/artifacts/migrations/0005_add_owner_nullable.py @@ -0,0 +1,24 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("artifacts", "0004_rename_persona_to_source"), + ] + + operations = [ + migrations.AddField( + model_name="artifact", + name="owner", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="artifacts", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/artifacts/migrations/0006_backfill_owner.py b/artifacts/migrations/0006_backfill_owner.py new file mode 100644 index 0000000..941bab7 --- /dev/null +++ b/artifacts/migrations/0006_backfill_owner.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +def backfill_owner(apps, schema_editor): + User = apps.get_model("auth", "User") + Artifact = apps.get_model("artifacts", "Artifact") + + first_superuser = User.objects.filter(is_superuser=True).order_by("date_joined").first() + if first_superuser is None: + return + + Artifact.objects.filter(owner__isnull=True).update(owner=first_superuser) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("artifacts", "0005_add_owner_nullable"), + ] + + operations = [ + migrations.RunPython(backfill_owner, noop_reverse), + ] diff --git a/artifacts/migrations/0007_owner_required.py b/artifacts/migrations/0007_owner_required.py new file mode 100644 index 0000000..f588a3f --- /dev/null +++ b/artifacts/migrations/0007_owner_required.py @@ -0,0 +1,23 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("artifacts", "0006_backfill_owner"), + ] + + operations = [ + migrations.AlterField( + model_name="artifact", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="artifacts", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/artifacts/migrations/0008_artifact_read_at.py b/artifacts/migrations/0008_artifact_read_at.py new file mode 100644 index 0000000..d86ddf0 --- /dev/null +++ b/artifacts/migrations/0008_artifact_read_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-23 21:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0007_owner_required'), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='read_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/artifacts/migrations/0009_quota_and_keep.py b/artifacts/migrations/0009_quota_and_keep.py new file mode 100644 index 0000000..704fbf1 --- /dev/null +++ b/artifacts/migrations/0009_quota_and_keep.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.15 on 2026-07-24 07:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0008_artifact_read_at'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='is_kept', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='artifact', + name='size_bytes', + field=models.BigIntegerField(default=0), + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quota_bytes', models.BigIntegerField(default=1073741824)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/artifacts/migrations/0010_backfill_sizes_and_profiles.py b/artifacts/migrations/0010_backfill_sizes_and_profiles.py new file mode 100644 index 0000000..7766cd9 --- /dev/null +++ b/artifacts/migrations/0010_backfill_sizes_and_profiles.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from django.conf import settings +from django.db import migrations + + +def backfill(apps, schema_editor): + Artifact = apps.get_model("artifacts", "Artifact") + User = apps.get_model("auth", "User") + UserProfile = apps.get_model("artifacts", "UserProfile") + + root = Path(settings.ARTIFACTS_ROOT) + for artifact in Artifact.objects.all(): + total = 0 + primary = root / artifact.path + if primary.exists(): + total += primary.stat().st_size + if artifact.kind == "markdown": + sidecar = primary.with_suffix(".md") + if sidecar.exists(): + total += sidecar.stat().st_size + if artifact.size_bytes != total: + artifact.size_bytes = total + artifact.save(update_fields=["size_bytes"]) + + default_quota = 1024 * 1024 * 1024 # 1 GB + for user in User.objects.all(): + UserProfile.objects.get_or_create(user=user, defaults={"quota_bytes": default_quota}) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("artifacts", "0009_quota_and_keep"), + ] + + operations = [ + migrations.RunPython(backfill, noop_reverse), + ] diff --git a/artifacts/migrations/0011_add_asset.py b/artifacts/migrations/0011_add_asset.py new file mode 100644 index 0000000..f2bc2c0 --- /dev/null +++ b/artifacts/migrations/0011_add_asset.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.15 on 2026-07-24 10:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0010_backfill_sizes_and_profiles'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Asset', + fields=[ + ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), + ('path', models.CharField(max_length=500)), + ('content_type', models.CharField(max_length=100)), + ('size_bytes', models.BigIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('artifact', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='artifacts.artifact')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/artifacts/models.py b/artifacts/models.py index 249f728..3ce7209 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.db import models @@ -10,6 +11,10 @@ class Artifact(models.Model): PDF = "pdf" BINARY = "binary" + AGENT = "agent" + PERSON = "person" + SYSTEM = "system" + KIND_CHOICES = [ (HTML_FRAGMENT, "HTML fragment"), (HTML_FULL, "Full HTML"), @@ -20,8 +25,15 @@ class Artifact(models.Model): (BINARY, "Binary"), ] + SOURCE_TYPE_CHOICES = [ + (AGENT, "Agent"), + (PERSON, "Person"), + (SYSTEM, "System"), + ] + id = models.CharField(max_length=8, primary_key=True) - persona = models.CharField(max_length=80, db_index=True) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="artifacts") + source = models.CharField(max_length=80, db_index=True) title = models.CharField(max_length=200) kind = models.CharField(max_length=20, choices=KIND_CHOICES) theme = models.CharField(max_length=40, blank=True) @@ -31,9 +43,63 @@ class Artifact(models.Model): original_kind = models.CharField(max_length=20) tags = models.JSONField(default=list, blank=True) source_prompt = models.TextField(blank=True) + source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES, default=AGENT) + source_name = models.CharField(max_length=120, blank=True) + source_id = models.CharField(max_length=120, blank=True) + metadata = models.JSONField(default=dict, blank=True) + read_at = models.DateTimeField(null=True, blank=True) + is_kept = models.BooleanField(default=False) + size_bytes = models.BigIntegerField(default=0) + + @property + def is_unread(self): + return self.read_at is None class Meta: ordering = ["-created_at"] def __str__(self): - return f"{self.title} ({self.persona})" + return f"{self.title} ({self.source})" + + +class TagColor(models.Model): + name = models.CharField(max_length=40, primary_key=True) + color = models.CharField(max_length=20) + + def __str__(self): + return f"{self.name} ({self.color})" + + +class UserProfile(models.Model): + DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB + + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile") + quota_bytes = models.BigIntegerField(default=DEFAULT_QUOTA_BYTES) + + def __str__(self): + return f"{self.user} quota={self.quota_bytes}" + + +class Asset(models.Model): + """An image uploaded for embedding inside an artifact's content. + + Uploaded before the artifact exists, so `artifact` is null until the + artifact referencing its URL is created. Unattached assets are swept + after ASSET_ORPHAN_GRACE; attached ones die with their artifact. + """ + + id = models.CharField(max_length=12, primary_key=True) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="assets") + artifact = models.ForeignKey( + Artifact, on_delete=models.CASCADE, related_name="assets", null=True, blank=True + ) + path = models.CharField(max_length=500) + content_type = models.CharField(max_length=100) + size_bytes = models.BigIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.id} ({self.content_type})" diff --git a/artifacts/storage.py b/artifacts/storage.py new file mode 100644 index 0000000..36e7069 --- /dev/null +++ b/artifacts/storage.py @@ -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 diff --git a/artifacts/urls.py b/artifacts/urls.py index 4215301..a3d1f65 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -5,6 +5,17 @@ from . import views urlpatterns = [ path("", views.feed, name="feed"), + path("feed/older", views.feed_older, name="feed-older"), + path("a//read", views.mark_artifact_read, name="artifact-mark-read"), + path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a/", views.artifact_view, name="artifact"), + path("assets/", views.asset_view, name="asset"), + path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), + path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), + path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), + path("settings/", views.settings_home, name="settings-home"), + path("settings/account/", views.settings_account, name="settings-account"), + path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), + path("settings/storage/", views.settings_storage, name="settings-storage"), ] diff --git a/artifacts/views.py b/artifacts/views.py index 7c2b847..758b2c1 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -1,26 +1,64 @@ +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, 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 .models import Artifact +from .demo_data import create_demo_artifacts +from .forms import DaybookLoginForm +from .models import Artifact, Asset, TagColor +from .storage import ( + QuotaExceeded, + artifact_disk_size, + delete_artifact_files, + disk_free_bytes, + make_room_for, + sweep_orphan_assets, + 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 + +MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image + +# Matches the asset id in a Daybook asset URL, e.g. /assets/ab12cd34ef56 +ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})") + + +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", @@ -28,70 +66,283 @@ 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"): + artifacts = artifacts.filter(title__icontains=title_query) 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 - return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201) + 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 = ["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) - artifact = create_text_artifact(data, request) - return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201) + 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) +class AssetUploadView(APIView): + """Upload an image for embedding inside artifact content. + + Returns a stable URL the agent puts in its markdown/fragment. The asset is + attached to whichever artifact later references that URL. + """ + + parser_classes = [MultiPartParser, FormParser] + + def post(self, request): + user = resolve_token_user(request) + if user is None: + return HttpResponseForbidden("Invalid or missing API token") + + uploaded = request.data.get("file") + if not uploaded: + return Response({"detail": "Missing required field: file"}, status=400) + + content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or "" + if not content_type.startswith("image/"): + return Response({"detail": "Only image uploads are supported."}, status=400) + if uploaded.size > MAX_ASSET_BYTES: + return Response( + {"detail": f"Image exceeds the {MAX_ASSET_BYTES // (1024 * 1024)} MB limit."}, + status=413, + ) + + sweep_orphan_assets(user) + + asset_id = generate_asset_id() + suffix = safe_suffix(uploaded.name, content_type) + directory = Path("_assets") / timezone.localdate().isoformat() + (settings.ARTIFACTS_ROOT / directory).mkdir(parents=True, exist_ok=True) + stored_path = directory / f"{asset_id}{suffix}" + + with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target: + for chunk in uploaded.chunks(): + target.write(chunk) + + size_bytes = (settings.ARTIFACTS_ROOT / stored_path).stat().st_size + 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) + + asset = Asset.objects.create( + id=asset_id, + owner=user, + path=stored_path.as_posix(), + content_type=content_type, + size_bytes=size_bytes, + ) + return Response( + { + "id": asset.id, + "url": request.build_absolute_uri(reverse("asset", args=[asset.id])), + "content_type": asset.content_type, + "size_bytes": asset.size_bytes, + }, + status=201, + ) + + +@login_required +@xframe_options_sameorigin +def asset_view(request, asset_id): + asset = get_object_or_404(Asset, pk=asset_id, owner=request.user) + path = safe_artifact_path(asset.path) + if not path.exists(): + raise Http404("Asset file not found") + return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream") + + +@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)) + + def delete(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) + delete_artifact_files(artifact) + artifact.delete() + return Response(status=204) + + +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", + "methods": { + "/api/artifacts": ["GET", "POST"], + "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], + "/api/assets": ["POST"], + }, + } + ) + + +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", "") - selected_day = parse_date(request.GET.get("day", "")) or timezone.localdate() + 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.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")) - if selected_day not in days: - days.append(selected_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 = 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: @@ -101,30 +352,107 @@ 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, "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) + 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(): @@ -139,16 +467,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) @@ -165,9 +493,19 @@ def create_text_artifact(data, request): stored_path = directory / f"{artifact_id}.html" (settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8") - return Artifact.objects.create( + 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) + + artifact = Artifact.objects.create( id=artifact_id, - persona=persona, + owner=user, + source=source, title=title, kind=kind, theme=theme, @@ -176,26 +514,33 @@ def create_text_artifact(data, request): original_kind=kind, tags=normalize_tags(data.get("tags", [])), source_prompt=data.get("source_prompt", ""), + size_bytes=size_bytes, + **source_fields(data, source), ) + attach_referenced_assets(artifact, content) + return artifact -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") 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() - 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) @@ -205,9 +550,17 @@ def create_binary_artifact(data): 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, - persona=persona, + owner=user, + source=source, title=title, kind=kind, theme="", @@ -216,6 +569,8 @@ def create_binary_artifact(data): original_kind=kind, tags=normalize_tags(data.get("tags", [])), source_prompt=data.get("source_prompt", ""), + size_bytes=size_bytes, + **source_fields(data, source), ) @@ -260,10 +615,38 @@ def generate_id(): return artifact_id -def has_valid_token(request): - expected = getattr(settings, "DAYBOOK_API_TOKEN", "") +def generate_asset_id(): + while True: + asset_id = secrets.token_hex(6) + if not Asset.objects.filter(pk=asset_id).exists(): + return asset_id + + +def attach_referenced_assets(artifact, content): + """Link assets whose URLs appear in the artifact's content. + + Assets are uploaded before the artifact exists, so this runs at creation + time and ties each referenced asset to the artifact that embeds it. + """ + referenced = set(ASSET_URL_RE.findall(content or "")) + if not referenced: + return 0 + return Asset.objects.filter( + owner=artifact.owner, id__in=referenced, artifact__isnull=True + ).update(artifact=artifact) + + +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): @@ -274,6 +657,100 @@ 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}: + 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 @@ -293,14 +770,25 @@ def serialize_artifact(artifact, request): return { "id": artifact.id, "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), - "persona": artifact.persona, + "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": 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, + "is_kept": artifact.is_kept, + "size_bytes": artifact.size_bytes, } @@ -321,6 +809,78 @@ 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"}, + ) + + +@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""" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index 805cbf7..3b396a6 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -1,11 +1,13 @@ +import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve() -SECRET_KEY = "dev-only-change-me" -DEBUG = True -ALLOWED_HOSTS = ["*"] +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me") +DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"} +ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()] INSTALLED_APPS = [ "django.contrib.admin", @@ -15,11 +17,13 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", + "rest_framework.authtoken", "artifacts", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -50,12 +54,15 @@ WSGI_APPLICATION = "daybook_project.wsgi.application" DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "NAME": os.environ.get("DATABASE_PATH", DATA_DIR / "db.sqlite3"), } } AUTH_PASSWORD_VALIDATORS = [] +LOGIN_URL = "login" +LOGIN_REDIRECT_URL = "feed" + LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True @@ -63,8 +70,13 @@ USE_TZ = True STATIC_URL = "static/" STATICFILES_DIRS = [BASE_DIR / "static"] +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -ARTIFACTS_ROOT = BASE_DIR / "artifacts_store" -DAYBOOK_API_TOKEN = "dev-token" +ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store")) diff --git a/daybook_project/urls.py b/daybook_project/urls.py index 07b927f..4204d51 100644 --- a/daybook_project/urls.py +++ b/daybook_project/urls.py @@ -1,8 +1,13 @@ from django.contrib import admin +from django.contrib.auth import views as auth_views from django.urls import include, path +from artifacts.views import DaybookLoginView + urlpatterns = [ path("admin/", admin.site.urls), + path("accounts/login/", DaybookLoginView.as_view(), name="login"), + path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"), path("", include("artifacts.urls")), ] diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..38e2602 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --upgrade pip \ + && pip install -r /app/requirements.txt + +COPY . /app +RUN sed -i 's/\r$//' /app/deploy/entrypoint.sh \ + && chmod +x /app/deploy/entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/app/deploy/entrypoint.sh"] +CMD ["gunicorn", "daybook_project.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..ee1dd7a --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,19 @@ +services: + daybook: + build: + context: .. + dockerfile: deploy/Dockerfile + container_name: daybook + restart: unless-stopped + env_file: + - .env + ports: + - "8000:8000" + volumes: + - ./data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/api/capabilities"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..cdd620d --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu + +mkdir -p "${DAYBOOK_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}" + +python manage.py migrate --noinput +python manage.py collectstatic --noinput + +exec "$@" diff --git a/deploy/env.example b/deploy/env.example new file mode 100644 index 0000000..e83e248 --- /dev/null +++ b/deploy/env.example @@ -0,0 +1,7 @@ +DJANGO_DEBUG=0 +DJANGO_SECRET_KEY=replace-with-a-long-random-secret +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-proxmox-container-ip,your-domain.example +DAYBOOK_API_TOKEN=replace-with-a-long-random-token +DAYBOOK_DATA_DIR=/data +DATABASE_PATH=/data/db.sqlite3 +ARTIFACTS_ROOT=/data/artifacts_store diff --git a/deploy/proxmox_llm_deploy.md b/deploy/proxmox_llm_deploy.md new file mode 100644 index 0000000..2d01b1d --- /dev/null +++ b/deploy/proxmox_llm_deploy.md @@ -0,0 +1,348 @@ +# Proxmox Container Deployment Runbook For LLM Operators + +This document is written for an LLM or automation agent that is deploying Daybook into a Linux container on a Proxmox host. + +The recommended deployment target is a Proxmox LXC or VM running Debian/Ubuntu with Docker and Docker Compose. Daybook itself runs as a Docker container inside that guest. + +## Deployment Model + +```text +Proxmox host + Debian/Ubuntu LXC or VM + Docker Engine + daybook container + /app application code baked into image + /data persistent mounted volume +``` + +Persistent runtime data lives outside the image in `deploy/data` on the guest: + +```text +deploy/data/ + db.sqlite3 + artifacts_store/ +``` + +Do not store runtime data inside the image. + +## Files In This Directory + +| File | Purpose | +|---|---| +| `Dockerfile` | Builds the Daybook image. | +| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. | +| `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. | +| `env.example` | Template for deployment environment variables. | +| `proxmox_llm_deploy.md` | This runbook. | + +## Prerequisites On The Proxmox Guest + +The guest OS should have: + +- Docker Engine +- Docker Compose plugin +- Git +- Network access to pull Python base images and Python packages + +For Debian/Ubuntu guests, install basics: + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates curl git +``` + +Install Docker using Docker's current official instructions for the guest OS. Then verify: + +```bash +docker --version +docker compose version +``` + +## Clone Or Copy The Project + +Choose a stable application path on the guest: + +```bash +sudo mkdir -p /opt/daybook +sudo chown "$USER:$USER" /opt/daybook +``` + +If using Git: + +```bash +git clone /opt/daybook +cd /opt/daybook +``` + +If files were copied by another mechanism, ensure the project root contains: + +```text +manage.py +requirements.txt +deploy/docker-compose.yml +deploy/Dockerfile +``` + +## Create The Environment File + +From the project root: + +```bash +cd /opt/daybook +cp deploy/env.example deploy/.env +``` + +Generate secrets: + +```bash +python3 - <<'PY' +import secrets +print('DJANGO_SECRET_KEY=' + secrets.token_urlsafe(50)) +print('DAYBOOK_API_TOKEN=' + secrets.token_urlsafe(32)) +PY +``` + +Edit `deploy/.env`: + +```bash +nano deploy/.env +``` + +Set at minimum: + +```text +DJANGO_DEBUG=0 +DJANGO_SECRET_KEY= +DJANGO_ALLOWED_HOSTS=,,localhost,127.0.0.1 +DAYBOOK_API_TOKEN= +DAYBOOK_DATA_DIR=/data +DATABASE_PATH=/data/db.sqlite3 +ARTIFACTS_ROOT=/data/artifacts_store +``` + +Important: + +- Do not commit `deploy/.env`. +- Do not use the development token `dev-token` for a real deployment. +- Include the actual IP or DNS name users will use in `DJANGO_ALLOWED_HOSTS`. + +## Create Persistent Data Directory + +From the project root: + +```bash +mkdir -p deploy/data +``` + +The compose file mounts this path into the container as `/data`. + +## Build And Start + +From the project root: + +```bash +docker compose -f deploy/docker-compose.yml up -d --build +``` + +Watch logs: + +```bash +docker compose -f deploy/docker-compose.yml logs -f daybook +``` + +Expected startup behavior: + +- Django migrations run. +- Static files are collected. +- Gunicorn starts on port `8000` inside the container. +- Host port `8000` maps to container port `8000`. + +## Verify Deployment + +Check container state: + +```bash +docker compose -f deploy/docker-compose.yml ps +``` + +Check capabilities endpoint from the guest: + +```bash +curl -fsS http://127.0.0.1:8000/api/capabilities +``` + +Expected JSON includes: + +```json +{ + "artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"], + "auth": "bearer_token" +} +``` + +Open the app from another machine: + +```text +http://:8000/ +``` + +If this fails: + +- Confirm the Proxmox guest firewall allows TCP `8000`. +- Confirm any Proxmox firewall rules allow TCP `8000`. +- Confirm `DJANGO_ALLOWED_HOSTS` includes the hostname/IP being used. + +## Publish A Smoke-Test Artifact + +Replace `` with `DAYBOOK_API_TOKEN` from `deploy/.env`. + +```bash +curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "persona": "deploy-smoke", + "source_type": "agent", + "source_name": "Deployment Smoke Agent", + "kind": "markdown", + "title": "Deployment Smoke Test", + "content": "# Deployment Smoke Test\n\nDaybook accepted an artifact.", + "tags": ["deploy", "smoke"], + "metadata": {"run_id": "deploy-smoke-001"} + }' +``` + +Then refresh: + +```text +http://:8000/ +``` + +## Operational Commands + +Stop: + +```bash +docker compose -f deploy/docker-compose.yml down +``` + +Start: + +```bash +docker compose -f deploy/docker-compose.yml up -d +``` + +Rebuild after code changes: + +```bash +docker compose -f deploy/docker-compose.yml up -d --build +``` + +View logs: + +```bash +docker compose -f deploy/docker-compose.yml logs -f daybook +``` + +Run Django management command: + +```bash +docker compose -f deploy/docker-compose.yml exec daybook python manage.py check +``` + +Open shell: + +```bash +docker compose -f deploy/docker-compose.yml exec daybook sh +``` + +## Backup + +Back up the guest directory: + +```text +/opt/daybook/deploy/data +``` + +It contains: + +- SQLite database +- Artifact files + +Simple backup command: + +```bash +tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +``` + +For consistent backups during heavy writes, stop the container first: + +```bash +docker compose -f /opt/daybook/deploy/docker-compose.yml down +tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +docker compose -f /opt/daybook/deploy/docker-compose.yml up -d +``` + +## Restore + +Stop Daybook: + +```bash +docker compose -f /opt/daybook/deploy/docker-compose.yml down +``` + +Replace `/opt/daybook/deploy/data` with the restored data directory, then start: + +```bash +docker compose -f /opt/daybook/deploy/docker-compose.yml up -d +``` + +## Updating The App + +If deployed from Git: + +```bash +cd /opt/daybook +git pull +docker compose -f deploy/docker-compose.yml up -d --build +``` + +The container entrypoint runs migrations on startup. + +## Reverse Proxy Notes + +If putting Daybook behind Nginx, Caddy, Traefik, or a Proxmox-level reverse proxy: + +- Proxy to `http://:8000`. +- Add the public hostname to `DJANGO_ALLOWED_HOSTS`. +- Terminate TLS at the proxy. +- Keep `DJANGO_DEBUG=0`. + +Example `DJANGO_ALLOWED_HOSTS`: + +```text +DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1 +``` + +## LLM Deployment Checklist + +Before declaring deployment complete, verify all items: + +- `deploy/.env` exists and does not use placeholder secrets. +- `DJANGO_DEBUG=0`. +- `DJANGO_ALLOWED_HOSTS` includes the access hostname/IP. +- `deploy/data/` exists. +- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` running or healthy. +- `curl -fsS http://127.0.0.1:8000/api/capabilities` succeeds on the guest. +- Browser opens `http://:8000/`. +- Smoke-test artifact POST succeeds with the configured bearer token. +- `deploy/data/db.sqlite3` exists after startup. +- `deploy/data/artifacts_store/` exists after posting an artifact. + +## Known MVP Constraints + +- SQLite is used by default. This is acceptable for local/self-hosted MVP usage. +- The API uses a single shared bearer token. +- No reader authentication is implemented yet. +- Manual drag/drop upload is deferred. +- Content revisions are deferred. diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md new file mode 100644 index 0000000..28d4fe6 --- /dev/null +++ b/docs/agent_quickstart.md @@ -0,0 +1,140 @@ +# Agent Quickstart + +This is the shortest path for an agent to publish and retrieve artifacts from Daybook. + +## 0. Get Your API Token + +Log in to the app in a browser and copy your personal token from **Settings → Account** +(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your +feed, and only yours. + +## 1. Check Capabilities + +Also confirms your token is valid: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" ` + -Headers @{ Authorization = "Bearer " } +``` + +## 2. Publish Markdown + +```powershell +$body = @{ + source = "hermes-research" + source_type = "agent" + source_name = "Hermes Agent" + source_id = "hermes-local" + kind = "markdown" + title = "Agent Status Update" + content = "# Status Update`n`nThe agent completed its run successfully." + tags = @("status", "agent") + metadata = @{ + run_id = "run-001" + model = "gpt-5.5" + } +} | ConvertTo-Json -Depth 5 + +$artifact = Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts" ` + -Method Post ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body + +$artifact +``` + +Open the artifact in the browser: + +```powershell +$artifact.url +``` + +## 3. Publish HTML Fragment + +```powershell +$body = @{ + source = "athena" + source_type = "agent" + source_name = "Athena Agent" + kind = "html_fragment" + theme = "tasks" + title = "Task List" + content = "

Tasks

  • Review API docs
  • Publish next artifact
" + tags = @("tasks") +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts" ` + -Method Post ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body +``` + +## 4. Publish A File + +```powershell +curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` + -H "Authorization: Bearer " ` + -F "source=orion" ` + -F "source_type=agent" ` + -F "source_name=Orion Agent" ` + -F "title=Generated PDF" ` + -F "tags=pdf,report" ` + -F "file=@C:\path\to\report.pdf;type=application/pdf" +``` + +## 5. Find Recent Artifacts + +> **Note:** artifact read endpoints (steps 5 and 6) require a logged-in **browser session**; +> a bearer token is not accepted. These are for browsing from the app, not for agents. +> Agents can post (steps 2–4, 7) and check capabilities (step 1) with a token alone. + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?source=hermes-research" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?label=status" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=status" +``` + +## 6. Read One Artifact Metadata + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" +``` + +## 7. Update Metadata + +```powershell +$body = @{ + tags = @("status", "agent", "reviewed") + metadata = @{ + run_id = "run-001" + model = "gpt-5.5" + reviewed = $true + } +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` + -Method Patch ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body +``` + +## Agent Payload Checklist + +Recommended fields for every agent post: + +- `source`: stable label identifying what posted this (agent-profile, script, or person name). +- `source_type`: usually `agent`. +- `source_name`: readable agent name. +- `source_id`: stable ID if the agent has one. +- `title`: useful human title. +- `kind`: artifact type. +- `content` or `file`: artifact content. +- `tags`: small set of useful filters. +- `metadata.run_id`: lets later artifacts be correlated to the same agent run. +- `metadata.model`: model or system that generated the artifact. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..4cfce20 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,425 @@ +# Daybook API + +Daybook is built around agents publishing artifacts. Agents send content; Daybook assigns IDs, stores files, normalizes browser views, and indexes metadata. + +Base URL while running locally: + +```text +http://127.0.0.1:8001 +``` + +Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app: + +```http +Authorization: Bearer +``` + +Every artifact belongs to the user whose token authenticated the POST — feeds are private per user. + +Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a +logged-in browser session, not a bearer token — visit the app in a browser and log in first. + +`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check +connectivity before posting. + +## Artifact Kinds + +Currently supported kinds: + +| Kind | Upload type | Rendered behavior | Raw support | +|---|---|---|---| +| `html_fragment` | JSON text | Wrapped with a Daybook theme | No | +| `html_full` | JSON text | Stored as complete HTML | Yes | +| `markdown` | JSON text | Rendered to HTML and original `.md` is preserved | Yes | +| `deck` | JSON text | Wrapped as a simple HTML slide/deck page | No | +| `image` | Multipart file | Browser image preview | No | +| `pdf` | Multipart file | Browser PDF preview | No | +| `binary` | Multipart file | Stored and downloadable | No | + +## Source Fields + +Every artifact can identify who or what produced it. `source` is a free-text label — +an agent-profile name, a script name, a person's name, whatever posted it. + +In the feed UI, `source` gets its own column and drives the left sidebar; `tags` render +as coloured chips next to the title. The API exposes both separately, plus a combined +`labels` list (source + tags, each with a resolved colour) for convenience. + +| Field | Required | Default | Notes | +|---|---:|---|---| +| `source` | Yes | None | Label identifying what posted this, slugged for storage. | +| `source_type` | No | `agent` | One of `agent`, `person`, `system`. | +| `source_name` | No | `source` | Human-readable source name. | +| `source_id` | No | Empty string | Stable external agent/person/system ID. | +| `metadata` | No | `{}` | Free-form object for model/run/tool metadata. | + +Example source metadata: + +```json +{ + "source": "hermes-research", + "source_type": "agent", + "source_name": "Hermes Agent", + "source_id": "agent-hermes-prod", + "metadata": { + "model": "gpt-5.5", + "run_id": "run-2026-06-10-001", + "tool": "nightly-brief" + } +} +``` + +## Create Text Artifact + +Endpoint: + +```http +POST /api/artifacts +Content-Type: application/json +Authorization: Bearer +``` + +Required fields: + +| Field | Notes | +|---|---| +| `source` | Required. | +| `kind` | One of `html_fragment`, `html_full`, `markdown`, `deck`. | +| `title` | Required. | +| `content` | Required text content. | + +Optional fields: + +| Field | Notes | +|---|---| +| `theme` | Defaults by kind. Common values: `report`, `tasks`, `deck`, `base`. | +| `tags` | Array or comma-separated string. | +| `tag_colors` | Object mapping tag name → `#RRGGBB`. Sets a colour override for those tags. | +| `source_prompt` | Prompt or instruction that led to the artifact. | +| `source_type` | Defaults to `agent`. | +| `source_name` | Defaults to `source`. | +| `source_id` | Optional stable source ID. | +| `metadata` | Optional object. | + +### Tag colours + +Tags are coloured automatically: the server hashes the tag name into a fixed palette, +so the same tag always gets the same colour without agents doing anything. + +To force a specific colour, pass `tag_colors`: + +```json +{"tags": ["urgent", "billing"], "tag_colors": {"urgent": "#d1453b"}} +``` + +Colour overrides are **global and shared across all users** — setting a colour for +`urgent` changes it everywhere, for everyone. Values must be `#RRGGBB`; anything else +returns `400`. Overrides can also be set via `PATCH` on an existing artifact. + +PowerShell example: + +```powershell +$body = @{ + source = "hermes-research" + source_type = "agent" + source_name = "Hermes Agent" + source_id = "agent-hermes-local" + kind = "markdown" + theme = "report" + title = "Morning Brief" + content = "# Morning Brief`n`n- Review overnight output`n- Plan next actions" + tags = @("brief", "daily") + source_prompt = "Prepare a concise morning brief." + metadata = @{ + model = "gpt-5.5" + run_id = "brief-001" + } +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts" ` + -Method Post ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body +``` + +Response shape: + +```json +{ + "id": "a1b2c3d4", + "url": "http://127.0.0.1:8001/a/a1b2c3d4", + "api_url": "http://127.0.0.1:8001/api/artifacts/a1b2c3d4", + "raw_url": "http://127.0.0.1:8001/a/a1b2c3d4?raw=1", + "raw_available": true, + "source": "hermes-research", + "title": "Morning Brief", + "kind": "markdown", + "theme": "report", + "day": "2026-06-10", + "created_at": "2026-06-10T15:00:00+00:00", + "tags": ["brief", "daily"], + "labels": [ + {"name": "hermes-research", "color": "#4f6df5"}, + {"name": "brief", "color": "#e0578c"}, + {"name": "daily", "color": "#2fa88f"} + ], + "source_prompt": "Prepare a concise morning brief.", + "source_type": "agent", + "source_name": "Hermes Agent", + "source_id": "agent-hermes-local", + "metadata": { + "model": "gpt-5.5", + "run_id": "brief-001" + }, + "read_at": null +} +``` + +`read_at` is `null` until the artifact is opened in the feed viewer, then holds the +timestamp it was first read. Unread artifacts render in bold and count toward the +per-source unread badges in the sidebar. + +## Create File Artifact + +Endpoint: + +```http +POST /api/artifacts +Content-Type: multipart/form-data +Authorization: Bearer +``` + +Required fields: + +| Field | Notes | +|---|---| +| `source` | Required. | +| `title` | Required. | +| `file` | Required file part. | + +File kind is inferred from content type: + +| Content type | Artifact kind | +|---|---| +| `image/*` | `image` | +| `application/pdf` | `pdf` | +| Anything else | `binary` | + +PowerShell/curl example: + +```powershell +curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` + -H "Authorization: Bearer " ` + -F "source=orion" ` + -F "source_type=agent" ` + -F "source_name=Orion Agent" ` + -F "title=Architecture Diagram" ` + -F "tags=diagram,architecture" ` + -F "file=@C:\path\to\diagram.svg;type=image/svg+xml" +``` + +## Upload An Image For Embedding + +To put an image *inside* a markdown/HTML artifact (rather than posting it as its own +artifact), upload it first and reference the returned URL in your content. + +```http +POST /api/assets +Content-Type: multipart/form-data +Authorization: Bearer +``` + +| Field | Notes | +|---|---| +| `file` | Required. Must be an `image/*` content type. Max 50 MB. | + +Response: + +```json +{ + "id": "ab12cd34ef56", + "url": "http://127.0.0.1:8001/assets/ab12cd34ef56", + "content_type": "image/png", + "size_bytes": 20481 +} +``` + +Then reference the URL normally: + +```markdown +![Chart](http://127.0.0.1:8001/assets/ab12cd34ef56) +``` + +Lifecycle and quota: + +- The image counts toward your quota **immediately on upload**, and can trigger eviction + of old unkept artifacts like any other post. +- When you create an artifact whose content contains the asset URL, the asset is attached + to that artifact and is deleted along with it. +- Uploads never referenced by any artifact are swept after 24 hours. +- Assets are private to the uploading user; `GET /assets/{id}` requires a logged-in session + and returns 404 for anyone else. + +## List Artifacts + +Endpoint: + +```http +GET /api/artifacts +``` + +Requires a logged-in browser session. Returns only the current user's artifacts. + +Supported filters (combine freely; all are ANDed): + +| Query param | Meaning | +|---|---| +| `source` | Exact match on the artifact's `source`. | +| `label` | Matches any of the artifact's `tags`. | +| `day` | Exact day, `YYYY-MM-DD`. | +| `kind` | Exact artifact kind. | +| `q` | Case-insensitive title fragment. | + +`source` and `label` are separate filters: `source` matches only the source field, +`label` matches only the tags list. + +Examples: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?source=hermes-research" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=brief" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?day=2026-06-10&label=daily" +``` + +## Get Artifact Metadata + +Endpoint: + +```http +GET /api/artifacts/{id} +``` + +Example: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" +``` + +## Update Artifact Metadata + +Endpoint: + +```http +PATCH /api/artifacts/{id} +Content-Type: application/json +Authorization: Bearer +``` + +Editable fields: + +| Field | Notes | +|---|---| +| `title` | Must not be blank. | +| `tags` | Array or comma-separated string. | +| `tag_colors` | Object mapping tag name → `#RRGGBB`. Updates the global colour registry. | +| `source_prompt` | String. | +| `source_type` | `agent`, `person`, or `system`. | +| `source_name` | String. | +| `source_id` | String. | +| `metadata` | Object. Replaces existing metadata. | + +You can only PATCH artifacts your own token owns; others return `404`. + +Not editable yet: + +| Field | Reason | +|---|---| +| `content` | Content replacement/revisions are intentionally deferred. | +| `source` | Affects storage layout and feed identity. | +| `kind` | Affects renderer and storage behavior. | +| `day` | Affects time-ordered feed and storage layout. | + +PowerShell example: + +```powershell +$body = @{ + title = "Morning Brief - Reviewed" + tags = @("brief", "daily", "reviewed") + metadata = @{ + model = "gpt-5.5" + run_id = "brief-001" + reviewed = $true + } +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" ` + -Method Patch ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body +``` + +## Rendered And Raw Content + +Rendered browser view: + +```http +GET /a/{id} +``` + +Raw/source view: + +```http +GET /a/{id}?raw=1 +``` + +Raw currently exists for: + +| Kind | Raw content | +|---|---| +| `markdown` | Preserved `.md` source. | +| `html_full` | Stored HTML file served as text. | + +If raw content is unavailable, Daybook returns a small HTML fallback page. + +## Capabilities + +Endpoint: + +```http +GET /api/capabilities +``` + +Example response: + +```json +{ + "artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"], + "filters": ["source", "label", "day", "kind", "q"], + "views": ["rendered", "raw"], + "raw_supported_for": ["markdown", "html_full"], + "source_types": ["agent", "person", "system"], + "auth": "bearer_token" +} +``` + +Agents should call this endpoint if they need to adapt to the current Daybook instance, or +simply to check the instance is reachable and their token is valid. Unlike the other read +endpoints, it accepts a bearer token as well as a browser session: + +```bash +curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/capabilities +``` + +## Current MVP Constraints + +- Each user has a personal ingest API token; there is no shared token. +- Reader/browser access requires login; feeds are private per user. +- Content modification/revisions are deferred. +- Office document conversion is not implemented. +- Manual browser upload/drag-and-drop is deferred. +- Pagination/cursors are not implemented yet. diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md new file mode 100644 index 0000000..cd8cf1d --- /dev/null +++ b/docs/llm_posting_helper.md @@ -0,0 +1,108 @@ +# LLM Posting Helper + +Agents should publish to Daybook with `skills/daybook-posting/scripts/daybook_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests. + +The project also keeps `scripts/daybook_post.py` as a compatibility wrapper. + +The helper is intentionally zero-dependency and uses only Python's standard library. It works on Windows and Linux. + +## Configuration + +```text +DAYBOOK_URL=http://127.0.0.1:8001 +DAYBOOK_TOKEN=your-personal-api-token +DAYBOOK_SOURCE=agent +``` + +`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment. + +## Commands + +Check the instance: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --check +``` + +Post Markdown from a file: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report +``` + +Post Markdown from stdin: + +```bash +cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" +``` + +Post a deck: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deployment Plan" --content-file deck.html +``` + +`deck.html` must contain top-level `
...
` blocks. + +Post an image/PDF/binary file: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" +python skills/daybook-posting/scripts/daybook_post.py --file report.pdf --title "Generated Report" +``` + +Render CSV/JSON/code as Markdown today: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV" +``` + +Dry run: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Draft" --content-file report.md --dry-run +``` + +The artifact URL is printed as the final stdout line. + +## Validation Guardrails + +The helper validates common LLM mistakes before sending: + +| Kind/input | Validation | +|---|---| +| `deck` | Must contain ` (html/pdf) or (image) -``` - -The viewer only ever handles **HTML, image, or PDF**, because everything is -collapsed into those three at ingest. No client-side office/pptx rendering. - -## Data model - -`Artifact` - -| Field | Type | Notes | -|---|---|---| -| `id` | str (8-char) | short unique id, used in URLs (`/a/{id}`) | -| `persona` | str (indexed) | the agent that published it; primary filter | -| `title` | str | display title | -| `kind` | str | `html_fragment` \| `html_full` \| `markdown` \| `deck` \| `image` \| `pdf` \| `binary` | -| `theme` | str | named CSS theme used at render (nullable) | -| `day` | date (indexed) | publication day; feed grouping key | -| `created_at` | datetime | exact timestamp | -| `path` | str | relative path to stored, normalized file | -| `original_kind` | str | what the agent sent, pre-normalization (audit) | -| `tags` | json/list | later: filtering/search | -| `source_prompt` | text | optional: the prompt that produced it | - -Share fields (`share_token`, `share_expires`, `share_password_hash`) added in the -share-links milestone, not MVP. - -## REST API (MVP contract) - -All ingest endpoints require an API token (per-persona; see Open Questions). - -### `POST /api/artifacts` (JSON — text content) -```json -{ - "persona": "hermes", - "kind": "html_fragment", // or html_full | markdown | deck - "theme": "report", // optional; defaults per kind - "title": "Daily Tasks 2026-06-10", - "content": "

Today

  • ...
", - "tags": ["tasks"], // optional - "source_prompt": "..." // optional -} -``` -Response: `{ "id": "a1b2c3d4", "url": "/a/a1b2c3d4" }` - -### `POST /api/artifacts` (multipart — binary) -Fields: `persona`, `title`, `tags`; file part `file` (image / pdf / office). -Office/deck files are converted to PDF on ingest. - -### `GET /api/artifacts?persona=&day=&tag=` -Returns metadata list (reverse-chronological). Drives the feed. - -### `GET /a/{id}` -Serves the rendered artifact (HTML/PDF/image) for inline viewing. - -## Theming (server-side CSS) - -The server owns styling so agents can send markup only. Themes live as static -files and are selected by name in the POST body: - -``` -static/themes/report.css -static/themes/tasks.css -static/themes/deck.css # reveal.js theme -static/themes/base.css # default -``` - -Fragment wrapping (conceptual): - -```python -WRAP = """ - -{title} -{body}""" -``` - -- `html_fragment` → wrapped with the chosen theme. -- `markdown` → rendered to HTML, then wrapped. -- `deck` → wrapped in reveal.js scaffold (agent sends `
` blocks). -- `html_full` → stored verbatim (agent owns everything). - -## Ingest normalization pipeline - -| Agent sends | Step | Stored as | Viewer | -|---|---|---|---| -| HTML fragment | wrap + theme | `.html` | iframe | -| Full HTML | verbatim | `.html` | iframe | -| Markdown | render + wrap | `.html` | iframe | -| Deck (sections) | reveal.js wrap | `.html` | iframe | -| Image | verbatim | image | `` | -| PDF | verbatim | `.pdf` | native / pdf.js | -| pptx/docx/xlsx | LibreOffice → PDF | `.pdf` | native / pdf.js | - -## Viewer - -- **Feed view (`/`):** reverse-chronological list grouped by day, with a persona - filter (dropdown or sidebar) and, later, tag/search. Each entry shows title, - persona, time, and kind. -- **Artifact view (`/a/{id}`):** renders inline — ` {% endif %} - {% else %}
-

Select an artifact

-

Choose an item from the middle column to preview it here.

-

POST to /api/artifacts with Authorization: Bearer dev-token.

+ {% if artifacts %} +

No artifact loaded

+

Press Right Arrow or select an artifact from the middle column to preview it.

+ {% else %} +

No artifacts for this day

+

This day is ready for new agent output.

+ {% endif %}
{% endif %} +
@@ -150,16 +126,24 @@ applySizes(); + // Per-pane resize bounds. The artifact list may grow much wider than the + // sources sidebar, but is still capped so it can't swallow the preview. + const sizeBounds = { + days: {min: 96, max: 320}, + artifacts: {min: 200, max: 720}, + }; + document.querySelectorAll(".splitter").forEach((splitter) => { splitter.addEventListener("pointerdown", (event) => { const target = splitter.dataset.splitter; const startX = event.clientX; const startWidth = sizes[target]; + const bounds = sizeBounds[target]; splitter.setPointerCapture(event.pointerId); document.body.classList.add("is-resizing"); const move = (moveEvent) => { - sizes[target] = Math.max(96, Math.min(420, startWidth + moveEvent.clientX - startX)); + sizes[target] = Math.max(bounds.min, Math.min(bounds.max, startWidth + moveEvent.clientX - startX)); applySizes(); }; @@ -178,7 +162,12 @@ }); const previewPane = document.querySelector(".preview-pane"); - const rows = Array.from(document.querySelectorAll(".artifact-row")); + const previewBody = document.querySelector(".preview-body"); + const artifactList = document.querySelector("[data-artifact-list]"); + // Rows grow as older pages load, so always read them live rather than caching an array. + const allRows = () => Array.from(document.querySelectorAll(".artifact-row")); + const sourceLinks = Array.from(document.querySelectorAll(".source-link")); + const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || ""; const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({ "&": "&", @@ -200,31 +189,147 @@ }, 1200); }; const selectedRow = () => document.querySelector(".artifact-row.selected"); + let navigationMode = selectedRow() ? "artifacts" : "sources"; const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl; const pageUrlFor = (row, view) => { const url = new URL(row.href, window.location.origin); url.searchParams.set("artifact", row.dataset.artifactId); url.searchParams.set("view", view); + url.searchParams.delete("preview"); + return url; + }; + + const noPreviewUrl = () => { + const url = new URL(window.location.href); + url.searchParams.delete("artifact"); + url.searchParams.set("preview", "none"); + url.searchParams.set("view", selectedView); return url; }; const updateActions = (row) => { const actions = document.querySelector("[data-actions]"); if (!actions) return; + const previewTitle = actions.querySelector("[data-preview-title]"); + if (previewTitle) { + previewTitle.textContent = row.dataset.title; + previewTitle.title = row.dataset.title; + } actions.querySelectorAll("[data-view]").forEach((link) => { const view = link.dataset.view; link.classList.toggle("selected", view === selectedView); + link.classList.remove("disabled"); + link.removeAttribute("aria-disabled"); link.href = pageUrlFor(row, view).toString(); }); actions.querySelectorAll(".artifact-action-links a").forEach((link) => { + link.classList.remove("disabled"); + link.removeAttribute("aria-disabled"); link.href = row.dataset.renderedUrl; }); const copyButton = actions.querySelector("[data-copy-url]"); - if (copyButton) copyButton.dataset.copyUrl = row.dataset.renderedUrl; + if (copyButton) { + copyButton.disabled = false; + copyButton.dataset.copyUrl = row.dataset.renderedUrl; + } + syncKeepButton(row.dataset.kept === "1"); + const keepButton = actions.querySelector("[data-keep-button]"); + if (keepButton) keepButton.dataset.artifactId = row.dataset.artifactId; + actions.querySelector("[data-details-toggle]").disabled = false; + renderDetails(row); + }; + + const syncKeepButton = (isKept) => { + const keepButton = document.querySelector("[data-keep-button]"); + if (!keepButton) return; + keepButton.disabled = false; + keepButton.classList.toggle("kept", isKept); + keepButton.textContent = isKept ? "✓ Kept" : "Keep"; + }; + + const renderDetails = (row) => { + const panel = document.querySelector("[data-details-panel]"); + if (!panel) return; + const d = row.dataset; + const tags = d.tags ? d.tags.split(",").map((t) => t.trim()).filter(Boolean) : []; + const rows = [ + ["Source", `${escapeHtml(d.source)} (${escapeHtml(d.sourceType)})`], + ["Source name", escapeHtml(d.sourceName || "—")], + ["Type", escapeHtml(d.kind)], + ["Created", escapeHtml(d.created)], + ["Size", escapeHtml(d.size)], + ["Tags", tags.length ? tags.map(escapeHtml).join(", ") : "—"], + ]; + panel.innerHTML = `
${rows.map(([k, v]) => `
${k}
${v}
`).join("")}
`; + }; + + const disableActions = () => { + const actions = document.querySelector("[data-actions]"); + if (!actions) return; + const previewTitle = actions.querySelector("[data-preview-title]"); + if (previewTitle) { + previewTitle.textContent = "No artifact loaded"; + previewTitle.removeAttribute("title"); + } + actions.querySelectorAll("[data-view]").forEach((link) => { + link.classList.add("disabled"); + link.setAttribute("aria-disabled", "true"); + link.href = "#"; + }); + actions.querySelectorAll(".artifact-action-links a").forEach((link) => { + link.classList.add("disabled"); + link.setAttribute("aria-disabled", "true"); + link.href = "#"; + }); + const copyButton = actions.querySelector("[data-copy-url]"); + if (copyButton) { + copyButton.disabled = true; + copyButton.dataset.copyUrl = ""; + } + const keepButton = actions.querySelector("[data-keep-button]"); + if (keepButton) { + keepButton.disabled = true; + keepButton.classList.remove("kept"); + keepButton.textContent = "Keep"; + } + actions.querySelector("[data-details-toggle]").disabled = true; + const panel = document.querySelector("[data-details-panel]"); + if (panel) panel.hidden = true; + }; + + const markRead = async (row) => { + if (!row.classList.contains("unread")) return; + row.classList.remove("unread"); + decrementSourceCount(row.querySelector(".artifact-source")?.textContent.trim()); + try { + await fetch(`/a/${row.dataset.artifactId}/read`, { + method: "POST", + headers: {"X-CSRFToken": csrfToken}, + }); + } catch { + // Non-critical: the row stays visually read until the next page load. + } + }; + + const decrementSourceCount = (sourceName) => { + document.querySelectorAll(".source-link").forEach((link) => { + const name = link.querySelector(".source-name")?.textContent.trim(); + if (name !== sourceName && name !== "All sources") return; + const badge = link.querySelector(".source-count"); + if (!badge) return; + const next = Number(badge.textContent) - 1; + if (next > 0) { + badge.textContent = String(next); + } else { + badge.remove(); + } + }); }; const renderPreview = (row, pushState = true) => { - rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row)); + navigationMode = "artifacts"; + markRead(row); + allRows().forEach((candidate) => candidate.classList.toggle("selected", candidate === row)); const source = urlFor(row, selectedView); const title = escapeHtml(row.dataset.title); const kind = row.dataset.kind; @@ -236,23 +341,188 @@ ? `

Binary Artifact

This artifact type cannot be previewed inline yet.

Open or download it.

` : ``; - let frameWrap = previewPane.querySelector(".preview-frame-wrap"); - if (!frameWrap) { - previewPane.innerHTML = `
`; - frameWrap = previewPane.querySelector(".preview-frame-wrap"); - } - frameWrap.innerHTML = content; + previewBody.innerHTML = `
${content}
`; updateActions(row); if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView)); }; - rows.forEach((row) => { - row.addEventListener("click", (event) => { - event.preventDefault(); - renderPreview(row); - }); + const clearPreview = () => { + navigationMode = "sources"; + if (document.activeElement && document.activeElement.classList.contains("artifact-row")) { + document.activeElement.blur(); + } + allRows().forEach((candidate) => candidate.classList.remove("selected")); + disableActions(); + previewBody.innerHTML = `

No artifact loaded

Press Right Arrow or select an artifact from the middle column to preview it.

`; + window.history.pushState({}, "", noPreviewUrl()); + const currentSource = document.querySelector(".source-link.selected"); + if (currentSource) currentSource.focus({preventScroll: true}); + }; + + // Delegated so rows appended by infinite scroll work without rebinding. + artifactList.addEventListener("click", (event) => { + const row = event.target.closest(".artifact-row"); + if (!row) return; + event.preventDefault(); + renderPreview(row); }); + const moveSelection = (direction) => { + const rows = allRows(); + if (!rows.length) return; + navigationMode = "artifacts"; + const current = selectedRow(); + const currentIndex = current ? rows.indexOf(current) : -1; + const nextIndex = Math.max(0, Math.min(rows.length - 1, currentIndex + direction)); + const nextRow = rows[nextIndex === -1 ? 0 : nextIndex]; + renderPreview(nextRow); + nextRow.focus({preventScroll: true}); + nextRow.scrollIntoView({block: "nearest"}); + }; + + const moveSource = (direction) => { + if (!sourceLinks.length) return; + navigationMode = "sources"; + const current = document.querySelector(".source-link.selected"); + const currentIndex = current ? sourceLinks.indexOf(current) : 0; + const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction)); + const nextSource = sourceLinks[nextIndex]; + if (nextSource && nextSource !== current) window.location.href = nextSource.href; + }; + + document.addEventListener("keydown", (event) => { + if (event.ctrlKey || event.metaKey || event.shiftKey) return; + if (["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(event.target.tagName)) return; + + if (event.altKey && event.key === "ArrowDown") { + event.preventDefault(); + moveSource(1); + } else if (event.altKey && event.key === "ArrowUp") { + event.preventDefault(); + moveSource(-1); + } else if (event.altKey) { + return; + } else if (event.key === "ArrowDown") { + event.preventDefault(); + if (navigationMode === "sources") { + moveSource(1); + } else { + moveSelection(1); + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + if (navigationMode === "sources") { + moveSource(-1); + } else { + moveSelection(-1); + } + } else if (event.key === "PageDown") { + if (navigationMode === "artifacts") { + event.preventDefault(); + moveSelection(5); + } + } else if (event.key === "PageUp") { + if (navigationMode === "artifacts") { + event.preventDefault(); + moveSelection(-5); + } + } else if (event.key === "ArrowRight") { + const rows = allRows(); + if (!selectedRow() && rows.length) { + event.preventDefault(); + renderPreview(rows[0]); + rows[0].focus({preventScroll: true}); + rows[0].scrollIntoView({block: "nearest"}); + } else if (selectedRow()) { + navigationMode = "artifacts"; + } + } else if (event.key === "ArrowLeft") { + if (selectedRow()) { + event.preventDefault(); + clearPreview(); + } else { + navigationMode = "sources"; + const currentSource = document.querySelector(".source-link.selected"); + if (currentSource) currentSource.focus({preventScroll: true}); + } + } + }); + + // Infinite scroll: when the sentinel at the end of the list scrolls into + // view, fetch the next page of older day-groups and append them. + let loadingOlder = false; + const loadOlder = async (sentinel) => { + if (loadingOlder) return; + loadingOlder = true; + sentinel.removeAttribute("data-feed-more"); + try { + const url = new URL("{% url 'feed-older' %}", window.location.origin); + const params = url.searchParams; + params.set("before", sentinel.dataset.nextBefore); + params.set("view", feed.dataset.selectedView || "rendered"); + if (feed.dataset.selectedSource) params.set("source", feed.dataset.selectedSource); + if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel); + if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind); + if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery); + const response = await fetch(url, {headers: {"X-Requested-With": "fetch"}}); + if (!response.ok) return; + const markup = (await response.text()).trim(); + sentinel.remove(); + if (markup) { + artifactList.insertAdjacentHTML("beforeend", markup); + observeSentinel(); + } + } catch { + // Leave the list as-is; the user can scroll again to retry. + } finally { + loadingOlder = false; + } + }; + + const olderObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + olderObserver.unobserve(entry.target); + loadOlder(entry.target); + } + }); + }, {root: artifactList, rootMargin: "200px"}); + + const observeSentinel = () => { + const sentinel = artifactList.querySelector("[data-feed-more]"); + if (sentinel) olderObserver.observe(sentinel); + }; + + observeSentinel(); + + // Populate the details panel for a server-preselected artifact so the first + // "Details" click shows content immediately. + const preselected = selectedRow(); + if (preselected) renderDetails(preselected); + + const toggleKeep = async (button) => { + const artifactId = button.dataset.artifactId; + if (!artifactId) return; + button.disabled = true; + try { + const response = await fetch(`/a/${artifactId}/keep`, { + method: "POST", + headers: {"X-CSRFToken": csrfToken}, + }); + const data = await response.json(); + syncKeepButton(data.is_kept); + const row = document.querySelector(`.artifact-row[data-artifact-id="${artifactId}"]`); + if (row) { + row.dataset.kept = data.is_kept ? "1" : "0"; + row.classList.toggle("kept", data.is_kept); + const badge = row.querySelector("[data-kept-badge]"); + if (badge) badge.hidden = !data.is_kept; + } + } catch { + button.disabled = false; + } + }; + previewPane.addEventListener("click", (event) => { const copyButton = event.target.closest("[data-copy-url]"); if (copyButton) { @@ -261,8 +531,30 @@ return; } + const keepButton = event.target.closest("[data-keep-button]"); + if (keepButton && !keepButton.disabled) { + event.preventDefault(); + toggleKeep(keepButton); + return; + } + + const detailsToggle = event.target.closest("[data-details-toggle]"); + if (detailsToggle && !detailsToggle.disabled) { + event.preventDefault(); + const panel = document.querySelector("[data-details-panel]"); + if (panel) { + panel.hidden = !panel.hidden; + detailsToggle.classList.toggle("selected", !panel.hidden); + } + return; + } + const toggle = event.target.closest("[data-view]"); if (!toggle) return; + if (toggle.classList.contains("disabled")) { + event.preventDefault(); + return; + } const row = selectedRow(); if (!row) return; event.preventDefault(); diff --git a/templates/artifacts/login.html b/templates/artifacts/login.html new file mode 100644 index 0000000..b48a1b5 --- /dev/null +++ b/templates/artifacts/login.html @@ -0,0 +1,34 @@ + + + + + + Log in — AgentFeed + + + +
+ +
+ + diff --git a/templates/artifacts/settings_account.html b/templates/artifacts/settings_account.html new file mode 100644 index 0000000..c5cec85 --- /dev/null +++ b/templates/artifacts/settings_account.html @@ -0,0 +1,15 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

Account

+

Username: {{ request.user.username }}

+ +

API Token

+

Use this token as Authorization: Bearer <token> when posting artifacts.

+

{{ token.key }}

+
+ {% csrf_token %} + + +
+{% endblock %} diff --git a/templates/artifacts/settings_base.html b/templates/artifacts/settings_base.html new file mode 100644 index 0000000..3b205b6 --- /dev/null +++ b/templates/artifacts/settings_base.html @@ -0,0 +1,29 @@ + + + + + + Settings — AgentFeed + + + + {% include "artifacts/_topbar.html" %} + +
+ +
+ {% if messages %} +
    + {% for message in messages %}
  • {{ message }}
  • {% endfor %} +
+ {% endif %} + {% block settings_content %}{% endblock %} +
+
+ + diff --git a/templates/artifacts/settings_demo_data.html b/templates/artifacts/settings_demo_data.html new file mode 100644 index 0000000..4726753 --- /dev/null +++ b/templates/artifacts/settings_demo_data.html @@ -0,0 +1,10 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

Demo Data

+

Generate one sample artifact of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.

+
+ {% csrf_token %} + +
+{% endblock %} diff --git a/templates/artifacts/settings_storage.html b/templates/artifacts/settings_storage.html new file mode 100644 index 0000000..b511fa0 --- /dev/null +++ b/templates/artifacts/settings_storage.html @@ -0,0 +1,39 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

Storage

+ +
+
+
+

+ {{ used_bytes|filesizeformat }} of {{ quota_bytes|filesizeformat }} used ({{ percent_used }}%) +

+ +
    +
  • {{ artifact_count }} artifact{{ artifact_count|pluralize }} total
  • +
  • {{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) — never auto-deleted
  • +
+ +

+ When your quota fills, the oldest unkept artifacts are deleted + automatically to make room for new ones. Click Keep on an artifact + to protect it. If the quota is full of kept artifacts, new posts are rejected until + you free space. +

+ + {% if is_superuser %} +

Admin

+
    +
  • Total quota allocated across all users: {{ total_allocated|filesizeformat }}
  • +
  • Free space on the artifact disk: {{ disk_free|filesizeformat }}
  • +
+ {% if total_allocated > disk_free %} +

+ ⚠ Allocated quota exceeds free disk space. Grow the volume before raising quotas, + or usage could fill the actual disk. +

+ {% endif %} +

Per-user quotas are set in the Django admin.

+ {% endif %} +{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py new file mode 100644 index 0000000..a2c7e71 --- /dev/null +++ b/tests/api/test_artifacts.py @@ -0,0 +1,276 @@ +import shutil +import tempfile +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.test import override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.authtoken.models import Token +from rest_framework.test import APITestCase + +from artifacts.models import Artifact + +User = get_user_model() + + +class ArtifactAPITestCase(APITestCase): + """Exercises the ingest/list/detail API against a real (temp) filesystem + and a real (test) database, the way an agent posting over HTTP would.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-artifacts-")) + cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root) + cls.settings_override.enable() + + @classmethod + def tearDownClass(cls): + cls.settings_override.disable() + shutil.rmtree(cls.artifacts_root, ignore_errors=True) + super().tearDownClass() + + def setUp(self): + self.user = User.objects.create_user(username="alice", password="pw") + self.token = Token.objects.create(user=self.user) + self.other_user = User.objects.create_user(username="bob", password="pw") + self.other_token = Token.objects.create(user=self.other_user) + + def auth_headers(self, token): + return {"HTTP_AUTHORIZATION": f"Bearer {token.key}"} + + def create_artifact(self, token=None, **overrides): + token = token or self.token + payload = { + "source": "test-agent", + "title": "Hello World", + "kind": "markdown", + "content": "# Hello\n\nBody text.", + } + payload.update(overrides) + return self.client.post( + reverse("artifact-api"), payload, format="json", **self.auth_headers(token) + ) + + # -- capabilities ------------------------------------------------- + + def test_capabilities_requires_auth(self): + response = self.client.get(reverse("capabilities-api")) + self.assertEqual(response.status_code, 403) + + def test_capabilities_with_token(self): + response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token)) + self.assertEqual(response.status_code, 200) + self.assertIn("markdown", response.data["artifact_kinds"]) + + def test_capabilities_lists_supported_methods(self): + response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token)) + methods = response.data["methods"] + self.assertEqual(methods["/api/artifacts"], ["GET", "POST"]) + self.assertEqual(methods["/api/artifacts/{id}"], ["GET", "PATCH", "DELETE"]) + + def test_capabilities_with_session(self): + self.client.force_login(self.user) + response = self.client.get(reverse("capabilities-api")) + self.assertEqual(response.status_code, 200) + + # -- create (POST) -------------------------------------------------- + + def test_create_markdown_artifact(self): + response = self.create_artifact(tags=["demo", "weekly"]) + self.assertEqual(response.status_code, 201) + body = response.data + self.assertEqual(body["source"], "test-agent") + self.assertEqual(body["kind"], "markdown") + self.assertEqual(body["title"], "Hello World") + self.assertEqual([tag["name"] for tag in body["tags"]], ["demo", "weekly"]) + + artifact = Artifact.objects.get(pk=body["id"]) + self.assertEqual(artifact.owner, self.user) + + def test_create_artifact_writes_files_to_disk(self): + response = self.create_artifact() + artifact = Artifact.objects.get(pk=response.data["id"]) + stored_path = self.artifacts_root / artifact.path + self.assertTrue(stored_path.exists()) + # Markdown artifacts also preserve the original .md alongside the rendered .html + self.assertTrue(stored_path.with_suffix(".md").exists()) + + def test_create_artifact_requires_token(self): + response = self.client.post( + reverse("artifact-api"), + {"source": "test-agent", "title": "x", "kind": "markdown", "content": "hi"}, + format="json", + ) + self.assertEqual(response.status_code, 403) + + def test_create_artifact_rejects_invalid_kind(self): + response = self.create_artifact(kind="pdf") + self.assertEqual(response.status_code, 400) + + def test_create_artifact_rejects_missing_fields(self): + response = self.client.post( + reverse("artifact-api"), {"kind": "markdown"}, format="json", **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 400) + + # -- list (GET) ------------------------------------------------------- + + def test_list_requires_session_login(self): + response = self.client.get(reverse("artifact-api")) + self.assertEqual(response.status_code, 302) # redirected to login + + def test_list_scoped_to_owner(self): + self.create_artifact() + self.create_artifact(token=self.other_token, title="Not mine") + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api")) + self.assertEqual(response.status_code, 200) + self.assertEqual([a["title"] for a in response.data], ["Hello World"]) + + def test_list_filter_by_label(self): + self.create_artifact(tags=["urgent"]) + self.create_artifact(title="Second", tags=["later"]) + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api"), {"label": "urgent"}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "Hello World") + + def test_list_filter_by_kind(self): + self.create_artifact(kind="markdown") + self.create_artifact(title="Frag", kind="html_fragment", content="

hi

") + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api"), {"kind": "html_fragment"}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "Frag") + + def test_list_filter_by_query(self): + self.create_artifact(title="Weekly Report") + self.create_artifact(title="Random Notes") + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api"), {"q": "weekly"}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "Weekly Report") + + def test_list_filter_by_day(self): + self.create_artifact() + today = timezone.localdate().isoformat() + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api"), {"day": today}) + self.assertEqual(len(response.data), 1) + + response = self.client.get(reverse("artifact-api"), {"day": "2000-01-01"}) + self.assertEqual(len(response.data), 0) + + # -- detail (GET/PATCH) ----------------------------------------------- + + def test_detail_get_scoped_to_owner(self): + artifact_id = self.create_artifact().data["id"] + + self.client.force_login(self.other_user) + response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) + self.assertEqual(response.status_code, 404) + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) + self.assertEqual(response.status_code, 200) + + def test_patch_updates_title_and_tags(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.patch( + reverse("artifact-detail-api", args=[artifact_id]), + {"title": "Updated title", "tags": ["a", "b"]}, + format="json", + **self.auth_headers(self.token), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["title"], "Updated title") + self.assertEqual([t["name"] for t in response.data["tags"]], ["a", "b"]) + + def test_patch_rejects_blank_title(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.patch( + reverse("artifact-detail-api", args=[artifact_id]), + {"title": ""}, + format="json", + **self.auth_headers(self.token), + ) + self.assertEqual(response.status_code, 400) + + def test_patch_requires_token(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.patch( + reverse("artifact-detail-api", args=[artifact_id]), {"title": "No token"}, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_patch_cannot_touch_other_users_artifact(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.patch( + reverse("artifact-detail-api", args=[artifact_id]), + {"title": "Hijacked"}, + format="json", + **self.auth_headers(self.other_token), + ) + self.assertEqual(response.status_code, 404) + + # -- delete (DELETE) -------------------------------------------------- + + def test_delete_removes_artifact_and_files(self): + artifact_id = self.create_artifact().data["id"] + artifact = Artifact.objects.get(pk=artifact_id) + stored_path = self.artifacts_root / artifact.path + self.assertTrue(stored_path.exists()) + + response = self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 204) + self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists()) + self.assertFalse(stored_path.exists()) + self.assertFalse(stored_path.with_suffix(".md").exists()) + + def test_delete_requires_token(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.delete(reverse("artifact-detail-api", args=[artifact_id])) + self.assertEqual(response.status_code, 403) + self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists()) + + def test_delete_cannot_touch_other_users_artifact(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.other_token) + ) + self.assertEqual(response.status_code, 404) + self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists()) + + def test_delete_twice_second_call_404s(self): + artifact_id = self.create_artifact().data["id"] + first = self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(first.status_code, 204) + + second = self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(second.status_code, 404) + + def test_deleted_artifact_is_gone_from_list_and_detail(self): + artifact_id = self.create_artifact().data["id"] + self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + + self.client.force_login(self.user) + list_response = self.client.get(reverse("artifact-api")) + self.assertEqual(list_response.data, []) + + detail_response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) + self.assertEqual(detail_response.status_code, 404)