diff --git a/.claude/commands/seed-demo.md b/.claude/commands/seed-demo.md deleted file mode 100644 index ccca3ce..0000000 --- a/.claude/commands/seed-demo.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -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 deleted file mode 100644 index 3e6ecaf..0000000 --- a/.dockerignore +++ /dev/null @@ -1,19 +0,0 @@ -.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 7417b79..624a5fc 100644 --- a/.gitignore +++ b/.gitignore @@ -31,10 +31,3 @@ 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 new file mode 100644 index 0000000..fdc260b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# 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 deleted file mode 100644 index e5041c9..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,75 +0,0 @@ -# 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 0a0d7f2..1fcc62f 100644 --- a/artifacts/admin.py +++ b/artifacts/admin.py @@ -1,23 +1,10 @@ from django.contrib import admin -from .models import Artifact, Asset, UserProfile +from .models import Artifact @admin.register(Artifact) class ArtifactAdmin(admin.ModelAdmin): - 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",) + list_display = ("id", "title", "persona", "kind", "day", "created_at") + list_filter = ("persona", "kind", "day") + search_fields = ("id", "title", "persona") diff --git a/artifacts/demo_data.py b/artifacts/demo_data.py deleted file mode 100644 index 2ac4f24..0000000 --- a/artifacts/demo_data.py +++ /dev/null @@ -1,87 +0,0 @@ -"""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 deleted file mode 100644 index 44f8e98..0000000 --- a/artifacts/forms.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 5494a97..0000000 --- a/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py +++ /dev/null @@ -1,41 +0,0 @@ -# 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 deleted file mode 100644 index a0c52ac..0000000 --- a/artifacts/migrations/0003_tagcolor.py +++ /dev/null @@ -1,20 +0,0 @@ -# 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 deleted file mode 100644 index 5c2480c..0000000 --- a/artifacts/migrations/0004_rename_persona_to_source.py +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index f3da6dc..0000000 --- a/artifacts/migrations/0005_add_owner_nullable.py +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 941bab7..0000000 --- a/artifacts/migrations/0006_backfill_owner.py +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index f588a3f..0000000 --- a/artifacts/migrations/0007_owner_required.py +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index d86ddf0..0000000 --- a/artifacts/migrations/0008_artifact_read_at.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index 704fbf1..0000000 --- a/artifacts/migrations/0009_quota_and_keep.py +++ /dev/null @@ -1,34 +0,0 @@ -# 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 deleted file mode 100644 index 7766cd9..0000000 --- a/artifacts/migrations/0010_backfill_sizes_and_profiles.py +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index f2bc2c0..0000000 --- a/artifacts/migrations/0011_add_asset.py +++ /dev/null @@ -1,31 +0,0 @@ -# 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 3ce7209..249f728 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.db import models @@ -11,10 +10,6 @@ class Artifact(models.Model): PDF = "pdf" BINARY = "binary" - AGENT = "agent" - PERSON = "person" - SYSTEM = "system" - KIND_CHOICES = [ (HTML_FRAGMENT, "HTML fragment"), (HTML_FULL, "Full HTML"), @@ -25,15 +20,8 @@ class Artifact(models.Model): (BINARY, "Binary"), ] - SOURCE_TYPE_CHOICES = [ - (AGENT, "Agent"), - (PERSON, "Person"), - (SYSTEM, "System"), - ] - id = models.CharField(max_length=8, primary_key=True) - owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="artifacts") - source = models.CharField(max_length=80, db_index=True) + persona = 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) @@ -43,63 +31,9 @@ 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.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})" + return f"{self.title} ({self.persona})" diff --git a/artifacts/storage.py b/artifacts/storage.py deleted file mode 100644 index 36e7069..0000000 --- a/artifacts/storage.py +++ /dev/null @@ -1,135 +0,0 @@ -"""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 a3d1f65..4215301 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -5,17 +5,6 @@ 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 758b2c1..7c2b847 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -1,64 +1,26 @@ -import hashlib import html -import json import mimetypes -import re import secrets -from datetime import timedelta from pathlib import Path from urllib.parse import urlencode import markdown from django.conf import settings -from django.contrib import messages -from django.contrib.auth import views as auth_views -from django.contrib.auth.decorators import login_required -from django.db.models import Count, Q, Sum -from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse -from django.shortcuts import get_object_or_404, redirect, render +from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden +from django.shortcuts import get_object_or_404, render from django.template.defaultfilters import slugify from django.urls import reverse from django.utils.dateparse import parse_date from django.utils import timezone -from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin -from django.views.decorators.http import require_POST -from rest_framework.authtoken.models import Token from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView -from .demo_data import create_demo_artifacts -from .forms import DaybookLoginForm -from .models import Artifact, 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, -) +from .models import Artifact 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", @@ -66,283 +28,70 @@ DEFAULT_THEMES = { } -@method_decorator(login_required, name="get") class ArtifactListCreateView(APIView): parser_classes = [JSONParser, FormParser, MultiPartParser] def get(self, request): - artifacts = Artifact.objects.filter(owner=request.user) - if source := request.query_params.get("source"): - artifacts = artifacts.filter(source=source) - if label := request.query_params.get("label"): - artifacts = filter_by_label(artifacts, label) + artifacts = Artifact.objects.all() + if persona := request.query_params.get("persona"): + artifacts = artifacts.filter(persona=persona) if day := request.query_params.get("day"): artifacts = artifacts.filter(day=day) - if kind := request.query_params.get("kind"): - artifacts = artifacts.filter(kind=kind) - if title_query := request.query_params.get("q"): - artifacts = artifacts.filter(title__icontains=title_query) + if tag := request.query_params.get("tag"): + artifacts = artifacts.filter(tags__contains=[tag]) return Response([serialize_artifact(artifact, request) for artifact in artifacts]) def post(self, request): - user = resolve_token_user(request) - if user is None: + if not has_valid_token(request): return HttpResponseForbidden("Invalid or missing API token") if request.content_type and request.content_type.startswith("multipart/"): - artifact = create_binary_artifact(request.data, user) + artifact = create_binary_artifact(request.data) if isinstance(artifact, Response): return artifact - apply_tag_colors(request.data) - return Response(serialize_artifact(artifact, request), status=201) + return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201) data = request.data kind = data.get("kind") if kind not in TEXT_KINDS: return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400) - required = ["source", "title", "content"] + required = ["persona", "title", "content"] missing = [field for field in required if not data.get(field)] if missing: return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) - source_error = validate_source_fields(data) - if source_error: - return source_error - - artifact = create_text_artifact(data, request, user) - if isinstance(artifact, Response): - return artifact - apply_tag_colors(data) - return Response(serialize_artifact(artifact, request), status=201) + artifact = create_text_artifact(data, request) + return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, 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): - selected_source = request.GET.get("source", "") - selected_label = request.GET.get("label", "") + persona = request.GET.get("persona", "") selected_kind = request.GET.get("kind", "") title_query = request.GET.get("q", "") - today = timezone.localdate() + selected_day = parse_date(request.GET.get("day", "")) or 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) - all_artifacts = Artifact.objects.filter(owner=request.user) - artifacts = apply_feed_filters( - all_artifacts, selected_source, selected_label, selected_kind, title_query - ) + days = list(artifacts.dates("day", "day", order="DESC")) + if selected_day not in days: + days.append(selected_day) + days.sort(reverse=True) - 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 + 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 if selected_kind: toggle_query["kind"] = selected_kind if title_query: @@ -352,107 +101,30 @@ def feed(request): rendered_url = f"?{urlencode(toggle_query)}" toggle_query["view"] = "raw" raw_url = f"?{urlencode(toggle_query)}" - - # Source counts come from the other filters only, so the sidebar still shows - # every source available under the current label/type/name filters. - source_scoped = apply_feed_filters( - all_artifacts, "", selected_label, selected_kind, title_query - ) - source_counts = ( - source_scoped.values("source") - .annotate(count=Count("id", filter=Q(read_at__isnull=True))) - .order_by("source") - ) - - all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags}) + personas = all_artifacts.order_by("persona").values_list("persona", flat=True).distinct() return render( request, "artifacts/feed.html", { - "artifacts": window_artifacts, - "day_groups": day_groups, - "older_count": older_count, - "window_start": window_start, - "source_counts": source_counts, - "total_count": source_scoped.filter(read_at__isnull=True).count(), - "all_labels": labels_with_colors_list(all_labels, overrides), + "artifacts": day_artifacts, + "days": days, + "personas": personas, "raw_url": raw_url, "rendered_url": rendered_url, "selected_artifact": selected_artifact, + "selected_day": selected_day, "selected_kind": selected_kind, - "selected_label": selected_label, - "selected_source": selected_source, + "selected_persona": persona, "selected_view": selected_view, - "preview_none": preview_none, - "today": today, "title_query": title_query, "kind_choices": Artifact.KIND_CHOICES, }, ) -@login_required -@require_POST -def mark_artifact_read(request, artifact_id): - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) - if artifact.read_at is None: - artifact.read_at = timezone.now() - artifact.save(update_fields=["read_at"]) - return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()}) - - -@login_required -@require_POST -def toggle_artifact_keep(request, artifact_id): - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) - artifact.is_kept = not artifact.is_kept - artifact.save(update_fields=["is_kept"]) - return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept}) - - -@login_required -def feed_older(request): - """Return the next page of older day-groups as an HTML fragment for infinite scroll.""" - before = parse_date(request.GET.get("before", "")) or timezone.localdate() - artifacts = apply_feed_filters( - Artifact.objects.filter(owner=request.user), - request.GET.get("source", ""), - request.GET.get("label", ""), - request.GET.get("kind", ""), - request.GET.get("q", ""), - ).filter(day__lt=before).order_by("-created_at") - - # Take a whole page of days at a time so a day is never split across pages. - page_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) - if not page_days: - return HttpResponse("") - - page_artifacts = list(artifacts.filter(day__gte=page_days[-1])) - day_groups = group_artifacts_by_day(page_artifacts, tag_color_overrides()) - remaining = artifacts.filter(day__lt=page_days[-1]).count() - - return render( - request, - "artifacts/_day_groups.html", - { - "day_groups": day_groups, - "next_before": page_days[-1].isoformat(), - "has_more": remaining > 0, - "selected_artifact": None, - "selected_source": request.GET.get("source", ""), - "selected_label": request.GET.get("label", ""), - "selected_kind": request.GET.get("kind", ""), - "title_query": request.GET.get("q", ""), - "selected_view": "raw" if request.GET.get("view") == "raw" else "rendered", - "today": timezone.localdate(), - }, - ) - - -@login_required @xframe_options_sameorigin def artifact_view(request, artifact_id): - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + artifact = get_object_or_404(Artifact, pk=artifact_id) if request.GET.get("raw") == "1": raw_path = raw_artifact_path(artifact) if raw_path and raw_path.exists(): @@ -467,16 +139,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, user): +def create_text_artifact(data, request): now = timezone.localtime() artifact_id = generate_id() - source = slugify(data["source"]) or "unknown" + persona = slugify(data["persona"]) or "unknown" kind = data["kind"] theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base") title = data["title"] content = data["content"] - directory = Path(source) / now.date().isoformat() + directory = Path(persona) / now.date().isoformat() target_dir = settings.ARTIFACTS_ROOT / directory target_dir.mkdir(parents=True, exist_ok=True) @@ -493,19 +165,9 @@ def create_text_artifact(data, request, user): stored_path = directory / f"{artifact_id}.html" (settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8") - size_bytes = artifact_disk_size(stored_path.as_posix(), kind) - try: - make_room_for(user, size_bytes) - except QuotaExceeded as exc: - (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) - if kind == Artifact.MARKDOWN: - (target_dir / f"{artifact_id}.md").unlink(missing_ok=True) - return Response({"detail": str(exc)}, status=413) - - artifact = Artifact.objects.create( + return Artifact.objects.create( id=artifact_id, - owner=user, - source=source, + persona=persona, title=title, kind=kind, theme=theme, @@ -514,33 +176,26 @@ def create_text_artifact(data, request, user): 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, user): +def create_binary_artifact(data): uploaded = data.get("file") - required = ["source", "title"] + required = ["persona", "title"] missing = [field for field in required if not data.get(field)] if not uploaded: missing.append("file") if missing: return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) - source_error = validate_source_fields(data) - if source_error: - return source_error now = timezone.localtime() artifact_id = generate_id() - source = slugify(data["source"]) or "unknown" + persona = slugify(data["persona"]) or "unknown" title = data["title"] content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or "" kind = binary_kind(content_type) - directory = Path(source) / now.date().isoformat() + directory = Path(persona) / now.date().isoformat() target_dir = settings.ARTIFACTS_ROOT / directory target_dir.mkdir(parents=True, exist_ok=True) @@ -550,17 +205,9 @@ def create_binary_artifact(data, user): for chunk in uploaded.chunks(): target.write(chunk) - size_bytes = artifact_disk_size(stored_path.as_posix(), kind) - try: - make_room_for(user, size_bytes) - except QuotaExceeded as exc: - (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) - return Response({"detail": str(exc)}, status=413) - return Artifact.objects.create( id=artifact_id, - owner=user, - source=source, + persona=persona, title=title, kind=kind, theme="", @@ -569,8 +216,6 @@ def create_binary_artifact(data, user): original_kind=kind, tags=normalize_tags(data.get("tags", [])), source_prompt=data.get("source_prompt", ""), - size_bytes=size_bytes, - **source_fields(data, source), ) @@ -615,38 +260,10 @@ def generate_id(): return artifact_id -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): +def has_valid_token(request): + expected = getattr(settings, "DAYBOOK_API_TOKEN", "") auth = request.headers.get("Authorization", "") - if not auth.startswith("Bearer "): - return None - key = auth[len("Bearer "):].strip() - if not key: - return None - try: - return Token.objects.select_related("user").get(key=key).user - except Token.DoesNotExist: - return None + return bool(expected) and auth == f"Bearer {expected}" def normalize_tags(tags): @@ -657,100 +274,6 @@ 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 @@ -770,25 +293,14 @@ def serialize_artifact(artifact, request): return { "id": artifact.id, "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), - "api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])), - "raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"), - "raw_available": raw_artifact_path(artifact) is not None, - "source": artifact.source, + "persona": artifact.persona, "title": artifact.title, "kind": artifact.kind, "theme": artifact.theme, "day": artifact.day.isoformat(), "created_at": artifact.created_at.isoformat(), - "tags": tags_with_colors(artifact.tags), - "labels": labels_with_colors(artifact), + "tags": artifact.tags, "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, } @@ -809,78 +321,6 @@ 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 3b396a6..805cbf7 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -1,13 +1,11 @@ -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 = 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()] +SECRET_KEY = "dev-only-change-me" +DEBUG = True +ALLOWED_HOSTS = ["*"] INSTALLED_APPS = [ "django.contrib.admin", @@ -17,13 +15,11 @@ 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", @@ -54,15 +50,12 @@ WSGI_APPLICATION = "daybook_project.wsgi.application" DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": os.environ.get("DATABASE_PATH", DATA_DIR / "db.sqlite3"), + "NAME": BASE_DIR / "db.sqlite3", } } AUTH_PASSWORD_VALIDATORS = [] -LOGIN_URL = "login" -LOGIN_REDIRECT_URL = "feed" - LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True @@ -70,13 +63,8 @@ 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 = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store")) +ARTIFACTS_ROOT = BASE_DIR / "artifacts_store" +DAYBOOK_API_TOKEN = "dev-token" diff --git a/daybook_project/urls.py b/daybook_project/urls.py index 4204d51..07b927f 100644 --- a/daybook_project/urls.py +++ b/daybook_project/urls.py @@ -1,13 +1,8 @@ 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 deleted file mode 100644 index 38e2602..0000000 --- a/deploy/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index ee1dd7a..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index cdd620d..0000000 --- a/deploy/entrypoint.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/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 deleted file mode 100644 index e83e248..0000000 --- a/deploy/env.example +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 2d01b1d..0000000 --- a/deploy/proxmox_llm_deploy.md +++ /dev/null @@ -1,348 +0,0 @@ -# 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 deleted file mode 100644 index 28d4fe6..0000000 --- a/docs/agent_quickstart.md +++ /dev/null @@ -1,140 +0,0 @@ -# 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 deleted file mode 100644 index 4cfce20..0000000 --- a/docs/api.md +++ /dev/null @@ -1,425 +0,0 @@ -# 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 deleted file mode 100644 index cd8cf1d..0000000 --- a/docs/llm_posting_helper.md +++ /dev/null @@ -1,108 +0,0 @@ -# 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 %}
- {% 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 %} +

