Release v0.1.5
This commit is contained in:
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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:
|
||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal 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?
|
||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal 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:
|
||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal 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:
|
||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal 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:
|
||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## Issue
|
||||
|
||||
Closes #
|
||||
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
Follow-up issues:
|
||||
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user