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:
parent
fcd7da853f
commit
c2a354809a
14 changed files with 562 additions and 23 deletions
29
artifacts/migrations/0017_label.py
Normal file
29
artifacts/migrations/0017_label.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue