"""Project management routes.""" import asyncio 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, parse_pagination from fabledassistant.services.milestones import list_milestones from fabledassistant.services.notes import list_notes from fabledassistant.services.projects import ( create_project, delete_project, get_project, get_project_for_user, get_project_summary, list_projects_for_user, update_project, ) logger = logging.getLogger(__name__) projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects") @projects_bp.route("", methods=["GET"]) @login_required async def list_projects_route(): uid = get_current_user_id() status = request.args.get("status") include_summary = request.args.get("include_summary", "").lower() in ("1", "true") projects = await list_projects_for_user(uid, status=status) if include_summary: # Fetch all summaries in parallel — one backend pass instead of N+1 frontend calls async def _attach(project_dict: dict) -> dict: try: owner_uid = project_dict.get("user_id") or uid # user_id now in to_dict() summary = await get_project_summary(owner_uid, project_dict["id"]) project_dict["summary"] = summary except Exception: pass return project_dict projects = list(await asyncio.gather(*[_attach(p) for p in projects])) return jsonify({"projects": projects}) @projects_bp.route("", methods=["POST"]) @login_required async def create_project_route(): uid = get_current_user_id() data = await request.get_json() if not data.get("title"): return jsonify({"error": "title is required"}), 400 status = data.get("status", "active") if status not in ("active", "paused", "completed", "archived"): return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 project = await create_project( uid, title=data["title"], description=data.get("description", ""), goal=data.get("goal", ""), color=data.get("color"), status=status, ) return jsonify(project.to_dict()), 201 @projects_bp.route("/", methods=["GET"]) @login_required async def get_project_route(project_id: int): uid = get_current_user_id() result = await get_project_for_user(uid, project_id) if result is None: return not_found("Project") project, permission = result # Summary uses the project owner's uid for stats when viewer is not the owner owner_uid = project.user_id or uid summary = await get_project_summary(owner_uid, project_id) data = project.to_dict() data["summary"] = summary data["permission"] = permission return jsonify(data) @projects_bp.route("/", methods=["PATCH"]) @login_required async def update_project_route(project_id: int): uid = get_current_user_id() data = await request.get_json() allowed = {"title", "description", "goal", "status", "color"} fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed} if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"): return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400 project = await update_project(uid, project_id, **fields) if project is None: return not_found("Project") return jsonify(project.to_dict()) @projects_bp.route("/", methods=["DELETE"]) @login_required async def delete_project_route(project_id: int): uid = get_current_user_id() from fabledassistant.services.trash import delete as trash_delete batch = await trash_delete(uid, "project", project_id) if batch is None: return not_found("Project") return "", 204 @projects_bp.route("//notes", methods=["GET"]) @login_required async def get_project_notes_route(project_id: int): uid = get_current_user_id() result = await get_project_for_user(uid, project_id) if result is None: return not_found("Project") project, _ = result # type filter: "note", "task", or None (both) type_filter = request.args.get("type") status_filter = request.args.get("status") limit, offset = parse_pagination(default_limit=100) is_task: bool | None = None if type_filter == "task": is_task = True elif type_filter == "note": is_task = False ms_list = await list_milestones(uid, project_id) milestone_ids = [m.id for m in ms_list] notes, total = await list_notes( uid, is_task=is_task, status=status_filter, project_id=project_id, milestone_ids=milestone_ids, limit=limit, offset=offset, sort="updated_at", order="desc", ) return jsonify({"notes": [n.to_dict() for n in notes], "total": total})