You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

182 lines
7.6 KiB

from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import DirectoryConflictError, DirectoryNotFoundError, ValidationError
from app.core.ids import new_id
from app.models.entities import Directory, FileEntry
from app.repositories.blob_replica_repository import BlobReplicaRepository
from app.repositories.directory_repository import DirectoryRepository
from app.repositories.file_repository import FileRepository
from app.services.storage_service import StorageService
ROOT_DIRECTORY_ID = "dir_root"
ROOT_DIRECTORY_NAME = "/"
ROOT_PATH_KEY = "/"
@dataclass
class NamespaceItem:
kind: Literal["directory", "file"]
item: Directory | FileEntry
replica_ready_count: int | None = None
replica_desired_count: int | None = None
replica_missing_count: int | None = None
@dataclass
class DirectoryChildrenResult:
directory: Directory
children: list[NamespaceItem]
class DirectoryService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repository = DirectoryRepository(session)
self.file_repository = FileRepository(session)
self.blob_replica_repository = BlobReplicaRepository(session)
self.storage_service = StorageService(session)
async def ensure_root_directory(self) -> Directory:
root = await self.repository.get_root()
if root is not None:
return root
root = Directory(
id=ROOT_DIRECTORY_ID,
parent_id=None,
name=ROOT_DIRECTORY_NAME,
path_key=ROOT_PATH_KEY,
)
await self.repository.create(root)
await self.session.commit()
return root
async def list_children(self, directory_id: str) -> DirectoryChildrenResult:
directory = await self._get_directory(directory_id)
child_directories = await self.repository.list_children(directory.id)
child_files = await self.file_repository.list_by_directory(directory.id)
items = [NamespaceItem(kind="directory", item=child) for child in child_directories]
for file_entry in child_files:
items.append(await self._file_namespace_item(file_entry))
items.sort(key=lambda item: (item.kind != "directory", item.item.name.lower()))
return DirectoryChildrenResult(directory=directory, children=items)
async def _file_namespace_item(self, file_entry: FileEntry) -> NamespaceItem:
blob = await self.file_repository.get_primary_blob_for_file(file_entry.id)
if blob is None:
return NamespaceItem(kind="file", item=file_entry, replica_ready_count=0, replica_desired_count=0, replica_missing_count=0)
replicas = await self.blob_replica_repository.list_by_blob(blob.id)
ready_backend_ids = {replica.backend_id for replica in replicas if replica.status == "ready"}
decision = await self.storage_service.get_replication_targets_for_mime_type(
file_entry.mime_type,
size_bytes=blob.size_bytes,
)
desired_backend_ids = {backend.id for backend in decision.backends}
missing_count = len(desired_backend_ids - ready_backend_ids)
return NamespaceItem(
kind="file",
item=file_entry,
replica_ready_count=len(ready_backend_ids & desired_backend_ids),
replica_desired_count=len(desired_backend_ids),
replica_missing_count=missing_count,
)
async def create_directory(self, parent_id: str, name: str) -> Directory:
cleaned_name = self._validate_directory_name(name)
parent = await self._get_directory(parent_id)
await self._ensure_name_available(parent.id, cleaned_name)
directory = Directory(
id=new_id("dir"),
parent_id=parent.id,
name=cleaned_name,
path_key=self._build_path_key(parent.path_key, cleaned_name),
)
await self.repository.create(directory)
await self.session.commit()
return directory
async def rename_directory(self, directory_id: str, name: str) -> Directory:
directory = await self._get_directory(directory_id)
if directory.id == ROOT_DIRECTORY_ID:
raise ValidationError("Root directory cannot be renamed.")
cleaned_name = self._validate_directory_name(name)
parent = await self._get_directory(directory.parent_id or ROOT_DIRECTORY_ID)
if directory.name == cleaned_name:
return directory
await self._ensure_name_available(parent.id, cleaned_name)
old_path_key = directory.path_key
directory.name = cleaned_name
directory.path_key = self._build_path_key(parent.path_key, cleaned_name)
await self._rewrite_descendant_paths(old_path_key, directory.path_key)
saved = await self.repository.save(directory)
await self.session.commit()
return saved
async def move_directory(self, directory_id: str, target_parent_id: str) -> Directory:
directory = await self._get_directory(directory_id)
if directory.id == ROOT_DIRECTORY_ID:
raise ValidationError("Root directory cannot be moved.")
target_parent = await self._get_directory(target_parent_id)
if directory.parent_id == target_parent.id:
return directory
if target_parent.path_key == directory.path_key or target_parent.path_key.startswith(f"{directory.path_key}/"):
raise ValidationError("A directory cannot be moved into itself or its descendants.")
await self._ensure_name_available(target_parent.id, directory.name)
old_path_key = directory.path_key
directory.parent_id = target_parent.id
directory.path_key = self._build_path_key(target_parent.path_key, directory.name)
await self._rewrite_descendant_paths(old_path_key, directory.path_key)
saved = await self.repository.save(directory)
await self.session.commit()
return saved
async def _get_directory(self, directory_id: str) -> Directory:
directory = await self.repository.get_by_id(directory_id)
if directory is None or directory.deleted_at is not None:
raise DirectoryNotFoundError()
return directory
async def _ensure_name_available(self, parent_id: str, name: str) -> None:
conflicting_file = await self.file_repository.get_by_directory_and_name(parent_id, name)
if conflicting_file is not None:
raise DirectoryConflictError("A file with this name already exists in the target location.")
existing = await self.repository.get_by_parent_and_name(parent_id, name)
if existing is not None:
raise DirectoryConflictError("A directory with this name already exists in the target location.")
async def _rewrite_descendant_paths(self, old_prefix: str, new_prefix: str) -> None:
descendants = await self.repository.list_descendants_by_path_prefix(old_prefix)
for descendant in descendants:
suffix = descendant.path_key.removeprefix(old_prefix)
descendant.path_key = f"{new_prefix}{suffix}"
@staticmethod
def _validate_directory_name(name: str) -> str:
cleaned_name = name.strip()
if not cleaned_name:
raise ValidationError("Directory name must not be empty.")
if "/" in cleaned_name:
raise ValidationError("Directory name must not contain '/'.")
return cleaned_name
@staticmethod
def _build_path_key(parent_path_key: str, name: str) -> str:
if parent_path_key == ROOT_PATH_KEY:
return f"/{name}"
return f"{parent_path_key}/{name}"