M2 labels backend: labels + note_labels, CRUD, note-label set, filter
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 32s

- Label + NoteLabel models; migration 0004 (labels unique per owner + note_labels
  join, cascade).
- /api/labels: list/create(idempotent)/rename(clash-checked)/delete, owner-scoped.
- PUT /api/notes/<id>/labels to set a note's labels (validated against owned).
- Note responses now include labels[] (merged via one explicit join query — no
  lazy relationship); GET /api/notes?...&label=<id> filters by label.
- DB-free auth-guard tests for labels endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-19 21:43:21 -04:00
co-authored by Claude Opus 4.8
parent fdaf5c370c
commit 4d1fc1bdf9
7 changed files with 267 additions and 14 deletions
+40
View File
@@ -0,0 +1,40 @@
"""labels + note_labels
Revision ID: 0004
Revises: 0003
Create Date: 2026-07-20
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"labels",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("owner_id", "name", name="uq_labels_owner_name"),
)
op.create_index("ix_labels_owner", "labels", ["owner_id"])
op.create_table(
"note_labels",
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
sa.Column("label_id", UUID(as_uuid=True), sa.ForeignKey("labels.id", ondelete="CASCADE"), primary_key=True),
)
op.create_index("ix_note_labels_label", "note_labels", ["label_id"])
def downgrade() -> None:
op.drop_index("ix_note_labels_label", table_name="note_labels")
op.drop_table("note_labels")
op.drop_index("ix_labels_owner", table_name="labels")
op.drop_table("labels")
+2
View File
@@ -10,6 +10,7 @@ from . import __version__
from .auth import bp as auth_bp
from .config import Config
from .db import session_scope
from .labels import bp as labels_bp
from .notes import bp as notes_bp
from .settings import get_public_config, get_setting, load_or_create_secret_key
from .settings_api import bp as settings_bp
@@ -30,6 +31,7 @@ def create_app() -> Quart:
app.register_blueprint(auth_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(labels_bp)
app.register_blueprint(settings_bp)
@app.before_serving
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import uuid
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from .auth import login_required
from .db import session_scope
from .models.label import Label
bp = Blueprint("labels", __name__, url_prefix="/api/labels")
def _serialize_label(label: Label) -> dict:
return {"id": str(label.id), "name": label.name}
async def _get_owned_label(db, label_id: str) -> Label | None:
try:
lid = uuid.UUID(label_id)
except (ValueError, TypeError):
return None
return await db.scalar(select(Label).where(Label.id == lid, Label.owner_id == g.user_id))
@bp.get("")
@login_required
async def list_labels():
async with session_scope() as db:
labels = (await db.scalars(select(Label).where(Label.owner_id == g.user_id).order_by(Label.name))).all()
return jsonify({"labels": [_serialize_label(lb) for lb in labels]})
@bp.post("")
@login_required
async def create_label():
data = await request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "label name is required"}), 400
async with session_scope() as db:
# Idempotent: creating an existing label just returns it.
existing = await db.scalar(select(Label).where(Label.owner_id == g.user_id, Label.name == name))
if existing is not None:
return jsonify(_serialize_label(existing)), 200
label = Label(owner_id=g.user_id, name=name)
db.add(label)
await db.commit()
await db.refresh(label)
return jsonify(_serialize_label(label)), 201
@bp.patch("/<label_id>")
@login_required
async def rename_label(label_id: str):
data = await request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "label name is required"}), 400
async with session_scope() as db:
label = await _get_owned_label(db, label_id)
if label is None:
return jsonify({"error": "not found"}), 404
clash = await db.scalar(
select(Label).where(Label.owner_id == g.user_id, Label.name == name, Label.id != label.id)
)
if clash is not None:
return jsonify({"error": "a label with that name already exists"}), 409
label.name = name
await db.commit()
await db.refresh(label)
return jsonify(_serialize_label(label))
@bp.delete("/<label_id>")
@login_required
async def delete_label(label_id: str):
async with session_scope() as db:
label = await _get_owned_label(db, label_id)
if label is None:
return jsonify({"error": "not found"}), 404
await db.delete(label) # note_labels rows cascade
await db.commit()
return jsonify({"ok": True})
+1 -1
View File
@@ -3,4 +3,4 @@
Imported for side effects only (model registration on Base.metadata).
"""
from . import group, note, settings, share, user # noqa: F401
from . import group, label, note, settings, share, user # noqa: F401
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class Label(Base):
__tablename__ = "labels"
__table_args__ = (UniqueConstraint("owner_id", "name", name="uq_labels_owner_name"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
owner_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text(), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class NoteLabel(Base):
"""Join between a note and a label (both owned by the same user)."""
__tablename__ = "note_labels"
note_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True
)
label_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("labels.id", ondelete="CASCADE"), primary_key=True
)
+81 -13
View File
@@ -4,11 +4,12 @@ import uuid
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from sqlalchemy import delete, select
from .acl import visible_to_user
from .auth import login_required
from .db import session_scope
from .models.label import Label, NoteLabel
from .models.note import NOTE_COLORS, Note
bp = Blueprint("notes", __name__, url_prefix="/api/notes")
@@ -25,8 +26,7 @@ def normalize_color(color: object) -> str:
def apply_filter(stmt, filter_name: str):
"""Narrow a notes query to one board view. `active` = live board (not trashed,
not archived); `archived` = archived but not trashed; `trash` = trashed."""
"""Narrow a notes query to one board view."""
if filter_name == "archived":
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(True))
if filter_name == "trash":
@@ -34,9 +34,31 @@ def apply_filter(stmt, filter_name: str):
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(False))
async def _labels_for_notes(db, note_ids: list) -> dict:
"""Map note_id -> [{id, name}] in one query (no lazy relationship loading)."""
result: dict = {}
if not note_ids:
return result
rows = await db.execute(
select(NoteLabel.note_id, Label.id, Label.name)
.join(Label, Label.id == NoteLabel.label_id)
.where(NoteLabel.note_id.in_(note_ids))
.order_by(Label.name)
)
for note_id, label_id, name in rows.all():
result.setdefault(note_id, []).append({"id": str(label_id), "name": name})
return result
async def _serialize_note(db, note: Note) -> dict:
data = note.serialize()
labels = await _labels_for_notes(db, [note.id])
data["labels"] = labels.get(note.id, [])
return data
async def _get_owned(db, note_id: str) -> Note | None:
"""Fetch a note the current user OWNS (mutations are owner-only in M1; share
write-permissions arrive with the sharing UI in a later milestone)."""
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
try:
nid = uuid.UUID(note_id)
except (ValueError, TypeError):
@@ -50,14 +72,25 @@ async def list_notes():
filter_name = request.args.get("filter", "active")
if filter_name not in VALID_FILTERS:
return jsonify({"error": "invalid filter"}), 400
label_param = request.args.get("label")
async with session_scope() as db:
# Read via the ACL predicate (owner OR shared) so shared notes appear for
# free once sharing lands (rule 47). With no shares yet this is owner-only.
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
stmt = apply_filter(stmt, filter_name)
if label_param:
try:
lid = uuid.UUID(label_param)
except (ValueError, TypeError):
return jsonify({"error": "invalid label"}), 400
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
stmt = stmt.order_by(Note.pinned.desc(), Note.updated_at.desc())
notes = (await db.scalars(stmt)).all()
return jsonify({"notes": [n.serialize() for n in notes]})
labels_map = await _labels_for_notes(db, [n.id for n in notes])
out = []
for n in notes:
data = n.serialize()
data["labels"] = labels_map.get(n.id, [])
out.append(data)
return jsonify({"notes": out})
@bp.post("")
@@ -78,7 +111,7 @@ async def create_note():
db.add(note)
await db.commit()
await db.refresh(note)
return jsonify(note.serialize()), 201
return jsonify(await _serialize_note(db, note)), 201
@bp.get("/<note_id>")
@@ -94,7 +127,7 @@ async def get_note(note_id: str):
)
if note is None:
return jsonify({"error": "not found"}), 404
return jsonify(note.serialize())
return jsonify(await _serialize_note(db, note))
@bp.patch("/<note_id>")
@@ -118,7 +151,42 @@ async def update_note(note_id: str):
note.archived = bool(data["archived"])
await db.commit()
await db.refresh(note)
return jsonify(note.serialize())
return jsonify(await _serialize_note(db, note))
@bp.put("/<note_id>/labels")
@login_required
async def set_note_labels(note_id: str):
data = await request.get_json(silent=True) or {}
raw_ids = data.get("label_ids")
if not isinstance(raw_ids, list):
return jsonify({"error": "label_ids must be a list"}), 400
label_ids: list = []
for rid in raw_ids:
try:
label_ids.append(uuid.UUID(str(rid)))
except (ValueError, TypeError):
return jsonify({"error": "invalid label id"}), 400
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return jsonify({"error": "not found"}), 404
owned: set = set()
if label_ids:
owned = set(
(
await db.scalars(
select(Label.id).where(Label.owner_id == g.user_id, Label.id.in_(label_ids))
)
).all()
)
# Replace the note's label set with the (validated, owned) ids provided.
await db.execute(delete(NoteLabel).where(NoteLabel.note_id == note.id))
for lid in label_ids:
if lid in owned:
db.add(NoteLabel(note_id=note.id, label_id=lid))
await db.commit()
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/trash")
@@ -131,7 +199,7 @@ async def trash_note(note_id: str):
note.deleted_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(note)
return jsonify(note.serialize())
return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/restore")
@@ -144,7 +212,7 @@ async def restore_note(note_id: str):
note.deleted_at = None
await db.commit()
await db.refresh(note)
return jsonify(note.serialize())
return jsonify(await _serialize_note(db, note))
@bp.delete("/<note_id>")
+23
View File
@@ -0,0 +1,23 @@
import pytest
from thoughtsync.app import create_app
@pytest.fixture
def app():
return create_app()
async def test_labels_requires_auth(app):
client = app.test_client()
resp = await client.get("/api/labels")
assert resp.status_code == 401
async def test_set_note_labels_requires_auth(app):
client = app.test_client()
resp = await client.put(
"/api/notes/00000000-0000-0000-0000-000000000000/labels",
json={"label_ids": []},
)
assert resp.status_code == 401