v0.7.1: in-app password change, custom labels, always-on folders

- Settings -> Account: change your own password (current + new), session
  preserved via update_session_auth_hash.
- Custom labels: per-user palette in Settings -> Labels; apply/remove from a
  message's ... menu (menu stays open, checkmark = applied). Labels are unified
  with tags; chip colours sync to the global TagColor. New Label model +
  migration 0017; POST /a/{id}/label (session, owner-scoped, palette-validated).
- Sidebar Keep/Deleted folders are now always shown; count badge only when
  non-empty. Updates the stale 'hide when empty' note in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-08 15:19:54 +01:00
parent fcd7da853f
commit c2a354809a
14 changed files with 562 additions and 23 deletions

View file

@ -0,0 +1,29 @@
# Generated by Django 5.2.15 on 2026-09-08 13:46
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0016_artifact_deleted_at'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Label',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=40)),
('color', models.CharField(max_length=20)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
'unique_together': {('user', 'name')},
},
),
]

View file

@ -96,6 +96,23 @@ class TagColor(models.Model):
return f"{self.name} ({self.color})"
class Label(models.Model):
"""A user's reusable label in their personal palette. Applying one from the
feed UI just adds its `name` to an artifact's `tags`; the chip colour lives
in the global TagColor table (kept in sync when the palette entry is saved)."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="labels")
name = models.CharField(max_length=40)
color = models.CharField(max_length=20) # #RRGGBB
class Meta:
unique_together = ("user", "name")
ordering = ["name"]
def __str__(self):
return f"{self.name} ({self.color})"
class UserProfile(models.Model):
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB

View file

@ -10,6 +10,7 @@ urlpatterns = [
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>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
path("a/<str:artifact_id>/label", views.toggle_artifact_label, name="artifact-toggle-label"),
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"),
path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"),
@ -28,6 +29,7 @@ urlpatterns = [
path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"),
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
path("settings/labels/", views.settings_labels, name="settings-labels"),
path("settings/users/", views.settings_users, name="settings-users"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"),

View file

@ -15,8 +15,9 @@ import nh3
import openpyxl
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth import update_session_auth_hash, views as auth_views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import User
from django.db import OperationalError, transaction
from django.db.models import Count, Max, Q, Sum
@ -36,7 +37,7 @@ from rest_framework.views import APIView
from .demo_data import create_demo_artifacts
from .forms import AgentFeedLoginForm
from .models import ApiKey, Artifact, Asset, TagColor
from .models import ApiKey, Artifact, Asset, Label, TagColor
from .storage import (
QuotaExceeded,
SIDECAR_SUFFIXES,
@ -625,6 +626,7 @@ def feed(request):
"today": today,
"title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES,
"label_palette": request.user.labels.all(),
},
)
@ -639,6 +641,31 @@ def mark_artifact_read(request, artifact_id):
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
@login_required
@require_POST
def toggle_artifact_label(request, artifact_id):
"""Toggle a palette label on/off for one of the user's own messages. The
label name is written into/removed from the artifact's `tags` list (labels
are unified with tags). Only names in the user's palette are accepted."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user, deleted_at__isnull=True)
name = (request.POST.get("name") or "").strip()
label = Label.objects.filter(user=request.user, name=name).first()
if label is None:
return JsonResponse({"error": "No such label in your palette."}, status=400)
tags = list(artifact.tags)
if name in tags:
tags.remove(name)
applied = False
else:
tags.append(name)
applied = True
artifact.tags = tags
artifact.save(update_fields=["tags"])
# Keep the chip colour in sync with the palette (TagColor is global).
TagColor.objects.update_or_create(name=name, defaults={"color": label.color})
return JsonResponse({"applied": applied, "name": name, "color": label.color, "tags": tags})
@login_required
@require_POST
def toggle_artifact_keep(request, artifact_id):
@ -1507,10 +1534,25 @@ def settings_home(request):
@login_required
def settings_account(request):
if request.method == "POST":
password_form = PasswordChangeForm(request.user, request.POST)
if password_form.is_valid():
user = password_form.save()
# Keep the current session valid — changing the password rotates the
# session auth hash, which would otherwise log the user out.
update_session_auth_hash(request, user)
messages.success(request, "Password changed.")
return redirect("settings-account")
else:
password_form = PasswordChangeForm(request.user)
return render(
request,
"artifacts/settings_account.html",
{"active_section": "account", "key_count": request.user.api_keys.count()},
{
"active_section": "account",
"key_count": request.user.api_keys.count(),
"password_form": password_form,
},
)
@ -1540,6 +1582,49 @@ def settings_api_keys(request):
)
LABEL_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
@login_required
def settings_labels(request):
if request.method == "POST":
action = request.POST.get("action")
if action == "create":
name = (request.POST.get("name") or "").strip()
color = (request.POST.get("color") or "").strip()
if not name:
messages.error(request, "Give the label a name.")
elif not LABEL_HEX_RE.match(color):
messages.error(request, "Pick a colour (#RRGGBB).")
elif Label.objects.filter(user=request.user, name=name).exists():
messages.error(request, f"You already have a label called '{name}'.")
else:
Label.objects.create(user=request.user, name=name, color=color)
TagColor.objects.update_or_create(name=name, defaults={"color": color})
messages.success(request, f"Created label '{name}'.")
elif action == "update":
color = (request.POST.get("color") or "").strip()
label = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).first()
if label is None:
messages.error(request, "No such label.")
elif not LABEL_HEX_RE.match(color):
messages.error(request, "Pick a colour (#RRGGBB).")
else:
label.color = color
label.save(update_fields=["color"])
TagColor.objects.update_or_create(name=label.name, defaults={"color": color})
messages.success(request, f"Updated '{label.name}'.")
elif action == "delete":
deleted, _ = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).delete()
messages.success(request, "Label removed from your palette.") if deleted else messages.error(request, "No such label.")
return redirect("settings-labels")
return render(
request,
"artifacts/settings_labels.html",
{"active_section": "labels", "labels": request.user.labels.all()},
)
@login_required
def settings_users(request):
if not request.user.is_superuser: