DRY refactoring pass: shared mixins, route helpers, composables, CSS

Backend:
- models/base.py: TimestampMixin + CreatedAtMixin; applied to all 10+ models
- routes/utils.py: not_found() + parse_iso_date() helpers; used across all route files
- routes/milestones.py: _milestone_dict() helper replaces 5 repeated to_dict + progress blocks

Frontend:
- 5 new composables: useAutoSave, useEditorGuards, useTagSuggestions,
  useFloatingAssist, useListKeyboardNavigation
- ConfirmDialog.vue: reusable confirm modal replacing inline <teleport> blocks
- editor-shared.css: sidebar CSS consolidated from both editor views
- viewer-shared.css: context-bar/breadcrumb CSS consolidated from both viewer views
- NoteEditorView + TaskEditorView: ~120 lines each replaced with composable calls;
  duplicate scoped sidebar CSS removed
- NotesListView + TasksListView: inline keyboard-nav replaced with composable
- NoteViewerView + TaskViewerView: duplicate context-bar CSS removed

No behaviour changes. Net: -634 lines, +237 lines across 31 files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 15:26:34 -05:00
parent 48f070f773
commit 16ecd6bbeb
32 changed files with 563 additions and 634 deletions
+3
View File
@@ -16,6 +16,9 @@ class Base(DeclarativeBase):
pass
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin # noqa: E402, F401
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
from fabledassistant.models.setting import Setting # noqa: E402, F401
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.orm import Mapped, mapped_column
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
class CreatedAtMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+4 -16
View File
@@ -1,14 +1,13 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy import ForeignKey, Index, Integer, Text
from sqlalchemy import inspect
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
class Conversation(Base):
class Conversation(Base, TimestampMixin):
__tablename__ = "conversations"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -17,14 +16,6 @@ class Conversation(Base):
)
title: Mapped[str] = mapped_column(Text, default="")
model: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation",
@@ -53,7 +44,7 @@ class Conversation(Base):
}
class Message(Base):
class Message(Base, CreatedAtMixin):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -67,9 +58,6 @@ class Message(Base):
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
+3 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class Milestone(Base):
class Milestone(Base, TimestampMixin):
__tablename__ = "milestones"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -16,14 +15,6 @@ class Milestone(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(Text, default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
+4 -11
View File
@@ -1,11 +1,12 @@
import enum
from datetime import date, datetime, timezone
from datetime import date
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy import Date, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class TaskStatus(str, enum.Enum):
@@ -21,7 +22,7 @@ class TaskPriority(str, enum.Enum):
high = "high"
class Note(Base):
class Note(Base, TimestampMixin):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -43,14 +44,6 @@ class Note(Base):
status: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
__table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"),
+3 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class NoteDraft(Base):
class NoteDraft(Base, TimestampMixin):
__tablename__ = "note_drafts"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -16,14 +15,6 @@ class NoteDraft(Base):
original_body: Mapped[str] = mapped_column(Text)
instruction: Mapped[str] = mapped_column(Text, default="")
scope: Mapped[str] = mapped_column(Text, default="document")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
+3 -7
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import ARRAY, DateTime, ForeignKey, Integer, Text
from sqlalchemy import ARRAY, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class NoteVersion(Base):
class NoteVersion(Base, CreatedAtMixin):
__tablename__ = "note_versions"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +14,6 @@ class NoteVersion(Base):
body: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
def to_dict(self, include_body: bool = True) -> dict:
d: dict = {
+3 -5
View File
@@ -1,8 +1,8 @@
import enum
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class ProjectStatus(str, enum.Enum):
@@ -11,7 +11,7 @@ class ProjectStatus(str, enum.Enum):
archived = "archived"
class Project(Base):
class Project(Base, TimestampMixin):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
@@ -20,8 +20,6 @@ class Project(Base):
goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self) -> dict:
return {
@@ -1,10 +1,11 @@
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class PushSubscription(Base):
class PushSubscription(Base, CreatedAtMixin):
__tablename__ = "push_subscriptions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
@@ -12,7 +13,6 @@ class PushSubscription(Base):
p256dh: Mapped[str] = mapped_column(Text)
auth: Mapped[str] = mapped_column(Text)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
+3 -12
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class TaskLog(Base):
class TaskLog(Base, TimestampMixin):
__tablename__ = "task_logs"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -14,14 +13,6 @@ class TaskLog(Base):
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
content: Mapped[str] = mapped_column(Text)
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
+3 -7
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, Index, Text
from sqlalchemy import Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class User(Base):
class User(Base, CreatedAtMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +14,6 @@ class User(Base):
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("ix_users_username", "username"),
+9 -8
View File
@@ -6,6 +6,7 @@ import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
@@ -61,7 +62,7 @@ async def get_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
result = conv.to_dict()
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
@@ -80,7 +81,7 @@ async def delete_conversation_route(conv_id: int):
uid = get_current_user_id()
deleted = await delete_conversation(uid, conv_id)
if not deleted:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
return "", 204
@@ -95,7 +96,7 @@ async def update_conversation_route(conv_id: int):
return jsonify({"error": "title or model is required"}), 400
conv = await update_conversation(uid, conv_id, title=title, model=model)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
return jsonify(conv.to_dict())
@@ -106,7 +107,7 @@ async def send_message_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
data = await request.get_json()
content = data.get("content", "").strip()
@@ -166,7 +167,7 @@ async def generation_stream_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None:
@@ -219,7 +220,7 @@ async def confirm_generation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
@@ -241,7 +242,7 @@ async def cancel_generation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
@@ -268,7 +269,7 @@ async def summarize_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return not_found("Conversation")
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
try:
note = await summarize_conversation_as_note(uid, conv_id, model)
+19 -23
View File
@@ -4,6 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.services.milestones import (
create_milestone,
delete_milestone,
@@ -20,21 +21,22 @@ logger = logging.getLogger(__name__)
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
async def _milestone_dict(m) -> dict:
d = m.to_dict()
d.update(await get_milestone_progress(m.id))
return d
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
@login_required
async def list_milestones_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
status = request.args.get("status")
milestones = await list_milestones(uid, project_id, status=status)
result = []
for m in milestones:
entry = m.to_dict()
progress = await get_milestone_progress(m.id)
entry.update(progress)
result.append(entry)
result = [await _milestone_dict(m) for m in milestones]
return jsonify({"milestones": result})
@@ -44,7 +46,7 @@ async def create_milestone_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
@@ -55,9 +57,7 @@ async def create_milestone_route(project_id: int):
description=data.get("description"),
order_index=data.get("order_index", 0),
)
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry), 201
return jsonify(await _milestone_dict(milestone)), 201
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
@@ -66,10 +66,8 @@ async def get_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry)
return not_found("Milestone")
return jsonify(await _milestone_dict(milestone))
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
@@ -78,16 +76,14 @@ async def update_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
data = await request.get_json()
allowed = {"title", "description", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed}
updated = await update_milestone(uid, milestone_id, **fields)
if updated is None:
return jsonify({"error": "Milestone not found"}), 404
entry = updated.to_dict()
entry.update(await get_milestone_progress(updated.id))
return jsonify(entry)
return not_found("Milestone")
return jsonify(await _milestone_dict(updated))
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
@@ -96,10 +92,10 @@ async def delete_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
deleted = await delete_milestone(uid, milestone_id)
if not deleted:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
return "", 204
@@ -109,7 +105,7 @@ async def get_milestone_tasks_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
return not_found("Milestone")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
+20 -23
View File
@@ -1,13 +1,13 @@
import asyncio
import logging
import re
from datetime import date
from fabledassistant.services.embeddings import upsert_note_embedding
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.config import Config
from fabledassistant.services.assist import build_assist_messages
from fabledassistant.services.generation_buffer import (
@@ -87,12 +87,9 @@ async def create_note_route():
# Optional task fields
status = data.get("status")
priority = data.get("priority")
due_date = None
if data.get("due_date"):
try:
due_date = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
due_date = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(due_date, tuple):
return due_date
note = await create_note(
uid,
@@ -144,7 +141,7 @@ async def append_tag_route(note_id: int):
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
existing = list(note.tags or [])
if tag not in existing:
@@ -162,7 +159,7 @@ async def get_note_by_title_route():
return jsonify({"error": "title parameter is required"}), 400
note = await get_note_by_title(uid, title)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return jsonify(note.to_dict())
@@ -184,7 +181,7 @@ async def get_note_route(note_id: int):
uid = get_current_user_id()
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return jsonify(note.to_dict())
@@ -200,10 +197,10 @@ async def update_note_route(note_id: int):
if "due_date" in data:
if data["due_date"]:
try:
fields["due_date"] = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
@@ -211,7 +208,7 @@ async def update_note_route(note_id: int):
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
@@ -228,15 +225,15 @@ async def patch_note_route(note_id: int):
if key in data:
fields[key] = data[key]
if "due_date" in data:
try:
fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None
except (ValueError, TypeError):
return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400
result = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return jsonify(note.to_dict())
@@ -246,7 +243,7 @@ async def delete_note_route(note_id: int):
uid = get_current_user_id()
deleted = await delete_note(uid, note_id)
if not deleted:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
return "", 204
@@ -474,7 +471,7 @@ async def upsert_draft_route(note_id: int):
# Verify note ownership
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
return not_found("Note")
data = await request.get_json()
draft = await upsert_draft(
user_id=uid,
@@ -511,5 +508,5 @@ async def get_version_route(note_id: int, version_id: int):
uid = get_current_user_id()
version = await get_version(uid, note_id, version_id)
if version is None:
return jsonify({"error": "Version not found"}), 404
return not_found("Version")
return jsonify(version.to_dict(include_body=True))
+5 -4
View File
@@ -4,6 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found
from fabledassistant.services.milestones import list_milestones
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import (
@@ -52,7 +53,7 @@ async def get_project_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
summary = await get_project_summary(uid, project_id)
data = project.to_dict()
data["summary"] = summary
@@ -68,7 +69,7 @@ async def update_project_route(project_id: int):
fields = {k: v for k, v in data.items() if k in allowed}
project = await update_project(uid, project_id, **fields)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
return jsonify(project.to_dict())
@@ -78,7 +79,7 @@ async def delete_project_route(project_id: int):
uid = get_current_user_id()
deleted = await delete_project(uid, project_id)
if not deleted:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
return "", 204
@@ -88,7 +89,7 @@ async def get_project_notes_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
return not_found("Project")
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
+18 -23
View File
@@ -1,9 +1,8 @@
from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.services.notes import (
create_note,
delete_note,
@@ -28,13 +27,12 @@ async def list_tasks_route():
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
due_before_str = request.args.get("due_before")
due_after_str = request.args.get("due_after")
try:
due_before = date.fromisoformat(due_before_str) if due_before_str else None
due_after = date.fromisoformat(due_after_str) if due_after_str else None
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
if isinstance(due_before, tuple):
return due_before
due_after = parse_iso_date(request.args.get("due_after"), "due_after")
if isinstance(due_after, tuple):
return due_after
tasks, total = await list_notes(
uid,
@@ -61,12 +59,9 @@ async def create_task_route():
body = data.get("body", "") or data.get("description", "")
tags = data.get("tags", [])
due_date = None
if data.get("due_date"):
try:
due_date = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
due_date = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(due_date, tuple):
return due_date
status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value
priority = (
@@ -94,7 +89,7 @@ async def get_task_route(task_id: int):
uid = get_current_user_id()
task = await get_note(uid, task_id)
if task is None:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
data = task.to_dict()
if task.parent_id:
parent = await get_note(uid, task.parent_id)
@@ -120,10 +115,10 @@ async def update_task_route(task_id: int):
if "due_date" in data:
if data["due_date"]:
try:
fields["due_date"] = date.fromisoformat(data["due_date"])
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
@@ -136,7 +131,7 @@ async def update_task_route(task_id: int):
task = await update_note(uid, task_id, **fields)
if task is None:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
return jsonify(task.to_dict())
@@ -156,7 +151,7 @@ async def patch_task_status(task_id: int):
task = await update_note(uid, task_id, status=status_val)
if task is None:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
return jsonify(task.to_dict())
@@ -166,5 +161,5 @@ async def delete_task_route(task_id: int):
uid = get_current_user_id()
deleted = await delete_note(uid, task_id)
if not deleted:
return jsonify({"error": "Task not found"}), 404
return not_found("Task")
return "", 204
+17
View File
@@ -0,0 +1,17 @@
from datetime import date
from quart import jsonify
def not_found(resource: str = "Item"):
return jsonify({"error": f"{resource} not found"}), 404
def parse_iso_date(value: str | None, field: str = "date"):
"""Parse an ISO date string. Returns a date, None, or a (response, 400) tuple."""
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return jsonify({"error": f"Invalid {field} format. Use YYYY-MM-DD."}), 400