Files
ubuntu-post-install/vendor/paintplus/backend/app/routers/patches.py
T
Claude b2d064573f Move ai-stack and paintplus vendored source under vendor/
Matches the existing vendor/easy-asterisk convention (used by
services/asterisk.sh) instead of two one-off top-level directories that
cluttered the repo root and didn't look like anything else next to
setup.sh, lib/, services/, extras/. Only the two services' own SRC_DIR
path resolution and header comments needed updating — nothing else in
the repo referenced the old ./ai-stack / ./paintplus paths.

Also documents vendor/ in README.md's Layout section.
2026-08-04 12:55:09 +00:00

309 lines
9.2 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from typing import List, Optional
import json
from app.database import get_db
from app.models.patch import Patch
from app.models.project import Project
from app.models.edit import Edit
from app.schemas import PatchCreate, PatchResponse, PatchApply, StatusResponse
from app.services.patch_library import PatchLibraryService
from app.config import settings
router = APIRouter(prefix="/patches", tags=["patches"])
@router.post("/", response_model=PatchResponse)
async def create_patch(
name: str = Form(...),
description: Optional[str] = Form(None),
source_type: str = Form(...),
category: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
source_project_id: Optional[int] = Form(None),
source_edit_id: Optional[int] = Form(None),
bbox: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None),
db: Session = Depends(get_db)
):
"""
Create a new patch in the library
Source types:
- ai_generated: From an edit (requires source_edit_id)
- manual_selection: Selected from current image (requires source_project_id and bbox)
- imported: Uploaded file (requires file)
"""
# Validate source_type
if source_type not in ["ai_generated", "manual_selection", "imported"]:
raise HTTPException(status_code=400, detail="Invalid source_type")
# Create patch record
patch = Patch(
name=name,
description=description,
source_type=source_type,
source_project_id=source_project_id,
source_edit_id=source_edit_id,
tags=tags,
category=category,
file_path="", # Will be set after saving
user_id=None # TODO: Add authentication
)
db.add(patch)
db.commit()
db.refresh(patch)
# Save patch file based on source type
patch_service = PatchLibraryService()
try:
if source_type == "ai_generated":
# Get edit directory and save AI-generated patch
if not source_edit_id:
raise HTTPException(status_code=400, detail="source_edit_id required for ai_generated")
edit = db.query(Edit).filter(Edit.id == source_edit_id).first()
if not edit:
raise HTTPException(status_code=404, detail="Edit not found")
from app.services.edit_service import EditService
edit_service = EditService()
edit_dir = edit_service.get_edit_dir(edit.project_id, edit.id)
file_path = patch_service.save_ai_generated_patch(patch.id, edit_dir)
# Get dimensions
width, height = patch_service.get_patch_size(patch.id)
patch.width = width
patch.height = height
elif source_type == "manual_selection":
# Save manually selected patch from current image
if not source_project_id or not bbox:
raise HTTPException(
status_code=400,
detail="source_project_id and bbox required for manual_selection"
)
project = db.query(Project).filter(Project.id == source_project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
bbox_dict = json.loads(bbox) if isinstance(bbox, str) else bbox
file_path = patch_service.save_manual_patch(patch.id, source_project_id, bbox_dict)
patch.width = bbox_dict['width']
patch.height = bbox_dict['height']
elif source_type == "imported":
# Save uploaded file
if not file:
raise HTTPException(status_code=400, detail="file required for imported")
image_bytes = await file.read()
file_path = patch_service.save_patch_from_bytes(patch.id, image_bytes)
# Get dimensions
width, height = patch_service.get_patch_size(patch.id)
patch.width = width
patch.height = height
# Update patch with file path
patch.file_path = file_path
patch.thumbnail_path = str(patch_service.get_thumbnail_path(patch.id))
db.commit()
db.refresh(patch)
return patch
except Exception as e:
# Cleanup on error
patch_service.delete_patch(patch.id)
db.delete(patch)
db.commit()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/", response_model=List[PatchResponse])
def list_patches(
category: Optional[str] = None,
tags: Optional[str] = None,
limit: int = 50,
offset: int = 0,
db: Session = Depends(get_db)
):
"""List patches in the library with optional filtering"""
query = db.query(Patch)
if category:
query = query.filter(Patch.category == category)
if tags:
# Simple tag search (could be improved with full-text search)
query = query.filter(Patch.tags.like(f"%{tags}%"))
patches = query.offset(offset).limit(limit).all()
return patches
@router.get("/{patch_id}", response_model=PatchResponse)
def get_patch(
patch_id: int,
db: Session = Depends(get_db)
):
"""Get patch details"""
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
return patch
@router.get("/{patch_id}/image")
def get_patch_image(
patch_id: int,
thumbnail: bool = False,
db: Session = Depends(get_db)
):
"""Get patch image file"""
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
patch_service = PatchLibraryService()
if thumbnail:
file_path = patch_service.get_thumbnail_path(patch_id)
else:
file_path = patch_service.get_patch_path(patch_id)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Patch image not found")
return FileResponse(file_path, media_type="image/png")
@router.post("/apply", response_model=StatusResponse)
async def apply_patch(
project_id: int = Form(...),
patch_id: int = Form(...),
bbox: str = Form(...),
feather_px: int = Form(5),
db: Session = Depends(get_db)
):
"""
Apply a saved patch to a project image
This creates a new edit in the project history.
"""
# Verify project exists
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Verify patch exists
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
# Parse bbox
bbox_dict = json.loads(bbox) if isinstance(bbox, str) else bbox
# Load current image
from app.services.edit_service import EditService
from PIL import Image
edit_service = EditService()
current_image_path = edit_service.get_current_image_path(project_id)
current_image = Image.open(current_image_path).convert('RGBA')
# Apply patch
patch_service = PatchLibraryService()
result_image = patch_service.apply_patch_to_image(
patch_id,
current_image,
bbox_dict,
feather_px
)
# Save result as current image
result_image.save(current_image_path)
# Create edit record
edit = Edit(
project_id=project_id,
mode="patch_library",
prompt=f"Applied saved patch: {patch.name}",
selection_type="rectangle",
bbox_json=json.dumps(bbox_dict),
feather_px=feather_px,
ai_provider="patch_library",
status="completed"
)
db.add(edit)
db.commit()
return StatusResponse(
status="success",
message=f"Applied patch '{patch.name}' to project",
data={"edit_id": edit.id}
)
@router.delete("/{patch_id}", response_model=StatusResponse)
def delete_patch(
patch_id: int,
db: Session = Depends(get_db)
):
"""Delete a patch from the library"""
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
# Delete files
patch_service = PatchLibraryService()
patch_service.delete_patch(patch_id)
# Delete record
db.delete(patch)
db.commit()
return StatusResponse(
status="success",
message=f"Deleted patch '{patch.name}'"
)
@router.put("/{patch_id}", response_model=PatchResponse)
def update_patch(
patch_id: int,
name: Optional[str] = None,
description: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Update patch metadata"""
patch = db.query(Patch).filter(Patch.id == patch_id).first()
if not patch:
raise HTTPException(status_code=404, detail="Patch not found")
if name:
patch.name = name
if description is not None:
patch.description = description
if category:
patch.category = category
if tags is not None:
patch.tags = tags
db.commit()
db.refresh(patch)
return patch