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,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})
|
||||
Reference in New Issue
Block a user