Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent ee5b06b1e7
commit a5c24fe73e
18 changed files with 335 additions and 122 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/files
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/files
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/files
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/files
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/files
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

View File

@@ -46,7 +46,10 @@ Frontend package:
@govoplan/files-webui @govoplan/files-webui
``` ```
The campaign module can integrate with files when both modules are installed, for example for managed attachment selection and campaign file sharing. The campaign module can integrate with files when both modules are installed,
for example for managed attachment selection and campaign file sharing. Files
does not import campaign internals; campaign share/existence checks use the core
`campaigns.access` capability registered by the campaign module.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.4", "version": "0.1.5",
"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.4", "@govoplan/core-webui": "^0.1.5",
"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",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-files" name = "govoplan-files"
version = "0.1.4" version = "0.1.5"
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.4", "govoplan-core>=0.1.5",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -30,5 +30,5 @@ class FilesCampaignCapability:
def campaign_capability(context: ModuleContext) -> FilesCampaignCapability: def campaign_capability(context: ModuleContext) -> FilesCampaignCapability:
configure_runtime(settings=context.settings) configure_runtime(registry=context.registry, settings=context.settings)
return FilesCampaignCapability() return FilesCampaignCapability()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
@@ -54,10 +55,30 @@ ROLE_TEMPLATES = (
) )
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_files.backend.db.models import FileAsset
return {"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count()}
def _veto_group_delete(session, tenant_id: str, group_id: str) -> None:
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
owned_asset_count = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id, FileAsset.owner_group_id == group_id).count()
owned_folder_count = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_group_id == group_id).count()
shared_asset_count = session.query(FileShare).filter(
FileShare.tenant_id == tenant_id,
FileShare.target_type == "group",
FileShare.target_id == group_id,
).count()
if owned_asset_count or owned_folder_count or shared_asset_count:
raise ValueError("Cannot remove the group while it owns files, folders or file shares.")
def _files_router(context: ModuleContext): def _files_router(context: ModuleContext):
from govoplan_files.backend.runtime import configure_runtime from govoplan_files.backend.runtime import configure_runtime
configure_runtime(settings=context.settings) configure_runtime(registry=context.registry, settings=context.settings)
from govoplan_files.backend.router import router from govoplan_files.backend.router import router
return router return router
@@ -66,12 +87,14 @@ def _files_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="files", id="files",
name="Files", name="Files",
version="0.1.4", version="0.1.5",
dependencies=("access",), dependencies=("access",),
optional_dependencies=(), optional_dependencies=(),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_files_router, route_factory=_files_router,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,),
delete_veto_providers={"group": (_veto_group_delete,)},
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),), nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
frontend=FrontendModule( frontend=FrontendModule(
module_id="files", module_id="files",
@@ -82,6 +105,28 @@ manifest = ModuleManifest(
module_id="files", module_id="files",
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
file_models.FileBlob,
file_models.FileFolder,
file_models.FileAsset,
file_models.FileVersion,
file_models.FileShare,
file_models.CampaignAttachmentUse,
label="Files",
),
retirement_notes="Destructive retirement drops files-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
file_models.FileBlob,
file_models.FileFolder,
file_models.FileAsset,
file_models.FileVersion,
file_models.FileShare,
file_models.CampaignAttachmentUse,
label="Files",
),
), ),
capability_factories={ capability_factories={
"files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), "files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),

View File

@@ -9,11 +9,10 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
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_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_scope
from govoplan_core.core.optional import reraise_unless_missing_package from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
from govoplan_files.backend.schemas import ( from govoplan_files.backend.schemas import (
ArchiveRequest, ArchiveRequest,
BulkFileShareRequest, BulkFileShareRequest,
@@ -43,12 +42,11 @@ from govoplan_files.backend.schemas import (
TransferResponse, TransferResponse,
_conflict_resolutions, _conflict_resolutions,
) )
from govoplan_core.db.models import Group, UserGroupMembership
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileFolder, FileShare, FileVersion from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileFolder, FileShare, FileVersion
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 get_registry, settings
from govoplan_files.backend.storage.paths import UnsafeFilePathError, filename_from_path, normalize_logical_path from govoplan_files.backend.storage.paths import UnsafeFilePathError, filename_from_path, normalize_logical_path
from govoplan_files.backend.storage.access import ensure_group_access, user_group_ids from govoplan_files.backend.storage.access import ensure_group_access, group_refs_for_ids, user_group_ids
from govoplan_files.backend.storage.archives import create_zip_file, extract_zip_upload from govoplan_files.backend.storage.archives import create_zip_file, extract_zip_upload
from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.files import ( from govoplan_files.backend.storage.files import (
@@ -69,13 +67,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(): def _campaign_access_provider() -> CampaignAccessProvider:
try: registry = get_registry()
from govoplan_campaign.backend.db.models import Campaign, CampaignShare if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
except ModuleNotFoundError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Campaign module is not installed")
reraise_unless_missing_package(exc, "govoplan_campaign") capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Campaign module is not installed") from exc if not isinstance(capability, CampaignAccessProvider):
return Campaign, CampaignShare raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Campaign access capability is invalid")
return capability
def _is_admin(principal: ApiPrincipal) -> bool: def _is_admin(principal: ApiPrincipal) -> bool:
@@ -116,36 +115,19 @@ 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() provider = _campaign_access_provider()
campaign = session.get(Campaign, campaign_id) if not provider.campaign_exists(session, tenant_id=principal.tenant_id, campaign_id=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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
if has_scope(principal, "tenant:*"): group_ids = set(user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id))
if provider.can_read_campaign(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
user_id=principal.user.id,
group_ids=group_ids,
tenant_admin=has_scope(principal, "tenant:*"),
):
return return
if campaign.owner_user_id == principal.user.id:
return
group_ids = {
row[0]
for row in session.query(UserGroupMembership.group_id)
.filter(UserGroupMembership.tenant_id == principal.tenant_id, UserGroupMembership.user_id == principal.user.id)
.all()
}
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
return
share = (
session.query(CampaignShare)
.filter(
CampaignShare.tenant_id == principal.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
or_(
(CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.user.id),
(CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(group_ids)),
),
)
.first()
)
if share is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this campaign") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this campaign")
@@ -332,7 +314,7 @@ def list_file_spaces(
] ]
group_ids = user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id, include_admin_groups=_is_admin(principal)) group_ids = user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id, include_admin_groups=_is_admin(principal))
if group_ids: if group_ids:
groups = session.query(Group).filter(Group.tenant_id == principal.tenant_id, Group.id.in_(group_ids)).order_by(Group.name.asc()).all() groups = group_refs_for_ids(tenant_id=principal.tenant_id, group_ids=group_ids)
spaces.extend( spaces.extend(
FileSpaceResponse( FileSpaceResponse(
id=f"group:{group.id}", id=f"group:{group.id}",

View File

@@ -2,15 +2,22 @@ from __future__ import annotations
from typing import Any from typing import Any
_runtime_registry: object | None = None
_runtime_settings: object | None = None _runtime_settings: object | None = None
def configure_runtime(*, settings: object | None = None) -> None: def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
global _runtime_settings global _runtime_registry, _runtime_settings
if registry is not None:
_runtime_registry = registry
if settings is not None: if settings is not None:
_runtime_settings = settings _runtime_settings = settings
def get_registry() -> object | None:
return _runtime_registry
def get_settings() -> object: def get_settings() -> object:
if _runtime_settings is not None: if _runtime_settings is not None:
return _runtime_settings return _runtime_settings

View File

@@ -2,33 +2,50 @@ from __future__ import annotations
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.db.models import Group, UserGroupMembership from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccessDirectory,
GroupRef,
)
from govoplan_core.core.runtime import get_registry
from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.common import FileStorageError
def _access_directory() -> AccessDirectory:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
raise FileStorageError("Access directory capability is not configured")
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
if not isinstance(capability, AccessDirectory):
raise FileStorageError("Access directory capability is invalid")
return capability
def group_refs_for_ids(*, tenant_id: str, group_ids: list[str]) -> list[GroupRef]:
if not group_ids:
return []
groups = _access_directory().get_groups(group_ids)
return sorted(
[group for group in groups.values() if group.tenant_id == tenant_id],
key=lambda group: group.name.casefold(),
)
def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]: def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]:
del session
directory = _access_directory()
if include_admin_groups: if include_admin_groups:
return [row.id for row in session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.name.asc()).all()] return [group.id for group in directory.groups_for_tenant(tenant_id)]
return [ return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)]
row.group_id
for row in session.query(UserGroupMembership)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id)
.all()
]
def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None: def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None:
group = session.get(Group, group_id) group = _access_directory().get_group(group_id)
if not group or group.tenant_id != tenant_id: if not group or group.tenant_id != tenant_id:
raise FileStorageError("Group not found") raise FileStorageError("Group not found")
if is_admin: if is_admin:
return return
membership = ( if group_id not in user_group_ids(session, tenant_id=tenant_id, user_id=user_id):
session.query(UserGroupMembership)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id, UserGroupMembership.group_id == group_id)
.one_or_none()
)
if membership is None:
raise FileStorageError("No access to this group file space") raise FileStorageError("No access to this group file space")
@@ -42,3 +59,21 @@ def ensure_owner_access(session: Session, *, tenant_id: str, owner_type: str, ow
ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin) ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin)
return return
raise FileStorageError("Files must be owned by a user or group") raise FileStorageError("Files must be owned by a user or group")
def ensure_share_target_exists(*, tenant_id: str, target_type: str, target_id: str) -> None:
target_type = target_type.lower().strip()
if target_type == "user":
user = _access_directory().get_user(target_id)
if not user or user.tenant_id != tenant_id or user.status != "active":
raise FileStorageError("User not found")
return
if target_type == "group":
group = _access_directory().get_group(target_id)
if not group or group.tenant_id != tenant_id or group.status != "active":
raise FileStorageError("Group not found")
return
if target_type == "tenant":
if target_id != tenant_id:
raise FileStorageError("Tenant not found")
return

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import TYPE_CHECKING, Iterable from typing import Any, Iterable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,8 +10,7 @@ from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, F
from govoplan_files.backend.storage.common import utcnow from govoplan_files.backend.storage.common import utcnow
from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user
if TYPE_CHECKING: CampaignJobLike = Any
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]:
@@ -29,7 +28,7 @@ def _attachment_use_key(*, job_id: str, file_version_id: str, filename_used: str
return job_id, file_version_id, filename_used, stage return job_id, file_version_id, filename_used, stage
def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, stage: str) -> set[AttachmentUseKey]: def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJobLike], *, stage: str) -> set[AttachmentUseKey]:
job_ids = {job.id for job in jobs if job.id} job_ids = {job.id for job in jobs if job.id}
if not job_ids: if not job_ids:
return set() return set()
@@ -72,13 +71,13 @@ def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, s
return keys return keys
def _known_use_keys(session: Session, job: CampaignJob, *, stage: str) -> set[AttachmentUseKey]: def _known_use_keys(session: Session, job: CampaignJobLike, *, stage: str) -> set[AttachmentUseKey]:
return _known_use_keys_for_jobs(session, [job], stage=stage) return _known_use_keys_for_jobs(session, [job], stage=stage)
def _add_use( def _add_use(
session: Session, session: Session,
job: CampaignJob, job: CampaignJobLike,
*, *,
asset: FileAsset, asset: FileAsset,
version: FileVersion, version: FileVersion,
@@ -118,7 +117,7 @@ def _add_use(
def record_campaign_attachment_uses_for_jobs( def record_campaign_attachment_uses_for_jobs(
session: Session, session: Session,
jobs: Iterable[CampaignJob], jobs: Iterable[CampaignJobLike],
*, *,
stage: str = "built", stage: str = "built",
) -> None: ) -> None:
@@ -249,13 +248,13 @@ def record_campaign_attachment_uses_for_jobs(
) )
def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJob, *, stage: str = "built") -> None: def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJobLike, *, stage: str = "built") -> None:
"""Record immutable managed file versions used by one built/sent job.""" """Record immutable managed file versions used by one built/sent job."""
record_campaign_attachment_uses_for_jobs(session, [job], stage=stage) record_campaign_attachment_uses_for_jobs(session, [job], stage=stage)
def mark_job_attachment_uses_sent(session: Session, job: CampaignJob) -> None: def mark_job_attachment_uses_sent(session: Session, job: CampaignJobLike) -> None:
record_campaign_attachment_uses_for_job(session, job, stage="built") record_campaign_attachment_uses_for_job(session, job, stage="built")
# Sessions use autoflush=False. Flush any compatibility-built evidence so # Sessions use autoflush=False. Flush any compatibility-built evidence so
# the following query can copy it to the sent stage in the same call. # the following query can copy it to the sent stage in the same call.

