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
+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})