From a5c24fe73eae1237ff9e9843543d03caa32f1f4b Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 7 Jul 2026 15:49:06 +0200 Subject: [PATCH] Release v0.1.5 --- .gitea/ISSUE_TEMPLATE/bug_report.md | 35 ++++++++++ .gitea/ISSUE_TEMPLATE/config.yaml | 1 + .gitea/ISSUE_TEMPLATE/docs_workflow.md | 27 ++++++++ .gitea/ISSUE_TEMPLATE/feature_request.md | 32 +++++++++ .gitea/ISSUE_TEMPLATE/task.md | 28 ++++++++ .gitea/ISSUE_TEMPLATE/tech_debt.md | 25 +++++++ .gitea/PULL_REQUEST_TEMPLATE.md | 15 ++++ README.md | 5 +- package.json | 4 +- pyproject.toml | 4 +- src/govoplan_files/backend/capabilities.py | 2 +- src/govoplan_files/backend/manifest.py | 49 ++++++++++++- src/govoplan_files/backend/router.py | 68 +++++++------------ src/govoplan_files/backend/runtime.py | 11 ++- src/govoplan_files/backend/storage/access.py | 65 ++++++++++++++---- .../backend/storage/campaign_usage.py | 17 +++-- src/govoplan_files/backend/storage/files.py | 65 ++++++------------ webui/package.json | 4 +- 18 files changed, 335 insertions(+), 122 deletions(-) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/docs_workflow.md create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/ISSUE_TEMPLATE/task.md create mode 100644 .gitea/ISSUE_TEMPLATE/tech_debt.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9851551 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..868a6f5 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -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? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..3fef56d --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..340381c --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -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: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..e044744 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -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: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/README.md b/README.md index 5c79fe5..37cbc89 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,10 @@ Frontend package: @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/`. diff --git a/package.json b/package.json index f277ff8..230dc31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/files-webui", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "main": "webui/src/index.ts", @@ -19,7 +19,7 @@ "LICENSE" ], "peerDependencies": { - "@govoplan/core-webui": "^0.1.4", + "@govoplan/core-webui": "^0.1.5", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pyproject.toml b/pyproject.toml index fff2c4e..20bd448 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-files" -version = "0.1.4" +version = "0.1.5" 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.4", + "govoplan-core>=0.1.5", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_files/backend/capabilities.py b/src/govoplan_files/backend/capabilities.py index dd47816..609d920 100644 --- a/src/govoplan_files/backend/capabilities.py +++ b/src/govoplan_files/backend/capabilities.py @@ -30,5 +30,5 @@ class FilesCampaignCapability: def campaign_capability(context: ModuleContext) -> FilesCampaignCapability: - configure_runtime(settings=context.settings) + configure_runtime(registry=context.registry, settings=context.settings) return FilesCampaignCapability() diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index 083ceae..e9cc396 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -2,6 +2,7 @@ from __future__ import annotations 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.db.base import Base 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): 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 return router @@ -66,12 +87,14 @@ def _files_router(context: ModuleContext): manifest = ModuleManifest( id="files", name="Files", - version="0.1.4", + version="0.1.5", dependencies=("access",), optional_dependencies=(), permissions=PERMISSIONS, route_factory=_files_router, 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),), frontend=FrontendModule( module_id="files", @@ -82,6 +105,28 @@ manifest = ModuleManifest( module_id="files", metadata=Base.metadata, 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={ "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 ac54771..27f85d0 100644 --- a/src/govoplan_files/backend/router.py +++ b/src/govoplan_files/backend/router.py @@ -9,11 +9,10 @@ from urllib.parse import quote from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status from fastapi.responses import FileResponse, StreamingResponse from starlette.background import BackgroundTask -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_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_scope +from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider from govoplan_files.backend.schemas import ( ArchiveRequest, BulkFileShareRequest, @@ -43,12 +42,11 @@ from govoplan_files.backend.schemas import ( TransferResponse, _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_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.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.common import FileStorageError 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"]) -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 _campaign_access_provider() -> CampaignAccessProvider: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Campaign module is not installed") + capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS) + if not isinstance(capability, CampaignAccessProvider): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Campaign access capability is invalid") + return capability def _is_admin(principal: ApiPrincipal) -> bool: @@ -116,37 +115,20 @@ 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: + provider = _campaign_access_provider() + if not provider.campaign_exists(session, tenant_id=principal.tenant_id, campaign_id=campaign_id): 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 - 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") def _http_error(exc: Exception, *, not_found: bool = False) -> HTTPException: @@ -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)) 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( FileSpaceResponse( id=f"group:{group.id}", diff --git a/src/govoplan_files/backend/runtime.py b/src/govoplan_files/backend/runtime.py index 1978213..6678ce8 100644 --- a/src/govoplan_files/backend/runtime.py +++ b/src/govoplan_files/backend/runtime.py @@ -2,15 +2,22 @@ from __future__ import annotations from typing import Any +_runtime_registry: object | None = None _runtime_settings: object | None = None -def configure_runtime(*, settings: object | None = None) -> None: - global _runtime_settings +def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None: + global _runtime_registry, _runtime_settings + if registry is not None: + _runtime_registry = registry if settings is not None: _runtime_settings = settings +def get_registry() -> object | None: + return _runtime_registry + + def get_settings() -> object: if _runtime_settings is not None: return _runtime_settings diff --git a/src/govoplan_files/backend/storage/access.py b/src/govoplan_files/backend/storage/access.py index b720771..98a0d26 100644 --- a/src/govoplan_files/backend/storage/access.py +++ b/src/govoplan_files/backend/storage/access.py @@ -2,33 +2,50 @@ from __future__ import annotations 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 +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]: + del session + directory = _access_directory() 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 [ - row.group_id - for row in session.query(UserGroupMembership) - .filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id) - .all() - ] + return [group.id for group in directory.groups_for_tenant(tenant_id)] + return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)] 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: raise FileStorageError("Group not found") if is_admin: return - membership = ( - 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: + if group_id not in user_group_ids(session, tenant_id=tenant_id, user_id=user_id): 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) return 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 diff --git a/src/govoplan_files/backend/storage/campaign_usage.py b/src/govoplan_files/backend/storage/campaign_usage.py index f8b28b6..c06aa1a 100644 --- a/src/govoplan_files/backend/storage/campaign_usage.py +++ b/src/govoplan_files/backend/storage/campaign_usage.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import defaultdict from pathlib import PurePosixPath -from typing import TYPE_CHECKING, Iterable +from typing import Any, Iterable 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.files import current_versions_and_blobs, list_assets_for_user -if TYPE_CHECKING: - from govoplan_campaign.backend.db.models import CampaignJob +CampaignJobLike = Any 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 -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} if not job_ids: return set() @@ -72,13 +71,13 @@ def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, s 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) def _add_use( session: Session, - job: CampaignJob, + job: CampaignJobLike, *, asset: FileAsset, version: FileVersion, @@ -118,7 +117,7 @@ def _add_use( def record_campaign_attachment_uses_for_jobs( session: Session, - jobs: Iterable[CampaignJob], + jobs: Iterable[CampaignJobLike], *, stage: str = "built", ) -> 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_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") # Sessions use autoflush=False. Flush any compatibility-built evidence so # the following query can copy it to the sent stage in the same call. diff --git a/src/govoplan_files/backend/storage/files.py b/src/govoplan_files/backend/storage/files.py index fc69f12..7fbf070 100644 --- a/src/govoplan_files/backend/storage/files.py +++ b/src/govoplan_files/backend/storage/files.py @@ -9,23 +9,28 @@ from uuid import uuid4 from sqlalchemy import or_ from sqlalchemy.orm import Session -from govoplan_core.db.models import Group, Tenant, User -from govoplan_core.core.optional import reraise_unless_missing_package +from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider 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 +from govoplan_files.backend.runtime import get_registry, settings +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.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 -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 _campaign_access_provider() -> CampaignAccessProvider: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS): + raise FileStorageError("Campaign module is not installed") + capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS) + if not isinstance(capability, CampaignAccessProvider): + 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): @@ -318,23 +323,10 @@ def share_file( raise FileStorageError("Unsupported share target") if permission not in {"read", "write", "manage"}: raise FileStorageError("Unsupported file permission") - if target_type == "user": - target_user = session.get(User, 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 in {"user", "group", "tenant"}: + ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id) 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") + _ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id) existing = ( session.query(FileShare) .filter( @@ -378,23 +370,10 @@ def share_files( raise FileStorageError("Unsupported share target") if permission not in {"read", "write", "manage"}: raise FileStorageError("Unsupported file permission") - if target_type == "user": - target_user = session.get(User, 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 in {"user", "group", "tenant"}: + ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id) 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") + _ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id) asset_list = list(assets) if not asset_list: diff --git a/webui/package.json b/webui/package.json index 593d79e..4413c13 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/files-webui", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "main": "src/index.ts", @@ -21,7 +21,7 @@ "react-dom": "^19.0.0", "react-router-dom": "^7.1.1", "lucide-react": "^0.555.0", - "@govoplan/core-webui": "^0.1.4" + "@govoplan/core-webui": "^0.1.5" }, "peerDependenciesMeta": { "@govoplan/core-webui": {