appsmith-api / api /projects.py
Kakashiix26's picture
version history endpoints + snapshot on change
4e71f9d verified
Raw
History Blame Contribute Delete
8.2 kB
"""
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
@router.get("", response_model=list[ProjectSummary])
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
]
@router.get("/{project_id}", response_model=ProjectDetail)
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
@router.post("/{project_id}/publish", response_model=PublishResponse)
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]
@field_validator("chat", "oncall_log", mode="before")
@classmethod
def must_be_list(cls, v: Any) -> list:
if not isinstance(v, list):
raise ValueError("must be a list")
return v
@router.put("/{project_id}/history")
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
@router.get("/{project_id}/versions", response_model=list[VersionSummary])
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
@router.post("/{project_id}/revert")
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}
@router.delete("/{project_id}")
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}