Select an artifact

+

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

+

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

{% endif %} -
@@ -126,24 +150,16 @@ 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(bounds.min, Math.min(bounds.max, startWidth + moveEvent.clientX - startX)); + sizes[target] = Math.max(96, Math.min(420, startWidth + moveEvent.clientX - startX)); applySizes(); }; @@ -162,12 +178,7 @@ }); const previewPane = document.querySelector(".preview-pane"); - 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 rows = Array.from(document.querySelectorAll(".artifact-row")); const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({ "&": "&", @@ -189,147 +200,31 @@ }, 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.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(); - } - }); + if (copyButton) copyButton.dataset.copyUrl = row.dataset.renderedUrl; }; const renderPreview = (row, pushState = true) => { - navigationMode = "artifacts"; - markRead(row); - allRows().forEach((candidate) => candidate.classList.toggle("selected", candidate === row)); + rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row)); const source = urlFor(row, selectedView); const title = escapeHtml(row.dataset.title); const kind = row.dataset.kind; @@ -341,187 +236,22 @@ ? `

Binary Artifact

This artifact type cannot be previewed inline yet.

Open or download it.

` : ``; - previewBody.innerHTML = `
${content}
`; + let frameWrap = previewPane.querySelector(".preview-frame-wrap"); + if (!frameWrap) { + previewPane.innerHTML = `
`; + frameWrap = previewPane.querySelector(".preview-frame-wrap"); + } + frameWrap.innerHTML = content; updateActions(row); if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView)); }; - 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") { + rows.forEach((row) => { + row.addEventListener("click", (event) => { 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); - } + renderPreview(row); }); - }, {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]"); @@ -531,30 +261,8 @@ 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 deleted file mode 100644 index b48a1b5..0000000 --- a/templates/artifacts/login.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Log in — AgentFeed - - - -
- -
- - diff --git a/templates/artifacts/settings_account.html b/templates/artifacts/settings_account.html deleted file mode 100644 index c5cec85..0000000 --- a/templates/artifacts/settings_account.html +++ /dev/null @@ -1,15 +0,0 @@ -{% 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 deleted file mode 100644 index 3b205b6..0000000 --- a/templates/artifacts/settings_base.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - 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 deleted file mode 100644 index 4726753..0000000 --- a/templates/artifacts/settings_demo_data.html +++ /dev/null @@ -1,10 +0,0 @@ -{% 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 deleted file mode 100644 index b511fa0..0000000 --- a/templates/artifacts/settings_storage.html +++ /dev/null @@ -1,39 +0,0 @@ -{% 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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/api/__init__.py b/tests/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py deleted file mode 100644 index a2c7e71..0000000 --- a/tests/api/test_artifacts.py +++ /dev/null @@ -1,276 +0,0 @@ -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)