idconvert/backend/services/conversion_service.py

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()