Spaces:
Sleeping
Sleeping
| """ | |
| Projects API — list and fetch saved projects by authenticated user. | |
| """ | |
| import logging | |
| import uuid | |
| from datetime import datetime | |
| from typing import Any | |
| from fastapi import APIRouter, Depends, HTTPException | |
| from pydantic import BaseModel, field_validator | |
| from sqlalchemy import select | |
| from agent.scaffold import build_publish_repo, slugify | |
| from api.enhance import snapshot_version | |
| from auth.crypto import decrypt | |
| from auth.dependencies import get_current_user | |
| from db.database import SessionLocal | |
| from db.models import Project, User | |
| from integrations.github_client import GitHubError, publish_files | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter(prefix="/projects", tags=["projects"]) | |
| class ProjectSummary(BaseModel): | |
| id: str | |
| title: str | |
| created_at: datetime | |
| updated_at: datetime | |
| class ProjectDetail(ProjectSummary): | |
| session_id: str | |
| brief: str | |
| files: dict | |
| chat: list | |
| oncall_log: list | |
| framework: str = "react" # default for old rows that predate the column | |
| def list_projects(current_user: User = Depends(get_current_user)) -> list[ProjectSummary]: | |
| with SessionLocal() as db: | |
| rows = db.execute( | |
| select(Project) | |
| .where(Project.user_id == current_user.id) | |
| .order_by(Project.created_at.desc()) | |
| ).scalars().all() | |
| return [ | |
| ProjectSummary( | |
| id=str(r.id), | |
| title=r.title, | |
| created_at=r.created_at, | |
| updated_at=r.updated_at, | |
| ) | |
| for r in rows | |
| ] | |
| def get_project(project_id: str, current_user: User = Depends(get_current_user)) -> ProjectDetail: | |
| try: | |
| pid = uuid.UUID(project_id) | |
| except ValueError: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| with SessionLocal() as db: | |
| row = db.get(Project, pid) | |
| if not row or row.user_id != current_user.id: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| return ProjectDetail( | |
| id=str(row.id), | |
| title=row.title, | |
| created_at=row.created_at, | |
| updated_at=row.updated_at, | |
| session_id=row.session_id or "", | |
| brief=row.brief, | |
| files=row.files, | |
| chat=row.chat or [], | |
| oncall_log=row.oncall_log or [], | |
| framework=getattr(row, "framework", None) or "react", | |
| ) | |
| class PublishResponse(BaseModel): | |
| repo_url: str | |
| vercel_import_url: str | |
| def publish_project( | |
| project_id: str, | |
| current_user: User = Depends(get_current_user), | |
| ) -> PublishResponse: | |
| """Build a framework-aware deployable repo from a project's files and push it to GitHub.""" | |
| try: | |
| pid = uuid.UUID(project_id) | |
| except ValueError: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| with SessionLocal() as db: | |
| row = db.get(Project, pid) | |
| if not row or row.user_id != current_user.id: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| enc = getattr(current_user, "github_token_enc", None) | |
| if not (isinstance(enc, str) and enc): | |
| raise HTTPException(status_code=400, detail="Connect GitHub first") | |
| token = decrypt(enc) | |
| # Old rows that predate the framework column default to "react". | |
| framework = getattr(row, "framework", None) or "react" | |
| repo_files = build_publish_repo(row.files, framework, row.title) | |
| repo_name = f"appsmith-{slugify(row.title)}" | |
| try: | |
| repo_url = publish_files(token, repo_name, repo_files) | |
| except GitHubError as exc: | |
| logger.error("GitHub publish failed for project %s: %s", project_id, exc) | |
| raise HTTPException(status_code=502, detail=str(exc)) | |
| except ValueError: | |
| # ponytail: stored token no longer valid — never echo it. | |
| raise HTTPException(status_code=400, detail="GitHub token is no longer valid; reconnect GitHub") | |
| return PublishResponse( | |
| repo_url=repo_url, | |
| vercel_import_url=f"https://vercel.com/new/clone?repository-url={repo_url}", | |
| ) | |
| class HistoryBody(BaseModel): | |
| chat: list[Any] | |
| oncall_log: list[Any] | |
| def must_be_list(cls, v: Any) -> list: | |
| if not isinstance(v, list): | |
| raise ValueError("must be a list") | |
| return v | |
| def update_history( | |
| project_id: str, | |
| body: HistoryBody, | |
| current_user: User = Depends(get_current_user), | |
| ) -> dict: | |
| """Persist chat + oncall_log arrays for a project (owner-gated).""" | |
| try: | |
| pid = uuid.UUID(project_id) | |
| except ValueError: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| with SessionLocal() as db: | |
| row = db.get(Project, pid) | |
| if not row or row.user_id != current_user.id: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| row.chat = body.chat | |
| row.oncall_log = body.oncall_log | |
| db.commit() | |
| return {"ok": True} | |
| class VersionSummary(BaseModel): | |
| id: str | |
| at: str | |
| source: str | |
| label: str | |
| def list_versions(project_id: str, current_user: User = Depends(get_current_user)) -> list[VersionSummary]: | |
| """List a project's saved versions, newest first (metadata only — no files).""" | |
| try: | |
| pid = uuid.UUID(project_id) | |
| except ValueError: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| with SessionLocal() as db: | |
| row = db.get(Project, pid) | |
| if not row or row.user_id != current_user.id: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| versions = list(row.versions or []) | |
| return [ | |
| VersionSummary( | |
| id=str(v.get("id", "")), | |
| at=str(v.get("at", "")), | |
| source=str(v.get("source", "edit")), | |
| label=str(v.get("label", "")), | |
| ) | |
| for v in reversed(versions) | |
| ] | |
| class RevertBody(BaseModel): | |
| version_id: str | |
| def revert_project(project_id: str, body: RevertBody, | |
| current_user: User = Depends(get_current_user)) -> dict: | |
| """Revert a project's files to a saved version. Snapshots the CURRENT files | |
| first (as a "revert" version) so the revert is itself undoable. Returns the | |
| reverted file set.""" | |
| try: | |
| pid = uuid.UUID(project_id) | |
| except ValueError: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| with SessionLocal() as db: | |
| row = db.get(Project, pid) | |
| if not row or row.user_id != current_user.id: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| target = next((v for v in (row.versions or []) if v.get("id") == body.version_id), None) | |
| if not target: | |
| raise HTTPException(status_code=404, detail="Version not found") | |
| # Snapshot current state so the revert can be undone, then restore. | |
| snapshot_version(row, "revert", "before revert") | |
| row.files = target["files"] | |
| db.commit() | |
| files = row.files | |
| return {"project_id": project_id, "files": files} | |
| def delete_project(project_id: str, current_user: User = Depends(get_current_user)) -> dict: | |
| """Delete a project the authed user owns. 404 (not 403) for missing or | |
| someone else's project — we don't reveal that another user's id exists. | |
| Idempotent from the client's view: the chat/oncall_log live inline on the | |
| row, so deleting the row removes the whole session.""" | |
| try: | |
| pid = uuid.UUID(project_id) | |
| except ValueError: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| with SessionLocal() as db: | |
| row = db.get(Project, pid) | |
| if not row or row.user_id != current_user.id: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| db.delete(row) | |
| db.commit() | |
| return {"ok": True} | |