122 lines
4.8 KiB
Python
122 lines
4.8 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from typing import Optional
|
|
|
|
import structlog
|
|
|
|
from export.parser import IDExportParser
|
|
from word.builder import DocxBuilder
|
|
from services.scan_service import ScanService
|
|
from repositories.conversion_repository import ConversionRepository
|
|
from models.conversion import ConversionResult
|
|
from core.exceptions import ConversionError, ExportParseError, InsufficientCreditsError
|
|
import core.storage as storage
|
|
|
|
log = structlog.get_logger()
|
|
|
|
# In-memory fallback for download when MinIO is not available
|
|
_conversion_results: dict = {}
|
|
|
|
|
|
class ConversionService:
|
|
def __init__(
|
|
self,
|
|
scan_service: ScanService,
|
|
conversion_repo: ConversionRepository,
|
|
):
|
|
self.scan_service = scan_service
|
|
self.conversion_repo = conversion_repo
|
|
self.parser = IDExportParser()
|
|
self.builder = DocxBuilder()
|
|
|
|
async def convert(
|
|
self,
|
|
session_id: str,
|
|
filename: str = 'document',
|
|
user: Optional[dict] = None,
|
|
admin_token: str = '',
|
|
) -> ConversionResult:
|
|
"""Parse the cached export data and build a DOCX.
|
|
|
|
If a user is provided, a PocketBase conversion record is created
|
|
(triggering atomic credit deduction). On failure the record is marked
|
|
failed, which triggers the credit refund hook.
|
|
"""
|
|
cached = self.scan_service.get_cached(session_id)
|
|
if not cached:
|
|
raise ConversionError('Session expired or not found. Please re-upload your file.')
|
|
|
|
data, scan_report = cached
|
|
file_hash = _hash_data(data)
|
|
|
|
# ── Credit deduction (if authenticated) ──────────────────────────────
|
|
conversion_id: Optional[str] = None
|
|
if user and admin_token:
|
|
try:
|
|
conversion_id = await self.conversion_repo.create(
|
|
user['id'], file_hash, admin_token
|
|
)
|
|
except Exception as e:
|
|
error_str = str(e)
|
|
if 'INSUFFICIENT_CREDITS' in error_str:
|
|
raise InsufficientCreditsError('You have no credits remaining.')
|
|
log.error('conversion_record_create_failed', error=error_str)
|
|
raise ConversionError('Failed to register conversion. Please try again.')
|
|
|
|
# ── Build DOCX ───────────────────────────────────────────────────────
|
|
try:
|
|
document = self.parser.parse(data)
|
|
except Exception as e:
|
|
log.error('export_parse_failed', error=str(e))
|
|
await self._mark_failed(conversion_id, admin_token)
|
|
raise ExportParseError(f'Failed to parse export: {e}')
|
|
|
|
try:
|
|
docx_bytes = self.builder.build(document)
|
|
except Exception as e:
|
|
log.error('docx_build_failed', error=str(e))
|
|
await self._mark_failed(conversion_id, admin_token)
|
|
raise ConversionError(f'Failed to build DOCX: {e}')
|
|
|
|
output_filename = (
|
|
filename.replace('.json', '').replace('idconvert_export', 'document') + '.docx'
|
|
)
|
|
|
|
# ── Upload to MinIO or fall back to in-memory ─────────────────────────
|
|
download_url = await self._store(session_id, docx_bytes, output_filename)
|
|
|
|
if conversion_id and admin_token:
|
|
await self.conversion_repo.mark_complete(conversion_id, download_url, admin_token)
|
|
|
|
_conversion_results[session_id] = (docx_bytes, output_filename)
|
|
|
|
return ConversionResult(download_url=download_url, filename=output_filename)
|
|
|
|
async def _store(self, session_id: str, docx_bytes: bytes, filename: str) -> str:
|
|
"""Upload to MinIO and return presigned URL; fall back to local endpoint."""
|
|
try:
|
|
object_key = f'conversions/{session_id}/{filename}'
|
|
return storage.upload_docx(object_key, docx_bytes)
|
|
except Exception as e:
|
|
log.warning('minio_unavailable_fallback', error=str(e))
|
|
return f'/api/download/{session_id}'
|
|
|
|
async def _mark_failed(self, conversion_id: Optional[str], admin_token: str) -> None:
|
|
if conversion_id and admin_token:
|
|
try:
|
|
await self.conversion_repo.mark_failed(conversion_id, admin_token)
|
|
except Exception as e:
|
|
log.error('mark_failed_error', error=str(e))
|
|
|
|
|
|
def get_conversion_result(session_id: str):
|
|
"""Return (docx_bytes, filename) from in-memory store, or None."""
|
|
return _conversion_results.get(session_id)
|
|
|
|
|
|
def _hash_data(data: dict) -> str:
|
|
import json
|
|
raw = json.dumps(data, sort_keys=True).encode()
|
|
return hashlib.sha256(raw).hexdigest()
|