Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 15794e920e
commit 02564047e9
73 changed files with 4073 additions and 478 deletions

View File

@@ -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."

View File

@@ -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(),
}

View File

@@ -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):

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -0,0 +1,3 @@
from govoplan_core.mail.config import ImapConfig, SmtpConfig, TransportSecurity
__all__ = ["ImapConfig", "SmtpConfig", "TransportSecurity"]

View 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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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),
}

View File

@@ -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)

View File

@@ -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")