Initial public commit
Daybook: a self-hosted, multi-user Django + DRF app where agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed. This squashes prior local development history (kept privately on a separate branch) into a single clean starting point for the public repository. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
940a703cd7
62 changed files with 5755 additions and 0 deletions
0
artifacts/__init__.py
Normal file
0
artifacts/__init__.py
Normal file
23
artifacts/admin.py
Normal file
23
artifacts/admin.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import Artifact, Asset, UserProfile
|
||||
|
||||
|
||||
@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",)
|
||||
6
artifacts/apps.py
Normal file
6
artifacts/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ArtifactsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "artifacts"
|
||||
87
artifacts/demo_data.py
Normal file
87
artifacts/demo_data.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Sample artifact content for the Settings -> Demo Data button.
|
||||
|
||||
Creates one artifact of every supported kind, owned by the requesting user,
|
||||
tagged "demo", with source "demo".
|
||||
"""
|
||||
|
||||
SOURCE = "demo"
|
||||
|
||||
TEXT_SAMPLES = [
|
||||
(
|
||||
"markdown",
|
||||
"Seed: Markdown Sample",
|
||||
"# Markdown Sample\n\nThis is a **seed** artifact demonstrating markdown rendering.\n\n- item one\n- item two\n",
|
||||
"markdown",
|
||||
),
|
||||
(
|
||||
"html_fragment",
|
||||
"Seed: HTML Fragment Sample",
|
||||
"<h2>HTML Fragment</h2><p>This is a seed HTML fragment artifact.</p>",
|
||||
"html-fragment",
|
||||
),
|
||||
(
|
||||
"html_full",
|
||||
"Seed: Full HTML Sample",
|
||||
"<!doctype html><html><head><title>Seed Full HTML</title></head>"
|
||||
"<body><h1>Full HTML Doc</h1><p>Seed artifact.</p></body></html>",
|
||||
"html-full",
|
||||
),
|
||||
(
|
||||
"deck",
|
||||
"Seed: Deck Sample",
|
||||
"<section><h1>Slide One</h1><p>Seed deck artifact.</p></section>"
|
||||
"<section><h1>Slide Two</h1><p>More content.</p></section>",
|
||||
"deck",
|
||||
),
|
||||
]
|
||||
|
||||
SEED_PNG_BYTES = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d494844520000000100000001080600000"
|
||||
"01f15c4890000000a49444154789c6360000002000155e59a1a0000000049454e44ae426082"
|
||||
)
|
||||
|
||||
SEED_PDF_BYTES = (
|
||||
b"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
|
||||
b"trailer<</Root 1 0 R>>\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
|
||||
6
artifacts/forms.py
Normal file
6
artifacts/forms.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django import forms
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
|
||||
|
||||
class DaybookLoginForm(AuthenticationForm):
|
||||
remember_me = forms.BooleanField(required=False, initial=True)
|
||||
27
artifacts/migrations/0001_initial.py
Normal file
27
artifacts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Artifact",
|
||||
fields=[
|
||||
("id", models.CharField(max_length=8, primary_key=True, serialize=False)),
|
||||
("persona", models.CharField(db_index=True, max_length=80)),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("kind", models.CharField(choices=[("html_fragment", "HTML fragment"), ("html_full", "Full HTML"), ("markdown", "Markdown"), ("deck", "Deck"), ("image", "Image"), ("pdf", "PDF"), ("binary", "Binary")], max_length=20)),
|
||||
("theme", models.CharField(blank=True, max_length=40)),
|
||||
("day", models.DateField(db_index=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("path", models.CharField(max_length=500)),
|
||||
("original_kind", models.CharField(max_length=20)),
|
||||
("tags", models.JSONField(blank=True, default=list)),
|
||||
("source_prompt", models.TextField(blank=True)),
|
||||
],
|
||||
options={"ordering": ["-created_at"]},
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 5.2.15 on 2026-06-10 15:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def fill_existing_source_names(apps, schema_editor):
|
||||
Artifact = apps.get_model('artifacts', 'Artifact')
|
||||
for artifact in Artifact.objects.filter(source_name=''):
|
||||
artifact.source_name = artifact.persona
|
||||
artifact.save(update_fields=['source_name'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('artifacts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='artifact',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='artifact',
|
||||
name='source_id',
|
||||
field=models.CharField(blank=True, max_length=120),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='artifact',
|
||||
name='source_name',
|
||||
field=models.CharField(blank=True, max_length=120),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='artifact',
|
||||
name='source_type',
|
||||
field=models.CharField(choices=[('agent', 'Agent'), ('person', 'Person'), ('system', 'System')], default='agent', max_length=20),
|
||||
),
|
||||
migrations.RunPython(fill_existing_source_names, migrations.RunPython.noop),
|
||||
]
|
||||
20
artifacts/migrations/0003_tagcolor.py
Normal file
20
artifacts/migrations/0003_tagcolor.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 5.2.15 on 2026-07-23 06:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('artifacts', '0002_artifact_metadata_artifact_source_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TagColor',
|
||||
fields=[
|
||||
('name', models.CharField(max_length=40, primary_key=True, serialize=False)),
|
||||
('color', models.CharField(max_length=20)),
|
||||
],
|
||||
),
|
||||
]
|
||||
16
artifacts/migrations/0004_rename_persona_to_source.py
Normal file
16
artifacts/migrations/0004_rename_persona_to_source.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("artifacts", "0003_tagcolor"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="artifact",
|
||||
old_name="persona",
|
||||
new_name="source",
|
||||
),
|
||||
]
|
||||
24
artifacts/migrations/0005_add_owner_nullable.py
Normal file
24
artifacts/migrations/0005_add_owner_nullable.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("artifacts", "0004_rename_persona_to_source"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="artifact",
|
||||
name="owner",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="artifacts",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
27
artifacts/migrations/0006_backfill_owner.py
Normal file
27
artifacts/migrations/0006_backfill_owner.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_owner(apps, schema_editor):
|
||||
User = apps.get_model("auth", "User")
|
||||
Artifact = apps.get_model("artifacts", "Artifact")
|
||||
|
||||
first_superuser = User.objects.filter(is_superuser=True).order_by("date_joined").first()
|
||||
if first_superuser is None:
|
||||
return
|
||||
|
||||
Artifact.objects.filter(owner__isnull=True).update(owner=first_superuser)
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("artifacts", "0005_add_owner_nullable"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(backfill_owner, noop_reverse),
|
||||
]
|
||||
23
artifacts/migrations/0007_owner_required.py
Normal file
23
artifacts/migrations/0007_owner_required.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("artifacts", "0006_backfill_owner"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="artifact",
|
||||
name="owner",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="artifacts",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
18
artifacts/migrations/0008_artifact_read_at.py
Normal file
18
artifacts/migrations/0008_artifact_read_at.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.15 on 2026-07-23 21:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('artifacts', '0007_owner_required'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='artifact',
|
||||
name='read_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
34
artifacts/migrations/0009_quota_and_keep.py
Normal file
34
artifacts/migrations/0009_quota_and_keep.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 5.2.15 on 2026-07-24 07:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('artifacts', '0008_artifact_read_at'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='artifact',
|
||||
name='is_kept',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='artifact',
|
||||
name='size_bytes',
|
||||
field=models.BigIntegerField(default=0),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quota_bytes', models.BigIntegerField(default=1073741824)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
43
artifacts/migrations/0010_backfill_sizes_and_profiles.py
Normal file
43
artifacts/migrations/0010_backfill_sizes_and_profiles.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill(apps, schema_editor):
|
||||
Artifact = apps.get_model("artifacts", "Artifact")
|
||||
User = apps.get_model("auth", "User")
|
||||
UserProfile = apps.get_model("artifacts", "UserProfile")
|
||||
|
||||
root = Path(settings.ARTIFACTS_ROOT)
|
||||
for artifact in Artifact.objects.all():
|
||||
total = 0
|
||||
primary = root / artifact.path
|
||||
if primary.exists():
|
||||
total += primary.stat().st_size
|
||||
if artifact.kind == "markdown":
|
||||
sidecar = primary.with_suffix(".md")
|
||||
if sidecar.exists():
|
||||
total += sidecar.stat().st_size
|
||||
if artifact.size_bytes != total:
|
||||
artifact.size_bytes = total
|
||||
artifact.save(update_fields=["size_bytes"])
|
||||
|
||||
default_quota = 1024 * 1024 * 1024 # 1 GB
|
||||
for user in User.objects.all():
|
||||
UserProfile.objects.get_or_create(user=user, defaults={"quota_bytes": default_quota})
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("artifacts", "0009_quota_and_keep"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(backfill, noop_reverse),
|
||||
]
|
||||
31
artifacts/migrations/0011_add_asset.py
Normal file
31
artifacts/migrations/0011_add_asset.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 5.2.15 on 2026-07-24 10:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('artifacts', '0010_backfill_sizes_and_profiles'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Asset',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=12, primary_key=True, serialize=False)),
|
||||
('path', models.CharField(max_length=500)),
|
||||
('content_type', models.CharField(max_length=100)),
|
||||
('size_bytes', models.BigIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('artifact', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='artifacts.artifact')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
artifacts/migrations/__init__.py
Normal file
0
artifacts/migrations/__init__.py
Normal file
105
artifacts/models.py
Normal file
105
artifacts/models.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Artifact(models.Model):
|
||||
HTML_FRAGMENT = "html_fragment"
|
||||
HTML_FULL = "html_full"
|
||||
MARKDOWN = "markdown"
|
||||
DECK = "deck"
|
||||
IMAGE = "image"
|
||||
PDF = "pdf"
|
||||
BINARY = "binary"
|
||||
|
||||
AGENT = "agent"
|
||||
PERSON = "person"
|
||||
SYSTEM = "system"
|
||||
|
||||
KIND_CHOICES = [
|
||||
(HTML_FRAGMENT, "HTML fragment"),
|
||||
(HTML_FULL, "Full HTML"),
|
||||
(MARKDOWN, "Markdown"),
|
||||
(DECK, "Deck"),
|
||||
(IMAGE, "Image"),
|
||||
(PDF, "PDF"),
|
||||
(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)
|
||||
title = models.CharField(max_length=200)
|
||||
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
||||
theme = models.CharField(max_length=40, blank=True)
|
||||
day = models.DateField(db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
path = models.CharField(max_length=500)
|
||||
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})"
|
||||
135
artifacts/storage.py
Normal file
135
artifacts/storage.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Disk-usage accounting, per-user quota, and automatic eviction.
|
||||
|
||||
An artifact's on-disk footprint is the primary stored file plus, for markdown,
|
||||
the preserved `.md` sidecar. We record it on the row (`size_bytes`) at ingest so
|
||||
usage is a cheap SUM rather than a stat of every file.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Artifact, Asset, UserProfile
|
||||
|
||||
|
||||
class QuotaExceeded(Exception):
|
||||
"""Raised when a post cannot fit even after evicting every unkept artifact."""
|
||||
|
||||
|
||||
def artifact_disk_size(relative_path, kind):
|
||||
"""Bytes an artifact occupies on disk, including the markdown sidecar."""
|
||||
root = settings.ARTIFACTS_ROOT
|
||||
total = 0
|
||||
primary = root / relative_path
|
||||
if primary.exists():
|
||||
total += primary.stat().st_size
|
||||
if kind == Artifact.MARKDOWN:
|
||||
sidecar = primary.with_suffix(".md")
|
||||
if sidecar.exists():
|
||||
total += sidecar.stat().st_size
|
||||
return total
|
||||
|
||||
|
||||
def delete_artifact_files(artifact):
|
||||
"""Remove an artifact's files from disk (primary, markdown sidecar, assets).
|
||||
|
||||
Assets are cascade-deleted from the DB by the FK, but their files must be
|
||||
removed here explicitly.
|
||||
"""
|
||||
root = settings.ARTIFACTS_ROOT
|
||||
primary = root / artifact.path
|
||||
primary.unlink(missing_ok=True)
|
||||
if artifact.kind == Artifact.MARKDOWN:
|
||||
primary.with_suffix(".md").unlink(missing_ok=True)
|
||||
for asset in artifact.assets.all():
|
||||
delete_asset_file(asset)
|
||||
|
||||
|
||||
def delete_asset_file(asset):
|
||||
(settings.ARTIFACTS_ROOT / asset.path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def artifact_total_bytes(artifact):
|
||||
"""An artifact's own bytes plus those of the assets embedded in it."""
|
||||
asset_bytes = artifact.assets.aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||
return artifact.size_bytes + asset_bytes
|
||||
|
||||
|
||||
def user_usage_bytes(user):
|
||||
artifact_bytes = (
|
||||
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||
)
|
||||
asset_bytes = (
|
||||
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||
)
|
||||
return artifact_bytes + asset_bytes
|
||||
|
||||
|
||||
def user_quota_bytes(user):
|
||||
profile, _ = UserProfile.objects.get_or_create(user=user)
|
||||
return profile.quota_bytes
|
||||
|
||||
|
||||
def make_room_for(user, incoming_bytes):
|
||||
"""Evict oldest unkept artifacts until `incoming_bytes` fits under quota.
|
||||
|
||||
Returns the list of evicted artifacts (already deleted). Raises QuotaExceeded
|
||||
if even removing every unkept artifact leaves no room.
|
||||
"""
|
||||
quota = user_quota_bytes(user)
|
||||
if incoming_bytes > quota:
|
||||
raise QuotaExceeded("Artifact is larger than the entire quota.")
|
||||
|
||||
used = user_usage_bytes(user)
|
||||
if used + incoming_bytes <= quota:
|
||||
return []
|
||||
|
||||
evicted = []
|
||||
unkept = (
|
||||
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
|
||||
)
|
||||
for artifact in unkept:
|
||||
if used + incoming_bytes <= quota:
|
||||
break
|
||||
# Freeing an artifact also frees the assets embedded in it.
|
||||
used -= artifact_total_bytes(artifact)
|
||||
delete_artifact_files(artifact)
|
||||
artifact.delete()
|
||||
evicted.append(artifact)
|
||||
|
||||
if used + incoming_bytes > quota:
|
||||
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
|
||||
return evicted
|
||||
|
||||
|
||||
ASSET_ORPHAN_GRACE = timedelta(hours=24)
|
||||
|
||||
|
||||
def sweep_orphan_assets(user=None):
|
||||
"""Delete assets uploaded but never referenced by an artifact.
|
||||
|
||||
Runs opportunistically on upload. The grace period gives an agent time to
|
||||
upload images and then post the artifact that references them.
|
||||
"""
|
||||
cutoff = timezone.now() - ASSET_ORPHAN_GRACE
|
||||
orphans = Asset.objects.filter(artifact__isnull=True, created_at__lt=cutoff)
|
||||
if user is not None:
|
||||
orphans = orphans.filter(owner=user)
|
||||
count = 0
|
||||
for asset in orphans:
|
||||
delete_asset_file(asset)
|
||||
asset.delete()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def disk_free_bytes():
|
||||
"""Free space on the volume that holds the artifact store."""
|
||||
return shutil.disk_usage(settings.ARTIFACTS_ROOT).free
|
||||
|
||||
|
||||
def total_allocated_bytes():
|
||||
return UserProfile.objects.aggregate(total=Sum("quota_bytes")).get("total") or 0
|
||||
21
artifacts/urls.py
Normal file
21
artifacts/urls.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.feed, name="feed"),
|
||||
path("feed/older", views.feed_older, name="feed-older"),
|
||||
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
||||
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
|
||||
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||
path("assets/<str:asset_id>", 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/<str:artifact_id>", 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"),
|
||||
]
|
||||
903
artifacts/views.py
Normal file
903
artifacts/views.py
Normal file
|
|
@ -0,0 +1,903 @@
|
|||
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.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,
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
Artifact.DECK: "deck",
|
||||
}
|
||||
|
||||
|
||||
@method_decorator(login_required, name="get")
|
||||
class ArtifactListCreateView(APIView):
|
||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||
|
||||
def get(self, request):
|
||||
artifacts = Artifact.objects.filter(owner=request.user)
|
||||
if source := request.query_params.get("source"):
|
||||
artifacts = artifacts.filter(source=source)
|
||||
if label := request.query_params.get("label"):
|
||||
artifacts = filter_by_label(artifacts, label)
|
||||
if day := request.query_params.get("day"):
|
||||
artifacts = artifacts.filter(day=day)
|
||||
if kind := request.query_params.get("kind"):
|
||||
artifacts = artifacts.filter(kind=kind)
|
||||
if title_query := request.query_params.get("q"):
|
||||
artifacts = artifacts.filter(title__icontains=title_query)
|
||||
|
||||
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
|
||||
|
||||
def post(self, request):
|
||||
user = resolve_token_user(request)
|
||||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
if request.content_type and request.content_type.startswith("multipart/"):
|
||||
artifact = create_binary_artifact(request.data, user)
|
||||
if isinstance(artifact, Response):
|
||||
return artifact
|
||||
apply_tag_colors(request.data)
|
||||
return Response(serialize_artifact(artifact, request), status=201)
|
||||
|
||||
data = request.data
|
||||
kind = data.get("kind")
|
||||
if kind not in TEXT_KINDS:
|
||||
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
|
||||
|
||||
required = ["source", "title", "content"]
|
||||
missing = [field for field in required if not data.get(field)]
|
||||
if missing:
|
||||
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||
|
||||
source_error = validate_source_fields(data)
|
||||
if source_error:
|
||||
return source_error
|
||||
|
||||
artifact = create_text_artifact(data, request, user)
|
||||
if isinstance(artifact, Response):
|
||||
return artifact
|
||||
apply_tag_colors(data)
|
||||
return Response(serialize_artifact(artifact, request), status=201)
|
||||
|
||||
|
||||
class AssetUploadView(APIView):
|
||||
"""Upload an image for embedding inside artifact content.
|
||||
|
||||
Returns a stable URL the agent puts in its markdown/fragment. The asset is
|
||||
attached to whichever artifact later references that URL.
|
||||
"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
user = resolve_token_user(request)
|
||||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
uploaded = request.data.get("file")
|
||||
if not uploaded:
|
||||
return Response({"detail": "Missing required field: file"}, status=400)
|
||||
|
||||
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
||||
if not content_type.startswith("image/"):
|
||||
return Response({"detail": "Only image uploads are supported."}, status=400)
|
||||
if uploaded.size > MAX_ASSET_BYTES:
|
||||
return Response(
|
||||
{"detail": f"Image exceeds the {MAX_ASSET_BYTES // (1024 * 1024)} MB limit."},
|
||||
status=413,
|
||||
)
|
||||
|
||||
sweep_orphan_assets(user)
|
||||
|
||||
asset_id = generate_asset_id()
|
||||
suffix = safe_suffix(uploaded.name, content_type)
|
||||
directory = Path("_assets") / timezone.localdate().isoformat()
|
||||
(settings.ARTIFACTS_ROOT / directory).mkdir(parents=True, exist_ok=True)
|
||||
stored_path = directory / f"{asset_id}{suffix}"
|
||||
|
||||
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
|
||||
for chunk in uploaded.chunks():
|
||||
target.write(chunk)
|
||||
|
||||
size_bytes = (settings.ARTIFACTS_ROOT / stored_path).stat().st_size
|
||||
try:
|
||||
make_room_for(user, size_bytes)
|
||||
except QuotaExceeded as exc:
|
||||
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
|
||||
return Response({"detail": str(exc)}, status=413)
|
||||
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
owner=user,
|
||||
path=stored_path.as_posix(),
|
||||
content_type=content_type,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"id": asset.id,
|
||||
"url": request.build_absolute_uri(reverse("asset", args=[asset.id])),
|
||||
"content_type": asset.content_type,
|
||||
"size_bytes": asset.size_bytes,
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@xframe_options_sameorigin
|
||||
def asset_view(request, asset_id):
|
||||
asset = get_object_or_404(Asset, pk=asset_id, owner=request.user)
|
||||
path = safe_artifact_path(asset.path)
|
||||
if not path.exists():
|
||||
raise Http404("Asset file not found")
|
||||
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
|
||||
|
||||
|
||||
@method_decorator(login_required, name="get")
|
||||
class ArtifactDetailView(APIView):
|
||||
def get(self, request, artifact_id):
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
return Response(serialize_artifact(artifact, request))
|
||||
|
||||
def patch(self, request, artifact_id):
|
||||
user = resolve_token_user(request)
|
||||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||
data = request.data
|
||||
if "title" in data:
|
||||
if not data["title"]:
|
||||
return Response({"detail": "title cannot be blank"}, status=400)
|
||||
artifact.title = data["title"]
|
||||
if "tags" in data:
|
||||
artifact.tags = normalize_tags(data["tags"])
|
||||
if "source_prompt" in data:
|
||||
artifact.source_prompt = data["source_prompt"] or ""
|
||||
if "source_type" in data:
|
||||
if data["source_type"] not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
|
||||
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
|
||||
artifact.source_type = data["source_type"]
|
||||
if "source_name" in data:
|
||||
artifact.source_name = data["source_name"] or ""
|
||||
if "source_id" in data:
|
||||
artifact.source_id = data["source_id"] or ""
|
||||
if "metadata" in data:
|
||||
if not isinstance(data["metadata"], dict):
|
||||
return Response({"detail": "metadata must be an object"}, status=400)
|
||||
artifact.metadata = data["metadata"]
|
||||
if "tag_colors" in data:
|
||||
try:
|
||||
apply_tag_colors(data)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=400)
|
||||
artifact.save(
|
||||
update_fields=[
|
||||
"title",
|
||||
"tags",
|
||||
"source_prompt",
|
||||
"source_type",
|
||||
"source_name",
|
||||
"source_id",
|
||||
"metadata",
|
||||
]
|
||||
)
|
||||
return Response(serialize_artifact(artifact, request))
|
||||
|
||||
def delete(self, request, artifact_id):
|
||||
user = resolve_token_user(request)
|
||||
if user is None:
|
||||
return HttpResponseForbidden("Invalid or missing API token")
|
||||
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||
delete_artifact_files(artifact)
|
||||
artifact.delete()
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class CapabilitiesView(APIView):
|
||||
"""Static capability info. Reachable by a logged-in session or a valid API token,
|
||||
so agents can check connectivity before posting. Exposes no user data."""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
||||
return HttpResponseForbidden("Login or a valid API token is required")
|
||||
return Response(
|
||||
{
|
||||
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
|
||||
"filters": ["source", "label", "day", "kind", "q"],
|
||||
"views": ["rendered", "raw"],
|
||||
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
|
||||
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
|
||||
"auth": "bearer_token",
|
||||
"methods": {
|
||||
"/api/artifacts": ["GET", "POST"],
|
||||
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
||||
"/api/assets": ["POST"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
FEED_WINDOW_DAYS = 7
|
||||
|
||||
|
||||
def apply_feed_filters(queryset, source="", label="", kind="", title_query=""):
|
||||
if source:
|
||||
queryset = queryset.filter(source=source)
|
||||
if label:
|
||||
queryset = filter_by_label(queryset, label)
|
||||
if kind:
|
||||
queryset = queryset.filter(kind=kind)
|
||||
if title_query:
|
||||
queryset = queryset.filter(title__icontains=title_query)
|
||||
return queryset
|
||||
|
||||
|
||||
def group_artifacts_by_day(artifacts, overrides):
|
||||
"""Turn a flat, reverse-chronological list into [{day, artifacts}] groups."""
|
||||
groups = []
|
||||
for artifact in artifacts:
|
||||
# Source has its own column in the row, so chips are the free-form tags only.
|
||||
artifact.label_chips = tags_with_colors(artifact.tags, overrides)
|
||||
if not groups or groups[-1]["day"] != artifact.day:
|
||||
groups.append({"day": artifact.day, "artifacts": []})
|
||||
groups[-1]["artifacts"].append(artifact)
|
||||
return groups
|
||||
|
||||
|
||||
@login_required
|
||||
def feed(request):
|
||||
selected_source = request.GET.get("source", "")
|
||||
selected_label = request.GET.get("label", "")
|
||||
selected_kind = request.GET.get("kind", "")
|
||||
title_query = request.GET.get("q", "")
|
||||
today = timezone.localdate()
|
||||
selected_artifact_id = request.GET.get("artifact", "")
|
||||
preview_none = request.GET.get("preview") == "none"
|
||||
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
||||
|
||||
all_artifacts = Artifact.objects.filter(owner=request.user)
|
||||
artifacts = apply_feed_filters(
|
||||
all_artifacts, selected_source, selected_label, selected_kind, title_query
|
||||
)
|
||||
|
||||
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
||||
if not artifacts.filter(day__gte=window_start).exists():
|
||||
# Nothing recent for these filters: fall back to the most recent days that
|
||||
# do match, so selecting a source is never met with a blank list.
|
||||
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
|
||||
if recent_days:
|
||||
window_start = recent_days[-1]
|
||||
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
|
||||
older_count = artifacts.filter(day__lt=window_start).count()
|
||||
|
||||
overrides = tag_color_overrides()
|
||||
day_groups = group_artifacts_by_day(window_artifacts, overrides)
|
||||
|
||||
selected_artifact = None
|
||||
if not preview_none:
|
||||
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
|
||||
|
||||
toggle_query = {"view": "rendered"}
|
||||
if selected_source:
|
||||
toggle_query["source"] = selected_source
|
||||
if selected_label:
|
||||
toggle_query["label"] = selected_label
|
||||
if selected_kind:
|
||||
toggle_query["kind"] = selected_kind
|
||||
if title_query:
|
||||
toggle_query["q"] = title_query
|
||||
if selected_artifact:
|
||||
toggle_query["artifact"] = selected_artifact.id
|
||||
rendered_url = f"?{urlencode(toggle_query)}"
|
||||
toggle_query["view"] = "raw"
|
||||
raw_url = f"?{urlencode(toggle_query)}"
|
||||
|
||||
# Source counts come from the other filters only, so the sidebar still shows
|
||||
# every source available under the current label/type/name filters.
|
||||
source_scoped = apply_feed_filters(
|
||||
all_artifacts, "", selected_label, selected_kind, title_query
|
||||
)
|
||||
source_counts = (
|
||||
source_scoped.values("source")
|
||||
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
|
||||
.order_by("source")
|
||||
)
|
||||
|
||||
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
|
||||
return render(
|
||||
request,
|
||||
"artifacts/feed.html",
|
||||
{
|
||||
"artifacts": window_artifacts,
|
||||
"day_groups": day_groups,
|
||||
"older_count": older_count,
|
||||
"window_start": window_start,
|
||||
"source_counts": source_counts,
|
||||
"total_count": source_scoped.filter(read_at__isnull=True).count(),
|
||||
"all_labels": labels_with_colors_list(all_labels, overrides),
|
||||
"raw_url": raw_url,
|
||||
"rendered_url": rendered_url,
|
||||
"selected_artifact": selected_artifact,
|
||||
"selected_kind": selected_kind,
|
||||
"selected_label": selected_label,
|
||||
"selected_source": selected_source,
|
||||
"selected_view": selected_view,
|
||||
"preview_none": preview_none,
|
||||
"today": today,
|
||||
"title_query": title_query,
|
||||
"kind_choices": Artifact.KIND_CHOICES,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def mark_artifact_read(request, artifact_id):
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
if artifact.read_at is None:
|
||||
artifact.read_at = timezone.now()
|
||||
artifact.save(update_fields=["read_at"])
|
||||
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
|
||||
|
||||
|
||||
@login_required
|
||||
@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)
|
||||
if request.GET.get("raw") == "1":
|
||||
raw_path = raw_artifact_path(artifact)
|
||||
if raw_path and raw_path.exists():
|
||||
return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8")
|
||||
return HttpResponse(raw_unavailable_html(artifact), content_type="text/html")
|
||||
|
||||
path = safe_artifact_path(artifact.path)
|
||||
if not path.exists():
|
||||
raise Http404("Artifact file not found")
|
||||
|
||||
content_type, _ = mimetypes.guess_type(path.name)
|
||||
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
|
||||
|
||||
|
||||
def create_text_artifact(data, request, user):
|
||||
now = timezone.localtime()
|
||||
artifact_id = generate_id()
|
||||
source = slugify(data["source"]) or "unknown"
|
||||
kind = data["kind"]
|
||||
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
|
||||
title = data["title"]
|
||||
content = data["content"]
|
||||
|
||||
directory = Path(source) / now.date().isoformat()
|
||||
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if kind == Artifact.HTML_FULL:
|
||||
rendered = content
|
||||
elif kind == Artifact.MARKDOWN:
|
||||
rendered = wrap_html(markdown.markdown(content, extensions=["extra", "sane_lists"]), title, theme, request)
|
||||
(target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8")
|
||||
elif kind == Artifact.DECK:
|
||||
rendered = wrap_deck(content, title, request)
|
||||
else:
|
||||
rendered = wrap_html(content, title, theme, request)
|
||||
|
||||
stored_path = directory / f"{artifact_id}.html"
|
||||
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
|
||||
|
||||
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
|
||||
try:
|
||||
make_room_for(user, size_bytes)
|
||||
except QuotaExceeded as exc:
|
||||
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
|
||||
if kind == Artifact.MARKDOWN:
|
||||
(target_dir / f"{artifact_id}.md").unlink(missing_ok=True)
|
||||
return Response({"detail": str(exc)}, status=413)
|
||||
|
||||
artifact = Artifact.objects.create(
|
||||
id=artifact_id,
|
||||
owner=user,
|
||||
source=source,
|
||||
title=title,
|
||||
kind=kind,
|
||||
theme=theme,
|
||||
day=now.date(),
|
||||
path=stored_path.as_posix(),
|
||||
original_kind=kind,
|
||||
tags=normalize_tags(data.get("tags", [])),
|
||||
source_prompt=data.get("source_prompt", ""),
|
||||
size_bytes=size_bytes,
|
||||
**source_fields(data, source),
|
||||
)
|
||||
attach_referenced_assets(artifact, content)
|
||||
return artifact
|
||||
|
||||
|
||||
def create_binary_artifact(data, user):
|
||||
uploaded = data.get("file")
|
||||
required = ["source", "title"]
|
||||
missing = [field for field in required if not data.get(field)]
|
||||
if not uploaded:
|
||||
missing.append("file")
|
||||
if missing:
|
||||
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||
source_error = validate_source_fields(data)
|
||||
if source_error:
|
||||
return source_error
|
||||
|
||||
now = timezone.localtime()
|
||||
artifact_id = generate_id()
|
||||
source = slugify(data["source"]) or "unknown"
|
||||
title = data["title"]
|
||||
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
||||
kind = binary_kind(content_type)
|
||||
|
||||
directory = Path(source) / now.date().isoformat()
|
||||
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
suffix = safe_suffix(uploaded.name, content_type)
|
||||
stored_path = directory / f"{artifact_id}{suffix}"
|
||||
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
|
||||
for chunk in uploaded.chunks():
|
||||
target.write(chunk)
|
||||
|
||||
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,
|
||||
title=title,
|
||||
kind=kind,
|
||||
theme="",
|
||||
day=now.date(),
|
||||
path=stored_path.as_posix(),
|
||||
original_kind=kind,
|
||||
tags=normalize_tags(data.get("tags", [])),
|
||||
source_prompt=data.get("source_prompt", ""),
|
||||
size_bytes=size_bytes,
|
||||
**source_fields(data, source),
|
||||
)
|
||||
|
||||
|
||||
def wrap_html(body, title, theme, request):
|
||||
stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css")
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{html.escape(title)}</title>
|
||||
<link rel="stylesheet" href="{stylesheet}">
|
||||
</head>
|
||||
<body class="artifact">
|
||||
{body}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def wrap_deck(body, title, request):
|
||||
stylesheet = request.build_absolute_uri("/static/themes/deck.css")
|
||||
return f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{html.escape(title)}</title>
|
||||
<link rel="stylesheet" href="{stylesheet}">
|
||||
</head>
|
||||
<body class="deck">
|
||||
<main class="slides">{body}</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def generate_id():
|
||||
while True:
|
||||
artifact_id = secrets.token_hex(4)
|
||||
if not Artifact.objects.filter(pk=artifact_id).exists():
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
def normalize_tags(tags):
|
||||
if isinstance(tags, str):
|
||||
return [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||
if isinstance(tags, list):
|
||||
return [str(tag).strip() for tag in tags if str(tag).strip()]
|
||||
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
|
||||
if content_type == "application/pdf":
|
||||
return Artifact.PDF
|
||||
return Artifact.BINARY
|
||||
|
||||
|
||||
def safe_suffix(filename, content_type):
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix and suffix[1:].isalnum() and len(suffix) <= 10:
|
||||
return suffix
|
||||
return mimetypes.guess_extension(content_type) or ".bin"
|
||||
|
||||
|
||||
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,
|
||||
"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),
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
def safe_artifact_path(relative_path):
|
||||
root = settings.ARTIFACTS_ROOT.resolve()
|
||||
path = (root / relative_path).resolve()
|
||||
if root not in path.parents and path != root:
|
||||
raise Http404("Invalid artifact path")
|
||||
return path
|
||||
|
||||
|
||||
def raw_artifact_path(artifact):
|
||||
path = safe_artifact_path(artifact.path)
|
||||
if artifact.kind == Artifact.MARKDOWN:
|
||||
return path.with_suffix(".md")
|
||||
if artifact.kind == Artifact.HTML_FULL:
|
||||
return path
|
||||
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"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Raw source unavailable</title>
|
||||
<style>
|
||||
body {{ background: #f4f1ea; color: #1d2528; font-family: system-ui, sans-serif; margin: 0; padding: 2rem; }}
|
||||
main {{ background: #fffaf0; border-radius: 16px; max-width: 42rem; padding: 1.4rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Raw source unavailable</h1>
|
||||
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
Loading…
Add table
Add a link
Reference in a new issue