222 lines
8.1 KiB
Python
222 lines
8.1 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
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 DeleteTaskService:
|
|
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_delete_task(self, path: str | None, recursive: bool = False) -> TaskCreateResponse:
|
|
if not path:
|
|
raise AppError(
|
|
code="invalid_request",
|
|
message="Query parameter 'path' is required",
|
|
status_code=400,
|
|
)
|
|
try:
|
|
item = self._build_delete_item(path=path, recursive=recursive)
|
|
|
|
task_id = str(uuid.uuid4())
|
|
task = self._repository.create_task(
|
|
operation="delete",
|
|
source=item["relative_path"],
|
|
destination="",
|
|
task_id=task_id,
|
|
)
|
|
self._record_history(
|
|
entry_id=task_id,
|
|
operation="delete",
|
|
status="queued",
|
|
path=item["relative_path"],
|
|
)
|
|
self._runner.enqueue_delete_path(task_id=task["id"], item=item)
|
|
return TaskCreateResponse(task_id=task["id"], status=task["status"])
|
|
except AppError as exc:
|
|
self._record_history(
|
|
operation="delete",
|
|
status="failed",
|
|
path=path,
|
|
error_code=exc.code,
|
|
error_message=exc.message,
|
|
finished_at=self._now_iso(),
|
|
)
|
|
raise
|
|
except OSError as exc:
|
|
error = AppError(
|
|
code="io_error",
|
|
message="Filesystem operation failed",
|
|
status_code=500,
|
|
details={"reason": str(exc)},
|
|
)
|
|
self._record_history(
|
|
operation="delete",
|
|
status="failed",
|
|
path=path,
|
|
error_code=error.code,
|
|
error_message=error.message,
|
|
finished_at=self._now_iso(),
|
|
)
|
|
raise error
|
|
|
|
def create_batch_delete_task(self, paths: list[str] | None, recursive_paths: list[str] | None = None) -> TaskCreateResponse:
|
|
if not paths or len(paths) < 2:
|
|
raise AppError(
|
|
code="invalid_request",
|
|
message="Batch delete requires at least 2 paths",
|
|
status_code=400,
|
|
)
|
|
|
|
recursive_paths_set = set(recursive_paths or [])
|
|
invalid_recursive = sorted(path for path in recursive_paths_set if path not in paths)
|
|
if invalid_recursive:
|
|
raise AppError(
|
|
code="invalid_request",
|
|
message="Recursive delete paths must be included in the batch selection",
|
|
status_code=400,
|
|
details={"path": invalid_recursive[0]},
|
|
)
|
|
|
|
try:
|
|
items = [
|
|
self._build_delete_item(
|
|
path=path,
|
|
recursive=path in recursive_paths_set,
|
|
include_root_prefix=True,
|
|
)
|
|
for path in paths
|
|
]
|
|
|
|
task_id = str(uuid.uuid4())
|
|
task = self._repository.create_task(
|
|
operation="delete",
|
|
source=f"{len(items)} items",
|
|
destination="",
|
|
task_id=task_id,
|
|
)
|
|
self._record_history(
|
|
entry_id=task_id,
|
|
operation="delete",
|
|
status="queued",
|
|
path=f"{len(items)} items",
|
|
)
|
|
self._runner.enqueue_delete_batch(task_id=task["id"], items=items)
|
|
return TaskCreateResponse(task_id=task["id"], status=task["status"])
|
|
except AppError as exc:
|
|
self._record_history(
|
|
operation="delete",
|
|
status="failed",
|
|
path=f"{len(paths or [])} items",
|
|
error_code=exc.code,
|
|
error_message=exc.message,
|
|
finished_at=self._now_iso(),
|
|
)
|
|
raise
|
|
except OSError as exc:
|
|
error = AppError(
|
|
code="io_error",
|
|
message="Filesystem operation failed",
|
|
status_code=500,
|
|
details={"reason": str(exc)},
|
|
)
|
|
self._record_history(
|
|
operation="delete",
|
|
status="failed",
|
|
path=f"{len(paths or [])} items",
|
|
error_code=error.code,
|
|
error_message=error.message,
|
|
finished_at=self._now_iso(),
|
|
)
|
|
raise error
|
|
|
|
def _build_delete_item(self, path: str, recursive: bool, include_root_prefix: bool = False) -> dict:
|
|
resolved_target = self._path_guard.resolve_existing_path(path)
|
|
|
|
if resolved_target.absolute.is_file():
|
|
label = resolved_target.absolute.name
|
|
files = [{"path": str(resolved_target.absolute), "label": label}]
|
|
directories: list[str] = []
|
|
kind = "file"
|
|
elif resolved_target.absolute.is_dir():
|
|
kind = "directory"
|
|
if not recursive and any(resolved_target.absolute.iterdir()):
|
|
raise AppError(
|
|
code="directory_not_empty",
|
|
message="Directory is not empty",
|
|
status_code=409,
|
|
details={"path": resolved_target.relative},
|
|
)
|
|
if recursive:
|
|
files, directories = self._build_recursive_delete_plan(
|
|
resolved_target.absolute,
|
|
include_root_prefix=include_root_prefix,
|
|
)
|
|
else:
|
|
files = []
|
|
directories = [str(resolved_target.absolute)]
|
|
else:
|
|
raise AppError(
|
|
code="type_conflict",
|
|
message="Unsupported path type for delete",
|
|
status_code=409,
|
|
details={"path": resolved_target.relative},
|
|
)
|
|
|
|
return {
|
|
"target": str(resolved_target.absolute),
|
|
"relative_path": resolved_target.relative,
|
|
"kind": kind,
|
|
"recursive": recursive,
|
|
"files": files,
|
|
"directories": directories,
|
|
"progress_total_items": len(files),
|
|
"progress_label": files[0]["label"] if files else None,
|
|
}
|
|
|
|
def _build_recursive_delete_plan(self, root: Path, include_root_prefix: bool = False) -> tuple[list[dict[str, str]], list[str]]:
|
|
files: list[dict[str, str]] = []
|
|
directories: list[str] = []
|
|
start_prefix = Path(root.name) if include_root_prefix else Path()
|
|
|
|
def walk(path: Path, relative_prefix: Path) -> None:
|
|
for entry in sorted(path.iterdir(), key=lambda child: child.name.lower()):
|
|
relative_path = relative_prefix / entry.name
|
|
if entry.is_symlink():
|
|
files.append({"path": str(entry), "label": relative_path.as_posix()})
|
|
continue
|
|
if entry.is_dir():
|
|
walk(entry, relative_path)
|
|
directories.append(str(entry))
|
|
continue
|
|
files.append({"path": str(entry), "label": relative_path.as_posix()})
|
|
|
|
walk(root, start_prefix)
|
|
directories.append(str(root))
|
|
return files, directories
|
|
|
|
def _record_history(self, **kwargs) -> None:
|
|
if self._history_repository:
|
|
self._history_repository.create_entry(**kwargs)
|
|
|
|
@staticmethod
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|