366 lines
14 KiB
Python
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)
|