stabilize directory workflow

This commit is contained in:
L-Nafaryus 2024-08-22 12:24:36 +05:00
parent 680b0172f0
commit b3be3d25ee
Signed by: L-Nafaryus
GPG Key ID: 553C97999B363D38
11 changed files with 531 additions and 267 deletions

View File

@ -12,6 +12,7 @@ from materia.models.database import (
DatabaseMigrationError, DatabaseMigrationError,
Cache, Cache,
CacheError, CacheError,
SessionContext,
) )
from materia.models.user import User, UserCredentials, UserInfo from materia.models.user import User, UserCredentials, UserInfo
@ -27,9 +28,18 @@ from materia.models.repository import (
from materia.models.directory import ( from materia.models.directory import (
Directory, Directory,
DirectoryPath,
DirectoryLink, DirectoryLink,
DirectoryInfo, DirectoryInfo,
DirectoryPath,
DirectoryRename,
DirectoryCopyMove,
) )
from materia.models.file import File, FileLink, FileInfo from materia.models.file import (
File,
FileLink,
FileInfo,
FilePath,
FileRename,
FileCopyMove,
)

View File

@ -107,7 +107,7 @@ class Database:
raise e from None raise e from None
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
raise DatabaseError(*e.args) from e raise e # DatabaseError(*e.args) from e
finally: finally:
await session.close() await session.close()

View File

@ -48,8 +48,8 @@ class Directory(Base):
await session.flush() await session.flush()
await session.refresh(self, attribute_names=["repository"]) await session.refresh(self, attribute_names=["repository"])
repository_path = await self.repository.path(session, config) repository_path = await self.repository.real_path(session, config)
directory_path = await self.path(session, config) directory_path = await self.real_path(session, config)
new_directory = FileSystem(directory_path, repository_path) new_directory = FileSystem(directory_path, repository_path)
await new_directory.make_directory() await new_directory.make_directory()
@ -70,8 +70,8 @@ class Directory(Base):
for file in self.files: for file in self.files:
file.remove(session, config) file.remove(session, config)
repository_path = await self.repository.path(session, config) repository_path = await self.repository.real_path(session, config)
directory_path = await self.path(session, config) directory_path = await self.real_path(session, config)
current_directory = FileSystem(directory_path, repository_path) current_directory = FileSystem(directory_path, repository_path)
await current_directory.remove() await current_directory.remove()
@ -80,32 +80,45 @@ class Directory(Base):
await session.flush() await session.flush()
async def relative_path(self, session: SessionContext) -> Optional[Path]: async def relative_path(self, session: SessionContext) -> Optional[Path]:
"""Get relative path of the current directory""" """Get path of the directory relative repository root."""
if inspect(self).was_deleted: if inspect(self).was_deleted:
return None return None
parts = [] parts = []
current_directory = self current_directory = self
async with session.begin_nested():
while True: while True:
# ISSUE: accessing `parent` attribute raises greenlet_spawn has not been called; can't call await_only() here
# parts.append(current_directory.name)
# session.add(current_directory)
# await session.refresh(current_directory, attribute_names=["parent"])
# if current_directory.parent is None:
# break
# current_directory = current_directory.parent
parts.append(current_directory.name) parts.append(current_directory.name)
session.add(current_directory) if current_directory.parent_id is None:
await session.refresh(current_directory, attribute_names=["parent"])
if current_directory.parent is None:
break break
current_directory = current_directory.parent current_directory = (
await session.scalars(
sa.select(Directory).where(
Directory.id == current_directory.parent_id,
)
)
).first()
return Path().joinpath(*reversed(parts)) return Path().joinpath(*reversed(parts))
async def path(self, session: SessionContext, config: Config) -> Optional[Path]: async def real_path(
self, session: SessionContext, config: Config
) -> Optional[Path]:
"""Get absolute path of the directory"""
if inspect(self).was_deleted: if inspect(self).was_deleted:
return None return None
repository_path = await self.repository.path(session, config) repository_path = await self.repository.real_path(session, config)
relative_path = await self.relative_path(session) relative_path = await self.relative_path(session)
return repository_path.joinpath(relative_path) return repository_path.joinpath(relative_path)
@ -123,6 +136,7 @@ class Directory(Base):
current_directory: Optional[Directory] = None current_directory: Optional[Directory] = None
for part in path.parts: for part in path.parts:
# from root directory to target directory
current_directory = ( current_directory = (
await session.scalars( await session.scalars(
sa.select(Directory).where( sa.select(Directory).where(
@ -145,77 +159,108 @@ class Directory(Base):
return current_directory return current_directory
async def copy( async def copy(
self, directory: Optional["Directory"], session: SessionContext, config: Config self,
target: Optional["Directory"],
session: SessionContext,
config: Config,
force: bool = False,
shallow: bool = False,
) -> Self: ) -> Self:
session.add(self) session.add(self)
await session.refresh(self, attribute_names=["repository"]) await session.refresh(self, attribute_names=["repository"])
repository_path = await self.repository.path(session, config) repository_path = await self.repository.real_path(session, config)
directory_path = await self.path(session, config) directory_path = await self.real_path(session, config)
directory_path = ( target_path = (
await directory.path(session, config) if directory else repository_path await target.real_path(session, config) if target else repository_path
) )
current_directory = FileSystem(directory_path, repository_path) current_directory = FileSystem(directory_path, repository_path)
new_directory = await current_directory.copy(directory_path) new_directory = await current_directory.copy(
target_path, force=force, shallow=shallow
)
cloned = self.clone() cloned = self.clone()
cloned.name = new_directory.name() cloned.name = new_directory.name()
cloned.parent_id = directory.id if directory else None cloned.parent_id = target.id if target else None
session.add(cloned) session.add(cloned)
await session.flush() await session.flush()
await session.refresh(self, attribute_names=["files", "directories"])
for directory in self.directories:
await directory.copy(cloned, session, config, shallow=True)
for file in self.files:
await file.copy(cloned, session, config, shallow=True)
return self return self
async def move( async def move(
self, directory: Optional["Directory"], session: SessionContext, config: Config self,
target: Optional["Directory"],
session: SessionContext,
config: Config,
force: bool = False,
shallow: bool = False,
) -> Self: ) -> Self:
session.add(self) session.add(self)
await session.refresh(self, attribute_names=["repository"]) await session.refresh(self, attribute_names=["repository"])
repository_path = await self.repository.path(session, config) repository_path = await self.repository.real_path(session, config)
directory_path = await self.path(session, config) directory_path = await self.real_path(session, config)
directory_path = ( target_path = (
await directory.path(session, config) if directory else repository_path await target.real_path(session, config) if target else repository_path
) )
current_directory = FileSystem(directory_path, repository_path) current_directory = FileSystem(directory_path, repository_path)
moved_directory = await current_directory.move(directory_path) moved_directory = await current_directory.move(
target_path, force=force, shallow=shallow
)
self.name = moved_directory.name() self.name = moved_directory.name()
self.parent_id = directory.id if directory else None self.parent_id = target.id if target else None
self.updated = time() self.updated = time()
await session.flush() await session.flush()
return self return self
async def rename(self, name: str, session: SessionContext, config: Config) -> Self: async def rename(
self,
name: str,
session: SessionContext,
config: Config,
force: bool = False,
shallow: bool = False,
) -> Self:
session.add(self) session.add(self)
await session.refresh(self, attribute_names=["repository"]) await session.refresh(self, attribute_names=["repository"])
repository_path = await self.repository.path(session, config) repository_path = await self.repository.real_path(session, config)
directory_path = await self.path(session, config) directory_path = await self.real_path(session, config)
current_directory = FileSystem(directory_path, repository_path) current_directory = FileSystem(directory_path, repository_path)
renamed_directory = await current_directory.rename(name, force=True) renamed_directory = await current_directory.rename(
name, force=force, shallow=shallow
)
self.name = renamed_directory.name() self.name = renamed_directory.name()
await session.flush() await session.flush()
return self return self
async def info(self, session: SessionContext) -> "DirectoryInfo": async def info(self, session: SessionContext) -> "DirectoryInfo":
info = DirectoryInfo.model_validate(self)
session.add(self) session.add(self)
await session.refresh(self, attribute_names=["files"]) await session.refresh(self, attribute_names=["files"])
info = DirectoryInfo.model_validate(self)
relative_path = await self.relative_path(session)
info.path = Path("/").joinpath(relative_path) if relative_path else None
info.used = sum([file.size for file in self.files]) info.used = sum([file.size for file in self.files])
return info return info
class DirectoryPath(BaseModel):
path: Path
class DirectoryLink(Base): class DirectoryLink(Base):
__tablename__ = "directory_link" __tablename__ = "directory_link"
@ -240,9 +285,26 @@ class DirectoryInfo(BaseModel):
name: str name: str
is_public: bool is_public: bool
path: Optional[Path] = None
used: Optional[int] = None used: Optional[int] = None
class DirectoryPath(BaseModel):
path: Path
class DirectoryRename(BaseModel):
path: Path
name: str
force: Optional[bool] = False
class DirectoryCopyMove(BaseModel):
path: Path
target: Path
force: Optional[bool] = False
from materia.models.repository import Repository from materia.models.repository import Repository
from materia.models.file import File from materia.models.file import File
from materia.models.filesystem import FileSystem from materia.models.filesystem import FileSystem

View File

@ -47,9 +47,11 @@ class File(Base):
await session.flush() await session.flush()
await session.refresh(self, attribute_names=["repository"]) await session.refresh(self, attribute_names=["repository"])
file_path = await self.path(session, config) file_path = await self.real_path(session, config)
new_file = FileSystem(file_path, await self.repository.path(session, config)) new_file = FileSystem(
file_path, await self.repository.real_path(session, config)
)
await new_file.write_file(data) await new_file.write_file(data)
self.size = await new_file.size() self.size = await new_file.size()
@ -60,9 +62,11 @@ class File(Base):
async def remove(self, session: SessionContext, config: Config): async def remove(self, session: SessionContext, config: Config):
session.add(self) session.add(self)
file_path = await self.path(session, config) file_path = await self.real_path(session, config)
new_file = FileSystem(file_path, await self.repository.path(session, config)) new_file = FileSystem(
file_path, await self.repository.real_path(session, config)
)
await new_file.remove() await new_file.remove()
await session.delete(self) await session.delete(self)
@ -83,7 +87,9 @@ class File(Base):
return file_path.joinpath(self.name) return file_path.joinpath(self.name)
async def path(self, session: SessionContext, config: Config) -> Optional[Path]: async def real_path(
self, session: SessionContext, config: Config
) -> Optional[Path]:
if inspect(self).was_deleted: if inspect(self).was_deleted:
return None return None
@ -94,9 +100,9 @@ class File(Base):
await session.refresh(self, attribute_names=["repository", "parent"]) await session.refresh(self, attribute_names=["repository", "parent"])
if self.parent: if self.parent:
file_path = await self.parent.path(session, config) file_path = await self.parent.real_path(session, config)
else: else:
file_path = await self.repository.path(session, config) file_path = await self.repository.real_path(session, config)
return file_path.joinpath(self.name) return file_path.joinpath(self.name)
@ -130,19 +136,24 @@ class File(Base):
return current_file return current_file
async def copy( async def copy(
self, directory: Optional["Directory"], session: SessionContext, config: Config self,
directory: Optional["Directory"],
session: SessionContext,
config: Config,
force: bool = False,
shallow: bool = False,
) -> Self: ) -> Self:
session.add(self) session.add(self)
await session.refresh(self, attribute_names=["repository"]) await session.refresh(self, attribute_names=["repository"])
repository_path = await self.repository.path(session, config) repository_path = await self.repository.real_path(session, config)
file_path = await self.path(session, config) file_path = await self.real_path(session, config)
directory_path = ( directory_path = (
await directory.path(session, config) if directory else repository_path await directory.real_path(session, config) if directory else repository_path
) )
current_file = FileSystem(file_path, repository_path) current_file = FileSystem(file_path, repository_path)
new_file = await current_file.copy(directory_path) new_file = await current_file.copy(directory_path, force=force, shallow=shallow)
cloned = self.clone() cloned = self.clone()
cloned.name = new_file.name() cloned.name = new_file.name()
@ -153,19 +164,26 @@ class File(Base):
return self return self
async def move( async def move(
self, directory: Optional["Directory"], session: SessionContext, config: Config self,
directory: Optional["Directory"],
session: SessionContext,
config: Config,
force: bool = False,
shallow: bool = False,
) -> Self: ) -> Self:
session.add(self) session.add(self)
await session.refresh(self, attribute_names=["repository"]) await session.refresh(self, attribute_names=["repository"])
repository_path = await self.repository.path(session, config) repository_path = await self.repository.real_path(session, config)
file_path = await self.path(session, config) file_path = await self.real_path(session, config)
directory_path = ( directory_path = (
await directory.path(session, config) if directory else repository_path await directory.real_path(session, config) if directory else repository_path
) )
current_file = FileSystem(file_path, repository_path) current_file = FileSystem(file_path, repository_path)
moved_file = await current_file.move(directory_path) moved_file = await current_file.move(
directory_path, force=force, shallow=shallow
)
self.name = moved_file.name() self.name = moved_file.name()
self.parent_id = directory.id if directory else None self.parent_id = directory.id if directory else None
@ -174,15 +192,22 @@ class File(Base):
return self return self
async def rename(self, name: str, session: SessionContext, config: Config) -> Self: async def rename(
self,
name: str,
session: SessionContext,
config: Config,
force: bool = False,
shallow: bool = False,
) -> Self:
session.add(self) session.add(self)
await session.refresh(self, attribute_names=["repository"]) await session.refresh(self, attribute_names=["repository"])
repository_path = await self.repository.path(session, config) repository_path = await self.repository.real_path(session, config)
file_path = await self.path(session, config) file_path = await self.real_path(session, config)
current_file = FileSystem(file_path, repository_path) current_file = FileSystem(file_path, repository_path)
renamed_file = await current_file.rename(name, force=True) renamed_file = await current_file.rename(name, force=force, shallow=shallow)
self.name = renamed_file.name() self.name = renamed_file.name()
self.updated = time() self.updated = time()
@ -226,6 +251,22 @@ class FileInfo(BaseModel):
size: int size: int
class FilePath(BaseModel):
path: Path
class FileRename(BaseModel):
path: Path
name: str
force: Optional[bool] = False
class FileCopyMove(BaseModel):
path: Path
target: Path
force: Optional[bool] = False
from materia.models.repository import Repository from materia.models.repository import Repository
from materia.models.directory import Directory from materia.models.directory import Directory
from materia.models.filesystem import FileSystem from materia.models.filesystem import FileSystem

View File

@ -59,7 +59,7 @@ class FileSystem:
except OSError as e: except OSError as e:
raise FileSystemError( raise FileSystemError(
f"Failed to remove file system content at /{self.relative_path}:", f"Failed to remove content at /{self.relative_path}:",
*e.args, *e.args,
) )
@ -68,9 +68,6 @@ class FileSystem:
count = 1 count = 1
new_path = target_directory.joinpath(name) new_path = target_directory.joinpath(name)
if new_path == self.path:
return name
while await async_path.exists(new_path): while await async_path.exists(new_path):
if await self.is_file(): if await self.is_file():
if with_counter := re.match(r"^(.+)\.(\d+)\.(\w+)$", new_path.name): if with_counter := re.match(r"^(.+)\.(\d+)\.(\w+)$", new_path.name):
@ -94,26 +91,41 @@ class FileSystem:
return new_path.name return new_path.name
async def _generate_new_path(
self,
target_directory: Path,
new_name: Optional[str] = None,
force: bool = False,
shallow: bool = False,
) -> Path:
if self.path == self.working_directory:
raise FileSystemError("Cannot modify working directory")
new_name = new_name or self.path.name
if await async_path.exists(target_directory.joinpath(new_name)) and not shallow:
if force:
new_name = await self.generate_name(target_directory, new_name)
else:
raise FileSystemError(
f"Target destination already exists /{target_directory.joinpath(new_name)}"
)
return target_directory.joinpath(new_name)
async def move( async def move(
self, self,
target_directory: Path, target_directory: Path,
new_name: Optional[str] = None, new_name: Optional[str] = None,
force: bool = False, force: bool = False,
shallow: bool = False,
): ):
new_name = new_name if new_name else self.path.name new_path = await self._generate_new_path(
target_directory, new_name, force=force, shallow=shallow
)
try: try:
if ( if not shallow:
await async_path.exists(target_directory.joinpath(new_name))
and not force
):
raise FileSystemError(
"Failed to move content to target destination: already exists"
)
new_path = target_directory.joinpath(
await self.generate_name(target_directory, new_name)
)
await aioshutil.move(self.path, new_path) await aioshutil.move(self.path, new_path)
except Exception as e: except Exception as e:
@ -124,30 +136,26 @@ class FileSystem:
return FileSystem(new_path, self.working_directory) return FileSystem(new_path, self.working_directory)
async def rename(self, new_name: str, force: bool = False) -> Path: async def rename(
return await self.move(self.path.parent, new_name=new_name, force=force) self, new_name: str, force: bool = False, shallow: bool = False
) -> Path:
return await self.move(
self.path.parent, new_name=new_name, force=force, shallow=shallow
)
async def copy( async def copy(
self, self,
target_directory: Path, target_directory: Path,
new_name: Optional[str] = None, new_name: Optional[str] = None,
force: bool = False, force: bool = False,
shallow: bool = False,
) -> Self: ) -> Self:
new_name = new_name if new_name else self.path.name new_path = await self._generate_new_path(
target_directory, new_name, force=force, shallow=shallow
)
try: try:
if ( if not shallow:
await async_path.exists(target_directory.joinpath(new_name))
and not force
):
raise FileSystemError(
"Failed to copy content to target destination: already exists"
)
new_path = target_directory.joinpath(
await self.generate_name(target_directory, new_name)
)
if await self.is_file(): if await self.is_file():
await aioshutil.copy(self.path, new_path) await aioshutil.copy(self.path, new_path)
@ -156,7 +164,7 @@ class FileSystem:
except Exception as e: except Exception as e:
raise FileSystemError( raise FileSystemError(
f"Failed to copy content from /{self.relative_path}:", f"Failed to copy content from /{new_path}:",
*e.args, *e.args,
) )
@ -193,6 +201,7 @@ class FileSystem:
@staticmethod @staticmethod
def normalize(path: Path) -> Path: def normalize(path: Path) -> Path:
"""Resolve path and make it relative."""
if not path.is_absolute(): if not path.is_absolute():
path = Path("/").joinpath(path) path = Path("/").joinpath(path)

View File

@ -35,7 +35,7 @@ class Repository(Base):
session.add(self) session.add(self)
await session.flush() await session.flush()
repository_path = await self.path(session, config) repository_path = await self.real_path(session, config)
relative_path = repository_path.relative_to( relative_path = repository_path.relative_to(
config.application.working_directory config.application.working_directory
) )
@ -52,12 +52,13 @@ class Repository(Base):
return self return self
async def path(self, session: SessionContext, config: Config) -> Path: async def real_path(self, session: SessionContext, config: Config) -> Path:
"""Get absolute path of the directory."""
session.add(self) session.add(self)
await session.refresh(self, attribute_names=["user"]) await session.refresh(self, attribute_names=["user"])
repository_path = config.application.working_directory.joinpath( repository_path = config.application.working_directory.joinpath(
"repository", self.user.lower_name, "default" "repository", self.user.lower_name
) )
return repository_path return repository_path
@ -73,7 +74,7 @@ class Repository(Base):
for file in self.files: for file in self.files:
await file.remove(session) await file.remove(session)
repository_path = await self.path(session, config) repository_path = await self.real_path(session, config)
try: try:
shutil.rmtree(str(repository_path)) shutil.rmtree(str(repository_path))

View File

@ -4,7 +4,17 @@ import shutil
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from materia.models import User, Directory, DirectoryPath, DirectoryInfo, FileSystem from materia.models import (
User,
Directory,
DirectoryInfo,
DirectoryPath,
DirectoryRename,
DirectoryCopyMove,
FileSystem,
Repository,
)
from materia.models.database import SessionContext
from materia.routers import middleware from materia.routers import middleware
from materia.config import Config from materia.config import Config
@ -13,23 +23,65 @@ from pydantic import BaseModel
router = APIRouter(tags=["directory"]) router = APIRouter(tags=["directory"])
async def validate_current_directory(
path: Path, repository: Repository, session: SessionContext, config: Config
) -> Directory:
if not FileSystem.check_path(path):
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
if not (
directory := await Directory.by_path(
repository,
FileSystem.normalize(path),
session,
config,
)
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found")
return directory
async def validate_target_directory(
path: Path, repository: Repository, session: SessionContext, config: Config
) -> Directory:
if not FileSystem.check_path(path):
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid target path"
)
if FileSystem.normalize(path) == Path():
# mean repository root
target_directory = None
else:
if not (
target_directory := await Directory.by_path(
repository,
FileSystem.normalize(path),
session,
config,
)
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Target directory not found")
return target_directory
@router.post("/directory") @router.post("/directory")
async def create( async def create(
path: DirectoryPath, path: DirectoryPath,
repository=Depends(middleware.repository), repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(), ctx: middleware.Context = Depends(),
): ):
if not FileSystem.check_path(path.path): if not FileSystem.check_path(path.path):
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path") raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
path = FileSystem.normalize(path.path)
async with ctx.database.session() as session: async with ctx.database.session() as session:
current_directory = None current_directory = None
current_path = Path() current_path = Path()
directory = None directory = None
for part in path.parts: for part in FileSystem.normalize(path.path).parts:
if not ( if not (
directory := await Directory.by_path( directory := await Directory.by_path(
repository, current_path.joinpath(part), session, ctx.config repository, current_path.joinpath(part), session, ctx.config
@ -50,65 +102,80 @@ async def create(
@router.get("/directory") @router.get("/directory")
async def info( async def info(
path: Path, path: Path,
repository=Depends(middleware.repository), repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(), ctx: middleware.Context = Depends(),
): ):
if not FileSystem.check_path(path):
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
path = FileSystem.normalize(path)
ctx.logger.info(path)
async with ctx.database.session() as session: async with ctx.database.session() as session:
if not ( directory = await validate_current_directory(
directory := await Directory.by_path( path, repository, session, ctx.config
repository,
path,
session,
ctx.config,
) )
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found")
ctx.logger.info(directory)
info = await directory.info(session) info = await directory.info(session)
ctx.logger.info(info)
return info return info
@router.delete("/directory") @router.delete("/directory")
async def remove( async def remove(
path: Path, path: Path,
repository=Depends(middleware.repository), repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(), ctx: middleware.Context = Depends(),
): ):
if not FileSystem.check_path(path):
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
path = FileSystem.normalize(path)
async with ctx.database.session() as session: async with ctx.database.session() as session:
if not ( directory = await validate_current_directory(
directory := await Directory.by_path( path, repository, session, ctx.config
repository,
path,
session,
ctx.config,
) )
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found")
await directory.remove(session, ctx.config) await directory.remove(session, ctx.config)
await session.commit()
@router.patch("/directory/rename") @router.patch("/directory/rename")
async def rename(): async def rename(
pass data: DirectoryRename,
repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
directory = await validate_current_directory(
data.path, repository, session, ctx.config
)
await directory.rename(data.name, session, ctx.config, force=data.force)
await session.commit()
@router.patch("/directory/move") @router.patch("/directory/move")
async def move(): async def move(
pass data: DirectoryCopyMove,
repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
directory = await validate_current_directory(
data.path, repository, session, ctx.config
)
target_directory = await validate_target_directory(
data.target, repository, session, ctx.config
)
await directory.move(target_directory, session, ctx.config, force=data.force)
await session.commit()
@router.post("/directory/copy") @router.post("/directory/copy")
async def copy(): async def copy(
pass data: DirectoryCopyMove,
repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
directory = await validate_current_directory(
data.path, repository, session, ctx.config
)
target_directory = await validate_target_directory(
data.target, repository, session, ctx.config
)
await directory.copy(target_directory, session, ctx.config, force=data.force)
await session.commit()

View File

@ -3,103 +3,84 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile from fastapi import APIRouter, Depends, HTTPException, status, UploadFile
from materia.models import User, File, FileInfo, Directory from materia.models import (
User,
File,
FileInfo,
Directory,
DirectoryPath,
Repository,
FileSystem,
FileRename,
FilePath,
FileCopyMove,
)
from materia.models.database import SessionContext
from materia.routers import middleware from materia.routers import middleware
from materia.config import Config from materia.config import Config
from materia.routers.api.directory import validate_target_directory
router = APIRouter(tags=["file"]) router = APIRouter(tags=["file"])
async def validate_current_file(
path: Path, repository: Repository, session: SessionContext, config: Config
) -> Directory:
if not FileSystem.check_path(path):
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
if not (
file := await File.by_path(
repository,
FileSystem.normalize(path),
session,
config,
)
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found")
return file
@router.post("/file") @router.post("/file")
async def create( async def create(
upload_file: UploadFile, file: UploadFile,
path: Path = Path(), path: DirectoryPath,
user: User = Depends(middleware.user), repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(), ctx: middleware.Context = Depends(),
): ):
if not upload_file.filename: if not file.filename:
raise HTTPException( raise HTTPException(
status.HTTP_417_EXPECTATION_FAILED, "Cannot upload file without name" status.HTTP_417_EXPECTATION_FAILED, "Cannot upload file without name"
) )
if not FileSystem.check_path(path.path):
repository_path = Config.data_dir() / "repository" / user.lower_name raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Invalid path")
blacklist = [os.sep, ".", "..", "*"]
directory_path = Path(
os.sep.join(filter(lambda part: part not in blacklist, path.parts))
)
async with ctx.database.session() as session: async with ctx.database.session() as session:
session.add(user) target_directory = await validate_target_directory(
await session.refresh(user, attribute_names=["repository"]) path.path, repository, session, ctx.config
if not user.repository:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
if not directory_path == Path():
directory = await Directory.by_path(
user.repository.id,
None if directory_path.parent == Path() else directory_path.parent,
directory_path.name,
ctx.database,
) )
if not directory: await File(
raise HTTPException(status.HTTP_404_NOT_FOUND, "Directory not found") repository_id=repository.id,
else: parent_id=target_directory.id if target_directory else None,
directory = None name=file.filename,
size=file.size,
).new(await file.read(), session, ctx.config)
file = File(
repository_id=user.repository.id,
parent_id=directory.id if directory else None,
name=upload_file.filename,
path=None if directory_path == Path() else str(directory_path),
size=upload_file.size,
)
try:
file_path = repository_path.joinpath(directory_path, upload_file.filename)
if file_path.exists():
raise HTTPException(
status.HTTP_409_CONFLICT, "File with given name already exists"
)
file_path.write_bytes(await upload_file.read())
except OSError:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to write a file"
)
async with ctx.database.session() as session:
session.add(file)
await session.commit() await session.commit()
@router.get("/file") @router.get("/file")
async def info( async def info(
path: Path, path: Path,
user: User = Depends(middleware.user), repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(), ctx: middleware.Context = Depends(),
): ):
async with ctx.database.session() as session: async with ctx.database.session() as session:
session.add(user) file = await validate_current_file(path, repository, session, ctx.config)
await session.refresh(user, attribute_names=["repository"])
if not user.repository: info = await file.info(session)
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found")
if not (
file := await File.by_path(
user.repository.id,
None if path.parent == Path() else path.parent,
path.name,
ctx.database,
)
):
raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found")
info = FileInfo.model_validate(file)
return info return info
@ -107,36 +88,56 @@ async def info(
@router.delete("/file") @router.delete("/file")
async def remove( async def remove(
path: Path, path: Path,
user: User = Depends(middleware.user), repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(), ctx: middleware.Context = Depends(),
): ):
async with ctx.database.session() as session: async with ctx.database.session() as session:
session.add(user) file = await validate_current_file(path, repository, session, ctx.config)
await session.refresh(user, attribute_names=["repository"])
if not user.repository: await file.remove(session, ctx.config)
raise HTTPException(status.HTTP_404_NOT_FOUND, "Repository not found") await session.commit()
if not (
file := await File.by_path( @router.patch("/file/rename")
user.repository.id, async def rename(
None if path.parent == Path() else path.parent, data: FileRename,
path.name, repository: Repository = Depends(middleware.repository),
ctx.database, ctx: middleware.Context = Depends(),
)
): ):
raise HTTPException(status.HTTP_404_NOT_FOUND, "File not found") async with ctx.database.session() as session:
file = await validate_current_file(data.path, repository, session, ctx.config)
repository_path = Config.data_dir() / "repository" / user.lower_name await file.rename(data.name, session, ctx.config, force=data.force)
await session.commit()
try:
file_path = repository_path.joinpath(path)
if file_path.exists(): @router.patch("/file/move")
file_path.unlink(missing_ok=True) async def move(
except OSError: data: FileCopyMove,
raise HTTPException( repository: Repository = Depends(middleware.repository),
status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to remove a file" ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
file = await validate_current_file(data.path, repository, session, ctx.config)
target_directory = await validate_target_directory(
data.target, repository, session, ctx.config
) )
await file.remove(ctx.database) await file.move(target_directory, session, ctx.config, force=data.force)
await session.commit()
@router.post("/file/copy")
async def copy(
data: FileCopyMove,
repository: Repository = Depends(middleware.repository),
ctx: middleware.Context = Depends(),
):
async with ctx.database.session() as session:
file = await validate_current_file(data.path, repository, session, ctx.config)
target_directory = await validate_target_directory(
data.target, repository, session, ctx.config
)
await file.copy(target_directory, session, ctx.config, force=data.force)
await session.commit()

View File

@ -21,6 +21,7 @@ from asgi_lifespan import LifespanManager
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from materia import routers from materia import routers
from pathlib import Path from pathlib import Path
from copy import deepcopy
@pytest_asyncio.fixture(scope="session") @pytest_asyncio.fixture(scope="session")

View File

@ -5,6 +5,8 @@ from materia.models.base import Base
import aiofiles import aiofiles
from io import BytesIO from io import BytesIO
# TODO: replace downloadable images for tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auth(api_client: AsyncClient, api_config: Config): async def test_auth(api_client: AsyncClient, api_config: Config):
@ -83,7 +85,7 @@ async def test_repository(auth_client: AsyncClient, api_config: Config):
assert create.status_code == 409, create.text assert create.status_code == 409, create.text
assert api_config.application.working_directory.joinpath( assert api_config.application.working_directory.joinpath(
"repository", "PyTest".lower(), "default" "repository", "PyTest".lower()
).exists() ).exists()
info = await auth_client.get("/api/repository") info = await auth_client.get("/api/repository")
@ -100,6 +102,22 @@ async def test_repository(auth_client: AsyncClient, api_config: Config):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_directory(auth_client: AsyncClient, api_config: Config): async def test_directory(auth_client: AsyncClient, api_config: Config):
first_dir_path = api_config.application.working_directory.joinpath(
"repository", "PyTest".lower(), "first_dir"
)
second_dir_path = api_config.application.working_directory.joinpath(
"repository", "PyTest".lower(), "second_dir"
)
second_dir_path_two = api_config.application.working_directory.joinpath(
"repository", "PyTest".lower(), "second_dir.1"
)
third_dir_path_one = api_config.application.working_directory.joinpath(
"repository", "PyTest".lower(), "third_dir"
)
third_dir_path_two = api_config.application.working_directory.joinpath(
"repository", "PyTest".lower(), "second_dir", "third_dir"
)
create = await auth_client.post("/api/repository") create = await auth_client.post("/api/repository")
assert create.status_code == 200, create.text assert create.status_code == 200, create.text
@ -109,17 +127,67 @@ async def test_directory(auth_client: AsyncClient, api_config: Config):
create = await auth_client.post("/api/directory", json={"path": "/first_dir"}) create = await auth_client.post("/api/directory", json={"path": "/first_dir"})
assert create.status_code == 200, create.text assert create.status_code == 200, create.text
assert api_config.application.working_directory.joinpath( assert first_dir_path.exists()
"repository", "PyTest".lower(), "default", "first_dir"
).exists()
info = await auth_client.get("/api/directory", params=[("path", "/first_dir")]) info = await auth_client.get("/api/directory", params=[("path", "/first_dir")])
assert info.status_code == 200, info.text assert info.status_code == 200, info.text
assert info.json()["used"] == 0 assert info.json()["used"] == 0
assert info.json()["path"] == "/first_dir"
delete = await auth_client.delete("/api/directory", params=[("path", "/first_dir")]) create = await auth_client.patch(
"/api/directory/rename",
json={"path": "/first_dir", "name": "first_dir_renamed"},
)
assert create.status_code == 200, create.text
delete = await auth_client.delete(
"/api/directory", params=[("path", "/first_dir_renamed")]
)
assert delete.status_code == 200, delete.text assert delete.status_code == 200, delete.text
assert not api_config.application.working_directory.joinpath( assert not first_dir_path.exists()
"repository", "PyTest".lower(), "default", "first_dir"
).exists() create = await auth_client.post("/api/directory", json={"path": "/second_dir"})
assert create.status_code == 200, create.text
create = await auth_client.post("/api/directory", json={"path": "/third_dir"})
assert create.status_code == 200, create.text
move = await auth_client.patch(
"/api/directory/move", json={"path": "/third_dir", "target": "/second_dir"}
)
assert move.status_code == 200, move.text
assert not third_dir_path_one.exists()
assert third_dir_path_two.exists()
info = await auth_client.get(
"/api/directory", params=[("path", "/second_dir/third_dir")]
)
assert info.status_code == 200, info.text
assert info.json()["path"] == "/second_dir/third_dir"
copy = await auth_client.post(
"/api/directory/copy",
json={"path": "/second_dir", "target": "/", "force": True},
)
assert copy.status_code == 200, copy.text
assert second_dir_path.exists()
assert second_dir_path_two.exists()
@pytest.mark.asyncio
async def test_file(auth_client: AsyncClient, api_config: Config):
create = await auth_client.post("/api/repository")
assert create.status_code == 200, create.text
async with AsyncClient() as client:
pytest_logo_res = await client.get(
"https://docs.pytest.org/en/stable/_static/pytest1.png"
)
assert isinstance(pytest_logo_res.content, bytes)
pytest_logo = BytesIO(pytest_logo_res.content)
create = await auth_client.post(
"/api/file", files={"file": ("pytest.png", pytest_logo)}, json={"path", "/"}
)
assert create.status_code == 200, create.text

View File

@ -60,7 +60,7 @@ async def test_repository(data, tmpdir, session: SessionContext, config: Config)
assert repository assert repository
assert repository.id is not None assert repository.id is not None
assert (await repository.path(session, config)).exists() assert (await repository.real_path(session, config)).exists()
assert await Repository.from_user(data.user, session) == repository assert await Repository.from_user(data.user, session) == repository
await session.refresh(repository, attribute_names=["user"]) await session.refresh(repository, attribute_names=["user"])
@ -76,7 +76,7 @@ async def test_repository(data, tmpdir, session: SessionContext, config: Config)
await session.flush() await session.flush()
with pytest.raises(RepositoryError): with pytest.raises(RepositoryError):
await repository.remove(session, config) await repository.remove(session, config)
assert not (await repository.path(session, config)).exists() assert not (await repository.real_path(session, config)).exists()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -107,7 +107,7 @@ async def test_directory(data, tmpdir, session: SessionContext, config: Config):
) )
) )
).first() == directory ).first() == directory
assert (await directory.path(session, config)).exists() assert (await directory.real_path(session, config)).exists()
# nested simple # nested simple
nested_directory = await Directory( nested_directory = await Directory(
@ -128,7 +128,7 @@ async def test_directory(data, tmpdir, session: SessionContext, config: Config):
) )
).first() == nested_directory ).first() == nested_directory
assert nested_directory.parent_id == directory.id assert nested_directory.parent_id == directory.id
assert (await nested_directory.path(session, config)).exists() assert (await nested_directory.real_path(session, config)).exists()
# relationship # relationship
await session.refresh(directory, attribute_names=["directories", "files"]) await session.refresh(directory, attribute_names=["directories", "files"])
@ -150,30 +150,34 @@ async def test_directory(data, tmpdir, session: SessionContext, config: Config):
) )
# remove nested # remove nested
nested_path = await nested_directory.path(session, config) nested_path = await nested_directory.real_path(session, config)
assert nested_path.exists() assert nested_path.exists()
await nested_directory.remove(session, config) await nested_directory.remove(session, config)
assert inspect(nested_directory).was_deleted assert inspect(nested_directory).was_deleted
assert await nested_directory.path(session, config) is None assert await nested_directory.real_path(session, config) is None
assert not nested_path.exists() assert not nested_path.exists()
await session.refresh(directory) # update attributes that was deleted await session.refresh(directory) # update attributes that was deleted
assert (await directory.path(session, config)).exists() assert (await directory.real_path(session, config)).exists()
# rename # rename
assert (await directory.rename("test1", session, config)).name == "test1" assert (
await directory.rename("test1", session, config, force=True)
).name == "test1.1"
await Directory(repository_id=repository.id, parent_id=None, name="test2").new( await Directory(repository_id=repository.id, parent_id=None, name="test2").new(
session, config session, config
) )
assert (await directory.rename("test2", session, config)).name == "test2.1" assert (
assert (await repository.path(session, config)).joinpath("test2.1").exists() await directory.rename("test2", session, config, force=True)
assert not (await repository.path(session, config)).joinpath("test1").exists() ).name == "test2.1"
assert (await repository.real_path(session, config)).joinpath("test2.1").exists()
assert not (await repository.real_path(session, config)).joinpath("test1").exists()
directory_path = await directory.path(session, config) directory_path = await directory.real_path(session, config)
assert directory_path.exists() assert directory_path.exists()
await directory.remove(session, config) await directory.remove(session, config)
assert await directory.path(session, config) is None assert await directory.real_path(session, config) is None
assert not directory_path.exists() assert not directory_path.exists()
@ -229,7 +233,7 @@ async def test_file(data, tmpdir, session: SessionContext, config: Config):
) )
# #
file_path = await file.path(session, config) file_path = await file.real_path(session, config)
assert file_path.exists() assert file_path.exists()
assert (await aiofiles.os.stat(file_path)).st_size == file.size assert (await aiofiles.os.stat(file_path)).st_size == file.size
async with aiofiles.open(file_path, mode="rb") as io: async with aiofiles.open(file_path, mode="rb") as io:
@ -238,21 +242,21 @@ async def test_file(data, tmpdir, session: SessionContext, config: Config):
# rename # rename
assert ( assert (
await file.rename("test_file_rename.txt", session, config) await file.rename("test_file_rename.txt", session, config, force=True)
).name == "test_file_rename.txt" ).name == "test_file_rename.txt"
await File( await File(
repository_id=repository.id, parent_id=directory.id, name="test_file_2.txt" repository_id=repository.id, parent_id=directory.id, name="test_file_2.txt"
).new(b"", session, config) ).new(b"", session, config)
assert ( assert (
await file.rename("test_file_2.txt", session, config) await file.rename("test_file_2.txt", session, config, force=True)
).name == "test_file_2.1.txt" ).name == "test_file_2.1.txt"
assert ( assert (
(await repository.path(session, config)) (await repository.real_path(session, config))
.joinpath("test1", "test_file_2.1.txt") .joinpath("test1", "test_file_2.1.txt")
.exists() .exists()
) )
assert ( assert (
not (await repository.path(session, config)) not (await repository.real_path(session, config))
.joinpath("test1", "test_file_rename.txt") .joinpath("test1", "test_file_rename.txt")
.exists() .exists()
) )
@ -262,12 +266,12 @@ async def test_file(data, tmpdir, session: SessionContext, config: Config):
await session.refresh(file, attribute_names=["parent"]) await session.refresh(file, attribute_names=["parent"])
assert file.parent == directory2 assert file.parent == directory2
assert ( assert (
not (await repository.path(session, config)) not (await repository.real_path(session, config))
.joinpath("test1", "test_file_2.1.txt") .joinpath("test1", "test_file_2.1.txt")
.exists() .exists()
) )
assert ( assert (
(await repository.path(session, config)) (await repository.real_path(session, config))
.joinpath("test2", "test_file_2.1.txt") .joinpath("test2", "test_file_2.1.txt")
.exists() .exists()
) )