velxio/backend/app/api/routes/projects.py

366 lines
14 KiB
Python

import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, require_auth
from app.database.session import get_db
from app.models.project import Project
from app.models.user import User
from app.schemas.project import (
FileGroup,
ProjectCreateRequest,
ProjectResponse,
ProjectUpdateRequest,
SketchFile,
)
from app.services.metrics import record_project_open, record_save
from app.services.project_files import (
LEGACY_GROUP_KEY,
delete_files,
read_groups,
write_groups,
)
from app.utils.slug import slugify
router = APIRouter()
def _active_group_id(project: Project) -> str:
"""Group ID that holds the active board's files. Used to label legacy
flat-layout files when promoting them into the multi-group response."""
# Try to extract activeFileGroupId from boards_json; fall back to a derived
# name based on board_type. This matches the frontend convention
# `group-${boardId}` (see useSimulatorStore.ts:615).
try:
boards = json.loads(project.boards_json or "[]")
if isinstance(boards, list) and boards:
first = boards[0]
if isinstance(first, dict):
gid = first.get("activeFileGroupId")
if isinstance(gid, str) and gid:
return gid
except (ValueError, TypeError):
pass
return f"group-{project.board_type or 'arduino-uno'}"
def _groups_for_project(project: Project) -> list[FileGroup]:
"""Load files from disk grouped per-board. Promotes legacy flat layouts
into a single group keyed off the project's active board id."""
raw = read_groups(project.id)
if not raw:
if project.code:
# No on-disk files but a legacy code field: synthesise a single
# sketch.ino under the active group.
return [
FileGroup(
groupId=_active_group_id(project),
files=[SketchFile(name="sketch.ino", content=project.code)],
)
]
return []
groups: list[FileGroup] = []
if LEGACY_GROUP_KEY in raw:
legacy_files = raw.pop(LEGACY_GROUP_KEY)
groups.append(
FileGroup(
groupId=_active_group_id(project),
files=[SketchFile(**f) for f in legacy_files],
)
)
for gid, files in raw.items():
groups.append(
FileGroup(groupId=gid, files=[SketchFile(**f) for f in files])
)
return groups
def _files_for_active_board(groups: list[FileGroup], project: Project) -> list[SketchFile]:
"""Pick the SketchFile list of the active board (legacy `files` field)."""
if not groups:
return []
target = _active_group_id(project)
for g in groups:
if g.groupId == target:
return list(g.files)
return list(groups[0].files)
def _to_response(project: Project, owner_username: str) -> ProjectResponse:
groups = _groups_for_project(project)
return ProjectResponse(
id=project.id,
name=project.name,
slug=project.slug,
description=project.description,
is_public=project.is_public,
board_type=project.board_type,
files=_files_for_active_board(groups, project),
file_groups=groups,
code=project.code,
components_json=project.components_json,
wires_json=project.wires_json,
boards_json=project.boards_json or "[]",
owner_username=owner_username,
created_at=project.created_at,
updated_at=project.updated_at,
compile_count=project.compile_count,
compile_error_count=project.compile_error_count,
run_count=project.run_count,
update_count=project.update_count,
last_compiled_at=project.last_compiled_at,
last_run_at=project.last_run_at,
)
def _persist_files_from_body(
project: Project,
body: "ProjectCreateRequest | ProjectUpdateRequest",
) -> None:
"""Write the request body's file representation to disk.
Priority:
1. ``body.file_groups`` (full multi-board layout) — full replace.
2. ``body.files`` (legacy single-board list) — update only the active
group, preserve other groups on disk.
3. ``body.code`` (legacy code field, create only) — wrap as sketch.ino
in the active group.
"""
if body.file_groups is not None:
groups = {
g.groupId: [f.model_dump() for f in g.files] for g in body.file_groups
}
write_groups(project.id, groups)
return
legacy_files: list[dict] | None = None
if body.files is not None:
legacy_files = [f.model_dump() for f in body.files]
elif getattr(body, "code", None):
legacy_files = [{"name": "sketch.ino", "content": body.code}]
if not legacy_files:
return
# Update only the active group, leaving any other groups (multi-board
# projects) intact on disk.
active_gid = _active_group_id(project)
existing = read_groups(project.id)
existing.pop(LEGACY_GROUP_KEY, None)
existing[active_gid] = legacy_files
write_groups(project.id, existing)
async def _unique_slug(db: AsyncSession, user_id: str, base_slug: str) -> str:
slug = base_slug or "project"
counter = 1
while True:
result = await db.execute(
select(Project).where(Project.user_id == user_id, Project.slug == slug)
)
if not result.scalar_one_or_none():
return slug
slug = f"{base_slug}-{counter}"
counter += 1
# ── My projects (literal route — must be before /{project_id}) ───────────────
@router.get("/projects/me", response_model=list[ProjectResponse])
async def my_projects(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth),
):
result = await db.execute(
select(Project).where(Project.user_id == user.id).order_by(Project.updated_at.desc())
)
projects = result.scalars().all()
return [_to_response(p, user.username) for p in projects]
# ── GET by ID ────────────────────────────────────────────────────────────────
@router.get("/projects/{project_id}", response_model=ProjectResponse)
async def get_project_by_id(
project_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_current_user),
):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
is_own = current_user and current_user.id == project.user_id
is_admin = current_user and current_user.is_admin
if not project.is_public and not is_own and not is_admin:
raise HTTPException(status_code=403, detail="This project is private.")
owner_result = await db.execute(select(User).where(User.id == project.user_id))
owner = owner_result.scalar_one_or_none()
# Record open events from non-owners (views), not owner edits.
if not is_own:
await record_project_open(db, user=current_user, project_id=project.id, request=request)
return _to_response(project, owner.username if owner else "")
# ── Create ───────────────────────────────────────────────────────────────────
@router.post("/projects/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(
body: ProjectCreateRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth),
):
base_slug = slugify(body.name) or "project"
slug = await _unique_slug(db, user.id, base_slug)
project = Project(
user_id=user.id,
name=body.name,
slug=slug,
description=body.description,
is_public=body.is_public,
board_type=body.board_type,
code=body.code,
components_json=body.components_json,
wires_json=body.wires_json,
boards_json=body.boards_json or "[]",
)
db.add(project)
await db.commit()
await db.refresh(project)
# Write sketch files to volume (multi-group layout under {pid}/{gid}/...)
_persist_files_from_body(project, body)
await record_save(db, user=user, project=project, is_create=True, request=request)
return _to_response(project, user.username)
# ── Update ───────────────────────────────────────────────────────────────────
@router.put("/projects/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: str,
body: ProjectUpdateRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth),
):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
if project.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden.")
if body.name is not None:
project.name = body.name
new_base = slugify(body.name)
if new_base != project.slug:
project.slug = await _unique_slug(db, user.id, new_base)
if body.description is not None:
project.description = body.description
if body.is_public is not None:
project.is_public = body.is_public
if body.board_type is not None:
project.board_type = body.board_type
if body.code is not None:
project.code = body.code
if body.components_json is not None:
project.components_json = body.components_json
if body.wires_json is not None:
project.wires_json = body.wires_json
if body.boards_json is not None:
project.boards_json = body.boards_json
project.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(project)
# Write updated files to volume (multi-group; legacy `files` only updates
# the active group, leaving other boards intact).
_persist_files_from_body(project, body)
await record_save(db, user=user, project=project, is_create=False, request=request)
return _to_response(project, user.username)
# ── Delete ───────────────────────────────────────────────────────────────────
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth),
):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
if project.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden.")
await db.delete(project)
await db.commit()
delete_files(project_id)
# ── User public projects ─────────────────────────────────────────────────────
@router.get("/user/{username}", response_model=list[ProjectResponse])
async def user_projects(
username: str,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_current_user),
):
result = await db.execute(select(User).where(User.username == username))
owner = result.scalar_one_or_none()
if not owner:
raise HTTPException(status_code=404, detail="User not found.")
is_own = current_user and current_user.id == owner.id
query = select(Project).where(Project.user_id == owner.id)
if not is_own:
query = query.where(Project.is_public == True) # noqa: E712
query = query.order_by(Project.updated_at.desc())
projects = (await db.execute(query)).scalars().all()
return [_to_response(p, owner.username) for p in projects]
# ── Get by username/slug ─────────────────────────────────────────────────────
@router.get("/user/{username}/{slug}", response_model=ProjectResponse)
async def get_project_by_slug(
username: str,
slug: str,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_current_user),
):
result = await db.execute(select(User).where(User.username == username))
owner = result.scalar_one_or_none()
if not owner:
raise HTTPException(status_code=404, detail="User not found.")
result2 = await db.execute(
select(Project).where(Project.user_id == owner.id, Project.slug == slug)
)
project = result2.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
is_own = current_user and current_user.id == owner.id
if not project.is_public and not is_own:
raise HTTPException(status_code=403, detail="This project is private.")
return _to_response(project, owner.username)