109 خطوط
6.1 KiB
Python
109 خطوط
6.1 KiB
Python
"""Comparison routes using the existing Job, storage, authentication and queue."""
|
|
import hashlib
|
|
import json
|
|
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 .expiry import expiration
|
|
from .db import Job,sessions,active_jobs
|
|
from .storage import storage
|
|
from .uploads import read_schematic
|
|
from .models.api import ComparisonStatus,ComparisonUploadResponse
|
|
|
|
FRONTEND=Path(__file__).resolve().parents[2]/'frontend'
|
|
|
|
|
|
def register_comparison_routes(app, *, authorize, require_api, error):
|
|
def comparison(identifier,key,token):
|
|
job=authorize(identifier,key,token,comparison=True)
|
|
if not job.input_key.endswith('/comparison.json'):error('not_found','Comparison not found',404)
|
|
return job
|
|
|
|
def state(job):
|
|
result={'success':job.status!='failed','id':job.id,'comparison_id':job.id,'status':job.status,
|
|
'url':settings().public_url+'/compare/'+job.id,'viewer_url':settings().public_url+'/compare/'+job.id,
|
|
'status_url':settings().public_url+'/api/v1/comparisons/'+job.id,
|
|
'expires_at':job.expires_at,'access':job.access}
|
|
if job.summary:result.update(json.loads(job.summary))
|
|
if job.error_code:result['error']={'code':job.error_code,'message':job.error_message}
|
|
return result
|
|
|
|
@app.post('/api/compare',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
|
|
@app.post('/api/v1/comparisons',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
|
|
async def create(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
|
|
access:Annotated[Literal['private','unlisted'],Form()]='private',
|
|
expires_in:Annotated[int|None,Form(ge=-1, description="-1 for no expiry, or positive seconds")]=None,
|
|
x_api_key:Annotated[str|None,Header()]=None):
|
|
require_api(x_api_key)
|
|
now=time.time();expires_at=expiration(expires_in,now)
|
|
try:
|
|
old_name,old=await read_schematic(old_file)
|
|
new_name,new=await read_schematic(new_file)
|
|
finally:
|
|
await old_file.close();await new_file.close()
|
|
identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32)
|
|
job=Job(id=identifier,filename='Schematic comparison',status='queued',access=access,
|
|
token_hash=hashlib.sha256(token.encode()).hexdigest(),created_at=now,updated_at=now,
|
|
expires_at=expires_at,attempt=0,input_key=identifier+'/comparison.json')
|
|
files={identifier+'/old.SchDoc':old,identifier+'/new.SchDoc':new,
|
|
job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()}
|
|
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']),active_jobs(now)))
|
|
if pending>=settings().max_pending:error('queue_full','Rendering queue is full; try again later',503)
|
|
for key,body in files.items():
|
|
try: storage().put(key,body)
|
|
except Exception: error('storage_unavailable','Upload storage unavailable',503)
|
|
written.append(key)
|
|
db.add(job);db.flush()
|
|
except Exception:
|
|
for key in written:
|
|
try:storage().delete(key)
|
|
except Exception:pass
|
|
raise
|
|
try:await run_in_threadpool(persist)
|
|
except OSError:error('storage_unavailable','Upload storage unavailable',503)
|
|
result=state(job)
|
|
if access=='private':
|
|
result['viewer_token']=token
|
|
result['viewer_url']+='#token='+token
|
|
result['url']=result['viewer_url']
|
|
return JSONResponse(result,status_code=202,headers={'Location':result['status_url'],'Retry-After':'1'})
|
|
|
|
@app.get('/api/compare/{identifier}',response_model=ComparisonStatus,response_model_exclude_unset=True,tags=['Comparisons'])
|
|
@app.get('/api/v1/comparisons/{identifier}',response_model=ComparisonStatus,response_model_exclude_unset=True,tags=['Comparisons'])
|
|
def get(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
|
job=comparison(identifier,x_api_key,x_viewer_token);result=state(job)
|
|
if job.status=='completed':
|
|
try:result.update(json.loads(storage().get(job.output_prefix+'/data.json')))
|
|
except Exception:error('storage_unavailable','Comparison result unavailable',503)
|
|
return result
|
|
|
|
@app.get('/api/v1/comparisons/{identifier}/svg/{side}',response_class=Response,tags=['Comparisons'])
|
|
def svg(identifier:str,side:Literal['old','new'],x_api_key:Annotated[str|None,Header()]=None,
|
|
x_viewer_token:Annotated[str|None,Header()]=None):
|
|
job=comparison(identifier,x_api_key,x_viewer_token)
|
|
if job.status=='failed':error(job.error_code,job.error_message,422)
|
|
if job.status!='completed':error('not_ready','Comparison is still in progress',409)
|
|
try:body=storage().get(job.output_prefix+'/'+side+'.svg')
|
|
except Exception:error('storage_unavailable','Comparison rendering unavailable',503)
|
|
return Response(body,media_type='image/svg+xml')
|
|
|
|
@app.get('/compare',include_in_schema=False)
|
|
def upload_page():return FileResponse(FRONTEND/'compare-upload.html')
|
|
|
|
@app.get('/compare/{identifier}',include_in_schema=False)
|
|
def page(identifier:str):
|
|
import re
|
|
if not re.fullmatch('[a-f0-9]{48}',identifier):error('not_found','Comparison not found',404)
|
|
return FileResponse(FRONTEND/'compare.html')
|