"""Project management routes.""" import logging from quart import Blueprint, g, 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_forges 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({ # The OWNER's keyring (#2778) — whether refresh could do anything, # regardless of who is looking. "configured": (await get_forges(owner_uid, project_id)).configured, "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_forges 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 selector = await get_forges(owner_uid, project_id) if not selector.configured: return jsonify({ "error": "The project owner has no forge connection " "(Settings → Git forges)" }), 400 try: coverage = await refresh_coverage(owner_uid, project_id, selector=selector) except ForgeError as exc: return jsonify({"error": str(exc)}), 502 if coverage is None: return jsonify({ "error": "No bound repo is served by the owner's forge connections — " "bind the project's repo (bind_repo) on a remote a connection hosts" }), 400 return jsonify({"coverage": coverage}) @projects_bp.route("//forge", methods=["PUT"]) @login_required async def set_project_forge_route(project_id: int): """Pin the project to one forge connection, or clear the pin (#2778). Body: {"connection_id": | null}. Owner-or-admin may ask; either way the pin can only reference a connection the project OWNER holds — the service enforces that, so a collaborator's token can never end up serving someone else's project. """ from scribe.services.forge_connections import set_project_pin 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 if permission != "owner" and g.user.role != "admin": return jsonify({"error": "Only the project owner can change its forge"}), 403 data = await request.get_json() or {} raw = data.get("connection_id") if raw is not None and not isinstance(raw, int): return jsonify({"error": "connection_id must be an integer or null"}), 400 owner_uid = project.user_id or uid if not await set_project_pin(owner_uid, project_id, raw): return jsonify({ "error": "That connection does not belong to the project owner" }), 400 return jsonify({"forge_connection_id": raw}) @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})