From 4d1fc1bdf91634ec830c9b5bec605ee81cf07b7e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 19 Jul 2026 21:43:21 -0400 Subject: [PATCH] M2 labels backend: labels + note_labels, CRUD, note-label set, filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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//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= filters by label. - DB-free auth-guard tests for labels endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- alembic/versions/0004_labels.py | 40 ++++++++++++++ src/thoughtsync/app.py | 2 + src/thoughtsync/labels.py | 85 +++++++++++++++++++++++++++++ src/thoughtsync/models/all.py | 2 +- src/thoughtsync/models/label.py | 35 ++++++++++++ src/thoughtsync/notes.py | 94 ++++++++++++++++++++++++++++----- tests/test_labels.py | 23 ++++++++ 7 files changed, 267 insertions(+), 14 deletions(-) create mode 100644 alembic/versions/0004_labels.py create mode 100644 src/thoughtsync/labels.py create mode 100644 src/thoughtsync/models/label.py create mode 100644 tests/test_labels.py diff --git a/alembic/versions/0004_labels.py b/alembic/versions/0004_labels.py new file mode 100644 index 0000000..5e1dd28 --- /dev/null +++ b/alembic/versions/0004_labels.py @@ -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") diff --git a/src/thoughtsync/app.py b/src/thoughtsync/app.py index 4a145de..8000ebd 100644 --- a/src/thoughtsync/app.py +++ b/src/thoughtsync/app.py @@ -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 diff --git a/src/thoughtsync/labels.py b/src/thoughtsync/labels.py new file mode 100644 index 0000000..ce7f746 --- /dev/null +++ b/src/thoughtsync/labels.py @@ -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("/") +@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("/") +@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}) diff --git a/src/thoughtsync/models/all.py b/src/thoughtsync/models/all.py index d2136b5..7a57230 100644 --- a/src/thoughtsync/models/all.py +++ b/src/thoughtsync/models/all.py @@ -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 diff --git a/src/thoughtsync/models/label.py b/src/thoughtsync/models/label.py new file mode 100644 index 0000000..d339c02 --- /dev/null +++ b/src/thoughtsync/models/label.py @@ -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 + ) diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index cbd3012..c2993e9 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -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("/") @@ -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("/") @@ -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("//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("//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("//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("/") diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..7ccc43d --- /dev/null +++ b/tests/test_labels.py @@ -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