اضافه شدن قابلیت مقایسه شماتیک

This commit is contained in:
2026-11-07 15:42:53 +03:30
والد f5da7ee41b
کامیت d2b8ae96c9
28فایلهای تغییر یافته به همراه1012 افزوده شده و 33 حذف شده
+108
مشاهده پرونده
@@ -0,0 +1,108 @@
"""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 .db import Job,sessions
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(gt=0)]=None,
x_api_key:Annotated[str|None,Header()]=None):
require_api(x_api_key)
ttl=expires_in or settings().link_ttl
if ttl>settings().link_ttl:error('invalid_expiry','expires_in exceeds the configured link lifetime',422)
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);now=time.time()
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=now+ttl,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']),Job.expires_at>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')