feat: logging toegevoegd
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
# History v1 Design
|
||||
|
||||
## 1. Doel
|
||||
History is nuttig om recente bestandsacties snel terug te kunnen zien zonder in logs of taskdetails te hoeven zoeken. In de huidige app is dat vooral relevant omdat er nu zowel directe acties bestaan als task-based acties.
|
||||
|
||||
Praktisch nut in v1:
|
||||
|
||||
- snel zien wat net is aangemaakt, hernoemd, verwijderd, gekopieerd of verplaatst
|
||||
- foutgevallen terugvinden zonder afhankelijk te zijn van vluchtige statusmeldingen in de UI
|
||||
- task-based acties en directe acties in één compact overzicht samenbrengen
|
||||
|
||||
Relatie tot het taskmodel:
|
||||
|
||||
- tasks beschrijven vooral uitvoering en progress van asynchrone operaties
|
||||
- history beschrijft afgeronde of mislukte gebruikersacties in een uniform audit-achtig overzicht
|
||||
- history is dus geen vervanging van tasks, maar een compacte gebruikslaag erboven
|
||||
|
||||
## 2. Scope
|
||||
Acties die in v1 in history komen:
|
||||
|
||||
- `mkdir`
|
||||
- `rename`
|
||||
- `delete`
|
||||
- `copy`
|
||||
- `move`
|
||||
|
||||
Voorstel voor opnamegedrag:
|
||||
|
||||
- zowel success als failure opnemen
|
||||
- validatiefouten vóór task-creatie ook opnemen, zodat mislukte gebruikersacties zichtbaar blijven
|
||||
- task-based runtime failures ook opnemen
|
||||
|
||||
Niet in scope in v1:
|
||||
|
||||
- browse-navigatie
|
||||
- bookmark-acties
|
||||
- view/edit openen
|
||||
- theme- of UI-acties
|
||||
|
||||
## 3. Relatie Tasks Versus History
|
||||
Aanbevolen richting: history als aparte tabel/model.
|
||||
|
||||
Waarom niet alleen afleiden uit tasks:
|
||||
|
||||
- `mkdir`, `rename` en `delete` zijn directe acties en hebben nu geen task-record
|
||||
- een uniforme history uit alleen tasks zou dus onvolledig zijn
|
||||
- directe acties kunstmatig als tasks modelleren zou scope verbreden en regressierisico verhogen
|
||||
|
||||
Aanbevolen model:
|
||||
|
||||
- `tasks` blijft bestaan voor asynchrone uitvoering en polling
|
||||
- `history` wordt een aparte persistente tabel
|
||||
- directe acties schrijven direct naar `history`
|
||||
- task-based acties schrijven naar `history`:
|
||||
- bij task-creatie: history-item aanmaken met `status = queued` of `running` is mogelijk, maar niet nodig voor compacte v1
|
||||
- aanbevolen eenvoudiger v1: history-item pas definitief vullen bij task completion/failure
|
||||
|
||||
Pragmatische v1-keuze:
|
||||
|
||||
- bij create van copy/move-task meteen een history-record aanmaken met initiële status `queued`
|
||||
- task-runner werkt dat record daarna bij naar `completed` of `failed`
|
||||
- directe acties schrijven direct één afgerond history-record
|
||||
|
||||
Dit geeft één consistent overzicht zonder complexe reconstructie.
|
||||
|
||||
## 4. Minimale Data Per History-Item
|
||||
Minimaal benodigde velden:
|
||||
|
||||
- `id`
|
||||
- `operation`
|
||||
- `status`
|
||||
- `source`
|
||||
- `destination`
|
||||
- `path`
|
||||
- `error_code`
|
||||
- `error_message`
|
||||
- `created_at`
|
||||
- `finished_at`
|
||||
|
||||
Aanbevolen semantiek per veld:
|
||||
|
||||
- `id`: intern history-id
|
||||
- `operation`: `mkdir | rename | delete | copy | move`
|
||||
- `status`: `completed | failed | queued | running`
|
||||
- `source`: bronpad voor `copy`, `move`, eventueel `rename`
|
||||
- `destination`: doelpad voor `copy`, `move`, eventueel impliciet nieuw pad bij `rename`
|
||||
- `path`: enkelvoudig actiedoel voor directe acties zoals `mkdir` en `delete`
|
||||
- `error_code`: gestandaardiseerde foutcode indien mislukt
|
||||
- `error_message`: compacte user-facing fouttekst
|
||||
- `created_at`: moment waarop actie of task gestart is
|
||||
- `finished_at`: moment waarop actie of task eindigde
|
||||
|
||||
Opmerking:
|
||||
|
||||
- niet elk veld is voor elke operatie gevuld
|
||||
- `path` is vooral nuttig voor enkelvoudige directe acties
|
||||
- `source` en `destination` zijn vooral nuttig voor `copy/move/rename`
|
||||
|
||||
## 5. UI-richting
|
||||
De dual-pane workspace moet leidend blijven. History moet dus niet als permanent groot paneel in het hoofdscherm verschijnen.
|
||||
|
||||
Aanbevolen UI-richting voor v1:
|
||||
|
||||
- compacte aparte modal of slide-over vanuit de topbar of functiebalk
|
||||
- toont een eenvoudige lijst van recente acties
|
||||
- recentste items bovenaan
|
||||
- per regel compact:
|
||||
- operation
|
||||
- status
|
||||
- kernpad of source -> destination
|
||||
- tijdstip
|
||||
|
||||
Waarom geen vast zijpaneel:
|
||||
|
||||
- dat zou de dual-pane workspace verstoren
|
||||
- history is ondersteunend, niet primair
|
||||
|
||||
Aanbevolen v1-keuze:
|
||||
|
||||
- aparte `History` modal met compacte lijstweergave
|
||||
- optioneel later uitbreidbaar met detailweergave
|
||||
|
||||
## 6. Scopebeperking
|
||||
Niet in scope in v1:
|
||||
|
||||
- undo
|
||||
- retry
|
||||
- export
|
||||
- uitgebreide filtering
|
||||
- full-text search
|
||||
- pagination, tenzij de lijst onverwacht groot wordt
|
||||
|
||||
Aanbevolen eenvoud:
|
||||
|
||||
- beperk API en UI in v1 tot recente items, bijvoorbeeld de laatste 100 of 200 records
|
||||
- sortering: `created_at DESC`
|
||||
|
||||
## 7. Backend-impact
|
||||
Waarschijnlijk geraakte componenten:
|
||||
|
||||
- SQLite schema/migratie
|
||||
- repository-laag voor `history`
|
||||
- services voor directe acties:
|
||||
- `mkdir`
|
||||
- `rename`
|
||||
- `delete`
|
||||
- task create-services:
|
||||
- `copy`
|
||||
- `move`
|
||||
- task runner voor statusupdate van task-based history-items
|
||||
- nieuwe read-endpoint(s) voor history
|
||||
|
||||
SQLite-uitbreiding:
|
||||
|
||||
- ja, een aparte `history` tabel is nodig
|
||||
|
||||
Aanbevolen v1-vulling:
|
||||
|
||||
- directe acties:
|
||||
- service maakt history-record direct bij succes/failure
|
||||
- task-acties:
|
||||
- bij task-creatie history-record met `queued`
|
||||
- task-runner update naar `running`, `completed` of `failed`
|
||||
|
||||
Voordeel:
|
||||
|
||||
- history wordt niet afgeleid uit meerdere bronnen bij read-time
|
||||
- minder complexe querylogica
|
||||
- lagere kans op inconsistent UI-gedrag
|
||||
|
||||
## 8. Teststrategie
|
||||
Golden tests:
|
||||
|
||||
- `GET /api/history` lege lijst
|
||||
- lijstshape voor gemengde directe en task-based entries
|
||||
- directe success-entry voor `mkdir`
|
||||
- directe failure-entry voor `rename` of `delete`
|
||||
- task-based completed-entry voor `copy` of `move`
|
||||
- task-based failed-entry voor `copy` of `move`
|
||||
- sortering `created_at DESC`
|
||||
|
||||
Regressietests:
|
||||
|
||||
- bestaande endpoints behouden hun contract
|
||||
- toevoegen van history mag directe actie- of taskflows niet veranderen
|
||||
- taskstatus en historystatus moeten consistent blijven
|
||||
|
||||
Handmatige validatie:
|
||||
|
||||
- na `mkdir/rename/delete` verschijnt history-item correct
|
||||
- na `copy/move` verschijnt history-item met juiste status na afloop
|
||||
- foutmeldingen in history zijn begrijpelijk en compact
|
||||
|
||||
## 9. Aanbeveling
|
||||
Aanbevolen v1-richting met laag regressierisico:
|
||||
|
||||
- voeg een aparte `history` tabel toe in SQLite
|
||||
- vul history expliciet vanuit services en task-runner
|
||||
- expose één eenvoudige read-API voor recente history-items
|
||||
- toon history in een compacte aparte modal, niet in het hoofdscherm
|
||||
|
||||
Waarom dit de veiligste richting is:
|
||||
|
||||
- geen herontwerp van bestaand taskmodel nodig
|
||||
- directe acties en task-based acties komen uniform samen
|
||||
- UI blijft compact en de dual-pane workflow blijft centraal
|
||||
- implementatie is incrementeel en goed testbaar
|
||||
@@ -0,0 +1,89 @@
|
||||
# UI Directory Selection Parity v1
|
||||
|
||||
## 1. Haalbaarheid
|
||||
De huidige advanced selection logica is grotendeels generiek genoeg om ook directories te dragen.
|
||||
|
||||
Wat al generiek werkt:
|
||||
|
||||
- `selectedItems` bevat zowel files als directories
|
||||
- `currentRowIndex` is niet typegebonden
|
||||
- `selectionAnchorIndex` is niet typegebonden
|
||||
- `Shift + ArrowUp/ArrowDown` werkt op `visibleItems` en kan daardoor in principe zowel files als directories meenemen
|
||||
- checkbox-toggle gebruikt al `{ path, name, kind }` en werkt daarmee voor beide itemtypes
|
||||
- `Cmd/Ctrl + klik` toggle-logica is ook type-onafhankelijk opgezet
|
||||
|
||||
Wat expliciet aandacht vraagt:
|
||||
|
||||
- directorynaam heeft een aparte open-semantiek en moet dus buiten selectieclicks blijven vallen
|
||||
- rij-click en naam-click moeten scherp gescheiden blijven, anders ontstaat onbedoelde navigatie of onbedoelde selectie
|
||||
- parent-entry `..` mag niet in gewone selectie terechtkomen
|
||||
|
||||
Conclusie: directory selection parity is technisch goed haalbaar binnen het huidige model. Er is geen nieuw state-model nodig.
|
||||
|
||||
## 2. Gewenst Gedrag Voor Directories
|
||||
Voor directories moet exact dit gelden:
|
||||
|
||||
- gewone rij-click op een directory, buiten de naam:
|
||||
- single-select van die directory
|
||||
- checkbox-click op een directory:
|
||||
- toggle selectie van die directory
|
||||
- `Cmd + klik` op Mac / `Ctrl + klik` op niet-Mac op een directoryrij, buiten de naam:
|
||||
- toggle die directory zonder andere selectie te wissen
|
||||
- `Shift + ArrowUp/ArrowDown`:
|
||||
- directories mogen normaal onderdeel zijn van een range
|
||||
- klik op directorynaam:
|
||||
- opent de directory
|
||||
- selecteert niet
|
||||
|
||||
Dit sluit aan op het bestaande uitgangspunt dat de directorynaam een navigatie-affordance is en de rest van de rij een selectie-affordance.
|
||||
|
||||
## 3. Gemengde Selectie
|
||||
Gemengde selectie van files en directories in dezelfde selectie of range is logisch en veilig binnen de huidige UI, mits dezelfde regels blijven gelden:
|
||||
|
||||
- range-selectie volgt de zichtbare volgorde in de lijst, ongeacht itemtype
|
||||
- `selectedItems` mag dus een mix bevatten van files en directories
|
||||
- vervolgacties bepalen daarna zelf of de selectie geldig is voor die actie
|
||||
|
||||
Dat past al bij de huidige UI:
|
||||
|
||||
- delete accepteert gemengde selectie al functioneel volgens backendmogelijkheden
|
||||
- move/copy tonen of blokkeren al op basis van inhoud en scope
|
||||
- rename/view/edit blijven enkel- of typegebonden
|
||||
|
||||
Conclusie: gemengde selectie is in de huidige UI logisch en hoeft niet apart verboden te worden.
|
||||
|
||||
## 4. Regressierisico
|
||||
Belangrijkste risico's:
|
||||
|
||||
- botsing tussen directorynaam = openen en rij-click = selecteren
|
||||
- modifier-click op of rond de directorynaam die per ongeluk navigeert in plaats van togglet
|
||||
- inconsistentie waarbij file-naam single-select doet, maar directorynaam opent
|
||||
|
||||
Dit risico is beheersbaar zolang de scheiding expliciet blijft:
|
||||
|
||||
- directorynaam is alleen navigatie
|
||||
- checkbox is alleen toggle
|
||||
- rij buiten de naam is selectie
|
||||
|
||||
Het grootste praktische regressierisico zit niet in range-selectie, maar in click-targets binnen de directoryrij. Als event bubbling of target-selectie daar niet scherp genoeg is, voelt directory-selectie inconsistent.
|
||||
|
||||
## 5. Aanbeveling
|
||||
Aanbevolen conclusie voor v1:
|
||||
|
||||
- directory selection parity vraagt waarschijnlijk geen herontwerp
|
||||
- er is waarschijnlijk hooguit een kleine frontend-correctiestap nodig als bij handmatige verificatie blijkt dat modifier-click of row-click rond directorynamen nog niet overal exact hetzelfde aanvoelt als bij files
|
||||
|
||||
Concreet advies:
|
||||
|
||||
- eerst handmatig valideren of directoryrijen zich nu al correct gedragen voor:
|
||||
- gewone rij-click
|
||||
- checkbox-toggle
|
||||
- `Cmd/Ctrl + klik`
|
||||
- `Shift + Arrow` range
|
||||
- alleen als daar inconsistentie blijkt, een kleine gerichte frontend-fix doen in `app.js`
|
||||
|
||||
Kort oordeel:
|
||||
|
||||
- het huidige model is sterk genoeg voor directory parity
|
||||
- waarschijnlijk is geen grote codewijziging nodig
|
||||
- mogelijk is alleen een kleine frontend-correctiestap nodig rond click-target gedrag
|
||||
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,
|
||||
)
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -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()
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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'])
|
||||
Reference in New Issue
Block a user