campaign sending prototype

This commit is contained in:
2026-06-24 16:20:37 +02:00
parent 44516a03aa
commit ce30b4d054
17 changed files with 1233 additions and 14 deletions

View File

@@ -0,0 +1,222 @@
from __future__ import annotations
import argparse
import importlib.util
import inspect
import os
import sys
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Any
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.server.config import GovoplanServerConfig, import_object, load_server_config
from govoplan_core.server.registry import build_platform_registry
DEFAULT_APP = "govoplan_core.server.app:app"
DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config"
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:
return None
module_name = object_path.split(":", 1)[0]
try:
spec = importlib.util.find_spec(module_name)
except (ImportError, AttributeError, ValueError):
return None
if spec is None or not spec.origin:
return None
source = Path(spec.origin).resolve()
if source.name == "__init__.py":
package_dir = source.parent
else:
package_dir = source.parent
if package_dir.name == "app":
return package_dir.parent
return 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
defaults = {
"DATABASE_URL": f"sqlite:///{runtime_root / 'multimailer-dev.db'}",
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "runtime" / "files"),
"MOCK_MAILBOX_DIR": str(runtime_root / "runtime" / "mock-mailbox"),
}
for key, value in defaults.items():
os.environ.setdefault(key, value)
return runtime_root
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
if not directory.exists():
return None
for candidate in (directory, *directory.parents):
if candidate.name == "src":
return candidate
return directory
def _source_roots_for_object(value: Any) -> tuple[Path, ...]:
candidates: list[Any] = [value]
if not inspect.isclass(value) and not inspect.ismodule(value):
candidates.append(type(value))
roots: list[Path] = []
for candidate in candidates:
try:
source = inspect.getsourcefile(candidate) or inspect.getfile(candidate)
except (TypeError, OSError):
continue
root = _source_root_for_path(source)
if root is not None:
roots.append(root)
return tuple(roots)
def _manifest_source_roots(manifest: ModuleManifest) -> tuple[Path, ...]:
roots: list[Path] = []
if manifest.route_factory is not None:
roots.extend(_source_roots_for_object(manifest.route_factory))
if manifest.migration_spec is not None and manifest.migration_spec.script_location:
root = _source_root_for_path(manifest.migration_spec.script_location)
if root is not None:
roots.append(root)
for provider in manifest.resource_acl_providers:
roots.extend(_source_roots_for_object(provider))
for provider in manifest.tenant_summary_providers:
roots.extend(_source_roots_for_object(provider))
for providers in manifest.delete_veto_providers.values():
for provider in providers:
roots.extend(_source_roots_for_object(provider))
return tuple(roots)
def _entry_point_source_roots(enabled_module_ids: set[str]) -> tuple[Path, ...]:
roots: list[Path] = []
for entry_point in iter_module_entry_points():
loaded = entry_point.load()
manifest = loaded() if callable(loaded) else loaded
if not isinstance(manifest, ModuleManifest) or manifest.id not in enabled_module_ids:
continue
roots.extend(_source_roots_for_object(loaded))
return tuple(roots)
def _config_source_roots(config_path: str | None) -> tuple[Path, ...]:
object_path = config_path or os.getenv("GOVOPLAN_SERVER_CONFIG") or DEFAULT_CONFIG
try:
loaded = import_object(object_path)
except ModuleNotFoundError:
if config_path or os.getenv("GOVOPLAN_SERVER_CONFIG"):
raise
loaded = import_object(DEFAULT_CONFIG)
return _source_roots_for_object(loaded)
def unique_existing_dirs(paths: Iterable[Path | str]) -> list[str]:
seen: set[Path] = set()
result: list[str] = []
for item in paths:
path = Path(item).resolve()
if path in seen or not path.is_dir():
continue
seen.add(path)
result.append(str(path))
return result
def build_reload_dirs(
config: GovoplanServerConfig,
*,
config_path: str | None = None,
registry: PlatformRegistry | None = None,
extra_dirs: Sequence[str] = (),
) -> list[str]:
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
manifests = active_registry.manifests()
enabled_module_ids = {manifest.id for manifest in manifests}
roots: list[Path | str] = []
roots.extend(_config_source_roots(config_path))
for factory in config.manifest_factories:
roots.extend(_source_roots_for_object(factory))
roots.extend(_entry_point_source_roots(enabled_module_ids))
for manifest in manifests:
roots.extend(_manifest_source_roots(manifest))
roots.extend(extra_dirs)
return unique_existing_dirs(roots)
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the GovOPlaN development server with module-aware reload directories.")
parser.add_argument("--config", help="GovoplanServerConfig object path, e.g. app.govoplan_config:get_server_config.")
parser.add_argument("--app", default=DEFAULT_APP, help=f"ASGI app import string passed to uvicorn. Default: {DEFAULT_APP}.")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1.")
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.")
return parser.parse_args(argv)
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)
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}")
try:
import uvicorn
except ModuleNotFoundError as exc:
raise SystemExit("uvicorn is not installed. Install govoplan-core with the server extra or requirements-dev.txt.") from exc
uvicorn.run(
args.app,
host=args.host,
port=args.port,
reload=not args.no_reload,
reload_dirs=reload_dirs if not args.no_reload else None,
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -39,6 +39,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"mail_servers:read": "mail:profile:read",
"mail_servers:use": "mail:profile:use",
"mail_servers:test": "mail:profile:test",
"mail_servers:mailbox_read": "mail:mailbox:read",
"mail_servers:write": "mail:profile:write",
"mail_servers:manage_credentials": "mail:secret:manage",
}

View File

@@ -19,6 +19,7 @@ class Settings(BaseSettings):
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="access,campaigns,files,mail", alias="ENABLED_MODULES")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
s3_region: str = Field(default="garage", alias="S3_REGION")
@@ -30,6 +31,7 @@ class Settings(BaseSettings):
# production can switch to Garage/S3 without changing API contracts.
file_storage_backend: str = Field(default="local", alias="FILE_STORAGE_BACKEND")
file_storage_local_root: str = Field(default="runtime/files", alias="FILE_STORAGE_LOCAL_ROOT")
file_storage_local_fallback_roots: str = Field(default="", alias="FILE_STORAGE_LOCAL_FALLBACK_ROOTS")
file_storage_s3_endpoint_url: str | None = Field(default=None, alias="FILE_STORAGE_S3_ENDPOINT_URL")
file_storage_s3_region: str | None = Field(default=None, alias="FILE_STORAGE_S3_REGION")
file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID")