"""Project and Frame CRUD endpoints.""" import logging from typing import List from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from database import get_db from models import Annotation, Mask, Project, Frame, User from routers.auth import get_current_user, require_editor from schemas import ProjectCopyRequest, ProjectCreate, ProjectOut, ProjectUpdate, FrameCreate, FrameOut from minio_client import get_presigned_url logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["Projects"]) def _next_project_copy_name(db: Session, source_name: str) -> str: base_name = f"{source_name} 副本" existing_names = { row[0] for row in db.query(Project.name) .filter(Project.name.like(f"{base_name}%")) .all() } if base_name not in existing_names: return base_name suffix = 2 while f"{base_name} {suffix}" in existing_names: suffix += 1 return f"{base_name} {suffix}" def _prepare_project_response(project: Project) -> Project: project.frame_count = len(project.frames) if project.thumbnail_url: project.thumbnail_url = get_presigned_url(project.thumbnail_url, expires=3600) return project # --------------------------------------------------------------------------- # Projects # --------------------------------------------------------------------------- @router.post( "", response_model=ProjectOut, status_code=status.HTTP_201_CREATED, summary="Create a new project", ) def create_project( payload: ProjectCreate, db: Session = Depends(get_db), current_user: User = Depends(require_editor), ) -> Project: """Create a new segmentation project.""" project = Project(**payload.model_dump(), owner_user_id=current_user.id) db.add(project) db.commit() db.refresh(project) logger.info("Created project id=%s name=%s", project.id, project.name) return project @router.get( "", response_model=List[ProjectOut], summary="List all projects", ) def list_projects( skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> List[Project]: """Retrieve a paginated list of projects.""" projects = ( db.query(Project) .offset(skip) .limit(limit) .all() ) for p in projects: _prepare_project_response(p) return projects @router.get( "/{project_id}", response_model=ProjectOut, summary="Get a single project", ) def get_project( project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> Project: """Retrieve a project by its ID.""" project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") return _prepare_project_response(project) @router.post( "/{project_id}/copy", response_model=ProjectOut, status_code=status.HTTP_201_CREATED, summary="Copy a project", ) def copy_project( project_id: int, payload: ProjectCopyRequest, db: Session = Depends(get_db), current_user: User = Depends(require_editor), ) -> Project: """Copy a project. Reset copies media/frame sequence; full also copies annotations and mask metadata.""" source = db.query(Project).filter(Project.id == project_id).first() if not source: raise HTTPException(status_code=404, detail="Project not found") next_name = (payload.name or "").strip() if payload.name is not None else "" if not next_name: next_name = _next_project_copy_name(db, source.name) copied = Project( name=next_name, description=source.description, video_path=source.video_path, thumbnail_url=source.thumbnail_url, status=source.status, source_type=source.source_type, original_fps=source.original_fps, parse_fps=source.parse_fps, owner_user_id=current_user.id, ) db.add(copied) db.flush() frame_id_map: dict[int, int] = {} for frame in sorted(source.frames, key=lambda item: item.frame_index): copied_frame = Frame( project_id=copied.id, frame_index=frame.frame_index, image_url=frame.image_url, width=frame.width, height=frame.height, timestamp_ms=frame.timestamp_ms, source_frame_number=frame.source_frame_number, ) db.add(copied_frame) db.flush() frame_id_map[frame.id] = copied_frame.id if payload.mode == "full": for annotation in sorted(source.annotations, key=lambda item: item.id): copied_annotation = Annotation( project_id=copied.id, frame_id=frame_id_map.get(annotation.frame_id) if annotation.frame_id is not None else None, template_id=annotation.template_id, mask_data=annotation.mask_data, points=annotation.points, bbox=annotation.bbox, ) db.add(copied_annotation) db.flush() for mask in annotation.masks: db.add(Mask( annotation_id=copied_annotation.id, mask_url=mask.mask_url, format=mask.format, )) db.commit() db.refresh(copied) logger.info("Copied project id=%s to id=%s mode=%s", project_id, copied.id, payload.mode) return _prepare_project_response(copied) @router.patch( "/{project_id}", response_model=ProjectOut, summary="Update a project", ) def update_project( project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_editor), ) -> Project: """Update project fields partially.""" project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") for key, value in payload.model_dump(exclude_unset=True).items(): if key == "name": value = (value or "").strip() if not value: raise HTTPException(status_code=400, detail="Project name is required") setattr(project, key, value) db.commit() db.refresh(project) logger.info("Updated project id=%s", project_id) return project @router.delete( "/{project_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a project", ) def delete_project( project_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_editor), ) -> None: """Delete a project and all related frames and annotations.""" project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") db.delete(project) db.commit() logger.info("Deleted project id=%s", project_id) # --------------------------------------------------------------------------- # Frames # --------------------------------------------------------------------------- @router.post( "/{project_id}/frames", response_model=FrameOut, status_code=status.HTTP_201_CREATED, summary="Add a frame to a project", ) def create_frame( project_id: int, payload: FrameCreate, db: Session = Depends(get_db), current_user: User = Depends(require_editor), ) -> Frame: """Register a new frame under a project.""" project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") frame = Frame(project_id=project_id, **payload.model_dump(exclude={"project_id"})) db.add(frame) db.commit() db.refresh(frame) return frame @router.get( "/{project_id}/frames", response_model=List[FrameOut], summary="List frames for a project", ) def list_frames( project_id: int, skip: int = 0, limit: int = 1000, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> List[Frame]: """Retrieve all frames belonging to a project.""" project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") frames = ( db.query(Frame) .filter(Frame.project_id == project_id) .order_by(Frame.frame_index) .offset(skip) .limit(limit) .all() ) for frame in frames: frame.image_url = get_presigned_url(frame.image_url, expires=3600) return frames @router.get( "/{project_id}/frames/{frame_id}", response_model=FrameOut, summary="Get a single frame", ) def get_frame( project_id: int, frame_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> Frame: """Retrieve a specific frame by ID.""" frame = ( db.query(Frame) .join(Project, Project.id == Frame.project_id) .filter( Frame.project_id == project_id, Frame.id == frame_id, ) .first() ) if not frame: raise HTTPException(status_code=404, detail="Frame not found") return frame