Spaces:
Running
Running
| from __future__ import annotations | |
| import io | |
| import os | |
| import platform | |
| import tarfile | |
| import time | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from fastapi import APIRouter, Depends, File, HTTPException, UploadFile | |
| from fastapi.responses import StreamingResponse | |
| from app.api.deps import get_extraction_service | |
| from app.config import get_settings | |
| from app.core.constants import ( | |
| ARCHIVE_EXTENSIONS, | |
| AUDIO_EXTENSIONS, | |
| DOCUMENT_EXTENSIONS, | |
| IMAGE_EXTENSIONS, | |
| OFFICE_EXTENSIONS, | |
| SUPPORTED_EXTENSIONS, | |
| TEXT_EXTENSIONS, | |
| WEB_EXTENSIONS, | |
| ) | |
| from app.core.logger import get_logger | |
| from app.core.thread_pool import run_in_executor | |
| from app.models.schemas import ( | |
| HealthResponse, | |
| InfoResponse, | |
| SpacyLabelsResponse, | |
| SupportedFormatsResponse, | |
| ) | |
| from app.services.extraction_service import ExtractionService | |
| router = APIRouter() # system routes | |
| _settings = get_settings() | |
| _START_TIME = time.time() | |
| _logger = get_logger(__name__) | |
| _MAINTENANCE_MODE: bool = False | |
| def _maintenance_lock_path() -> Path: | |
| return _get_data_dir() / ".maintenance.lock" | |
| def is_maintenance() -> bool: | |
| if _MAINTENANCE_MODE: | |
| return True | |
| if os.environ.get("APP_UNDER_MAINTENANCE", "").lower() in ("1", "true", "yes"): | |
| return True | |
| lock_path = _maintenance_lock_path() | |
| return lock_path.is_file() | |
| async def health(): | |
| return HealthResponse( | |
| success=True, | |
| status="ok", | |
| version=_settings.app_version, | |
| uptime_seconds=round(time.time() - _START_TIME, 2), | |
| timestamp=datetime.now(timezone.utc).isoformat(), | |
| ) | |
| async def info(): | |
| return InfoResponse( | |
| success=True, | |
| app=_settings.app_name, | |
| version=_settings.app_version, | |
| python_version=platform.python_version(), | |
| platform=platform.system(), | |
| uptime_seconds=round(time.time() - _START_TIME, 2), | |
| max_upload_mb=_settings.max_upload_mb, | |
| supported_extensions=len(SUPPORTED_EXTENSIONS), | |
| timestamp=datetime.now(timezone.utc).isoformat(), | |
| ) | |
| async def list_formats(): | |
| by_category = { | |
| "documents": [e for e in SUPPORTED_EXTENSIONS if e in DOCUMENT_EXTENSIONS], | |
| "office": [e for e in SUPPORTED_EXTENSIONS if e in OFFICE_EXTENSIONS], | |
| "data": [e for e in SUPPORTED_EXTENSIONS if e in {".csv", ".json", ".xml"}], | |
| "web": [e for e in SUPPORTED_EXTENSIONS if e in WEB_EXTENSIONS], | |
| "text": [e for e in SUPPORTED_EXTENSIONS if e in TEXT_EXTENSIONS], | |
| "images": [e for e in SUPPORTED_EXTENSIONS if e in IMAGE_EXTENSIONS], | |
| "audio": [e for e in SUPPORTED_EXTENSIONS if e in AUDIO_EXTENSIONS], | |
| "archives": [e for e in SUPPORTED_EXTENSIONS if e in ARCHIVE_EXTENSIONS], | |
| } | |
| return SupportedFormatsResponse( | |
| success=True, | |
| total_count=len(SUPPORTED_EXTENSIONS), | |
| all_extensions=sorted(SUPPORTED_EXTENSIONS), | |
| by_category={k: sorted(v) for k, v in by_category.items()}, | |
| ) | |
| async def list_spacy_labels( | |
| extraction_service: ExtractionService = Depends(get_extraction_service), | |
| ): | |
| return SpacyLabelsResponse( | |
| success=True, | |
| spacy_labels=extraction_service.get_spacy_labels(), | |
| source_types={ | |
| "entity": "Extract using spaCy NER labels (ORG, PERSON, DATE, etc.)", | |
| "regex": "Extract using custom regular expressions", | |
| "token_attr": "Extract using token attributes (text, pos_, tag_, etc.)", | |
| }, | |
| example_mappings={ | |
| "company": {"source_type": "entity", "label": "ORG"}, | |
| "person": {"source_type": "entity", "label": "PERSON"}, | |
| "date": {"source_type": "entity", "label": "DATE"}, | |
| "money": {"source_type": "entity", "label": "MONEY"}, | |
| "email": { | |
| "source_type": "regex", | |
| "pattern": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", | |
| }, | |
| "phone": {"source_type": "regex", "pattern": r"\b\d{3}-\d{3}-\d{4}\b"}, | |
| }, | |
| ) | |
| def _build_archive(data_dir: Path) -> io.BytesIO: | |
| buf = io.BytesIO() | |
| with tarfile.open(fileobj=buf, mode="w:gz") as tar: | |
| for path in sorted(data_dir.rglob("*")): | |
| if path.is_file(): | |
| arcname = path.relative_to(data_dir.parent) | |
| tar.add(str(path), arcname=str(arcname)) | |
| buf.seek(0) | |
| return buf | |
| def _extract_archive(content: bytes, data_dir: Path) -> int: | |
| restored = 0 | |
| with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as tar: | |
| for member in tar.getmembers(): | |
| if member.isfile(): | |
| tar.extract(member, path=data_dir.parent) | |
| restored += 1 | |
| return restored | |
| def _get_data_dir() -> Path: | |
| raw = _settings.data_dir | |
| p = Path(raw) | |
| if not p.is_absolute(): | |
| p = Path.cwd() / p | |
| return p.resolve() | |
| async def download_backup(): | |
| data_dir = _get_data_dir() | |
| if not data_dir.is_dir(): | |
| raise HTTPException(status_code=404, detail="Data directory not found") | |
| try: | |
| archive = await run_in_executor(_build_archive, data_dir) | |
| except Exception as exc: | |
| _logger.error("Backup creation failed: %s", exc) | |
| raise HTTPException(status_code=500, detail=f"Backup failed: {exc}") | |
| return StreamingResponse( | |
| archive, | |
| media_type="application/gzip", | |
| headers={"Content-Disposition": "attachment; filename=backup.tar.gz"}, | |
| ) | |
| async def upload_and_restore( | |
| file: UploadFile = File(...), | |
| ): | |
| data_dir = _get_data_dir() | |
| data_dir.mkdir(parents=True, exist_ok=True) | |
| try: | |
| content = await file.read() | |
| except Exception as exc: | |
| raise HTTPException(status_code=400, detail=f"Failed to read upload: {exc}") | |
| if not content: | |
| raise HTTPException(status_code=400, detail="Empty file") | |
| restored = 0 | |
| try: | |
| restored = await run_in_executor(_extract_archive, content, data_dir) | |
| except tarfile.TarError as exc: | |
| raise HTTPException(status_code=400, detail=f"Invalid archive: {exc}") | |
| return { | |
| "success": True, | |
| "message": f"Restored {restored} files to {data_dir}", | |
| "files_restored": restored, | |
| "data_dir": str(data_dir), | |
| } | |
| async def enable_maintenance(): | |
| global _MAINTENANCE_MODE | |
| _MAINTENANCE_MODE = True | |
| lock_path = _maintenance_lock_path() | |
| try: | |
| lock_path.parent.mkdir(parents=True, exist_ok=True) | |
| lock_path.write_text(str(time.time()), encoding="utf-8") | |
| except Exception as exc: | |
| _logger.warning("Could not write maintenance lock file: %s", exc) | |
| _logger.warning("Maintenance mode ENABLED — all write operations blocked") | |
| return {"success": True, "message": "Maintenance mode enabled", "maintenance": True} | |
| async def disable_maintenance(): | |
| global _MAINTENANCE_MODE | |
| _MAINTENANCE_MODE = False | |
| lock_path = _maintenance_lock_path() | |
| try: | |
| lock_path.unlink(missing_ok=True) | |
| except Exception as exc: | |
| _logger.warning("Could not remove maintenance lock file: %s", exc) | |
| _logger.warning("Maintenance mode DISABLED — write operations resumed") | |
| return {"success": True, "message": "Maintenance mode disabled", "maintenance": False} | |
| async def maintenance_status(): | |
| lock_path = _maintenance_lock_path() | |
| source = "off" | |
| if _MAINTENANCE_MODE: | |
| source = "api_flag" | |
| elif os.environ.get("APP_UNDER_MAINTENANCE"): | |
| source = "env_var" | |
| elif lock_path.is_file(): | |
| source = "lock_file" | |
| return { | |
| "success": True, | |
| "maintenance": is_maintenance(), | |
| "source": source, | |
| } | |