Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 189e950589 | |||
| b5f7c43028 |
37
AGENTS.md
Normal file
37
AGENTS.md
Normal file
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.0",
|
"@govoplan/core-webui": "^0.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -27,5 +27,10 @@
|
|||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^6.0.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-files"
|
name = "govoplan-files"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
description = "GovOPlaN files module with backend and WebUI integration."
|
description = "GovOPlaN files module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.0",
|
"govoplan-core>=0.1.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
34
src/govoplan_files/backend/capabilities.py
Normal file
34
src/govoplan_files/backend/capabilities.py
Normal file
@@ -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()
|
||||||
@@ -83,6 +83,9 @@ manifest = ModuleManifest(
|
|||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
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),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from sqlalchemy import or_
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope
|
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 (
|
from govoplan_files.backend.schemas import (
|
||||||
ArchiveRequest,
|
ArchiveRequest,
|
||||||
BulkDeleteRequest,
|
BulkDeleteRequest,
|
||||||
@@ -41,7 +42,6 @@ from govoplan_files.backend.schemas import (
|
|||||||
_conflict_resolutions,
|
_conflict_resolutions,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.models import Group, UserGroupMembership
|
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_files.backend.db.models import FileAsset, FileFolder, FileShare
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_files.backend.runtime import settings
|
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"])
|
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:
|
def _is_admin(principal: ApiPrincipal) -> bool:
|
||||||
return has_scope(principal, "files:file:admin")
|
return has_scope(principal, "files:file:admin")
|
||||||
@@ -105,6 +113,7 @@ def _ensure_campaign_file_access(session: Session, principal: ApiPrincipal, camp
|
|||||||
return
|
return
|
||||||
if not has_scope(principal, "campaigns:campaign:read"):
|
if not has_scope(principal, "campaigns:campaign:read"):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: 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)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
if not campaign or campaign.tenant_id != principal.tenant_id:
|
if not campaign or campaign.tenant_id != principal.tenant_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, Protocol
|
from typing import Iterable, Protocol
|
||||||
|
|
||||||
import boto3
|
|
||||||
|
|
||||||
from govoplan_files.backend.runtime import settings
|
from govoplan_files.backend.runtime import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -94,6 +92,10 @@ class S3StorageBackend:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def client(self):
|
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(
|
return boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=self.endpoint_url,
|
endpoint_url=self.endpoint_url,
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Iterable
|
from typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
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.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion
|
||||||
from govoplan_files.backend.storage.common import utcnow
|
from govoplan_files.backend.storage.common import utcnow
|
||||||
from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user
|
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]:
|
def _candidate_match_keys(raw_match: str) -> set[str]:
|
||||||
cleaned = raw_match.replace("\\", "/").strip().strip("/")
|
cleaned = raw_match.replace("\\", "/").strip().strip("/")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy import or_
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.db.models import Group, Tenant, User
|
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.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion
|
||||||
from govoplan_files.backend.runtime import settings
|
from govoplan_files.backend.runtime import settings
|
||||||
from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids
|
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
|
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):
|
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)
|
query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id, FileAsset.owner_type == owner_type)
|
||||||
if owner_type == "user":
|
if owner_type == "user":
|
||||||
@@ -296,6 +305,7 @@ def share_file(
|
|||||||
if target_id != tenant_id or not tenant or not tenant.is_active:
|
if target_id != tenant_id or not tenant or not tenant.is_active:
|
||||||
raise FileStorageError("Tenant not found")
|
raise FileStorageError("Tenant not found")
|
||||||
if target_type == "campaign":
|
if target_type == "campaign":
|
||||||
|
Campaign = _campaign_model()
|
||||||
campaign = session.get(Campaign, target_id)
|
campaign = session.get(Campaign, target_id)
|
||||||
if not campaign or campaign.tenant_id != tenant_id:
|
if not campaign or campaign.tenant_id != tenant_id:
|
||||||
raise FileStorageError("Campaign not found")
|
raise FileStorageError("Campaign not found")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -21,6 +21,11 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"@govoplan/core-webui": "^0.1.0"
|
"@govoplan/core-webui": "^0.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createElement, lazy } from "react";
|
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 FilesPage = lazy(() => import("./features/files/FilesPage"));
|
||||||
|
|
||||||
const fileRead = ["files:file:read"];
|
const fileRead = ["files:file:read"];
|
||||||
@@ -12,7 +14,10 @@ export const filesModule: PlatformWebModule = {
|
|||||||
navItems: [{ to: "/files", label: "Files", iconName: "folder", anyOf: fileRead, order: 40 }],
|
navItems: [{ to: "/files", label: "Files", iconName: "folder", anyOf: fileRead, order: 40 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }
|
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }
|
||||||
]
|
],
|
||||||
|
uiCapabilities: {
|
||||||
|
"files.fileExplorer": { FolderTree } satisfies FilesFileExplorerUiCapability
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default filesModule;
|
export default filesModule;
|
||||||
|
|||||||
Reference in New Issue
Block a user