"""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')