"""Project management routes.""" import logging from quart import Blueprint, jsonify, request from scribe.auth import login_required, get_current_user_id from scribe.routes.utils import not_found, parse_pagination from scribe.services.milestones import list_milestones from scribe.services.notes import list_notes from scribe.services.projects import ( create_project, delete_project, get_project, get_project_for_user, get_project_summaries, 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: # Batched: four queries plus two, in two sessions, for ALL projects. # This replaced an asyncio.gather over a per-project summary that opened # its own session and then one more per milestone — ~250 concurrent # checkouts against a pool of 15, all waiting out the 30s timeout and # starving every other route on the instance (#2384). # # Grouped by OWNER because a shared project's counts belong to its # owner's records, matching what the per-project path passed. by_owner: dict[int, list[dict]] = {} for p in projects: by_owner.setdefault(p.get("user_id") or uid, []).append(p) for owner_uid, owned in by_owner.items(): try: summaries = await get_project_summaries( owner_uid, [p["id"] for p in owned] ) except Exception: logger.warning("Project summaries failed", exc_info=True) continue for p in owned: if p["id"] in summaries: p["summary"] = summaries[p["id"]] 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', 'paused', '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', 'paused', '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 scribe.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("//coverage", methods=["GET"]) @login_required async def get_coverage_route(project_id: int): """The cached pattern-library coverage summary — never computes. `configured` tells the card whether offering a Refresh button makes sense; `coverage` is null until something has computed it (a webhook push or an explicit refresh). """ from scribe.services.coverage import cached_coverage from scribe.services.forge import get_forge uid = get_current_user_id() result = await get_project_for_user(uid, project_id) if result is None: return not_found("Project") project, _ = result owner_uid = project.user_id or uid return jsonify({ "configured": await get_forge() is not None, "coverage": await cached_coverage(owner_uid, project_id), }) @projects_bp.route("//coverage/refresh", methods=["POST"]) @login_required async def refresh_coverage_route(project_id: int): """Recompute coverage now (archive fetch — seconds, not milliseconds). Synchronous on purpose: the caller is a person who just clicked Refresh and wants the new number, and the forge timeout bounds the wait. """ from scribe.services.coverage import refresh_coverage from scribe.services.forge import ForgeError, get_forge uid = get_current_user_id() result = await get_project_for_user(uid, project_id) if result is None: return not_found("Project") project, _ = result owner_uid = project.user_id or uid if await get_forge() is None: return jsonify({"error": "No git forge is configured (Settings → Config → Git Forge)"}), 400 try: coverage = await refresh_coverage(owner_uid, project_id) except ForgeError as exc: return jsonify({"error": str(exc)}), 502 if coverage is None: return jsonify({ "error": "No bound repo is served by the configured forge — " "bind the project's repo (bind_repo) on a remote the forge hosts" }), 400 return jsonify({"coverage": coverage}) @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 # Use the project owner's uid so the ownership filter on notes/milestones # matches for shared collaborators (who'd otherwise see an empty panel). owner_uid = project.user_id or uid # 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(owner_uid, project_id) milestone_ids = [m.id for m in ms_list] notes, total = await list_notes( owner_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})