diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index 3e523b5..713b674 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -686,10 +686,11 @@ manifest = ModuleManifest( summary="Use managed files in the Records and documents area and keep a compact file surface beside current work.", body=( "Files contributes its authorized workspace to Records and documents. When Quick Access is enabled, the owner-rendered " - "recent-file selector appears in Files on the right rail and can return one exact file-version reference to the current task. " - "Opening the selector causes Files to re-run its own authorization; completion and cancellation are explicit, and the full Files " - "workspace remains available as a deep-link fallback. View and rail settings may recommend or focus this tool, but do not bypass " - "file ownership, shares, connector policy, integrity gates, or purpose-aware access." + "selector shows at most seven recently changed files and can return one exact file-version reference to the current task. " + "Accounts with upload permission can launch the full Files upload dialog into a freshly reauthorized managed space. Opening " + "either path causes Files to re-run its own provider, space, folder, object, and scope checks; completion and cancellation are " + "explicit, and the full Files workspace remains available as a deep-link fallback. View and rail settings may recommend or " + "focus this tool, but do not bypass file ownership, shares, connector policy, integrity gates, or purpose-aware access." ), layer="configured", documentation_types=("user", "admin"), @@ -715,11 +716,11 @@ manifest = ModuleManifest( "summary": "Verwaltete Dateien im Produktbereich Akten und Dokumente und optional neben der aktuellen Arbeit verwenden.", "body": ( "Files ordnet seinen berechtigten Arbeitsbereich Akten und Dokumente zu. Ist der Schnellzugriff aktiviert, erscheint " - "die vom Modul gerenderte Auswahl zuletzt berechtigter Dateien rechts neben der aktuellen Seite und kann einen exakten " - "Dateiversionsverweis an die aktuelle Aufgabe zurückgeben. Files prüft die Berechtigung beim Öffnen erneut; Abschluss und " - "Abbruch sind ausdrücklich, und der vollständige Files-Arbeitsbereich bleibt als Ausweichziel erreichbar. Ansichts- und " - "Leistenkonfiguration dürfen das Werkzeug empfehlen oder fokussieren, umgehen aber weder Eigentum, Freigaben, Connector-" - "Richtlinien, Integritätsprüfungen noch zweckgebundenen Zugriff." + "die vom Modul gerenderte Auswahl von höchstens sieben zuletzt geänderten berechtigten Dateien rechts neben der aktuellen " + "Seite und kann einen exakten Dateiversionsverweis zurückgeben. Mit Upload-Berechtigung lässt sich der vollständige " + "Upload-Dialog in einem erneut berechtigungsgeprüften verwalteten Bereich öffnen. Files prüft Anbieter, Bereich, Ordner, " + "Objekt und Berechtigung bei jedem Pfad erneut. Abschluss und Abbruch sind ausdrücklich; Eigentum, Freigaben, Connector-" + "Richtlinien, Integritätsprüfungen und zweckgebundener Zugriff bleiben maßgeblich." ), } }, diff --git a/src/govoplan_files/backend/routes/listing.py b/src/govoplan_files/backend/routes/listing.py index 8e50e90..0cec9f4 100644 --- a/src/govoplan_files/backend/routes/listing.py +++ b/src/govoplan_files/backend/routes/listing.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Literal -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, require_scope @@ -14,6 +14,7 @@ from govoplan_files.backend.storage.files import ( count_assets_for_user, list_assets_for_user, list_assets_for_user_window, + list_recent_assets_for_user, ) @@ -79,6 +80,7 @@ def list_files( path_prefix: str | None = None, campaign_usage: Literal["linked", "unlinked"] | None = None, audit_relevant: bool | None = None, + sort: Literal["path", "recent"] = "path", page_size: int | None = Query(default=None, ge=1, le=1000), cursor: str | None = None, session: Session = Depends(get_session), @@ -99,6 +101,31 @@ def list_files( audit_relevant=audit_relevant, is_admin=_is_admin(principal), ) + if sort == "recent": + if cursor: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Recent file projections do not accept a path-order cursor.", + ) + recent_limit = page_size or 25 + assets = list_recent_assets_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + limit=recent_limit, + owner_type=owner_type, + owner_id=owner_id, + campaign_id=campaign_id, + path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, + is_admin=_is_admin(principal), + ) + return FileListResponse( + files=_asset_list_response(session, assets, include_shares=True), + total=total, + watermark=watermark, + ) effective_page_size = _cursor_page_size(FILES_LIST_CURSOR_SCOPE, cursor, page_size) if effective_page_size is not None: fingerprint = _files_list_fingerprint( diff --git a/src/govoplan_files/backend/storage/files.py b/src/govoplan_files/backend/storage/files.py index c7ccfa9..4320474 100644 --- a/src/govoplan_files/backend/storage/files.py +++ b/src/govoplan_files/backend/storage/files.py @@ -529,6 +529,41 @@ def list_assets_for_user( return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc(), FileAsset.id.asc()).all() +def list_recent_assets_for_user( + session: Session, + *, + tenant_id: str, + user_id: str, + limit: int, + owner_type: str | None = None, + owner_id: str | None = None, + campaign_id: str | None = None, + path_prefix: str | None = None, + campaign_usage: str | None = None, + audit_relevant: bool | None = None, + is_admin: bool = False, +) -> list[FileAsset]: + """Return a bounded recent projection through the normal access query.""" + + query = _asset_visibility_query_for_user( + session, + tenant_id=tenant_id, + user_id=user_id, + owner_type=owner_type, + owner_id=owner_id, + campaign_id=campaign_id, + path_prefix=path_prefix, + campaign_usage=campaign_usage, + audit_relevant=audit_relevant, + is_admin=is_admin, + ) + return ( + query.order_by(FileAsset.updated_at.desc(), FileAsset.id.asc()) + .limit(max(1, limit)) + .all() + ) + + def list_assets_for_user_window( session: Session, *, diff --git a/tests/test_access_provider.py b/tests/test_access_provider.py index e392e89..28bbb96 100644 --- a/tests/test_access_provider.py +++ b/tests/test_access_provider.py @@ -2,6 +2,7 @@ from __future__ import annotations import unittest from datetime import datetime, timedelta, timezone +from unittest.mock import patch from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker @@ -12,7 +13,11 @@ from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.db.base import Base from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare -from govoplan_files.backend.storage.files import count_assets_for_user, list_assets_for_user +from govoplan_files.backend.storage.files import ( + count_assets_for_user, + list_assets_for_user, + list_recent_assets_for_user, +) TENANT_ID = "tenant-1" @@ -22,6 +27,54 @@ GROUP_ID = "group-1" class FilesAccessProviderTests(unittest.TestCase): + def test_recent_projection_is_bounded_ordered_and_access_filtered(self) -> None: + session = _session() + self.addCleanup(_close_session, session) + _seed_access_subjects(session) + now = datetime.now(timezone.utc) + session.add_all( + [ + FileAsset( + id="owned-older", + tenant_id=TENANT_ID, + owner_type="user", + owner_user_id=USER_ID, + display_path="older.pdf", + filename="older.pdf", + updated_at=now - timedelta(hours=2), + ), + FileAsset( + id="owned-newer", + tenant_id=TENANT_ID, + owner_type="user", + owner_user_id=USER_ID, + display_path="newer.pdf", + filename="newer.pdf", + updated_at=now - timedelta(hours=1), + ), + FileAsset( + id="other-newest", + tenant_id=TENANT_ID, + owner_type="user", + owner_user_id=OTHER_USER_ID, + display_path="private.pdf", + filename="private.pdf", + updated_at=now, + ), + ] + ) + session.commit() + + with patch("govoplan_files.backend.storage.files.user_group_ids", return_value=[]): + recent = list_recent_assets_for_user( + session, + tenant_id=TENANT_ID, + user_id=USER_ID, + limit=1, + ) + + self.assertEqual(["owned-newer"], [asset.id for asset in recent]) + def test_file_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None: session = _session() self.addCleanup(_close_session, session) diff --git a/tests/test_quick_access_contract.py b/tests/test_quick_access_contract.py new file mode 100644 index 0000000..1ee0899 --- /dev/null +++ b/tests/test_quick_access_contract.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +from govoplan_files.backend.manifest import manifest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +class FilesQuickAccessContractTests(unittest.TestCase): + def test_manifest_declares_exact_version_result_and_fallback(self) -> None: + tool = manifest.frontend.quick_access_tools[0] + + self.assertEqual("files.recent", tool.id) + self.assertEqual(("files.file-version",), tool.returned_reference_kinds) + self.assertEqual("files.quick_access.files", tool.help_context_id) + self.assertEqual("/files", tool.full_page_path) + + def test_renderer_and_page_keep_recent_selection_and_upload_bounded(self) -> None: + renderer = ( + REPOSITORY_ROOT / "webui/src/features/files/FileQuickAccess.tsx" + ).read_text(encoding="utf-8") + page = ( + REPOSITORY_ROOT / "webui/src/features/files/FilesPage.tsx" + ).read_text(encoding="utf-8") + + self.assertIn('sort: "recent", page_size: 7', renderer) + self.assertIn('kind: "file-version"', renderer) + self.assertIn("quickAccessLaunchState(launchContext)", renderer) + self.assertIn('to="/files?quickAction=upload"', renderer) + self.assertIn('parameters.get("quickAction") !== "upload"', page) + self.assertIn('openDialog("upload"', page) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_router_contract.py b/tests/test_router_contract.py index 0b1290f..dee8f3d 100644 --- a/tests/test_router_contract.py +++ b/tests/test_router_contract.py @@ -127,6 +127,7 @@ class FilesRouterContractTests(unittest.TestCase): self.assertIn("campaign_usage", parameters) self.assertIn("audit_relevant", parameters) + self.assertIn("sort", parameters) self.assertIn("total", route.response_model.model_fields) diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts index 7c99e08..a98ed62 100644 --- a/webui/src/api/files.ts +++ b/webui/src/api/files.ts @@ -470,7 +470,7 @@ payload: {owner_type: "user" | "group";owner_id: string;path: string;recursive?: return apiFetch(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) }); } -export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;campaign_usage?: FileCampaignUsageFilter;audit_relevant?: boolean;page_size?: number;cursor?: string | null;} = {}): Promise { +export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;campaign_usage?: FileCampaignUsageFilter;audit_relevant?: boolean;sort?: "path" | "recent";page_size?: number;cursor?: string | null;} = {}): Promise { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== "") search.set(key, String(value)); diff --git a/webui/src/features/files/FileQuickAccess.tsx b/webui/src/features/files/FileQuickAccess.tsx index 308ca46..a3d0d11 100644 --- a/webui/src/features/files/FileQuickAccess.tsx +++ b/webui/src/features/files/FileQuickAccess.tsx @@ -1,5 +1,6 @@ -import { FileText, X } from "lucide-react"; +import { FileText, UploadCloud, X } from "lucide-react"; import { useCallback } from "react"; +import { Link } from "react-router"; import { Button, DismissibleAlert, @@ -7,6 +8,8 @@ import { SelectionList, SelectionListItem, SelectionListItemContent, + hasScope, + quickAccessLaunchState, useDashboardWidgetData, type QuickAccessToolRenderContext } from "@govoplan/core-webui"; @@ -14,7 +17,7 @@ import { listFiles } from "../../api/files"; type Props = Pick< QuickAccessToolRenderContext, - "settings" | "launchContext" | "complete" | "cancel" + "settings" | "auth" | "launchContext" | "complete" | "cancel" | "close" >; /** @@ -23,15 +26,18 @@ type Props = Pick< */ export default function FileQuickAccess({ settings, + auth, launchContext, complete, - cancel + cancel, + close }: Props) { const load = useCallback( - async () => (await listFiles(settings, { page_size: 7 })).files, + async () => (await listFiles(settings, { sort: "recent", page_size: 7 })).files, [settings] ); const { data: files, loading, error } = useDashboardWidgetData(load, 0); + const canUpload = hasScope(auth, "files:upload"); return ( @@ -74,7 +80,17 @@ export default function FileQuickAccess({

No authorized files are available.

) : null}
- + {canUpload ? ( + +
); diff --git a/webui/src/features/files/FilesPage.tsx b/webui/src/features/files/FilesPage.tsx index d926347..574177d 100644 --- a/webui/src/features/files/FilesPage.tsx +++ b/webui/src/features/files/FilesPage.tsx @@ -16,6 +16,7 @@ import { FormGrid, ActionToolbar, type ApiSettings, type AuthInfo, i18nMessage } from "@govoplan/core-webui"; +import { useLocation, useNavigate } from "react-router"; import { bulkDeleteFiles, bulkRenameFiles, @@ -114,12 +115,15 @@ const FILES_WORKFLOW_DOCUMENTATION = { } as const; export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { + const location = useLocation(); + const navigate = useNavigate(); const canDownload = hasScope(auth, "files:download"); const canUpload = hasScope(auth, "files:upload"); const canOrganize = hasScope(auth, "files:organize"); const canDelete = hasScope(auth, "files:delete"); const canShare = hasScope(auth, "files:file:share"); const [spaces, setSpaces] = useState(EMPTY_SPACES); + const [spacesLoaded, setSpacesLoaded] = useState(false); const [activeSpaceId, setActiveSpaceId] = useState(""); const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null; const activeSpaceIsConnector = activeSpace?.space_type === "connector"; @@ -330,6 +334,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut }, [activeSpaceId, currentFolder, propertyFiltersActive, propertyFilterResults, searchActive, searchResults, sortColumn, sortDirection]); async function loadSpaces() { + setSpacesLoaded(false); setBusy(true); setError(""); try { @@ -341,6 +346,7 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { + setSpacesLoaded(true); setBusy(false); } } @@ -434,6 +440,40 @@ export default function FilesPage({ settings, auth }: {settings: ApiSettings;aut // eslint-disable-next-line react-hooks/exhaustive-deps }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); + useEffect(() => { + const parameters = new URLSearchParams(location.search); + if (parameters.get("quickAction") !== "upload" || !spacesLoaded) return; + + const uploadSpace = + activeSpace && !isConnectorSpace(activeSpace) + ? activeSpace + : spaces.find((space) => !isConnectorSpace(space)) ?? null; + if (canUpload && uploadSpace) { + setActiveSpaceId(uploadSpace.id); + openDialog("upload", { spaceId: uploadSpace.id, folderPath: "" }); + } else if (!canUpload) { + setError("File upload permission is required."); + } else { + setError("No authorized managed file space is available for upload."); + } + + parameters.delete("quickAction"); + const search = parameters.toString(); + navigate( + { pathname: location.pathname, search: search ? `?${search}` : "" }, + { replace: true, state: location.state } + ); + }, [ + activeSpace, + canUpload, + location.pathname, + location.search, + location.state, + navigate, + spaces, + spacesLoaded + ]); + useEffect(() => { if (!contextMenu) return undefined; const close = () => setContextMenu(null);