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 .auth import bp as auth_bp
from .config import Config from .config import Config
from .db import session_scope from .db import session_scope
from .labels import bp as labels_bp
from .notes import bp as notes_bp from .notes import bp as notes_bp
from .settings import get_public_config, get_setting, load_or_create_secret_key from .settings import get_public_config, get_setting, load_or_create_secret_key
from .settings_api import bp as settings_bp from .settings_api import bp as settings_bp
@@ -30,6 +31,7 @@ def create_app() -> Quart:
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(notes_bp) app.register_blueprint(notes_bp)
app.register_blueprint(labels_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
@app.before_serving @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). 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 datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from sqlalchemy import select from sqlalchemy import delete, select
from .acl import visible_to_user from .acl import visible_to_user
from .auth import login_required from .auth import login_required
from .db import session_scope from .db import session_scope
from .models.label import Label, NoteLabel
from .models.note import NOTE_COLORS, Note from .models.note import NOTE_COLORS, Note
bp = Blueprint("notes", __name__, url_prefix="/api/notes") 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): def apply_filter(stmt, filter_name: str):
"""Narrow a notes query to one board view. `active` = live board (not trashed, """Narrow a notes query to one board view."""
not archived); `archived` = archived but not trashed; `trash` = trashed."""
if filter_name == "archived": if filter_name == "archived":
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(True)) return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(True))
if filter_name == "trash": 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)) 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: async def _get_owned(db, note_id: str) -> Note | None:
"""Fetch a note the current user OWNS (mutations are owner-only in M1; share """Fetch a note the current user OWNS (mutations are owner-only in M1/M2)."""
write-permissions arrive with the sharing UI in a later milestone)."""
try: try:
nid = uuid.UUID(note_id) nid = uuid.UUID(note_id)
except (ValueError, TypeError): except (ValueError, TypeError):
@@ -50,14 +72,25 @@ async def list_notes():
filter_name = request.args.get("filter", "active") filter_name = request.args.get("filter", "active")
if filter_name not in VALID_FILTERS: if filter_name not in VALID_FILTERS:
return jsonify({"error": "invalid filter"}), 400 return jsonify({"error": "invalid filter"}), 400
label_param = request.args.get("label")
async with session_scope() as db: 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 = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
stmt = apply_filter(stmt, filter_name) 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()) stmt = stmt.order_by(Note.pinned.desc(), Note.updated_at.desc())
notes = (await db.scalars(stmt)).all() 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("") @bp.post("")
@@ -78,7 +111,7 @@ async def create_note():
db.add(note) db.add(note)
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
return jsonify(note.serialize()), 201 return jsonify(await _serialize_note(db, note)), 201
@bp.get("/<note_id>") @bp.get("/<note_id>")
@@ -94,7 +127,7 @@ async def get_note(note_id: str):
) )
if note is None: if note is None:
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
return jsonify(note.serialize()) return jsonify(await _serialize_note(db, note))
@bp.patch("/<note_id>") @bp.patch("/<note_id>")
@@ -118,7 +151,42 @@ async def update_note(note_id: str):
note.archived = bool(data["archived"]) note.archived = bool(data["archived"])
await db.commit() await db.commit()
await db.refresh(note) 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") @bp.post("/<note_id>/trash")
@@ -131,7 +199,7 @@ async def trash_note(note_id: str):
note.deleted_at = datetime.now(timezone.utc) note.deleted_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
return jsonify(note.serialize()) return jsonify(await _serialize_note(db, note))
@bp.post("/<note_id>/restore") @bp.post("/<note_id>/restore")
@@ -144,7 +212,7 @@ async def restore_note(note_id: str):
note.deleted_at = None note.deleted_at = None
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
return jsonify(note.serialize()) return jsonify(await _serialize_note(db, note))
@bp.delete("/<note_id>") @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