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
|
@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/`.
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user