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