feat: logging toegevoegd
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,70 +2,94 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import uuid
|
||||
|
||||
from backend.app.api.errors import AppError
|
||||
from backend.app.api.schemas import TaskCreateResponse
|
||||
from backend.app.db.history_repository import HistoryRepository
|
||||
from backend.app.db.task_repository import TaskRepository
|
||||
from backend.app.security.path_guard import PathGuard
|
||||
from backend.app.tasks_runner import TaskRunner
|
||||
|
||||
|
||||
class CopyTaskService:
|
||||
def __init__(self, path_guard: PathGuard, repository: TaskRepository, runner: TaskRunner):
|
||||
def __init__(self, path_guard: PathGuard, repository: TaskRepository, runner: TaskRunner, history_repository: HistoryRepository | None = None):
|
||||
self._path_guard = path_guard
|
||||
self._repository = repository
|
||||
self._runner = runner
|
||||
self._history_repository = history_repository
|
||||
|
||||
def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse:
|
||||
resolved_source = self._path_guard.resolve_existing_path(source)
|
||||
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source)
|
||||
if lexical_source.is_symlink():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must be a regular file",
|
||||
status_code=409,
|
||||
details={"path": source},
|
||||
try:
|
||||
resolved_source = self._path_guard.resolve_existing_path(source)
|
||||
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source)
|
||||
if lexical_source.is_symlink():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must be a regular file",
|
||||
status_code=409,
|
||||
details={"path": source},
|
||||
)
|
||||
if not resolved_source.absolute.is_file():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must be a file",
|
||||
status_code=409,
|
||||
details={"path": source},
|
||||
)
|
||||
|
||||
resolved_destination = self._path_guard.resolve_path(destination)
|
||||
destination_parent = resolved_destination.absolute.parent
|
||||
parent_relative = self._path_guard.entry_relative_path(
|
||||
resolved_destination.alias,
|
||||
destination_parent,
|
||||
display_style=resolved_destination.display_style,
|
||||
)
|
||||
if not resolved_source.absolute.is_file():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must be a file",
|
||||
status_code=409,
|
||||
details={"path": source},
|
||||
self._map_directory_validation(parent_relative)
|
||||
|
||||
if resolved_destination.absolute.exists():
|
||||
raise AppError(
|
||||
code="already_exists",
|
||||
message="Target path already exists",
|
||||
status_code=409,
|
||||
details={"path": resolved_destination.relative},
|
||||
)
|
||||
|
||||
total_bytes = int(resolved_source.absolute.stat().st_size)
|
||||
task_id = str(uuid.uuid4())
|
||||
task = self._repository.create_task(
|
||||
operation="copy",
|
||||
source=resolved_source.relative,
|
||||
destination=resolved_destination.relative,
|
||||
task_id=task_id,
|
||||
)
|
||||
self._record_history(
|
||||
entry_id=task_id,
|
||||
operation="copy",
|
||||
status="queued",
|
||||
source=resolved_source.relative,
|
||||
destination=resolved_destination.relative,
|
||||
)
|
||||
|
||||
resolved_destination = self._path_guard.resolve_path(destination)
|
||||
|
||||
destination_parent = resolved_destination.absolute.parent
|
||||
parent_relative = self._path_guard.entry_relative_path(
|
||||
resolved_destination.alias,
|
||||
destination_parent,
|
||||
display_style=resolved_destination.display_style,
|
||||
)
|
||||
self._map_directory_validation(parent_relative)
|
||||
|
||||
if resolved_destination.absolute.exists():
|
||||
raise AppError(
|
||||
code="already_exists",
|
||||
message="Target path already exists",
|
||||
status_code=409,
|
||||
details={"path": resolved_destination.relative},
|
||||
self._runner.enqueue_copy_file(
|
||||
task_id=task["id"],
|
||||
source=str(resolved_source.absolute),
|
||||
destination=str(resolved_destination.absolute),
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
|
||||
total_bytes = int(resolved_source.absolute.stat().st_size)
|
||||
task = self._repository.create_task(
|
||||
operation="copy",
|
||||
source=resolved_source.relative,
|
||||
destination=resolved_destination.relative,
|
||||
)
|
||||
|
||||
self._runner.enqueue_copy_file(
|
||||
task_id=task["id"],
|
||||
source=str(resolved_source.absolute),
|
||||
destination=str(resolved_destination.absolute),
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
|
||||
return TaskCreateResponse(task_id=task["id"], status=task["status"])
|
||||
return TaskCreateResponse(task_id=task["id"], status=task["status"])
|
||||
except AppError as exc:
|
||||
self._record_history(
|
||||
operation="copy",
|
||||
status="failed",
|
||||
source=source,
|
||||
destination=destination,
|
||||
error_code=exc.code,
|
||||
error_message=exc.message,
|
||||
finished_at=self._now_iso(),
|
||||
)
|
||||
raise
|
||||
|
||||
def _map_directory_validation(self, relative_path: str) -> None:
|
||||
try:
|
||||
@@ -79,3 +103,13 @@ class CopyTaskService:
|
||||
details=exc.details,
|
||||
)
|
||||
raise
|
||||
|
||||
def _record_history(self, **kwargs) -> None:
|
||||
if self._history_repository:
|
||||
self._history_repository.create_entry(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
from backend.app.api.errors import AppError
|
||||
from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse
|
||||
from backend.app.db.history_repository import HistoryRepository
|
||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||
from backend.app.security.path_guard import PathGuard
|
||||
|
||||
@@ -27,93 +28,117 @@ SPECIAL_TEXT_FILENAMES = {
|
||||
|
||||
|
||||
class FileOpsService:
|
||||
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter):
|
||||
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter, history_repository: HistoryRepository | None = None):
|
||||
self._path_guard = path_guard
|
||||
self._filesystem = filesystem
|
||||
self._history_repository = history_repository
|
||||
|
||||
def mkdir(self, parent_path: str, name: str) -> MkdirResponse:
|
||||
resolved_parent = self._path_guard.resolve_directory_path(parent_path)
|
||||
safe_name = self._path_guard.validate_name(name, field="name")
|
||||
target_relative = self._join_relative(resolved_parent.relative, safe_name)
|
||||
resolved_target = self._path_guard.resolve_path(target_relative)
|
||||
|
||||
if resolved_target.absolute.exists():
|
||||
raise AppError(
|
||||
code="already_exists",
|
||||
message="Target path already exists",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
|
||||
try:
|
||||
resolved_parent = self._path_guard.resolve_directory_path(parent_path)
|
||||
safe_name = self._path_guard.validate_name(name, field="name")
|
||||
target_relative = self._join_relative(resolved_parent.relative, safe_name)
|
||||
resolved_target = self._path_guard.resolve_path(target_relative)
|
||||
|
||||
if resolved_target.absolute.exists():
|
||||
raise AppError(
|
||||
code="already_exists",
|
||||
message="Target path already exists",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
|
||||
self._filesystem.make_directory(resolved_target.absolute)
|
||||
self._record_history(operation="mkdir", status="completed", path=resolved_target.relative, finished_at=self._now_iso())
|
||||
return MkdirResponse(path=resolved_target.relative)
|
||||
except FileExistsError:
|
||||
raise AppError(
|
||||
error = AppError(
|
||||
code="already_exists",
|
||||
message="Target path already exists",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
details={"path": self._join_relative(parent_path, name)},
|
||||
)
|
||||
self._record_history_error(operation="mkdir", path=self._join_relative(parent_path, name), error=error)
|
||||
raise error
|
||||
except AppError as exc:
|
||||
self._record_history_error(operation="mkdir", path=self._join_relative(parent_path, name), error=exc)
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise AppError(
|
||||
error = AppError(
|
||||
code="io_error",
|
||||
message="Filesystem operation failed",
|
||||
status_code=500,
|
||||
details={"reason": str(exc)},
|
||||
)
|
||||
|
||||
return MkdirResponse(path=resolved_target.relative)
|
||||
self._record_history_error(operation="mkdir", path=self._join_relative(parent_path, name), error=error)
|
||||
raise error
|
||||
|
||||
def rename(self, path: str, new_name: str) -> RenameResponse:
|
||||
resolved_source = self._path_guard.resolve_existing_path(path)
|
||||
safe_name = self._path_guard.validate_name(new_name, field="new_name")
|
||||
|
||||
parent_relative = self._path_guard.entry_relative_path(
|
||||
resolved_source.alias,
|
||||
resolved_source.absolute.parent,
|
||||
display_style=resolved_source.display_style,
|
||||
)
|
||||
target_relative = self._join_relative(parent_relative, safe_name)
|
||||
resolved_target = self._path_guard.resolve_path(target_relative)
|
||||
|
||||
if resolved_target.absolute.exists():
|
||||
raise AppError(
|
||||
code="already_exists",
|
||||
message="Target path already exists",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
|
||||
try:
|
||||
resolved_source = self._path_guard.resolve_existing_path(path)
|
||||
safe_name = self._path_guard.validate_name(new_name, field="new_name")
|
||||
|
||||
parent_relative = self._path_guard.entry_relative_path(
|
||||
resolved_source.alias,
|
||||
resolved_source.absolute.parent,
|
||||
display_style=resolved_source.display_style,
|
||||
)
|
||||
target_relative = self._join_relative(parent_relative, safe_name)
|
||||
resolved_target = self._path_guard.resolve_path(target_relative)
|
||||
|
||||
if resolved_target.absolute.exists():
|
||||
raise AppError(
|
||||
code="already_exists",
|
||||
message="Target path already exists",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
|
||||
self._filesystem.rename_path(resolved_source.absolute, resolved_target.absolute)
|
||||
self._record_history(
|
||||
operation="rename",
|
||||
status="completed",
|
||||
source=path,
|
||||
destination=resolved_target.relative,
|
||||
path=resolved_target.relative,
|
||||
finished_at=self._now_iso(),
|
||||
)
|
||||
return RenameResponse(path=resolved_target.relative)
|
||||
except FileNotFoundError:
|
||||
raise AppError(
|
||||
error = AppError(
|
||||
code="path_not_found",
|
||||
message="Requested path was not found",
|
||||
status_code=404,
|
||||
details={"path": path},
|
||||
)
|
||||
self._record_history_error(operation="rename", source=path, destination=new_name, path=path, error=error)
|
||||
raise error
|
||||
except FileExistsError:
|
||||
raise AppError(
|
||||
error = AppError(
|
||||
code="already_exists",
|
||||
message="Target path already exists",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
details={"path": new_name},
|
||||
)
|
||||
self._record_history_error(operation="rename", source=path, destination=new_name, path=path, error=error)
|
||||
raise error
|
||||
except AppError as exc:
|
||||
self._record_history_error(operation="rename", source=path, destination=new_name, path=path, error=exc)
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise AppError(
|
||||
error = AppError(
|
||||
code="io_error",
|
||||
message="Filesystem operation failed",
|
||||
status_code=500,
|
||||
details={"reason": str(exc)},
|
||||
)
|
||||
|
||||
return RenameResponse(path=resolved_target.relative)
|
||||
self._record_history_error(operation="rename", source=path, destination=new_name, path=path, error=error)
|
||||
raise error
|
||||
|
||||
def delete(self, path: str) -> DeleteResponse:
|
||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||
|
||||
try:
|
||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||
|
||||
if resolved_target.absolute.is_file():
|
||||
self._filesystem.delete_file(resolved_target.absolute)
|
||||
elif resolved_target.absolute.is_dir():
|
||||
@@ -132,24 +157,29 @@ class FileOpsService:
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
except AppError:
|
||||
self._record_history(operation="delete", status="completed", path=resolved_target.relative, finished_at=self._now_iso())
|
||||
return DeleteResponse(path=resolved_target.relative)
|
||||
except AppError as exc:
|
||||
self._record_history_error(operation="delete", path=path, error=exc)
|
||||
raise
|
||||
except FileNotFoundError:
|
||||
raise AppError(
|
||||
error = AppError(
|
||||
code="path_not_found",
|
||||
message="Requested path was not found",
|
||||
status_code=404,
|
||||
details={"path": path},
|
||||
)
|
||||
self._record_history_error(operation="delete", path=path, error=error)
|
||||
raise error
|
||||
except OSError as exc:
|
||||
raise AppError(
|
||||
error = AppError(
|
||||
code="io_error",
|
||||
message="Filesystem operation failed",
|
||||
status_code=500,
|
||||
details={"reason": str(exc)},
|
||||
)
|
||||
|
||||
return DeleteResponse(path=resolved_target.relative)
|
||||
self._record_history_error(operation="delete", path=path, error=error)
|
||||
raise error
|
||||
|
||||
def view(self, path: str, for_edit: bool = False) -> ViewResponse:
|
||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||
@@ -282,3 +312,54 @@ class FileOpsService:
|
||||
if special_name:
|
||||
return special_name
|
||||
return TEXT_CONTENT_TYPES.get(path.suffix.lower())
|
||||
|
||||
def _record_history(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
status: str,
|
||||
source: str | None = None,
|
||||
destination: str | None = None,
|
||||
path: str | None = None,
|
||||
error_code: str | None = None,
|
||||
error_message: str | None = None,
|
||||
finished_at: str | None = None,
|
||||
) -> None:
|
||||
if not self._history_repository:
|
||||
return
|
||||
self._history_repository.create_entry(
|
||||
operation=operation,
|
||||
status=status,
|
||||
source=source,
|
||||
destination=destination,
|
||||
path=path,
|
||||
error_code=error_code,
|
||||
error_message=error_message,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
|
||||
def _record_history_error(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
error: AppError,
|
||||
source: str | None = None,
|
||||
destination: str | None = None,
|
||||
path: str | None = None,
|
||||
) -> None:
|
||||
self._record_history(
|
||||
operation=operation,
|
||||
status="failed",
|
||||
source=source,
|
||||
destination=destination,
|
||||
path=path,
|
||||
error_code=error.code,
|
||||
error_message=error.message,
|
||||
finished_at=self._now_iso(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.app.api.schemas import HistoryListResponse
|
||||
from backend.app.db.history_repository import HistoryRepository
|
||||
|
||||
|
||||
class HistoryService:
|
||||
def __init__(self, repository: HistoryRepository):
|
||||
self._repository = repository
|
||||
|
||||
def list_history(self) -> HistoryListResponse:
|
||||
return HistoryListResponse(items=self._repository.list_history(limit=100))
|
||||
@@ -2,18 +2,22 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import uuid
|
||||
|
||||
from backend.app.api.errors import AppError
|
||||
from backend.app.api.schemas import TaskCreateResponse
|
||||
from backend.app.db.history_repository import HistoryRepository
|
||||
from backend.app.db.task_repository import TaskRepository
|
||||
from backend.app.security.path_guard import PathGuard, ResolvedPath
|
||||
from backend.app.tasks_runner import TaskRunner
|
||||
|
||||
|
||||
class MoveTaskService:
|
||||
def __init__(self, path_guard: PathGuard, repository: TaskRepository, runner: TaskRunner):
|
||||
def __init__(self, path_guard: PathGuard, repository: TaskRepository, runner: TaskRunner, history_repository: HistoryRepository | None = None):
|
||||
self._path_guard = path_guard
|
||||
self._repository = repository
|
||||
self._runner = runner
|
||||
self._history_repository = history_repository
|
||||
|
||||
def create_move_task(self, source: str | None, destination: str | None) -> TaskCreateResponse:
|
||||
if not source or not destination:
|
||||
@@ -23,30 +27,50 @@ class MoveTaskService:
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
item = self._build_move_item(source=source, destination=destination)
|
||||
|
||||
task = self._repository.create_task(
|
||||
operation="move",
|
||||
source=item["source_relative"],
|
||||
destination=item["destination_relative"],
|
||||
)
|
||||
|
||||
if item["kind"] == "directory":
|
||||
self._runner.enqueue_move_directory(
|
||||
task_id=task["id"],
|
||||
source=item["source_absolute"],
|
||||
destination=item["destination_absolute"],
|
||||
try:
|
||||
item = self._build_move_item(source=source, destination=destination)
|
||||
task_id = str(uuid.uuid4())
|
||||
task = self._repository.create_task(
|
||||
operation="move",
|
||||
source=item["source_relative"],
|
||||
destination=item["destination_relative"],
|
||||
task_id=task_id,
|
||||
)
|
||||
else:
|
||||
self._runner.enqueue_move_file(
|
||||
task_id=task["id"],
|
||||
source=item["source_absolute"],
|
||||
destination=item["destination_absolute"],
|
||||
total_bytes=item["total_bytes"],
|
||||
same_root=item["same_root"],
|
||||
self._record_history(
|
||||
entry_id=task_id,
|
||||
operation="move",
|
||||
status="queued",
|
||||
source=item["source_relative"],
|
||||
destination=item["destination_relative"],
|
||||
)
|
||||
|
||||
return TaskCreateResponse(task_id=task["id"], status=task["status"])
|
||||
if item["kind"] == "directory":
|
||||
self._runner.enqueue_move_directory(
|
||||
task_id=task["id"],
|
||||
source=item["source_absolute"],
|
||||
destination=item["destination_absolute"],
|
||||
)
|
||||
else:
|
||||
self._runner.enqueue_move_file(
|
||||
task_id=task["id"],
|
||||
source=item["source_absolute"],
|
||||
destination=item["destination_absolute"],
|
||||
total_bytes=item["total_bytes"],
|
||||
same_root=item["same_root"],
|
||||
)
|
||||
|
||||
return TaskCreateResponse(task_id=task["id"], status=task["status"])
|
||||
except AppError as exc:
|
||||
self._record_history(
|
||||
operation="move",
|
||||
status="failed",
|
||||
source=source,
|
||||
destination=destination,
|
||||
error_code=exc.code,
|
||||
error_message=exc.message,
|
||||
finished_at=self._now_iso(),
|
||||
)
|
||||
raise
|
||||
|
||||
def create_batch_move_task(self, sources: list[str] | None, destination_base: str | None) -> TaskCreateResponse:
|
||||
if not sources or len(sources) < 2:
|
||||
@@ -92,10 +116,19 @@ class MoveTaskService:
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
task = self._repository.create_task(
|
||||
operation="move",
|
||||
source=f"{len(items)} items",
|
||||
destination=resolved_destination_base.relative,
|
||||
task_id=task_id,
|
||||
)
|
||||
self._record_history(
|
||||
entry_id=task_id,
|
||||
operation="move",
|
||||
status="queued",
|
||||
source=f"{len(items)} items",
|
||||
destination=resolved_destination_base.relative,
|
||||
)
|
||||
self._runner.enqueue_move_batch(
|
||||
task_id=task["id"],
|
||||
@@ -225,3 +258,13 @@ class MoveTaskService:
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _record_history(self, **kwargs) -> None:
|
||||
if self._history_repository:
|
||||
self._history_repository.create_entry(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
Reference in New Issue
Block a user