|
| 1 | +import logging |
| 2 | +import os |
| 3 | +from typing import Annotated |
| 4 | + |
| 5 | +from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile |
| 6 | +from fastapi.responses import Response |
| 7 | +from werkzeug.utils import secure_filename |
| 8 | + |
| 9 | +from lab import Experiment, storage |
| 10 | +from transformerlab.services.permission_service import require_permission |
| 11 | + |
| 12 | +logger = logging.getLogger(__name__) |
| 13 | + |
| 14 | +router = APIRouter(prefix="/notes", tags=["notes"]) |
| 15 | + |
| 16 | +ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg"} |
| 17 | +MAX_ASSET_BYTES = 20 * 1024 * 1024 |
| 18 | + |
| 19 | +IMAGE_MEDIA_TYPES = { |
| 20 | + ".png": "image/png", |
| 21 | + ".jpg": "image/jpeg", |
| 22 | + ".jpeg": "image/jpeg", |
| 23 | + ".gif": "image/gif", |
| 24 | + ".svg": "image/svg+xml", |
| 25 | +} |
| 26 | + |
| 27 | + |
| 28 | +async def read_notes(experimentId: str) -> str: |
| 29 | + """Read notes content, falling back to legacy readme.md at experiment root.""" |
| 30 | + exp_obj = Experiment(experimentId) |
| 31 | + experiment_dir = await exp_obj.get_dir() |
| 32 | + notes_file = storage.join(experiment_dir, "notes", "readme.md") |
| 33 | + |
| 34 | + try: |
| 35 | + async with await storage.open(notes_file, "r", encoding="utf-8") as f: |
| 36 | + return await f.read() |
| 37 | + except FileNotFoundError: |
| 38 | + pass |
| 39 | + |
| 40 | + legacy_file = storage.join(experiment_dir, "readme.md") |
| 41 | + try: |
| 42 | + async with await storage.open(legacy_file, "r", encoding="utf-8") as f: |
| 43 | + return await f.read() |
| 44 | + except FileNotFoundError: |
| 45 | + return "" |
| 46 | + |
| 47 | + |
| 48 | +async def migrate_if_needed(experimentId: str) -> None: |
| 49 | + """Move legacy readme.md → notes/readme.md on first save if needed.""" |
| 50 | + exp_obj = Experiment(experimentId) |
| 51 | + experiment_dir = await exp_obj.get_dir() |
| 52 | + notes_dir = storage.join(experiment_dir, "notes") |
| 53 | + notes_file = storage.join(notes_dir, "readme.md") |
| 54 | + legacy_file = storage.join(experiment_dir, "readme.md") |
| 55 | + |
| 56 | + if await storage.exists(notes_file): |
| 57 | + return |
| 58 | + |
| 59 | + if not await storage.exists(legacy_file): |
| 60 | + return |
| 61 | + |
| 62 | + async with await storage.open(legacy_file, "r", encoding="utf-8") as f: |
| 63 | + content = await f.read() |
| 64 | + |
| 65 | + await storage.makedirs(notes_dir, exist_ok=True) |
| 66 | + |
| 67 | + async with await storage.open(notes_file, "w", encoding="utf-8") as f: |
| 68 | + await f.write(content) |
| 69 | + |
| 70 | + await storage.rm(legacy_file) |
| 71 | + |
| 72 | + |
| 73 | +@router.get("") |
| 74 | +async def get_notes(experimentId: str): |
| 75 | + return await read_notes(experimentId) |
| 76 | + |
| 77 | + |
| 78 | +@router.post("") |
| 79 | +async def save_notes( |
| 80 | + experimentId: str, |
| 81 | + file_contents: Annotated[str, Body()], |
| 82 | + _: None = Depends(require_permission("experiment", "write", id_param="experimentId")), |
| 83 | +): |
| 84 | + await migrate_if_needed(experimentId) |
| 85 | + exp_obj = Experiment(experimentId) |
| 86 | + experiment_dir = await exp_obj.get_dir() |
| 87 | + notes_dir = storage.join(experiment_dir, "notes") |
| 88 | + notes_file = storage.join(notes_dir, "readme.md") |
| 89 | + await storage.makedirs(notes_dir, exist_ok=True) |
| 90 | + # Write a space instead of empty string — some storage backends treat empty files as deletions |
| 91 | + async with await storage.open(notes_file, "w", encoding="utf-8") as f: |
| 92 | + await f.write(file_contents if file_contents.strip() else " ") |
| 93 | + return {"message": "OK"} |
| 94 | + |
| 95 | + |
| 96 | +@router.post("/assets") |
| 97 | +async def upload_asset( |
| 98 | + experimentId: str, |
| 99 | + file: UploadFile, |
| 100 | + _: None = Depends(require_permission("experiment", "write", id_param="experimentId")), |
| 101 | +): |
| 102 | + filename = secure_filename(file.filename or "") |
| 103 | + if not filename: |
| 104 | + raise HTTPException(status_code=400, detail="Invalid filename") |
| 105 | + |
| 106 | + _, ext = os.path.splitext(filename.lower()) |
| 107 | + if ext not in ALLOWED_IMAGE_EXTENSIONS: |
| 108 | + raise HTTPException( |
| 109 | + status_code=400, |
| 110 | + detail=f'File type "{ext}" not allowed. Allowed: {", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS))}', |
| 111 | + ) |
| 112 | + |
| 113 | + content = await file.read() |
| 114 | + if len(content) > MAX_ASSET_BYTES: |
| 115 | + raise HTTPException(status_code=400, detail="File exceeds 20 MB limit") |
| 116 | + |
| 117 | + exp_obj = Experiment(experimentId) |
| 118 | + experiment_dir = await exp_obj.get_dir() |
| 119 | + assets_dir = storage.join(experiment_dir, "notes", "assets") |
| 120 | + await storage.makedirs(assets_dir, exist_ok=True) |
| 121 | + |
| 122 | + asset_path = storage.join(assets_dir, filename) |
| 123 | + async with await storage.open(asset_path, "wb") as f: |
| 124 | + await f.write(content) |
| 125 | + |
| 126 | + return {"path": f"notes/assets/{filename}"} |
| 127 | + |
| 128 | + |
| 129 | +@router.get("/assets/{filename}") |
| 130 | +async def get_asset(experimentId: str, filename: str): |
| 131 | + filename = secure_filename(filename) |
| 132 | + if not filename: |
| 133 | + raise HTTPException(status_code=400, detail="Invalid filename") |
| 134 | + |
| 135 | + _, ext = os.path.splitext(filename.lower()) |
| 136 | + if ext not in ALLOWED_IMAGE_EXTENSIONS: |
| 137 | + raise HTTPException(status_code=400, detail="File type not allowed") |
| 138 | + |
| 139 | + exp_obj = Experiment(experimentId) |
| 140 | + experiment_dir = await exp_obj.get_dir() |
| 141 | + asset_path = storage.join(experiment_dir, "notes", "assets", filename) |
| 142 | + |
| 143 | + if not await storage.exists(asset_path): |
| 144 | + raise HTTPException(status_code=404, detail=f"Asset '{filename}' not found") |
| 145 | + |
| 146 | + async with await storage.open(asset_path, "rb") as f: |
| 147 | + content = await f.read() |
| 148 | + |
| 149 | + return Response( |
| 150 | + content=content, |
| 151 | + media_type=IMAGE_MEDIA_TYPES.get(ext, "application/octet-stream"), |
| 152 | + ) |
0 commit comments