View File

@@ -9,23 +9,28 @@ from uuid import uuid4
from sqlalchemy import or_ 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.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
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 get_registry, settings
from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids from govoplan_files.backend.storage.access import ensure_owner_access, ensure_share_target_exists, user_group_ids
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow
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(): def _campaign_access_provider() -> CampaignAccessProvider:
try: registry = get_registry()
from govoplan_campaign.backend.db.models import Campaign if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
except ModuleNotFoundError as exc: raise FileStorageError("Campaign module is not installed")
reraise_unless_missing_package(exc, "govoplan_campaign") capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
raise FileStorageError("Campaign module is not installed") from exc if not isinstance(capability, CampaignAccessProvider):
return Campaign raise FileStorageError("Campaign access capability is invalid")
return capability
def _ensure_campaign_exists(session: Session, *, tenant_id: str, campaign_id: str) -> None:
if not _campaign_access_provider().campaign_exists(session, tenant_id=tenant_id, campaign_id=campaign_id):
raise FileStorageError("Campaign not found")
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):
@@ -318,23 +323,10 @@ def share_file(
raise FileStorageError("Unsupported share target") raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}: if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission") raise FileStorageError("Unsupported file permission")
if target_type == "user": if target_type in {"user", "group", "tenant"}:
target_user = session.get(User, target_id) ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active:
raise FileStorageError("User not found")
if target_type == "group":
group = session.get(Group, target_id)
if not group or group.tenant_id != tenant_id or not group.is_active:
raise FileStorageError("Group not found")
if target_type == "tenant":
tenant = session.get(Tenant, target_id)
if target_id != tenant_id or not tenant or not tenant.is_active:
raise FileStorageError("Tenant not found")
if target_type == "campaign": if target_type == "campaign":
Campaign = _campaign_model() _ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
campaign = session.get(Campaign, target_id)
if not campaign or campaign.tenant_id != tenant_id:
raise FileStorageError("Campaign not found")
existing = ( existing = (
session.query(FileShare) session.query(FileShare)
.filter( .filter(
@@ -378,23 +370,10 @@ def share_files(
raise FileStorageError("Unsupported share target") raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}: if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission") raise FileStorageError("Unsupported file permission")
if target_type == "user": if target_type in {"user", "group", "tenant"}:
target_user = session.get(User, target_id) ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active:
raise FileStorageError("User not found")
if target_type == "group":
group = session.get(Group, target_id)
if not group or group.tenant_id != tenant_id or not group.is_active:
raise FileStorageError("Group not found")
if target_type == "tenant":
tenant = session.get(Tenant, target_id)
if target_id != tenant_id or not tenant or not tenant.is_active:
raise FileStorageError("Tenant not found")
if target_type == "campaign": if target_type == "campaign":
Campaign = _campaign_model() _ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
campaign = session.get(Campaign, target_id)
if not campaign or campaign.tenant_id != tenant_id:
raise FileStorageError("Campaign not found")
asset_list = list(assets) asset_list = list(assets)
if not asset_list: if not asset_list:

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/files-webui", "name": "@govoplan/files-webui",
"version": "0.1.4", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -21,7 +21,7 @@
"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.4" "@govoplan/core-webui": "^0.1.5"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {