- 내보내기 엔진 신설 — 캐드 도면(JSON)을 ezdxf 로 DXF 작성(선·폴리선·글자·점·원·호, 도면층 유지), DWG 는 LibreDWG dxf2dwg 를 별도 프로세스로 호출
- POST /{project_id}/drawing-export 신설, 한글 파일명은 RFC 5987 방식으로 전달, 담지 못한 그림 수는 X-Aislo-Skipped 헤더로 통지
- CAD 출력 리본에 「DXF 내보내기」·「DWG 내보내기」 추가 — 캐드가 부모에 요청하면 부모가 서버 파일을 받아 내려받기
- 도각·내보내기 엔드포인트를 B07_DesignDetail_Router_Frame 으로 분리 (700줄 제한)
- 그림(Image)은 DXF 외부 참조 방식이라 제외하고 개수를 안내
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
189 lines
8.4 KiB
Python
189 lines
8.4 KiB
Python
"""B07 도각·내보내기 라우터 (B07_DesignDetail_Router 에서 분리, 700줄 제한).
|
|
|
|
도각을 읽고 고치고 되돌리는 길, 외부 도각 파일(DXF·DWG) 불러오기, 그리고 캐드 도면을
|
|
DXF·DWG 파일로 내보내는 길을 한곳에 둔다.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, File, Response, UploadFile
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import export_drawing
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
|
|
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
|
clear_company_template,
|
|
company_template_path,
|
|
frame_document,
|
|
frame_template_document,
|
|
save_company_template,
|
|
use_company_templates,
|
|
validate_template_entities,
|
|
)
|
|
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
|
DrawingExportRequest,
|
|
FrameTemplateImportResponse,
|
|
FrameTemplateResponse,
|
|
FrameTemplateSaveRequest,
|
|
FrameTemplateSaveResponse,
|
|
)
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from config.config_db import get_db_pool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
|
|
|
|
|
async def _company_dir(project_id: UUID) -> Path:
|
|
"""프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`."""
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
root = Path(resolve_stored_project_path(stored_path)).resolve()
|
|
return root.parent.parent
|
|
|
|
|
|
@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse)
|
|
async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse:
|
|
"""도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본."""
|
|
try:
|
|
company_dir = await _company_dir(project_id)
|
|
use_company_templates(company_dir)
|
|
return FrameTemplateResponse(
|
|
project_id=str(project_id),
|
|
drawing=frame_template_document(),
|
|
customized=company_template_path(company_dir).is_file(),
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도각 조회 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "도각을 읽지 못했습니다."},
|
|
)
|
|
|
|
|
|
# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다.
|
|
_FRAME_IMPORT_MAX_BYTES = 20 * 1024 * 1024
|
|
|
|
|
|
@router.post("/{project_id}/drawing-export")
|
|
async def export_drawing_file(project_id: UUID, request: DrawingExportRequest) -> Response:
|
|
"""캐드 화면의 도면을 DXF·DWG 파일로 내려보낸다 (2026-09-06 사용자 지시).
|
|
|
|
DWG 는 LibreDWG 가 서버에 있을 때만 나간다 — 없으면 「DXF 로 받으라」는 안내로 떨어진다.
|
|
"""
|
|
try:
|
|
data, skipped = await asyncio.to_thread(
|
|
export_drawing, request.drawing, request.file_format
|
|
)
|
|
except ValueError as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도면 내보내기 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "도면을 내보내지 못했습니다."},
|
|
)
|
|
name = re.sub(r"[^0-9A-Za-z가-힣_.-]", "_", request.name or "drawing")[:120] or "drawing"
|
|
# 한글 파일 이름은 헤더에 그대로 못 싣는다(latin-1) — 옛 브라우저용 영문 이름과
|
|
# UTF-8 이름을 함께 준다.
|
|
ascii_name = re.sub(r"[^0-9A-Za-z_.-]", "_", name) or "drawing"
|
|
encoded_name = quote(f"{name}.{request.file_format}")
|
|
return Response(
|
|
content=data,
|
|
media_type="application/octet-stream",
|
|
headers={
|
|
"Content-Disposition": (
|
|
f'attachment; filename="{ascii_name}.{request.file_format}"; '
|
|
f"filename*=UTF-8''{encoded_name}"
|
|
),
|
|
# 그림처럼 못 담은 도형 수 — 화면이 안내 문구를 띄우는 데 쓴다.
|
|
"X-Aislo-Skipped": str(sum(skipped.values())),
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/{project_id}/frame-template/import", response_model=FrameTemplateImportResponse)
|
|
async def import_frame_template(
|
|
project_id: UUID, file: UploadFile = File(...)
|
|
) -> FrameTemplateImportResponse | JSONResponse:
|
|
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다.
|
|
|
|
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
|
|
"""
|
|
try:
|
|
data = await file.read()
|
|
if len(data) > _FRAME_IMPORT_MAX_BYTES:
|
|
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
|
|
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
|
|
validate_template_entities(entities)
|
|
return FrameTemplateImportResponse(
|
|
project_id=str(project_id),
|
|
drawing=frame_document(entities),
|
|
entity_count=len(entities),
|
|
)
|
|
except ValueError as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도각 불러오기 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "도각 파일을 읽지 못했습니다."},
|
|
)
|
|
|
|
|
|
@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
|
async def put_frame_template(
|
|
project_id: UUID, request: FrameTemplateSaveRequest
|
|
) -> FrameTemplateSaveResponse | JSONResponse:
|
|
"""편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다.
|
|
|
|
이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면
|
|
다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정).
|
|
"""
|
|
try:
|
|
entities = request.drawing.get("entities")
|
|
if not isinstance(entities, list):
|
|
raise ValueError("도각 엔티티가 없습니다.")
|
|
company_dir = await _company_dir(project_id)
|
|
await asyncio.to_thread(save_company_template, company_dir, entities)
|
|
return FrameTemplateSaveResponse(project_id=str(project_id))
|
|
except (FileNotFoundError, ValueError) as exc:
|
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도각 저장 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "도각을 저장하지 못했습니다."},
|
|
)
|
|
|
|
|
|
@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
|
async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse:
|
|
"""회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설).
|
|
|
|
되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
|
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다.
|
|
"""
|
|
try:
|
|
company_dir = await _company_dir(project_id)
|
|
removed = await asyncio.to_thread(clear_company_template, company_dir)
|
|
return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed)
|
|
except FileNotFoundError as exc:
|
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
|
except Exception:
|
|
logger.exception("B07 도각 되돌리기 실패: project_id=%s", project_id)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": "기본 도각으로 되돌리지 못했습니다."},
|
|
)
|