Release v0.1.2
This commit is contained in:
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.service import AdminConflictError, AdminValidationError, slugify
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_core.db.models import (
|
||||
ApiKey,
|
||||
GovernanceTemplate,
|
||||
@@ -17,7 +18,6 @@ from govoplan_core.db.models import (
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
||||
from govoplan_core.security.permissions import validate_tenant_permissions
|
||||
|
||||
SYSTEM_SETTINGS_ID = "global"
|
||||
@@ -25,6 +25,15 @@ TEMPLATE_KINDS = {"group", "role"}
|
||||
ASSIGNMENT_MODES = {"available", "required"}
|
||||
|
||||
|
||||
def _file_models():
|
||||
try:
|
||||
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, "govoplan_files")
|
||||
return None
|
||||
return FileAsset, FileFolder, FileShare
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectiveTenantGovernance:
|
||||
allow_custom_groups: bool
|
||||
@@ -246,11 +255,17 @@ def _remove_materialized_template(session: Session, item: GovernanceTemplate, te
|
||||
return
|
||||
membership_count = session.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count()
|
||||
role_count = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count()
|
||||
owned_asset_count = session.query(FileAsset).filter(FileAsset.owner_group_id == group.id).count()
|
||||
owned_folder_count = session.query(FileFolder).filter(FileFolder.owner_group_id == group.id).count()
|
||||
shared_asset_count = session.query(FileShare).filter(
|
||||
FileShare.target_type == "group", FileShare.target_id == group.id
|
||||
).count()
|
||||
file_models = _file_models()
|
||||
owned_asset_count = 0
|
||||
owned_folder_count = 0
|
||||
shared_asset_count = 0
|
||||
if file_models is not None:
|
||||
FileAsset, FileFolder, FileShare = file_models
|
||||
owned_asset_count = session.query(FileAsset).filter(FileAsset.owner_group_id == group.id).count()
|
||||
owned_folder_count = session.query(FileFolder).filter(FileFolder.owner_group_id == group.id).count()
|
||||
shared_asset_count = session.query(FileShare).filter(
|
||||
FileShare.target_type == "group", FileShare.target_id == group.id
|
||||
).count()
|
||||
if membership_count or role_count or owned_asset_count or owned_folder_count or shared_asset_count:
|
||||
raise AdminConflictError(
|
||||
f"Cannot remove {item.name!r} from the tenant while its managed group has members, roles, files, folders or shares."
|
||||
|
||||
@@ -9,6 +9,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_core.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
@@ -548,16 +549,25 @@ def role_assignment_counts(session: Session, role_id: str) -> tuple[int, int]:
|
||||
)
|
||||
|
||||
|
||||
def _optional_model(module_name: str, model_name: str):
|
||||
try:
|
||||
module = __import__(module_name, fromlist=[model_name])
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, module_name.split(".", 1)[0])
|
||||
return None
|
||||
return getattr(module, model_name)
|
||||
|
||||
|
||||
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_files.backend.db.models import FileAsset
|
||||
Campaign = _optional_model("govoplan_campaign.backend.db.models", "Campaign")
|
||||
FileAsset = _optional_model("govoplan_files.backend.db.models", "FileAsset")
|
||||
|
||||
return {
|
||||
"users": session.query(User).filter(User.tenant_id == tenant_id).count(),
|
||||
"active_users": session.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
|
||||
"groups": session.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
||||
"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count(),
|
||||
"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count(),
|
||||
"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count() if Campaign is not None else 0,
|
||||
"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count() if FileAsset is not None else 0,
|
||||
"api_keys": session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -317,6 +317,7 @@ class PolicySourceStepItem(BaseModel):
|
||||
scope_id: str | None = None
|
||||
label: str
|
||||
applied_fields: list[str] = Field(default_factory=list)
|
||||
policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyItem(BaseModel):
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from govoplan_core.auth.dependencies import ApiPrincipal, require_any_scope
|
||||
from govoplan_core.core.optional import module_not_found_for
|
||||
|
||||
router = APIRouter(prefix="/schemas", tags=["schemas"])
|
||||
|
||||
@@ -13,12 +14,22 @@ def _load_campaign_schema() -> dict[str, Any]:
|
||||
try:
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_schema
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name == "govoplan_campaign" or exc.name.startswith("govoplan_campaign."):
|
||||
if module_not_found_for(exc, "govoplan_campaign"):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign module is not installed") from exc
|
||||
raise
|
||||
return load_campaign_schema()
|
||||
|
||||
|
||||
def _load_campaign_schema_ui() -> dict[str, Any]:
|
||||
try:
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_schema_ui
|
||||
except ModuleNotFoundError as exc:
|
||||
if module_not_found_for(exc, "govoplan_campaign"):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign module is not installed") from exc
|
||||
raise
|
||||
return load_campaign_schema_ui()
|
||||
|
||||
|
||||
@router.get("/campaign")
|
||||
def get_campaign_schema(
|
||||
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
|
||||
@@ -27,3 +38,14 @@ def get_campaign_schema(
|
||||
|
||||
del principal
|
||||
return _load_campaign_schema()
|
||||
|
||||
|
||||
@router.get("/campaign/ui")
|
||||
def get_campaign_schema_ui(
|
||||
principal: ApiPrincipal = Depends(require_any_scope("campaigns:campaign:read", "campaigns:campaign:create", "admin:roles:read")),
|
||||
) -> dict[str, Any]:
|
||||
"""Return UI metadata paired with the campaign JSON Schema."""
|
||||
|
||||
del principal
|
||||
return _load_campaign_schema_ui()
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ def discover_module_manifests(
|
||||
enabled = set(enabled_modules) if enabled_modules is not None else None
|
||||
manifests: list[ModuleManifest] = []
|
||||
for entry_point in iter_module_entry_points(group):
|
||||
if enabled is not None and entry_point.name not in enabled:
|
||||
continue
|
||||
manifest = _load_manifest(entry_point)
|
||||
if enabled is not None and manifest.id not in enabled:
|
||||
continue
|
||||
|
||||
@@ -104,6 +104,7 @@ class ResourceAclProvider(Protocol):
|
||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||
DeleteVetoProvider = Callable[[object, str, str], None]
|
||||
RouteFactory = Callable[[ModuleContext], "APIRouter"]
|
||||
CapabilityFactory = Callable[[ModuleContext], object]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -122,4 +123,5 @@ class ModuleManifest:
|
||||
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||
|
||||
|
||||
13
src/govoplan_core/core/optional.py
Normal file
13
src/govoplan_core/core/optional.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def module_not_found_for(exc: ModuleNotFoundError, package: str) -> bool:
|
||||
"""Return true when a ModuleNotFoundError belongs to an optional package."""
|
||||
|
||||
missing = exc.name or ""
|
||||
return missing == package or missing.startswith(f"{package}.")
|
||||
|
||||
|
||||
def reraise_unless_missing_package(exc: ModuleNotFoundError, package: str) -> None:
|
||||
if not module_not_found_for(exc, package):
|
||||
raise exc
|
||||
@@ -6,7 +6,9 @@ from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityFactory,
|
||||
DeleteVetoProvider,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
@@ -36,6 +38,9 @@ class PlatformRegistry:
|
||||
self._manifests: dict[str, ModuleManifest] = {}
|
||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
||||
self._delete_veto_providers: dict[str, list[DeleteVetoProvider]] = defaultdict(list)
|
||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||
self._capabilities: dict[str, object] = {}
|
||||
self._capability_context: ModuleContext | None = None
|
||||
|
||||
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
||||
if manifest.id in self._manifests:
|
||||
@@ -46,6 +51,8 @@ class PlatformRegistry:
|
||||
for resource_type, providers in manifest.delete_veto_providers.items():
|
||||
for provider in providers:
|
||||
self.register_delete_veto(resource_type, provider)
|
||||
for name, factory in manifest.capability_factories.items():
|
||||
self.register_capability_factory(manifest.id, name, factory)
|
||||
return manifest
|
||||
|
||||
def get(self, module_id: str) -> ModuleManifest | None:
|
||||
@@ -79,6 +86,36 @@ class PlatformRegistry:
|
||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||
|
||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||
self._capability_context = context
|
||||
self._capabilities.clear()
|
||||
|
||||
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
||||
if name in self._capability_factories:
|
||||
raise RegistryError(f"Duplicate capability: {name}")
|
||||
self._capability_factories[name] = factory
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self._capability_factories
|
||||
|
||||
def capability(self, name: str) -> object | None:
|
||||
if name in self._capabilities:
|
||||
return self._capabilities[name]
|
||||
factory = self._capability_factories.get(name)
|
||||
if factory is None:
|
||||
return None
|
||||
if self._capability_context is None:
|
||||
raise RegistryError(f"Capability context is not configured: {name}")
|
||||
capability = factory(self._capability_context)
|
||||
self._capabilities[name] = capability
|
||||
return capability
|
||||
|
||||
def require_capability(self, name: str) -> object:
|
||||
capability = self.capability(name)
|
||||
if capability is None:
|
||||
raise RegistryError(f"Required capability is not available: {name}")
|
||||
return capability
|
||||
|
||||
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
||||
self._tenant_summary_providers[module_id] = provider
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.service import ensure_default_roles, get_or_create_account
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.models import Role, SystemRoleAssignment, Tenant, User, UserRoleAssignment
|
||||
from govoplan_core.db.session import get_database
|
||||
@@ -21,15 +22,26 @@ class BootstrapResult:
|
||||
created_api_key: CreatedApiKey | None
|
||||
|
||||
|
||||
def _import_optional_models(module_name: str) -> None:
|
||||
try:
|
||||
__import__(module_name)
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, module_name.split(".", 1)[0])
|
||||
|
||||
|
||||
def create_all_tables() -> None:
|
||||
# Import models so SQLAlchemy sees all tables.
|
||||
# Import models so SQLAlchemy sees all tables from installed modules.
|
||||
from govoplan_core.db import models # noqa: F401
|
||||
from govoplan_core.access.db import models as access_models # noqa: F401
|
||||
from govoplan_files.backend.db import models as file_models # noqa: F401
|
||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401
|
||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401
|
||||
from govoplan_core.access.db.base import AccessBase
|
||||
|
||||
for module_name in (
|
||||
"govoplan_files.backend.db.models",
|
||||
"govoplan_mail.backend.db.models",
|
||||
"govoplan_campaign.backend.db.models",
|
||||
):
|
||||
_import_optional_models(module_name)
|
||||
|
||||
engine = get_database().engine
|
||||
Base.metadata.create_all(bind=engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
||||
from govoplan_core.server.default_config import get_server_config
|
||||
@@ -23,6 +23,7 @@ from govoplan_core.settings import settings
|
||||
REVISION_AUTH_RBAC = "2c3d4e5f6a7b"
|
||||
REVISION_FILE_STORAGE = "3d4e5f6a7b8c"
|
||||
REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
|
||||
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
|
||||
|
||||
_FILE_STORAGE_TABLES = {
|
||||
"file_blobs",
|
||||
@@ -69,6 +70,61 @@ _FILE_STORAGE_COLUMNS = {
|
||||
"file_folders": {"id", "tenant_id", "owner_type", "path"},
|
||||
}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_TABLES = {
|
||||
"accounts",
|
||||
"auth_sessions",
|
||||
"audit_log",
|
||||
"campaign_versions",
|
||||
"campaign_jobs",
|
||||
"send_attempts",
|
||||
"system_role_assignments",
|
||||
"system_settings",
|
||||
"governance_templates",
|
||||
"governance_template_assignments",
|
||||
"mail_server_profiles",
|
||||
}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
|
||||
"auth_sessions": {"account_id", "csrf_token_hash"},
|
||||
"audit_log": {"scope", "tenant_id", "user_id", "api_key_id"},
|
||||
"campaign_versions": {
|
||||
"workflow_state",
|
||||
"current_flow",
|
||||
"user_lock_state",
|
||||
"user_locked_at",
|
||||
"user_locked_by_user_id",
|
||||
"execution_snapshot",
|
||||
"execution_snapshot_hash",
|
||||
"execution_snapshot_at",
|
||||
},
|
||||
"campaign_jobs": {"claimed_at", "claim_token", "smtp_started_at", "outcome_unknown_at", "eml_sha256"},
|
||||
"send_attempts": {"status", "claim_token"},
|
||||
"users": {"account_id", "settings", "mail_profile_policy"},
|
||||
"groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
|
||||
"roles": {"system_template_id", "system_required"},
|
||||
"tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
|
||||
"campaigns": {"settings", "mail_profile_policy"},
|
||||
"mail_server_profiles": {"scope_type", "scope_id"},
|
||||
"system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
|
||||
}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
|
||||
"file_folders": {"uq_file_folders_active_user_path", "uq_file_folders_active_group_path"},
|
||||
"campaign_versions": {
|
||||
"ix_campaign_versions_user_lock_state",
|
||||
"ix_campaign_versions_user_locked_by_user_id",
|
||||
"ix_campaign_versions_execution_snapshot_hash",
|
||||
},
|
||||
"campaign_jobs": {"ix_campaign_jobs_claim_token", "ix_campaign_jobs_eml_sha256"},
|
||||
"send_attempts": {"ix_send_attempts_status", "ix_send_attempts_claim_token"},
|
||||
"audit_log": {"ix_audit_log_scope_created_at", "ix_audit_log_tenant_scope_created_at"},
|
||||
"auth_sessions": {"ix_auth_sessions_account_id"},
|
||||
"users": {"ix_users_account_id"},
|
||||
"groups": {"ix_groups_system_template_id"},
|
||||
"roles": {"ix_roles_system_template_id"},
|
||||
"mail_server_profiles": {"ix_mail_server_profiles_scope_type", "ix_mail_server_profiles_scope_id", "ix_mail_server_profiles_scope"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MigrationResult:
|
||||
@@ -123,8 +179,44 @@ def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
|
||||
return required.issubset(actual)
|
||||
|
||||
|
||||
def _has_indexes(inspector, table_name: str, required: set[str]) -> bool:
|
||||
try:
|
||||
actual = {index["name"] for index in inspector.get_indexes(table_name)}
|
||||
except Exception:
|
||||
return False
|
||||
return required.issubset(actual)
|
||||
|
||||
|
||||
def _has_create_all_schema_through_hierarchical_settings(inspector, tables: set[str]) -> bool:
|
||||
if not _CREATE_ALL_THROUGH_HIERARCHICAL_TABLES.issubset(tables):
|
||||
return False
|
||||
for table_name, required_columns in _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS.items():
|
||||
if table_name not in tables or not _has_columns(inspector, table_name, required_columns):
|
||||
return False
|
||||
for table_name, required_indexes in _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES.items():
|
||||
if table_name not in tables or not _has_indexes(inspector, table_name, required_indexes):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
|
||||
engine = create_engine(database_url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("""
|
||||
UPDATE campaign_versions
|
||||
SET user_lock_state = 'permanent',
|
||||
user_locked_at = published_at,
|
||||
user_locked_by_user_id = NULL
|
||||
WHERE published_at IS NOT NULL
|
||||
AND user_lock_state IS NULL
|
||||
"""))
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str | None:
|
||||
"""Repair the known Alembic/create_all drift without modifying table data.
|
||||
"""Repair the known Alembic/create_all drift without modifying application data.
|
||||
|
||||
Returns the revision stamped during reconciliation, or ``None`` when no
|
||||
repair was necessary. A partial/unknown schema is intentionally left alone
|
||||
@@ -148,6 +240,7 @@ def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str |
|
||||
"file_folders",
|
||||
_FILE_STORAGE_COLUMNS["file_folders"],
|
||||
)
|
||||
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
@@ -158,6 +251,17 @@ def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str |
|
||||
target = REVISION_FILE_STORAGE
|
||||
elif current == REVISION_FILE_STORAGE and has_file_folders:
|
||||
target = REVISION_FILE_FOLDERS
|
||||
elif current == REVISION_FILE_FOLDERS and has_create_all_hierarchical_schema:
|
||||
# Development DBs may be stamped at 4e after earlier create_all
|
||||
# reconciliation even though later tables/columns were already created
|
||||
# by a newer model set. Skip only when that complete known schema is
|
||||
# present, then let newer migrations such as mail credential usernames
|
||||
# run normally.
|
||||
_backfill_user_lock_state_for_create_all_schema(url)
|
||||
target = REVISION_HIERARCHICAL_SETTINGS
|
||||
elif current is None and has_create_all_hierarchical_schema:
|
||||
_backfill_user_lock_state_for_create_all_schema(url)
|
||||
target = REVISION_HIERARCHICAL_SETTINGS
|
||||
elif current is None and has_file_storage and has_file_folders:
|
||||
# This is the other create_all-only development shape. The strict
|
||||
# column checks above ensure that we only stamp a complete known schema.
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
@@ -19,6 +20,17 @@ DEFAULT_APP = "govoplan_core.server.app:app"
|
||||
DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DevserverState:
|
||||
config_path: str | None
|
||||
runtime_root: Path | None
|
||||
database_url: str
|
||||
bootstrap_db_path: Path | None
|
||||
config: GovoplanServerConfig
|
||||
registry: PlatformRegistry
|
||||
reload_dirs: list[str]
|
||||
|
||||
|
||||
def _config_module_runtime_root(config_path: str | None) -> Path | None:
|
||||
object_path = config_path or os.getenv("GOVOPLAN_SERVER_CONFIG")
|
||||
if not object_path:
|
||||
@@ -42,12 +54,13 @@ def _config_module_runtime_root(config_path: str | None) -> Path | None:
|
||||
|
||||
|
||||
def apply_runtime_defaults(config_path: str | None) -> Path | None:
|
||||
runtime_root = _config_module_runtime_root(config_path)
|
||||
if runtime_root is None:
|
||||
return None
|
||||
runtime_root = _config_module_runtime_root(config_path) or Path.cwd().resolve()
|
||||
(runtime_root / "runtime").mkdir(parents=True, exist_ok=True)
|
||||
(runtime_root / "runtime" / "files").mkdir(parents=True, exist_ok=True)
|
||||
(runtime_root / "runtime" / "mock-mailbox").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
defaults = {
|
||||
"DATABASE_URL": f"sqlite:///{runtime_root / 'multimailer-dev.db'}",
|
||||
"DATABASE_URL": f"sqlite:///{runtime_root / 'runtime' / 'multimailer-dev.db'}",
|
||||
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "runtime" / "files"),
|
||||
"MOCK_MAILBOX_DIR": str(runtime_root / "runtime" / "mock-mailbox"),
|
||||
}
|
||||
@@ -56,6 +69,55 @@ def apply_runtime_defaults(config_path: str | None) -> Path | None:
|
||||
return runtime_root
|
||||
|
||||
|
||||
def _sqlite_database_path(database_url: str) -> Path | None:
|
||||
if not database_url.startswith("sqlite:///"):
|
||||
return None
|
||||
raw_path = database_url[len("sqlite:///"):]
|
||||
if not raw_path or raw_path == ":memory:":
|
||||
return None
|
||||
path = Path(raw_path).expanduser()
|
||||
return path if path.is_absolute() else Path.cwd() / path
|
||||
|
||||
|
||||
def validate_sqlite_database_url(database_url: str) -> None:
|
||||
db_path = _sqlite_database_path(database_url)
|
||||
if db_path is None:
|
||||
return
|
||||
if db_path.name.startswith(".fuse"):
|
||||
raise SystemExit(
|
||||
"DATABASE_URL points at a FUSE-hidden SQLite file. "
|
||||
"Stop the process still holding the deleted database, unset DATABASE_URL, "
|
||||
"or set DATABASE_URL to a normal .db path under the core runtime directory."
|
||||
)
|
||||
if not db_path.parent.exists():
|
||||
raise SystemExit(
|
||||
f"DATABASE_URL points to {db_path}, but its parent directory does not exist. "
|
||||
"Unset stale DATABASE_URL or set it to "
|
||||
f"sqlite:///{Path.cwd().resolve() / 'runtime' / 'multimailer-dev.db'}."
|
||||
)
|
||||
|
||||
|
||||
def _env_truthy(value: str | None) -> bool:
|
||||
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def enable_dev_bootstrap_for_missing_sqlite(database_url: str) -> Path | None:
|
||||
db_path = _sqlite_database_path(database_url)
|
||||
if db_path is None or db_path.name.startswith(".fuse") or not db_path.parent.exists():
|
||||
return None
|
||||
if db_path.exists() and db_path.stat().st_size > 0:
|
||||
return None
|
||||
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "true")
|
||||
return db_path
|
||||
|
||||
|
||||
def apply_detected_dev_bootstrap(config: GovoplanServerConfig, bootstrap_db_path: Path | None) -> None:
|
||||
if bootstrap_db_path is None or not _env_truthy(os.getenv("DEV_BOOTSTRAP_ENABLED")):
|
||||
return
|
||||
if config.settings is not None and hasattr(config.settings, "dev_bootstrap_enabled"):
|
||||
setattr(config.settings, "dev_bootstrap_enabled", True)
|
||||
|
||||
|
||||
def _source_root_for_path(path: str | os.PathLike[str]) -> Path | None:
|
||||
source_path = Path(path).resolve()
|
||||
directory = source_path if source_path.is_dir() else source_path.parent
|
||||
@@ -177,31 +239,86 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
|
||||
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
|
||||
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
|
||||
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[str] = ()) -> DevserverState:
|
||||
runtime_root = apply_runtime_defaults(config_path)
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
validate_sqlite_database_url(database_url)
|
||||
bootstrap_db_path = enable_dev_bootstrap_for_missing_sqlite(database_url)
|
||||
config = load_server_config(config_path)
|
||||
database_url = str(getattr(config.settings, "database_url", database_url) or "")
|
||||
validate_sqlite_database_url(database_url)
|
||||
if bootstrap_db_path is None:
|
||||
bootstrap_db_path = enable_dev_bootstrap_for_missing_sqlite(database_url)
|
||||
apply_detected_dev_bootstrap(config, bootstrap_db_path)
|
||||
registry = build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
|
||||
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs)
|
||||
return DevserverState(
|
||||
config_path=config_path,
|
||||
runtime_root=runtime_root,
|
||||
database_url=database_url,
|
||||
bootstrap_db_path=bootstrap_db_path,
|
||||
config=config,
|
||||
registry=registry,
|
||||
reload_dirs=reload_dirs,
|
||||
)
|
||||
|
||||
|
||||
def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool) -> None:
|
||||
print(f"Starting GovOPlaN dev server: {app}")
|
||||
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
|
||||
print(f"Runtime root: {state.runtime_root}")
|
||||
if state.database_url:
|
||||
print(f"Database: {state.database_url}")
|
||||
if state.bootstrap_db_path is not None:
|
||||
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
|
||||
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
|
||||
print("Modules: " + ", ".join(manifest.id for manifest in state.registry.manifests()))
|
||||
if no_reload:
|
||||
print("Reload: disabled")
|
||||
else:
|
||||
print("Reload dirs:")
|
||||
for directory in state.reload_dirs:
|
||||
print(f" - {directory}")
|
||||
|
||||
|
||||
def run_smoke_check(state: DevserverState) -> None:
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
except ModuleNotFoundError as exc:
|
||||
raise SystemExit("fastapi.testclient is not available. Install govoplan-core dev/server requirements before running --smoke.") from exc
|
||||
|
||||
from govoplan_core.server.app import create_app
|
||||
|
||||
print("Smoke check: creating ASGI app and running startup")
|
||||
app = create_app(state.config)
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/health")
|
||||
if response.status_code != 200:
|
||||
raise SystemExit(f"Smoke check failed: /health returned {response.status_code}: {response.text}")
|
||||
|
||||
db_path = _sqlite_database_path(state.database_url)
|
||||
if state.bootstrap_db_path is not None:
|
||||
if db_path is None or not db_path.exists() or db_path.stat().st_size <= 0:
|
||||
raise SystemExit(f"Smoke check failed: expected initialized SQLite DB at {state.bootstrap_db_path}")
|
||||
print("Smoke check: OK")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
if args.config:
|
||||
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
|
||||
|
||||
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
|
||||
runtime_root = apply_runtime_defaults(config_path)
|
||||
config = load_server_config(config_path)
|
||||
registry = build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
|
||||
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=args.reload_dir)
|
||||
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir)
|
||||
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
|
||||
|
||||
print(f"Starting GovOPlaN dev server: {args.app}")
|
||||
print(f"Config: {config_path or DEFAULT_CONFIG}")
|
||||
if runtime_root is not None:
|
||||
print(f"Runtime root: {runtime_root}")
|
||||
print("Modules: " + ", ".join(manifest.id for manifest in registry.manifests()))
|
||||
if args.no_reload:
|
||||
print("Reload: disabled")
|
||||
else:
|
||||
print("Reload dirs:")
|
||||
for directory in reload_dirs:
|
||||
print(f" - {directory}")
|
||||
if args.smoke:
|
||||
run_smoke_check(state)
|
||||
return 0
|
||||
|
||||
try:
|
||||
import uvicorn
|
||||
@@ -213,7 +330,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=not args.no_reload,
|
||||
reload_dirs=reload_dirs if not args.no_reload else None,
|
||||
reload_dirs=state.reload_dirs if not args.no_reload else None,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
3
src/govoplan_core/mail/__init__.py
Normal file
3
src/govoplan_core/mail/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from govoplan_core.mail.config import ImapConfig, SmtpConfig, TransportSecurity
|
||||
|
||||
__all__ = ["ImapConfig", "SmtpConfig", "TransportSecurity"]
|
||||
81
src/govoplan_core/mail/config.py
Normal file
81
src/govoplan_core/mail/config.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
|
||||
class TransportSecurity(StrEnum):
|
||||
PLAIN = "plain"
|
||||
TLS = "tls"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
|
||||
class TransportCredentials(StrictModel):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class SmtpServerConfig(StrictModel):
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
security: TransportSecurity = TransportSecurity.STARTTLS
|
||||
timeout_seconds: int = Field(default=30, ge=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def apply_default_port(self) -> "SmtpServerConfig":
|
||||
if self.port is None:
|
||||
self.port = default_smtp_port(self.security)
|
||||
return self
|
||||
|
||||
|
||||
class ImapServerConfig(StrictModel):
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
security: TransportSecurity = TransportSecurity.TLS
|
||||
sent_folder: str = "auto"
|
||||
timeout_seconds: int = Field(default=30, ge=1)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def discard_legacy_enabled(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict) and "enabled" in value:
|
||||
data = dict(value)
|
||||
data.pop("enabled", None)
|
||||
return data
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def apply_default_port(self) -> "ImapServerConfig":
|
||||
if self.port is None:
|
||||
self.port = default_imap_port(self.security)
|
||||
return self
|
||||
|
||||
|
||||
class SmtpConfig(SmtpServerConfig):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class ImapConfig(ImapServerConfig):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
def default_smtp_port(security: TransportSecurity | str | None) -> int:
|
||||
if security == TransportSecurity.TLS or security == "tls":
|
||||
return 465
|
||||
if security == TransportSecurity.PLAIN or security == "plain":
|
||||
return 25
|
||||
return 587
|
||||
|
||||
|
||||
def default_imap_port(security: TransportSecurity | str | None) -> int:
|
||||
if security == TransportSecurity.TLS or security == "tls":
|
||||
return 993
|
||||
return 143
|
||||
@@ -11,8 +11,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.governance import get_system_settings
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_core.db.models import AuditLog, Group, SystemSettings, Tenant, User
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus, JobSendStatus
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
|
||||
@@ -57,11 +57,11 @@ FINAL_VERSION_STATES = {
|
||||
"archived",
|
||||
}
|
||||
FINAL_EML_SEND_STATUSES = {
|
||||
JobSendStatus.SMTP_ACCEPTED.value,
|
||||
JobSendStatus.SENT.value,
|
||||
JobSendStatus.FAILED_PERMANENT.value,
|
||||
JobSendStatus.CANCELLED.value,
|
||||
JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||
"smtp_accepted",
|
||||
"sent",
|
||||
"failed_permanent",
|
||||
"cancelled",
|
||||
"outcome_unknown",
|
||||
}
|
||||
AUDIT_DETAIL_REDACTION_KEYS = {
|
||||
"attachments",
|
||||
@@ -148,6 +148,22 @@ class PrivacyPolicyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _campaign_models():
|
||||
try:
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, "govoplan_campaign")
|
||||
raise PrivacyPolicyError("Campaign module is not installed") from exc
|
||||
return Campaign, CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
||||
|
||||
|
||||
def _campaign_models_or_none():
|
||||
try:
|
||||
return _campaign_models()
|
||||
except PrivacyPolicyError:
|
||||
return None
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -251,8 +267,9 @@ def effective_privacy_policy(
|
||||
campaign_id: str | None = None,
|
||||
) -> PrivacyRetentionPolicy:
|
||||
policy = privacy_policy_from_session(session)
|
||||
campaign: Campaign | None = None
|
||||
campaign: Any | None = None
|
||||
if campaign_id:
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if campaign is None:
|
||||
raise PrivacyPolicyError("Campaign not found for privacy policy")
|
||||
@@ -290,6 +307,7 @@ def parent_privacy_policy(session: Session, *, tenant_id: str, scope_type: str,
|
||||
return policy
|
||||
if clean_scope != "campaign" or not scope_id:
|
||||
return policy
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise PrivacyPolicyError("Campaign not found for privacy policy")
|
||||
@@ -318,11 +336,13 @@ def _retention_policy_source_fields(patch: dict[str, Any], *, baseline: bool = F
|
||||
|
||||
|
||||
def _retention_policy_source_step(scope_type: str, label: str, scope_id: str | None, patch: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
|
||||
source_policy = PrivacyRetentionPolicy.model_validate(patch).model_dump(mode="json") if baseline else dict(patch)
|
||||
return {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"label": label,
|
||||
"applied_fields": _retention_policy_source_fields(patch, baseline=baseline),
|
||||
"policy": source_policy,
|
||||
}
|
||||
|
||||
|
||||
@@ -336,8 +356,9 @@ def effective_privacy_policy_sources(
|
||||
) -> list[dict[str, Any]]:
|
||||
system_settings = get_system_settings(session)
|
||||
sources = [_retention_policy_source_step("system", "System", None, _privacy_policy_patch_from_settings(system_settings.settings or {}), baseline=True)]
|
||||
campaign: Campaign | None = None
|
||||
campaign: Any | None = None
|
||||
if campaign_id:
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if campaign is None:
|
||||
raise PrivacyPolicyError("Campaign not found for privacy policy")
|
||||
@@ -378,6 +399,7 @@ def parent_privacy_policy_sources(session: Session, *, tenant_id: str, scope_typ
|
||||
return sources
|
||||
if clean_scope != "campaign" or not scope_id:
|
||||
return sources
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise PrivacyPolicyError("Campaign not found for privacy policy")
|
||||
@@ -413,6 +435,7 @@ def get_privacy_policy_for_scope(session: Session, *, tenant_id: str, scope_type
|
||||
raise PrivacyPolicyError("Group privacy policy not found")
|
||||
return _privacy_policy_patch_from_settings(group.settings or {})
|
||||
if clean_scope == "campaign":
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise PrivacyPolicyError("Campaign privacy policy not found")
|
||||
@@ -460,6 +483,7 @@ def set_privacy_policy_for_scope(
|
||||
session.add(group)
|
||||
return patch
|
||||
if clean_scope == "campaign":
|
||||
Campaign, _, _, _, _ = _campaign_models()
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise PrivacyPolicyError("Campaign privacy policy not found")
|
||||
@@ -542,6 +566,10 @@ def _campaign_policy_for_id(session: Session, campaign_id: str | None, cache: di
|
||||
|
||||
def _apply_raw_json_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
|
||||
result = {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0}
|
||||
models = _campaign_models_or_none()
|
||||
if models is None:
|
||||
return result
|
||||
_, _, CampaignVersion, _, _ = models
|
||||
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
|
||||
versions = (
|
||||
session.query(CampaignVersion)
|
||||
@@ -579,6 +607,10 @@ def _apply_raw_json_retention(session: Session, *, dry_run: bool, now: datetime)
|
||||
|
||||
def _apply_eml_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
|
||||
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}
|
||||
models = _campaign_models_or_none()
|
||||
if models is None:
|
||||
return result
|
||||
_, CampaignJob, _, JobImapStatus, JobQueueStatus = models
|
||||
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
@@ -616,6 +648,10 @@ def _apply_eml_retention(session: Session, *, dry_run: bool, now: datetime) -> d
|
||||
|
||||
def _apply_report_detail_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
|
||||
result = {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0}
|
||||
models = _campaign_models_or_none()
|
||||
if models is None:
|
||||
return result
|
||||
_, _, CampaignVersion, _, _ = models
|
||||
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
|
||||
versions = (
|
||||
session.query(CampaignVersion)
|
||||
@@ -708,9 +744,12 @@ def _parse_datetime(value: Any) -> datetime | None:
|
||||
|
||||
def _privacy_policy_for_audit_item(session: Session, item: AuditLog, campaign_cache: dict[str, PrivacyRetentionPolicy], tenant_cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy:
|
||||
if item.object_type == "campaign" and item.object_id:
|
||||
campaign = session.get(Campaign, str(item.object_id))
|
||||
if campaign is not None:
|
||||
return _campaign_policy_for_id(session, campaign.id, campaign_cache)
|
||||
models = _campaign_models_or_none()
|
||||
if models is not None:
|
||||
Campaign, _, _, _, _ = models
|
||||
campaign = session.get(Campaign, str(item.object_id))
|
||||
if campaign is not None:
|
||||
return _campaign_policy_for_id(session, campaign.id, campaign_cache)
|
||||
if item.tenant_id:
|
||||
if item.tenant_id not in tenant_cache:
|
||||
tenant_cache[item.tenant_id] = effective_privacy_policy(session, tenant_id=item.tenant_id)
|
||||
|
||||
@@ -27,11 +27,12 @@ def create_app(config: GovoplanServerConfig | str | None = None):
|
||||
api_router.include_router(router)
|
||||
|
||||
module_context = ModuleContext(registry=registry, settings=server_config.settings, data=server_config.module_context_data)
|
||||
registry.configure_capability_context(module_context)
|
||||
for manifest in registry.manifests():
|
||||
if manifest.route_factory is not None:
|
||||
api_router.include_router(manifest.route_factory(module_context))
|
||||
|
||||
api_router.include_router(create_platform_router())
|
||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||
|
||||
for router in server_config.post_module_routers:
|
||||
api_router.include_router(router)
|
||||
|
||||
@@ -48,7 +48,16 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
||||
}
|
||||
|
||||
|
||||
def create_platform_router() -> APIRouter:
|
||||
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
||||
capabilities: list[str] = []
|
||||
if manifest_id == "mail":
|
||||
app_env = str(getattr(settings, "app_env", "dev")).lower()
|
||||
if app_env == "dev" and bool(getattr(settings, "dev_mailbox_api_enabled", False)) and registry.has_module("mail"):
|
||||
capabilities.append("mail.devMailbox")
|
||||
return capabilities
|
||||
|
||||
|
||||
def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
router = APIRouter(prefix="/platform", tags=["platform"])
|
||||
|
||||
@router.get("/modules")
|
||||
@@ -63,6 +72,7 @@ def create_platform_router() -> APIRouter:
|
||||
"dependencies": list(manifest.dependencies),
|
||||
"optional_dependencies": list(manifest.optional_dependencies),
|
||||
"enabled": True,
|
||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest.frontend),
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = ()) -> dict[str, ModuleManifest]:
|
||||
manifests = {manifest.id: manifest for manifest in discover_module_manifests()}
|
||||
def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = (), *, enabled_modules: Iterable[str] | None = None) -> dict[str, ModuleManifest]:
|
||||
manifests = {manifest.id: manifest for manifest in discover_module_manifests(enabled_modules=enabled_modules)}
|
||||
manifests.setdefault("access", get_access_manifest())
|
||||
for factory in manifest_factories:
|
||||
manifest = factory()
|
||||
@@ -32,7 +32,7 @@ def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_fa
|
||||
if "access" not in requested:
|
||||
requested.insert(0, "access")
|
||||
|
||||
available = available_module_manifests(manifest_factories)
|
||||
available = available_module_manifests(manifest_factories, enabled_modules=requested)
|
||||
registry = PlatformRegistry()
|
||||
for module_id in requested:
|
||||
manifest = available.get(module_id)
|
||||
|
||||
@@ -8,7 +8,7 @@ class Settings(BaseSettings):
|
||||
app_env: str = Field(default="dev", alias="APP_ENV")
|
||||
|
||||
database_url: str = Field(
|
||||
default="sqlite:///./multimailer-dev.db",
|
||||
default="sqlite:///./runtime/multimailer-dev.db",
|
||||
alias="DATABASE_URL",
|
||||
)
|
||||
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
|
||||
|
||||
Reference in New Issue
Block a user