@@ -0,0 +1,130 @@
|
||||
"""Two PCB creation endpoints using the existing durable queue and capabilities."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
from fastapi import File, Form, Header, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from sqlalchemy import select, func, text
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .config import settings
|
||||
from .db import Job, sessions
|
||||
from .storage import storage
|
||||
from .uploads import read_pcb
|
||||
from .models.pcb import PCBData, PCBStatus, PCBUploadResponse, PCBComparisonStatus, PCBComparisonUploadResponse
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
|
||||
|
||||
|
||||
def register_pcb_routes(app, *, authorize, require_api, error):
|
||||
def state(job, comparison=False):
|
||||
base = settings().public_url
|
||||
api = f'/api/v1/pcbs/comparisons/{job.id}' if comparison else f'/api/v1/pcbs/{job.id}'
|
||||
view = f'/compare/pcb/{job.id}' if comparison else f'/view/pcb/{job.id}'
|
||||
result = dict(success=job.status != 'failed', id=job.id, status=job.status,
|
||||
document_type='pcb_comparison' if comparison else 'pcb',
|
||||
viewer_url=base+view, status_url=base+api+('' if comparison else '/status'),
|
||||
access=job.access, expires_at=job.expires_at)
|
||||
if comparison: result.update(comparison_id=job.id, url=base+view)
|
||||
if job.summary: result.update(json.loads(job.summary))
|
||||
if job.error_code: result['error'] = dict(code=job.error_code, message=job.error_message)
|
||||
return result
|
||||
|
||||
async def create(files, access, expires_in, comparison):
|
||||
ttl = expires_in or settings().link_ttl
|
||||
if ttl > settings().link_ttl: error('invalid_expiry','expires_in exceeds configured link lifetime',422)
|
||||
sources = {}
|
||||
try:
|
||||
for field, file in files.items(): sources[field] = await read_pcb(file)
|
||||
finally:
|
||||
for file in files.values(): await file.close()
|
||||
identifier = secrets.token_hex(24); token = secrets.token_urlsafe(32); now = time.time()
|
||||
input_name = 'pcb-comparison.json' if comparison else 'input.PcbDoc'
|
||||
job = Job(id=identifier, filename='PCB comparison' if comparison else sources['file'][0],
|
||||
status='queued', access=access, token_hash=hashlib.sha256(token.encode()).hexdigest(),
|
||||
created_at=now, updated_at=now, expires_at=now+ttl, attempt=0, input_key=f'{identifier}/{input_name}')
|
||||
if comparison:
|
||||
bodies = {f'{identifier}/{side}.PcbDoc':sources[side+'_file'][1] for side in ('old','new')}
|
||||
bodies[job.input_key] = json.dumps(dict(schema_version='1.0',document_type='pcb_comparison',
|
||||
old_filename=sources['old_file'][0],new_filename=sources['new_file'][0])).encode()
|
||||
else: bodies = {job.input_key:sources['file'][1]}
|
||||
def persist():
|
||||
written = []
|
||||
try:
|
||||
with sessions().begin() as db:
|
||||
if db.bind.dialect.name == 'postgresql': db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
|
||||
pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),Job.expires_at>now))
|
||||
if pending >= settings().max_pending: error('queue_full','Rendering queue is full; try again later',503)
|
||||
for key, body in bodies.items():
|
||||
# Include the attempted key so a write-then-raise backend is also cleaned up.
|
||||
written.append(key)
|
||||
try: storage().put(key,body)
|
||||
except Exception: error('storage_unavailable','Upload storage unavailable',503)
|
||||
db.add(job); db.flush()
|
||||
except Exception:
|
||||
for key in written:
|
||||
try: storage().delete(key)
|
||||
except Exception: pass
|
||||
raise
|
||||
await run_in_threadpool(persist)
|
||||
result = state(job,comparison)
|
||||
if access == 'private':
|
||||
result['viewer_token'] = token; result['viewer_url'] += '#token='+token
|
||||
if comparison: result['url'] = result['viewer_url']
|
||||
return JSONResponse(result,status_code=202,headers={'Location':result['status_url'],'Retry-After':'1'})
|
||||
|
||||
@app.post('/api/v1/pcbs/render', status_code=202, response_model=PCBUploadResponse, response_model_exclude_none=True, tags=['PCB'])
|
||||
async def render(file:Annotated[UploadFile,File()], access:Annotated[Literal['private','unlisted'],Form()]='private',
|
||||
expires_in:Annotated[int|None,Form(gt=0)]=None, x_api_key:Annotated[str|None,Header()]=None):
|
||||
require_api(x_api_key)
|
||||
return await create({'file':file},access,expires_in,False)
|
||||
|
||||
@app.post('/api/v1/pcbs/comparisons',status_code=202,response_model=PCBComparisonUploadResponse,response_model_exclude_none=True,tags=['PCB'])
|
||||
async def compare(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
|
||||
access:Annotated[Literal['private','unlisted'],Form()]='private',
|
||||
expires_in:Annotated[int|None,Form(gt=0)]=None,x_api_key:Annotated[str|None,Header()]=None):
|
||||
require_api(x_api_key)
|
||||
return await create({'old_file':old_file,'new_file':new_file},access,expires_in,True)
|
||||
|
||||
def get_job(identifier,key,token,comparison=False):
|
||||
return authorize(identifier,key,token,resource_kind='pcb_comparison' if comparison else 'pcb_render')
|
||||
|
||||
def artifact(job,name):
|
||||
if job.status == 'failed': error(job.error_code,job.error_message,422)
|
||||
if job.status != 'completed': error('not_ready','PCB processing is still in progress',409)
|
||||
try: body = storage().get(job.output_prefix+'/'+name)
|
||||
except Exception: error('storage_unavailable','PCB artifact unavailable',503)
|
||||
return Response(body,media_type='application/json' if name.endswith('.json') else 'image/svg+xml')
|
||||
|
||||
@app.get('/api/v1/pcbs/comparisons/{identifier}',response_model=PCBComparisonStatus,response_model_exclude_none=True,tags=['PCB'])
|
||||
def comparison_status(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
job=get_job(identifier,x_api_key,x_viewer_token,True); result=state(job,True)
|
||||
if job.status == 'completed':
|
||||
try: result.update(json.loads(storage().get(job.output_prefix+'/data.json')))
|
||||
except Exception: error('storage_unavailable','PCB comparison unavailable',503)
|
||||
return result
|
||||
|
||||
@app.get('/api/v1/pcbs/comparisons/{identifier}/svg/{side}',response_class=Response,tags=['PCB'])
|
||||
def comparison_svg(identifier:str,side:Literal['old','new'],x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return artifact(get_job(identifier,x_api_key,x_viewer_token,True),side+'.svg')
|
||||
|
||||
@app.get('/api/v1/pcbs/{identifier}/status',response_model=PCBStatus,response_model_exclude_none=True,tags=['PCB'])
|
||||
def status(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return state(get_job(identifier,x_api_key,x_viewer_token))
|
||||
|
||||
@app.get('/api/v1/pcbs/{identifier}/data',response_model=PCBData,tags=['PCB'])
|
||||
def data(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return artifact(get_job(identifier,x_api_key,x_viewer_token),'data.json')
|
||||
|
||||
@app.get('/api/v1/pcbs/{identifier}/svg',response_class=Response,tags=['PCB'])
|
||||
def svg(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return artifact(get_job(identifier,x_api_key,x_viewer_token),'drawing.svg')
|
||||
|
||||
@app.get('/view/pcb/{identifier}',include_in_schema=False)
|
||||
@app.get('/compare/pcb/{identifier}',include_in_schema=False)
|
||||
def page(identifier:str):
|
||||
if not re.fullmatch('[a-f0-9]{48}',identifier): error('not_found','PCB not found',404)
|
||||
return FileResponse(FRONTEND/'pcb.html')
|
||||
مرجع در شماره جدید
مسدود کردن کاربر