added web service
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_token
|
||||
from app.core.database import get_db
|
||||
from app.models import Component, LlmMessage, Project, ProjectParam
|
||||
from app.schemas import (
|
||||
ExportRequest,
|
||||
GenerateTableRequest,
|
||||
InscriptionsUpdate,
|
||||
LlmApplyRequest,
|
||||
LlmChatRequest,
|
||||
LlmChatResponse,
|
||||
LlmEdit,
|
||||
ProjectCreate,
|
||||
ProjectOut,
|
||||
ProjectUpdate,
|
||||
TableRowsPatch,
|
||||
TableRowsResponse,
|
||||
)
|
||||
from app.services.excel_export import export_xlsx
|
||||
from app.services.llm import chat_edit_table
|
||||
from app.services.pdf_export import export_pdf
|
||||
from app.services.project_service import get_project_full, ingest_zip, project_dir
|
||||
from app.services.table_service import (
|
||||
TABLE_TYPES,
|
||||
apply_llm_edits,
|
||||
generate_table,
|
||||
get_inscriptions,
|
||||
get_rows,
|
||||
patch_rows,
|
||||
set_inscriptions,
|
||||
)
|
||||
from app.services import table_service
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
secured = APIRouter(dependencies=[Depends(require_token)])
|
||||
|
||||
|
||||
def _project_out(db: Session, project: Project) -> ProjectOut:
|
||||
variants = [v.name for v in project.variants] if project.variants else []
|
||||
component_count = db.scalar(
|
||||
select(func.count()).select_from(Component).where(Component.project_id == project.id)
|
||||
) or 0
|
||||
layer_count = project.pcb_data.layer_count if project.pcb_data else 0
|
||||
return ProjectOut(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
status=project.status,
|
||||
current_variant=project.current_variant,
|
||||
zip_path=project.zip_path,
|
||||
pcb_doc_name=project.pcb_doc_name,
|
||||
decimal_number=project.decimal_number,
|
||||
board_name=project.board_name,
|
||||
error_message=project.error_message,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
variants=variants,
|
||||
component_count=component_count,
|
||||
layer_count=layer_count,
|
||||
)
|
||||
|
||||
|
||||
def _get_project(db: Session, project_id: int) -> Project:
|
||||
project = get_project_full(db, project_id)
|
||||
if not project:
|
||||
raise HTTPException(404, "Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@secured.get("/projects", response_model=list[ProjectOut])
|
||||
def list_projects(db: Session = Depends(get_db)):
|
||||
projects = db.scalars(select(Project).order_by(Project.id.desc())).all()
|
||||
result = []
|
||||
for p in projects:
|
||||
full = get_project_full(db, p.id) or p
|
||||
result.append(_project_out(db, full))
|
||||
return result
|
||||
|
||||
|
||||
@secured.post("/projects", response_model=ProjectOut)
|
||||
def create_project(body: ProjectCreate, db: Session = Depends(get_db)):
|
||||
project = Project(name=body.name, status="created")
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
project_dir(project.id)
|
||||
return _project_out(db, project)
|
||||
|
||||
|
||||
@secured.get("/projects/{project_id}", response_model=ProjectOut)
|
||||
def get_project(project_id: int, db: Session = Depends(get_db)):
|
||||
return _project_out(db, _get_project(db, project_id))
|
||||
|
||||
|
||||
@secured.patch("/projects/{project_id}", response_model=ProjectOut)
|
||||
def update_project(project_id: int, body: ProjectUpdate, db: Session = Depends(get_db)):
|
||||
project = _get_project(db, project_id)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(project, field, value)
|
||||
db.commit()
|
||||
return _project_out(db, _get_project(db, project_id))
|
||||
|
||||
|
||||
@secured.delete("/projects/{project_id}")
|
||||
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
||||
project = db.get(Project, project_id)
|
||||
if not project:
|
||||
raise HTTPException(404, "Project not found")
|
||||
db.delete(project)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@secured.post("/projects/{project_id}/upload", response_model=ProjectOut)
|
||||
async def upload_zip(
|
||||
project_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
project = _get_project(db, project_id)
|
||||
data = await file.read()
|
||||
try:
|
||||
ingest_zip(db, project, data, filename=file.filename or "project.zip", keep_tables=False)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return _project_out(db, _get_project(db, project_id))
|
||||
|
||||
|
||||
@secured.put("/projects/{project_id}/upload", response_model=ProjectOut)
|
||||
async def reupload_zip(
|
||||
project_id: int,
|
||||
file: UploadFile = File(...),
|
||||
keep_tables: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
project = _get_project(db, project_id)
|
||||
data = await file.read()
|
||||
try:
|
||||
ingest_zip(
|
||||
db,
|
||||
project,
|
||||
data,
|
||||
filename=file.filename or "project.zip",
|
||||
keep_tables=keep_tables,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return _project_out(db, _get_project(db, project_id))
|
||||
|
||||
|
||||
@secured.get("/projects/{project_id}/components")
|
||||
def list_components(project_id: int, db: Session = Depends(get_db)):
|
||||
project = _get_project(db, project_id)
|
||||
views = table_service.load_components_for_variant(db, project)
|
||||
return [
|
||||
{
|
||||
"designator": v.designator,
|
||||
"properties": v.properties,
|
||||
"is_fitted": v.is_fitted,
|
||||
}
|
||||
for v in views
|
||||
]
|
||||
|
||||
|
||||
@secured.get("/projects/{project_id}/params")
|
||||
def list_params(project_id: int, db: Session = Depends(get_db)):
|
||||
_get_project(db, project_id)
|
||||
rows = db.scalars(select(ProjectParam).where(ProjectParam.project_id == project_id)).all()
|
||||
return [
|
||||
{"id": r.id, "name": r.name, "value": r.value, "variant_name": r.variant_name}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@secured.get("/projects/{project_id}/pcb")
|
||||
def get_pcb(project_id: int, db: Session = Depends(get_db)):
|
||||
project = _get_project(db, project_id)
|
||||
if not project.pcb_data:
|
||||
return {"layer_count": 0, "materials": []}
|
||||
return {
|
||||
"layer_count": project.pcb_data.layer_count,
|
||||
"materials": [
|
||||
{
|
||||
"name": m.name,
|
||||
"value": m.value,
|
||||
"height": m.height,
|
||||
"diel_type": m.diel_type,
|
||||
"layer_number": m.layer_number,
|
||||
}
|
||||
for m in project.pcb_data.diel_materials
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@secured.get("/projects/{project_id}/inscriptions")
|
||||
def read_inscriptions(project_id: int, db: Session = Depends(get_db)):
|
||||
_get_project(db, project_id)
|
||||
return get_inscriptions(db, project_id)
|
||||
|
||||
|
||||
@secured.patch("/projects/{project_id}/inscriptions")
|
||||
def update_inscriptions(project_id: int, body: InscriptionsUpdate, db: Session = Depends(get_db)):
|
||||
_get_project(db, project_id)
|
||||
return set_inscriptions(db, project_id, body.inscriptions)
|
||||
|
||||
|
||||
@secured.get("/projects/{project_id}/tables/{table_type}", response_model=TableRowsResponse)
|
||||
def read_table(project_id: int, table_type: str, db: Session = Depends(get_db)):
|
||||
_get_project(db, project_id)
|
||||
if table_type not in TABLE_TYPES:
|
||||
raise HTTPException(400, f"Unknown table type. Use one of: {TABLE_TYPES}")
|
||||
return TableRowsResponse(table_type=table_type, rows=get_rows(db, project_id, table_type))
|
||||
|
||||
|
||||
@secured.patch("/projects/{project_id}/tables/{table_type}", response_model=TableRowsResponse)
|
||||
def update_table(
|
||||
project_id: int,
|
||||
table_type: str,
|
||||
body: TableRowsPatch,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_get_project(db, project_id)
|
||||
if table_type not in TABLE_TYPES:
|
||||
raise HTTPException(400, f"Unknown table type")
|
||||
rows = patch_rows(db, project_id, table_type, body.rows)
|
||||
return TableRowsResponse(table_type=table_type, rows=rows)
|
||||
|
||||
|
||||
@secured.post("/projects/{project_id}/tables/{table_type}/generate", response_model=TableRowsResponse)
|
||||
def generate(
|
||||
project_id: int,
|
||||
table_type: str,
|
||||
body: GenerateTableRequest | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
project = _get_project(db, project_id)
|
||||
if table_type not in TABLE_TYPES:
|
||||
raise HTTPException(400, f"Unknown table type")
|
||||
body = body or GenerateTableRequest()
|
||||
try:
|
||||
rows = generate_table(
|
||||
db,
|
||||
project,
|
||||
table_type,
|
||||
name_field=body.name_field,
|
||||
tech_reserve_percent=body.tech_reserve_percent,
|
||||
boards_count=body.boards_count,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return TableRowsResponse(table_type=table_type, rows=rows)
|
||||
|
||||
|
||||
@secured.post("/projects/{project_id}/export")
|
||||
def export_document(project_id: int, body: ExportRequest, db: Session = Depends(get_db)):
|
||||
project = _get_project(db, project_id)
|
||||
if body.table_type not in TABLE_TYPES:
|
||||
raise HTTPException(400, "Unknown table type")
|
||||
rows = get_rows(db, project_id, body.table_type)
|
||||
if not rows:
|
||||
raise HTTPException(400, "Table is empty. Generate it first.")
|
||||
inscriptions = get_inscriptions(db, project_id)
|
||||
|
||||
if body.format == "xlsx":
|
||||
data = export_xlsx(body.table_type, rows, title=project.name)
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{project.name}_{body.table_type}.xlsx"'
|
||||
},
|
||||
)
|
||||
if body.format == "pdf":
|
||||
try:
|
||||
data = export_pdf(body.table_type, rows, inscriptions)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{project.name}_{body.table_type}.pdf"'
|
||||
},
|
||||
)
|
||||
raise HTTPException(400, "format must be pdf or xlsx")
|
||||
|
||||
|
||||
@secured.post("/projects/{project_id}/llm/chat", response_model=LlmChatResponse)
|
||||
async def llm_chat(project_id: int, body: LlmChatRequest, db: Session = Depends(get_db)):
|
||||
_get_project(db, project_id)
|
||||
if body.table_type not in TABLE_TYPES:
|
||||
raise HTTPException(400, "Unknown table type")
|
||||
rows = get_rows(db, project_id, body.table_type)
|
||||
db.add(
|
||||
LlmMessage(
|
||||
project_id=project_id,
|
||||
role="user",
|
||||
content=body.message,
|
||||
table_type=body.table_type,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
try:
|
||||
result = await chat_edit_table(body.message, body.table_type, rows)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"LLM request failed: {e}") from e
|
||||
|
||||
msg = LlmMessage(
|
||||
project_id=project_id,
|
||||
role="assistant",
|
||||
content=result["reply"],
|
||||
table_type=body.table_type,
|
||||
proposed_edits=json.dumps(result["edits"], ensure_ascii=False),
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
db.refresh(msg)
|
||||
return LlmChatResponse(
|
||||
reply=result["reply"],
|
||||
edits=[LlmEdit(**e) for e in result["edits"]],
|
||||
message_id=msg.id,
|
||||
)
|
||||
|
||||
|
||||
@secured.post("/projects/{project_id}/llm/apply", response_model=TableRowsResponse)
|
||||
def llm_apply(project_id: int, body: LlmApplyRequest, db: Session = Depends(get_db)):
|
||||
_get_project(db, project_id)
|
||||
if body.table_type not in TABLE_TYPES:
|
||||
raise HTTPException(400, "Unknown table type")
|
||||
edits = [e.model_dump() for e in body.edits]
|
||||
rows = apply_llm_edits(db, project_id, body.table_type, edits)
|
||||
return TableRowsResponse(table_type=body.table_type, rows=rows)
|
||||
|
||||
|
||||
@secured.get("/projects/{project_id}/llm/history")
|
||||
def llm_history(project_id: int, db: Session = Depends(get_db)):
|
||||
_get_project(db, project_id)
|
||||
msgs = db.scalars(
|
||||
select(LlmMessage)
|
||||
.where(LlmMessage.project_id == project_id)
|
||||
.order_by(LlmMessage.id.asc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"table_type": m.table_type,
|
||||
"proposed_edits": json.loads(m.proposed_edits) if m.proposed_edits else None,
|
||||
"created_at": m.created_at,
|
||||
}
|
||||
for m in msgs
|
||||
]
|
||||
|
||||
router.include_router(secured)
|
||||
|
||||
Reference in New Issue
Block a user