M2 labels backend: labels + note_labels, CRUD, note-label set, filter
- 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:
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user