From 189e9505897b779e13964c308aa7279515252a42 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 25 Jun 2026 19:58:20 +0200 Subject: [PATCH] Release v0.1.2 --- AGENTS.md | 37 +++++++++++++++++++ pyproject.toml | 4 +- src/govoplan_files/backend/capabilities.py | 34 +++++++++++++++++ src/govoplan_files/backend/manifest.py | 3 ++ src/govoplan_files/backend/router.py | 11 +++++- .../backend/storage/backends.py | 6 ++- .../backend/storage/campaign_usage.py | 6 ++- src/govoplan_files/backend/storage/files.py | 12 +++++- webui/src/module.ts | 9 ++++- 9 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/govoplan_files/backend/capabilities.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2c8bef9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# GovOPlaN Files Codex Guide + +## Scope + +This repository owns the `files` module: managed file storage APIs, file metadata, shares, uploads/downloads, folder and pattern helpers, backend module manifest, and `@govoplan/files-webui`. + +Core owns auth, tenants, RBAC, database/session primitives, CSRF/API helpers, shared components, and shell layout. Campaign integration must remain optional and go through core module metadata or capabilities. + +## Local Commands + +Install and run through core: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -r requirements-dev.txt +``` + +For combined checks, run: + +```bash +cd /mnt/DATA/git/govoplan-core +./scripts/check-focused.sh +``` + +For WebUI permutation checks that include files: + +```bash +cd /mnt/DATA/git/govoplan-core/webui +PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-permutations +``` + +## Working Rules + +- Keep files behavior in this module, not core. +- Do not require campaign or mail imports for files-only startup. +- Shared WebUI components belong in core; files WebUI should consume them through `@govoplan/core-webui`. +- Preserve optional campaign usage tracking through capability/registry boundaries. diff --git a/pyproject.toml b/pyproject.toml index cabcf81..3b433d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-files" -version = "0.1.1" +version = "0.1.2" description = "GovOPlaN files module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.1", + "govoplan-core>=0.1.2", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_files/backend/capabilities.py b/src/govoplan_files/backend/capabilities.py new file mode 100644 index 0000000..dd47816 --- /dev/null +++ b/src/govoplan_files/backend/capabilities.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any + +from govoplan_core.core.modules import ModuleContext +from govoplan_files.backend.runtime import configure_runtime +from govoplan_files.backend.storage.campaign_attachments import ( + annotate_built_messages_with_managed_files, + managed_match_payloads, + prepared_campaign_snapshot, + public_attachment_summary_payload, +) +from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs +from govoplan_files.backend.storage.files import current_version_and_blob + + +class FilesCampaignCapability: + prepared_campaign_snapshot = staticmethod(prepared_campaign_snapshot) + managed_match_payloads = staticmethod(managed_match_payloads) + public_attachment_summary_payload = staticmethod(public_attachment_summary_payload) + annotate_built_messages_with_managed_files = staticmethod(annotate_built_messages_with_managed_files) + record_campaign_attachment_uses_for_jobs = staticmethod(record_campaign_attachment_uses_for_jobs) + current_version_and_blob = staticmethod(current_version_and_blob) + + @staticmethod + def mark_job_attachment_uses_sent(session: Any, job: Any) -> None: + from govoplan_files.backend.storage.campaign_usage import mark_job_attachment_uses_sent + + mark_job_attachment_uses_sent(session, job) + + +def campaign_capability(context: ModuleContext) -> FilesCampaignCapability: + configure_runtime(settings=context.settings) + return FilesCampaignCapability() diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index d39bdaa..cc416eb 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -83,6 +83,9 @@ manifest = ModuleManifest( metadata=Base.metadata, script_location=str(Path(__file__).with_name("migrations") / "versions"), ), + capability_factories={ + "files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), + }, ) diff --git a/src/govoplan_files/backend/router.py b/src/govoplan_files/backend/router.py index 88aa807..2a5eda1 100644 --- a/src/govoplan_files/backend/router.py +++ b/src/govoplan_files/backend/router.py @@ -13,6 +13,7 @@ from sqlalchemy import or_ from sqlalchemy.orm import Session from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope +from govoplan_core.core.optional import reraise_unless_missing_package from govoplan_files.backend.schemas import ( ArchiveRequest, BulkDeleteRequest, @@ -41,7 +42,6 @@ from govoplan_files.backend.schemas import ( _conflict_resolutions, ) from govoplan_core.db.models import Group, UserGroupMembership -from govoplan_campaign.backend.db.models import Campaign, CampaignShare from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare from govoplan_core.db.session import get_session from govoplan_files.backend.runtime import settings @@ -66,6 +66,14 @@ from govoplan_files.backend.storage.transfers import rename_selection, transfer_ router = APIRouter(prefix="/files", tags=["files"]) +def _campaign_models(): + try: + from govoplan_campaign.backend.db.models import Campaign, CampaignShare + except ModuleNotFoundError as exc: + reraise_unless_missing_package(exc, "govoplan_campaign") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Campaign module is not installed") from exc + return Campaign, CampaignShare + def _is_admin(principal: ApiPrincipal) -> bool: return has_scope(principal, "files:file:admin") @@ -105,6 +113,7 @@ def _ensure_campaign_file_access(session: Session, principal: ApiPrincipal, camp return if not has_scope(principal, "campaigns:campaign:read"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: campaign:read") + Campaign, CampaignShare = _campaign_models() campaign = session.get(Campaign, campaign_id) if not campaign or campaign.tenant_id != principal.tenant_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found") diff --git a/src/govoplan_files/backend/storage/backends.py b/src/govoplan_files/backend/storage/backends.py index ea6746d..e427206 100644 --- a/src/govoplan_files/backend/storage/backends.py +++ b/src/govoplan_files/backend/storage/backends.py @@ -4,8 +4,6 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Protocol -import boto3 - from govoplan_files.backend.runtime import settings @@ -94,6 +92,10 @@ class S3StorageBackend: @property def client(self): + try: + import boto3 + except ModuleNotFoundError as exc: + raise StorageBackendError("boto3 is required for the S3 storage backend") from exc return boto3.client( "s3", endpoint_url=self.endpoint_url, diff --git a/src/govoplan_files/backend/storage/campaign_usage.py b/src/govoplan_files/backend/storage/campaign_usage.py index 547b83e..e235a0e 100644 --- a/src/govoplan_files/backend/storage/campaign_usage.py +++ b/src/govoplan_files/backend/storage/campaign_usage.py @@ -2,15 +2,17 @@ from __future__ import annotations from collections import defaultdict from pathlib import PurePosixPath -from typing import Iterable +from typing import TYPE_CHECKING, Iterable from sqlalchemy.orm import Session -from govoplan_campaign.backend.db.models import CampaignJob from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion from govoplan_files.backend.storage.common import utcnow from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user +if TYPE_CHECKING: + from govoplan_campaign.backend.db.models import CampaignJob + def _candidate_match_keys(raw_match: str) -> set[str]: cleaned = raw_match.replace("\\", "/").strip().strip("/") diff --git a/src/govoplan_files/backend/storage/files.py b/src/govoplan_files/backend/storage/files.py index e99d18a..90a11b9 100644 --- a/src/govoplan_files/backend/storage/files.py +++ b/src/govoplan_files/backend/storage/files.py @@ -10,7 +10,7 @@ from sqlalchemy import or_ from sqlalchemy.orm import Session from govoplan_core.db.models import Group, Tenant, User -from govoplan_campaign.backend.db.models import Campaign +from govoplan_core.core.optional import reraise_unless_missing_package from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion from govoplan_files.backend.runtime import settings from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids @@ -19,6 +19,15 @@ from govoplan_files.backend.storage.common import FileConflictResolution, FileSt from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component +def _campaign_model(): + try: + from govoplan_campaign.backend.db.models import Campaign + except ModuleNotFoundError as exc: + reraise_unless_missing_package(exc, "govoplan_campaign") + raise FileStorageError("Campaign module is not installed") from exc + return Campaign + + def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str): query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id, FileAsset.owner_type == owner_type) if owner_type == "user": @@ -296,6 +305,7 @@ def share_file( if target_id != tenant_id or not tenant or not tenant.is_active: raise FileStorageError("Tenant not found") if target_type == "campaign": + Campaign = _campaign_model() campaign = session.get(Campaign, target_id) if not campaign or campaign.tenant_id != tenant_id: raise FileStorageError("Campaign not found") diff --git a/webui/src/module.ts b/webui/src/module.ts index eaaf904..0650191 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,5 +1,7 @@ import { createElement, lazy } from "react"; -import type { PlatformWebModule } from "@govoplan/core-webui"; +import type { FilesFileExplorerUiCapability, PlatformWebModule } from "@govoplan/core-webui"; +import { FolderTree } from "./features/files/components/FileManagerComponents"; + const FilesPage = lazy(() => import("./features/files/FilesPage")); const fileRead = ["files:file:read"]; @@ -12,7 +14,10 @@ export const filesModule: PlatformWebModule = { navItems: [{ to: "/files", label: "Files", iconName: "folder", anyOf: fileRead, order: 40 }], routes: [ { path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) } - ] + ], + uiCapabilities: { + "files.fileExplorer": { FolderTree } satisfies FilesFileExplorerUiCapability + } }; export default filesModule;