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.directory_repository import DirectoryRepository from app.repositories.file_repository import FileRepository ROOT_DIRECTORY_ID = "dir_root" ROOT_DIRECTORY_NAME = "/" ROOT_PATH_KEY = "/" @dataclass class NamespaceItem: kind: Literal["directory", "file"] item: Directory | FileEntry @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) 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] items.extend(NamespaceItem(kind="file", item=file_entry) for file_entry in child_files) items.sort(key=lambda item: (item.kind != "directory", item.item.name.lower())) return DirectoryChildrenResult(directory=directory, children=items) 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}"