feat: logging toegevoegd

This commit is contained in:
kodi
2026-03-12 07:32:44 +01:00
parent ea6eac9536
commit 9901c77919
30 changed files with 1069 additions and 124 deletions
Binary file not shown.
+14
View File
@@ -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()
+17
View File
@@ -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]
+169
View File
@@ -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")
+2 -2
View File
@@ -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:
+24 -2
View File
@@ -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())
+2
View File
@@ -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")
+80 -46
View File
@@ -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")
+132 -51
View File
@@ -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))
+65 -22
View File
@@ -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")
+25 -1
View File
@@ -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,
)
@@ -0,0 +1,180 @@
from __future__ import annotations
import asyncio
import sys
import tempfile
import time
import unittest
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from backend.app.dependencies import get_copy_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service
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.main import app
from backend.app.security.path_guard import PathGuard
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
class FailingCopyFilesystemAdapter(FilesystemAdapter):
def copy_file(self, source: str, destination: str, on_progress=None) -> None:
raise OSError('forced copy failure')
class HistoryApiGoldenTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root1 = Path(self.temp_dir.name) / 'root1'
self.root2 = Path(self.temp_dir.name) / 'root2'
self.root1.mkdir(parents=True, exist_ok=True)
self.root2.mkdir(parents=True, exist_ok=True)
db_path = str(Path(self.temp_dir.name) / 'tasks.db')
self.task_repo = TaskRepository(db_path)
self.history_repo = HistoryRepository(db_path)
self.path_guard = PathGuard({'storage1': str(self.root1), 'storage2': str(self.root2)})
self._set_services(FilesystemAdapter())
def tearDown(self) -> None:
app.dependency_overrides.clear()
self.temp_dir.cleanup()
def _set_services(self, filesystem: FilesystemAdapter) -> None:
runner = TaskRunner(repository=self.task_repo, filesystem=filesystem, history_repository=self.history_repo)
file_ops_service = FileOpsService(path_guard=self.path_guard, filesystem=filesystem, history_repository=self.history_repo)
copy_service = CopyTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo)
move_service = MoveTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo)
task_service = TaskService(repository=self.task_repo)
history_service = HistoryService(repository=self.history_repo)
async def _override_file_ops_service() -> FileOpsService:
return file_ops_service
async def _override_copy_service() -> CopyTaskService:
return copy_service
async def _override_move_service() -> MoveTaskService:
return move_service
async def _override_task_service() -> TaskService:
return task_service
async def _override_history_service() -> HistoryService:
return history_service
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
app.dependency_overrides[get_copy_task_service] = _override_copy_service
app.dependency_overrides[get_move_task_service] = _override_move_service
app.dependency_overrides[get_task_service] = _override_task_service
app.dependency_overrides[get_history_service] = _override_history_service
def _request(self, method: str, url: str, payload: dict | None = None) -> httpx.Response:
async def _run() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url='http://testserver') as client:
if method == 'GET':
return await client.get(url)
return await client.post(url, json=payload)
return asyncio.run(_run())
def _wait_task(self, task_id: str, timeout_s: float = 2.0) -> dict:
deadline = time.time() + timeout_s
while time.time() < deadline:
response = self._request('GET', f'/api/tasks/{task_id}')
body = response.json()
if body['status'] in {'completed', 'failed'}:
return body
time.sleep(0.02)
self.fail('task did not reach terminal state in time')
def test_get_history_empty_list(self) -> None:
response = self._request('GET', '/api/history')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {'items': []})
def test_get_history_list_shape_and_sorting(self) -> None:
self.history_repo.insert_entry_for_testing(
{
'id': 'old', 'operation': 'mkdir', 'status': 'completed', 'source': None, 'destination': None,
'path': 'storage1/old', 'error_code': None, 'error_message': None,
'created_at': '2026-03-10T10:00:00Z', 'finished_at': '2026-03-10T10:00:00Z',
}
)
self.history_repo.insert_entry_for_testing(
{
'id': 'new', 'operation': 'move', 'status': 'queued', 'source': 'storage1/a.txt', 'destination': 'storage1/b.txt',
'path': None, 'error_code': None, 'error_message': None,
'created_at': '2026-03-10T10:01:00Z', 'finished_at': None,
}
)
response = self._request('GET', '/api/history')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {
'items': [
{
'id': 'new', 'operation': 'move', 'status': 'queued', 'source': 'storage1/a.txt', 'destination': 'storage1/b.txt',
'path': None, 'error_code': None, 'error_message': None,
'created_at': '2026-03-10T10:01:00Z', 'finished_at': None,
},
{
'id': 'old', 'operation': 'mkdir', 'status': 'completed', 'source': None, 'destination': None,
'path': 'storage1/old', 'error_code': None, 'error_message': None,
'created_at': '2026-03-10T10:00:00Z', 'finished_at': '2026-03-10T10:00:00Z',
},
]
})
def test_mkdir_success_history_item(self) -> None:
response = self._request('POST', '/api/files/mkdir', {'parent_path': 'storage1', 'name': 'newdir'})
self.assertEqual(response.status_code, 200)
history = self._request('GET', '/api/history').json()['items']
self.assertEqual(history[0]['operation'], 'mkdir')
self.assertEqual(history[0]['status'], 'completed')
self.assertEqual(history[0]['path'], 'storage1/newdir')
def test_rename_failure_history_item(self) -> None:
response = self._request('POST', '/api/files/rename', {'path': 'storage1/missing.txt', 'new_name': 'renamed.txt'})
self.assertEqual(response.status_code, 404)
history = self._request('GET', '/api/history').json()['items']
self.assertEqual(history[0]['operation'], 'rename')
self.assertEqual(history[0]['status'], 'failed')
self.assertEqual(history[0]['error_code'], 'path_not_found')
def test_copy_completed_history_item(self) -> None:
src = self.root1 / 'source.txt'
src.write_text('hello', encoding='utf-8')
response = self._request('POST', '/api/files/copy', {'source': 'storage1/source.txt', 'destination': 'storage1/copied.txt'})
self.assertEqual(response.status_code, 202)
self._wait_task(response.json()['task_id'])
history = self._request('GET', '/api/history').json()['items']
self.assertEqual(history[0]['operation'], 'copy')
self.assertEqual(history[0]['status'], 'completed')
self.assertEqual(history[0]['source'], 'storage1/source.txt')
self.assertEqual(history[0]['destination'], 'storage1/copied.txt')
def test_move_failed_history_item(self) -> None:
src = self.root1 / 'source.txt'
src.write_text('hello', encoding='utf-8')
self._set_services(FailingCopyFilesystemAdapter())
response = self._request('POST', '/api/files/move', {'source': 'storage1/source.txt', 'destination': 'storage2/moved.txt'})
self.assertEqual(response.status_code, 202)
self._wait_task(response.json()['task_id'])
history = self._request('GET', '/api/history').json()['items']
self.assertEqual(history[0]['operation'], 'move')
self.assertEqual(history[0]['status'], 'failed')
self.assertEqual(history[0]['error_code'], 'io_error')
@@ -67,6 +67,7 @@ class TaskSchemaMigrationApiGoldenTest(unittest.TestCase):
def _clear_dependency_caches() -> None:
dependencies.get_path_guard.cache_clear()
dependencies.get_task_repository.cache_clear()
dependencies.get_history_repository.cache_clear()
dependencies.get_task_runner.cache_clear()
dependencies.get_bookmark_repository.cache_clear()
@@ -0,0 +1,50 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from backend.app.db.history_repository import HistoryRepository
class HistoryRepositoryTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.repo = HistoryRepository(str(Path(self.temp_dir.name) / 'history.db'))
def tearDown(self) -> None:
self.temp_dir.cleanup()
def test_list_history_sorted_created_at_desc(self) -> None:
self.repo.insert_entry_for_testing(
{
'id': 'old',
'operation': 'mkdir',
'status': 'completed',
'source': None,
'destination': None,
'path': 'storage1/old',
'error_code': None,
'error_message': None,
'created_at': '2026-03-10T10:00:00Z',
'finished_at': '2026-03-10T10:00:00Z',
}
)
self.repo.insert_entry_for_testing(
{
'id': 'new',
'operation': 'move',
'status': 'failed',
'source': 'storage1/a.txt',
'destination': 'storage1/b.txt',
'path': None,
'error_code': 'io_error',
'error_message': 'failed',
'created_at': '2026-03-10T10:01:00Z',
'finished_at': '2026-03-10T10:01:01Z',
}
)
items = self.repo.list_history(limit=100)
self.assertEqual([item['id'] for item in items], ['new', 'old'])