feat: logging toegevoegd
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from backend.app.api.schemas import HistoryListResponse
|
||||
from backend.app.dependencies import get_history_service
|
||||
from backend.app.services.history_service import HistoryService
|
||||
|
||||
router = APIRouter(prefix="/history")
|
||||
|
||||
|
||||
@router.get("", response_model=HistoryListResponse)
|
||||
async def list_history(service: HistoryService = Depends(get_history_service)) -> HistoryListResponse:
|
||||
return service.list_history()
|
||||
@@ -149,3 +149,20 @@ class BookmarkListResponse(BaseModel):
|
||||
|
||||
class BookmarkDeleteResponse(BaseModel):
|
||||
id: int
|
||||
|
||||
|
||||
class HistoryItem(BaseModel):
|
||||
id: str
|
||||
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
|
||||
created_at: str
|
||||
finished_at: str | None = None
|
||||
|
||||
|
||||
class HistoryListResponse(BaseModel):
|
||||
items: list[HistoryItem]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
VALID_HISTORY_STATUSES = {"queued", "completed", "failed"}
|
||||
VALID_HISTORY_OPERATIONS = {"mkdir", "rename", "delete", "copy", "move"}
|
||||
|
||||
|
||||
class HistoryRepository:
|
||||
def __init__(self, db_path: str):
|
||||
self._db_path = db_path
|
||||
self._ensure_schema()
|
||||
|
||||
def create_entry(
|
||||
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,
|
||||
created_at: str | None = None,
|
||||
finished_at: str | None = None,
|
||||
entry_id: str | None = None,
|
||||
) -> dict:
|
||||
if operation not in VALID_HISTORY_OPERATIONS:
|
||||
raise ValueError("invalid operation")
|
||||
if status not in VALID_HISTORY_STATUSES:
|
||||
raise ValueError("invalid status")
|
||||
|
||||
history_id = entry_id or str(uuid.uuid4())
|
||||
created_value = created_at or self._now_iso()
|
||||
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO history (
|
||||
id, operation, status, source, destination, path,
|
||||
error_code, error_message, created_at, finished_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
history_id,
|
||||
operation,
|
||||
status,
|
||||
source,
|
||||
destination,
|
||||
path,
|
||||
error_code,
|
||||
error_message,
|
||||
created_value,
|
||||
finished_at,
|
||||
),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM history WHERE id = ?", (history_id,)).fetchone()
|
||||
return self._to_dict(row)
|
||||
|
||||
def update_entry(
|
||||
self,
|
||||
*,
|
||||
entry_id: str,
|
||||
status: str,
|
||||
error_code: str | None = None,
|
||||
error_message: str | None = None,
|
||||
finished_at: str | None = None,
|
||||
) -> None:
|
||||
if status not in VALID_HISTORY_STATUSES:
|
||||
raise ValueError("invalid status")
|
||||
finished_value = finished_at or self._now_iso()
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE history
|
||||
SET status = ?, error_code = ?, error_message = ?, finished_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, error_code, error_message, finished_value, entry_id),
|
||||
)
|
||||
|
||||
def list_history(self, limit: int = 100) -> list[dict]:
|
||||
max_limit = max(1, min(limit, 200))
|
||||
with self._connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM history
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(max_limit,),
|
||||
).fetchall()
|
||||
return [self._to_dict(row) for row in rows]
|
||||
|
||||
def insert_entry_for_testing(self, entry: dict) -> None:
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO history (
|
||||
id, operation, status, source, destination, path,
|
||||
error_code, error_message, created_at, finished_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
entry["id"],
|
||||
entry["operation"],
|
||||
entry["status"],
|
||||
entry.get("source"),
|
||||
entry.get("destination"),
|
||||
entry.get("path"),
|
||||
entry.get("error_code"),
|
||||
entry.get("error_message"),
|
||||
entry["created_at"],
|
||||
entry.get("finished_at"),
|
||||
),
|
||||
)
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
db_path = Path(self._db_path)
|
||||
if db_path.parent and str(db_path.parent) not in {"", "."}:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id TEXT PRIMARY KEY,
|
||||
operation TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
source TEXT NULL,
|
||||
destination TEXT NULL,
|
||||
path TEXT NULL,
|
||||
error_code TEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
finished_at TEXT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_history_created_at_desc
|
||||
ON history(created_at DESC)
|
||||
"""
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _connection(self):
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: sqlite3.Row | None) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
@@ -32,11 +32,11 @@ class TaskRepository:
|
||||
self._db_path = db_path
|
||||
self._ensure_schema()
|
||||
|
||||
def create_task(self, operation: str, source: str, destination: str) -> dict:
|
||||
def create_task(self, operation: str, source: str, destination: str, task_id: str | None = None) -> dict:
|
||||
if operation not in VALID_OPERATIONS:
|
||||
raise ValueError("invalid operation")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
task_id = task_id or str(uuid.uuid4())
|
||||
created_at = self._now_iso()
|
||||
|
||||
with self._connection() as conn:
|
||||
|
||||
@@ -4,6 +4,7 @@ from functools import lru_cache
|
||||
|
||||
from backend.app.config import Settings, get_settings
|
||||
from backend.app.db.bookmark_repository import BookmarkRepository
|
||||
from backend.app.db.history_repository import HistoryRepository
|
||||
from backend.app.db.task_repository import TaskRepository
|
||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||
from backend.app.security.path_guard import PathGuard
|
||||
@@ -11,6 +12,7 @@ from backend.app.services.bookmark_service import BookmarkService
|
||||
from backend.app.services.browse_service import BrowseService
|
||||
from backend.app.services.copy_task_service import CopyTaskService
|
||||
from backend.app.services.file_ops_service import FileOpsService
|
||||
from backend.app.services.history_service import HistoryService
|
||||
from backend.app.services.move_task_service import MoveTaskService
|
||||
from backend.app.services.task_service import TaskService
|
||||
from backend.app.tasks_runner import TaskRunner
|
||||
@@ -38,9 +40,19 @@ def get_bookmark_repository() -> BookmarkRepository:
|
||||
return BookmarkRepository(db_path=settings.task_db_path)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_history_repository() -> HistoryRepository:
|
||||
settings: Settings = get_settings()
|
||||
return HistoryRepository(db_path=settings.task_db_path)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_task_runner() -> TaskRunner:
|
||||
return TaskRunner(repository=get_task_repository(), filesystem=get_filesystem_adapter())
|
||||
return TaskRunner(
|
||||
repository=get_task_repository(),
|
||||
filesystem=get_filesystem_adapter(),
|
||||
history_repository=get_history_repository(),
|
||||
)
|
||||
|
||||
|
||||
async def get_browse_service() -> BrowseService:
|
||||
@@ -48,7 +60,11 @@ async def get_browse_service() -> BrowseService:
|
||||
|
||||
|
||||
async def get_file_ops_service() -> FileOpsService:
|
||||
return FileOpsService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
|
||||
return FileOpsService(
|
||||
path_guard=get_path_guard(),
|
||||
filesystem=get_filesystem_adapter(),
|
||||
history_repository=get_history_repository(),
|
||||
)
|
||||
|
||||
|
||||
async def get_task_service() -> TaskService:
|
||||
@@ -60,6 +76,7 @@ async def get_copy_task_service() -> CopyTaskService:
|
||||
path_guard=get_path_guard(),
|
||||
repository=get_task_repository(),
|
||||
runner=get_task_runner(),
|
||||
history_repository=get_history_repository(),
|
||||
)
|
||||
|
||||
|
||||
@@ -68,8 +85,13 @@ async def get_move_task_service() -> MoveTaskService:
|
||||
path_guard=get_path_guard(),
|
||||
repository=get_task_repository(),
|
||||
runner=get_task_runner(),
|
||||
history_repository=get_history_repository(),
|
||||
)
|
||||
|
||||
|
||||
async def get_bookmark_service() -> BookmarkService:
|
||||
return BookmarkService(path_guard=get_path_guard(), repository=get_bookmark_repository())
|
||||
|
||||
|
||||
async def get_history_service() -> HistoryService:
|
||||
return HistoryService(repository=get_history_repository())
|
||||
|
||||
@@ -11,6 +11,7 @@ from backend.app.api.routes_bookmarks import router as bookmarks_router
|
||||
from backend.app.api.routes_browse import router as browse_router
|
||||
from backend.app.api.routes_copy import router as copy_router
|
||||
from backend.app.api.routes_files import router as files_router
|
||||
from backend.app.api.routes_history import router as history_router
|
||||
from backend.app.api.routes_move import router as move_router
|
||||
from backend.app.api.routes_tasks import router as tasks_router
|
||||
from backend.app.logging import configure_logging
|
||||
@@ -29,6 +30,7 @@ app.include_router(files_router, prefix="/api")
|
||||
app.include_router(copy_router, prefix="/api")
|
||||
app.include_router(move_router, prefix="/api")
|
||||
app.include_router(bookmarks_router, prefix="/api")
|
||||
app.include_router(history_router, prefix="/api")
|
||||
app.include_router(tasks_router, prefix="/api")
|
||||
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -3,14 +3,16 @@ from __future__ import annotations
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.db.history_repository import HistoryRepository
|
||||
from backend.app.db.task_repository import TaskRepository
|
||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
def __init__(self, repository: TaskRepository, filesystem: FilesystemAdapter):
|
||||
def __init__(self, repository: TaskRepository, filesystem: FilesystemAdapter, history_repository: HistoryRepository | None = None):
|
||||
self._repository = repository
|
||||
self._filesystem = filesystem
|
||||
self._history_repository = history_repository
|
||||
|
||||
def enqueue_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None:
|
||||
thread = threading.Thread(
|
||||
@@ -77,6 +79,7 @@ class TaskRunner:
|
||||
done_bytes=total_bytes,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -86,6 +89,7 @@ class TaskRunner:
|
||||
done_bytes=progress["done"],
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
|
||||
def _run_move_file(
|
||||
self,
|
||||
@@ -112,6 +116,7 @@ class TaskRunner:
|
||||
done_bytes=total_bytes,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
return
|
||||
|
||||
def on_progress(done_bytes: int) -> None:
|
||||
@@ -130,6 +135,7 @@ class TaskRunner:
|
||||
done_bytes=total_bytes,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -139,6 +145,7 @@ class TaskRunner:
|
||||
done_bytes=progress["done"],
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
|
||||
def _run_move_directory(self, task_id: str, source: str, destination: str) -> None:
|
||||
self._repository.mark_running(
|
||||
@@ -155,6 +162,7 @@ class TaskRunner:
|
||||
done_items=1,
|
||||
total_items=1,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -164,6 +172,7 @@ class TaskRunner:
|
||||
done_items=0,
|
||||
total_items=1,
|
||||
)
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
|
||||
def _run_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None:
|
||||
total_items = len(items)
|
||||
@@ -203,6 +212,7 @@ class TaskRunner:
|
||||
done_items=completed_items,
|
||||
total_items=total_items,
|
||||
)
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
return
|
||||
|
||||
self._repository.mark_completed(
|
||||
@@ -210,3 +220,17 @@ class TaskRunner:
|
||||
done_items=total_items,
|
||||
total_items=total_items,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
|
||||
def _update_history_completed(self, task_id: str) -> None:
|
||||
if self._history_repository:
|
||||
self._history_repository.update_entry(entry_id=task_id, status="completed")
|
||||
|
||||
def _update_history_failed(self, task_id: str, error_message: str) -> None:
|
||||
if self._history_repository:
|
||||
self._history_repository.update_entry(
|
||||
entry_id=task_id,
|
||||
status="failed",
|
||||
error_code="io_error",
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user