Files
webmanager-mvp/webui/backend/app/services/delete_task_service.py
T
2026-03-15 15:51:13 +01:00

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")