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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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__ = (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user