M1: notes model + migration 0002 + notes API (CRUD, trash/restore)
- Note model (owner_id, title, body, color-key, pinned, archived, deleted_at soft-delete, timestamps) + board index; NOTE_COLORS palette keys. - Migration 0002 (notes table + ix_notes_owner_board). - /api/notes blueprint (login_required): list (?filter=active|archived|trash, pinned-then-updated, read via visible_to_user ACL), create, get, patch (title/body/color/pinned/archived), trash, restore, permanent delete (trash-only). Mutations owner-scoped; empty note rejected (400). - DB-free unit tests: is_empty_note, normalize_color, palette, serialize, auth-guard on list/create. 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,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from .acl import visible_to_user
|
||||
from .auth import login_required
|
||||
from .db import session_scope
|
||||
from .models.note import NOTE_COLORS, Note
|
||||
|
||||
bp = Blueprint("notes", __name__, url_prefix="/api/notes")
|
||||
|
||||
VALID_FILTERS = {"active", "archived", "trash"}
|
||||
|
||||
|
||||
def is_empty_note(title: str | None, body: str | None) -> bool:
|
||||
return not (title or "").strip() and not (body or "").strip()
|
||||
|
||||
|
||||
def normalize_color(color: object) -> str:
|
||||
return color if color in NOTE_COLORS else "default"
|
||||
|
||||
|
||||
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."""
|
||||
if filter_name == "archived":
|
||||
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(True))
|
||||
if filter_name == "trash":
|
||||
return stmt.where(Note.deleted_at.is_not(None))
|
||||
return stmt.where(Note.deleted_at.is_(None), Note.archived.is_(False))
|
||||
|
||||
|
||||
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)."""
|
||||
try:
|
||||
nid = uuid.UUID(note_id)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return await db.scalar(select(Note).where(Note.id == nid, Note.owner_id == g.user_id))
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@login_required
|
||||
async def list_notes():
|
||||
filter_name = request.args.get("filter", "active")
|
||||
if filter_name not in VALID_FILTERS:
|
||||
return jsonify({"error": "invalid filter"}), 400
|
||||
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)
|
||||
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]})
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@login_required
|
||||
async def create_note():
|
||||
data = await request.get_json(silent=True) or {}
|
||||
title = data.get("title") if isinstance(data.get("title"), str) else ""
|
||||
body = data.get("body") if isinstance(data.get("body"), str) else ""
|
||||
if is_empty_note(title, body):
|
||||
return jsonify({"error": "note is empty"}), 400
|
||||
async with session_scope() as db:
|
||||
note = Note(
|
||||
owner_id=g.user_id,
|
||||
title=title.strip() or None,
|
||||
body=body,
|
||||
color=normalize_color(data.get("color")),
|
||||
)
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return jsonify(note.serialize()), 201
|
||||
|
||||
|
||||
@bp.get("/<note_id>")
|
||||
@login_required
|
||||
async def get_note(note_id: str):
|
||||
try:
|
||||
nid = uuid.UUID(note_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
async with session_scope() as db:
|
||||
note = await db.scalar(
|
||||
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||
)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
return jsonify(note.serialize())
|
||||
|
||||
|
||||
@bp.patch("/<note_id>")
|
||||
@login_required
|
||||
async def update_note(note_id: str):
|
||||
data = await request.get_json(silent=True) or {}
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
if "title" in data:
|
||||
title = data["title"] if isinstance(data["title"], str) else ""
|
||||
note.title = title.strip() or None
|
||||
if "body" in data and isinstance(data["body"], str):
|
||||
note.body = data["body"]
|
||||
if "color" in data:
|
||||
note.color = normalize_color(data["color"])
|
||||
if "pinned" in data:
|
||||
note.pinned = bool(data["pinned"])
|
||||
if "archived" in data:
|
||||
note.archived = bool(data["archived"])
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return jsonify(note.serialize())
|
||||
|
||||
|
||||
@bp.post("/<note_id>/trash")
|
||||
@login_required
|
||||
async def trash_note(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
note.deleted_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return jsonify(note.serialize())
|
||||
|
||||
|
||||
@bp.post("/<note_id>/restore")
|
||||
@login_required
|
||||
async def restore_note(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
note.deleted_at = None
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return jsonify(note.serialize())
|
||||
|
||||
|
||||
@bp.delete("/<note_id>")
|
||||
@login_required
|
||||
async def delete_note(note_id: str):
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
if note.deleted_at is None:
|
||||
return jsonify({"error": "note must be trashed before permanent delete"}), 409
|
||||
await db.delete(note)
|
||||
await db.commit()
|
||||
return jsonify({"ok": True})
|
||||
Reference in New Issue
Block a user