Исходный код EJIO.user.media.facade

## /user/media/facade.py

import csv
import io
import shutil
import datetime
from pathlib import Path
from threading import Lock
from loguru import logger
from EJIO.utils import encrypt_payload, decrypt_payload, generate_timestamped_filename
from .model import FileNode

__all__ = ['FileFacade']


[документация] class FileFacade: """ Универсальный фасад управления физическими файлами и их метаданными. Инициализируется индивидуальными путями для изоляции контекстов (аватары, логи, отчеты). """ def __init__(self, working_dir: str | Path, db_path: str | Path, crypto_key: str, file_base_name: str) -> None: self._lock: Lock = Lock() self._working_dir: Path = Path(working_dir).resolve() self._db_path: Path = Path(db_path).resolve() self._crypto_key: str = crypto_key self._registry: dict[str, FileNode] = {} self._csv_headers: list[str] = ["id", "original_name", "system_name", "created_at", "updated_at"] self._file_base_name: str = file_base_name self._working_dir.mkdir(parents=True, exist_ok=True) self._db_path.parent.mkdir(parents=True, exist_ok=True) self._load_registry() def _generate_incremental_id(self, fill_gaps: bool = True) -> str: """Находит следующий свободный числовой ID в таблице файлов с заполнением дыр.""" if not self._registry: return "1" existing_ids = sorted([int(fid) for idx, fid in enumerate(self._registry.keys()) if fid.isdigit()]) if not existing_ids: return "1" if fill_gaps: if existing_ids[0] > 1: return "1" for i in range(len(existing_ids) - 1): if existing_ids[i + 1] - existing_ids[i] > 1: return str(existing_ids[i] + 1) return str(existing_ids[-1] + 1) def _load_registry(self) -> None: if not self._db_path.exists(): return try: with open(self._db_path, "rb") as f: encrypted_bytes = f.read() if not encrypted_bytes: return # Дешифруем двоичный CSV-поток через AES-256 decrypted_bytes = decrypt_payload(encrypted_bytes, self._crypto_key) csv_text = decrypted_bytes.decode('utf-8') # Читаем CSV таблицу из текстового буфера в памяти csv_buffer = io.StringIO(csv_text) reader = csv.DictReader(csv_buffer) headers = next(reader, None) if headers is None: return for row in reader: if len(row) < len(self._csv_headers): continue row_dict = dict(zip(self._csv_headers, row)) node = FileNode.from_dict(row_dict) self._registry[node.id] = node logger.debug(f"CSV file registry successfully decrypted. Indices loaded: {len(self._registry)}") except Exception as e: logger.error(f"Critical read failure for the tabular file registry: {e}") def _save_registry(self) -> None: try: # Создаем текстовый буфер для сборки CSV таблицы в ОЗУ csv_buffer = io.StringIO() writer = csv.DictWriter(csv_buffer, fieldnames=self._csv_headers, lineterminator='\n') writer.writeheader() for node in self._registry.values(): writer.writerow(node.to_dict()) # Переводим таблицу в байты и шифруем raw_csv_bytes = csv_buffer.getvalue().encode('utf-8') encrypted_payload = encrypt_payload(raw_csv_bytes, self._crypto_key) # Атомарная транзакционная запись, защищающая реестр от повреждений def _write(temp_path): with open(temp_path, "wb") as f: f.write(encrypted_payload) from EJIO.utils import save_atomic save_atomic(self._db_path, _write) except Exception as e: logger.error(f"File registry write error: {e}")
[документация] def store_file(self, source_path: str | Path) -> str: """ Копирует внешний файл в хранилище, присваивает ему уникальное имя по DateTime, фиксирует метаданные в псевдо-таблицу и возвращает строковый ID. """ src = Path(source_path) if not src.exists() or not src.is_file(): raise FileNotFoundError(f"File '{source_path}' does not exist.") with self._lock: file_id = self._generate_incremental_id(fill_gaps=True) # Генерация уникального системного имени файла по DateTime ext = src.suffix.lstrip('.') or "png" system_name = generate_timestamped_filename( base_name=self._file_base_name, extension=ext, prefix_date=False ) dest_path = self._working_dir / system_name # Физическое копирование shutil.copy2(src, dest_path) # Регистрация в псевдо-таблице метаданных now_str = datetime.datetime.now().isoformat() node = FileNode( id=file_id, original_name=src.name, system_name=system_name, created_at=now_str ) self._registry[file_id] = node self._save_registry() return file_id
[документация] def delete_file(self, file_id: str) -> None: """Физически удаляет файл с диска и стирает его запись из таблицы.""" with self._lock: if file_id in self._registry: node = self._registry.pop(file_id) file_path = self._working_dir / node.system_name if file_path.exists(): file_path.unlink(missing_ok=True) self._save_registry()
[документация] def get_absolute_path(self, file_id: str) -> Path | None: """Возвращает полный физический путь к файлу в ОС по его ID.""" with self._lock: if file_id in self._registry: return self._working_dir / self._registry[file_id].system_name return None