270 lines
9.0 KiB
Python
270 lines
9.0 KiB
Python
"""Dedicated storage helpers for user-defined org architectures."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from opc.core.config import (
|
|
COMPANY_ORG_KIND,
|
|
COMPANY_ORG_SCHEMA_VERSION,
|
|
DEFAULT_ORGANIZATION_ID,
|
|
OPCConfig,
|
|
_atomic_write_text,
|
|
_company_org_payload_to_org_mapping,
|
|
_read_yaml_file,
|
|
build_company_org_payload_from_config,
|
|
slugify_organization_name,
|
|
validate_organization_id,
|
|
)
|
|
|
|
ORG_INDEX_FILENAME = "org_index.yaml"
|
|
ORG_CONFIGS_DIRNAME = "company_orgs"
|
|
ORG_CONFIG_KIND = COMPANY_ORG_KIND
|
|
ORG_CONFIG_SCHEMA_VERSION = COMPANY_ORG_SCHEMA_VERSION
|
|
RESERVED_ORG_CONFIG_IDS = frozenset({DEFAULT_ORGANIZATION_ID})
|
|
|
|
|
|
class RunnableOrgConfigError(ValueError):
|
|
"""Raised when a saved custom org cannot be safely activated or run."""
|
|
|
|
|
|
def org_index_path(config_dir: Path) -> Path:
|
|
return Path(config_dir) / ORG_INDEX_FILENAME
|
|
|
|
|
|
def org_configs_dir(config_dir: Path) -> Path:
|
|
return Path(config_dir) / ORG_CONFIGS_DIRNAME
|
|
|
|
|
|
def is_reserved_org_config_id(value: Any) -> bool:
|
|
try:
|
|
org_id = validate_organization_id(value)
|
|
except ValueError:
|
|
return False
|
|
return org_id in RESERVED_ORG_CONFIG_IDS
|
|
|
|
|
|
def validate_saved_org_id(value: Any) -> str:
|
|
org_id = validate_organization_id(value)
|
|
if org_id in RESERVED_ORG_CONFIG_IDS:
|
|
raise ValueError(f"Reserved organization_id for built-in company profile: {org_id!r}")
|
|
return org_id
|
|
|
|
|
|
def org_config_filename(organization_id: Any) -> str:
|
|
return f"org_{validate_saved_org_id(organization_id)}_config.yaml"
|
|
|
|
|
|
def organization_id_from_org_config_filename(path: Path) -> str | None:
|
|
name = Path(path).name
|
|
prefix = "org_"
|
|
suffix = "_config.yaml"
|
|
if not name.startswith(prefix) or not name.endswith(suffix):
|
|
return None
|
|
candidate = name[len(prefix):-len(suffix)]
|
|
try:
|
|
return validate_saved_org_id(candidate)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def org_config_path(config_dir: Path, organization_id: Any) -> Path:
|
|
return org_configs_dir(config_dir) / org_config_filename(organization_id)
|
|
|
|
|
|
def org_config_relative_path(organization_id: Any) -> str:
|
|
return f"{ORG_CONFIGS_DIRNAME}/{org_config_filename(organization_id)}"
|
|
|
|
|
|
def read_org_index(config_dir: Path) -> str | None:
|
|
path = org_index_path(config_dir)
|
|
if not path.exists():
|
|
return None
|
|
data = _read_yaml_file(path)
|
|
active_id = data.get("active_organization_id")
|
|
if not active_id:
|
|
return None
|
|
org_id = validate_organization_id(active_id)
|
|
return None if org_id in RESERVED_ORG_CONFIG_IDS else org_id
|
|
|
|
|
|
def write_org_index(config_dir: Path, organization_id: Any) -> None:
|
|
org_id = validate_saved_org_id(organization_id)
|
|
_atomic_write_text(
|
|
org_index_path(config_dir),
|
|
yaml.dump(
|
|
{
|
|
"schema_version": 1,
|
|
"active_organization_id": org_id,
|
|
},
|
|
default_flow_style=False,
|
|
sort_keys=False,
|
|
allow_unicode=True,
|
|
),
|
|
)
|
|
|
|
|
|
def list_org_config_paths(config_dir: Path) -> list[Path]:
|
|
org_dir = org_configs_dir(config_dir)
|
|
if not org_dir.is_dir():
|
|
return []
|
|
return sorted(path for path in org_dir.glob("org_*_config.yaml") if organization_id_from_org_config_filename(path))
|
|
|
|
|
|
def allocate_org_config_id(config_dir: Path, organization_name: Any, *, preferred_id: Any = "") -> str:
|
|
base = str(preferred_id or "").strip()
|
|
try:
|
|
candidate = validate_saved_org_id(base) if base else ""
|
|
except ValueError:
|
|
candidate = ""
|
|
if not candidate:
|
|
candidate = slugify_organization_name(organization_name)
|
|
existing = {
|
|
org_id
|
|
for path in list_org_config_paths(config_dir)
|
|
for org_id in [organization_id_from_org_config_filename(path)]
|
|
if org_id
|
|
}
|
|
existing.update(RESERVED_ORG_CONFIG_IDS)
|
|
if candidate not in existing:
|
|
return candidate
|
|
suffix = 2
|
|
while True:
|
|
tail = f"_{suffix}"
|
|
stem = candidate[: max(1, 64 - len(tail))].rstrip("_-") or "org"
|
|
next_id = f"{stem}{tail}"
|
|
if next_id not in existing:
|
|
return next_id
|
|
suffix += 1
|
|
|
|
|
|
def validate_org_config_payload(path: Path, data: dict[str, Any]) -> dict[str, Any]:
|
|
schema_version = int(data.get("schema_version", 1) or 1)
|
|
if schema_version > ORG_CONFIG_SCHEMA_VERSION:
|
|
raise ValueError(
|
|
f"{path.name} schema_version {schema_version} is not supported by this version of OpenOPC"
|
|
)
|
|
kind = str(data.get("kind", "") or "").strip()
|
|
if schema_version >= ORG_CONFIG_SCHEMA_VERSION and kind and kind != ORG_CONFIG_KIND:
|
|
raise ValueError(f"Unsupported org architecture kind in {path.name}: {kind}")
|
|
data["schema_version"] = schema_version
|
|
return data
|
|
|
|
|
|
def build_org_config_payload_from_config(
|
|
config: OPCConfig,
|
|
*,
|
|
organization_id: str | None = None,
|
|
organization_name: str | None = None,
|
|
) -> dict[str, Any]:
|
|
payload = build_company_org_payload_from_config(
|
|
config,
|
|
organization_id=organization_id,
|
|
organization_name=organization_name,
|
|
force_profile="custom",
|
|
)
|
|
org_id = validate_saved_org_id(payload.get("organization_id"))
|
|
payload["metadata"] = {
|
|
**dict(payload.get("metadata", {}) or {}),
|
|
"source": "org_mode",
|
|
"organization_config_file": org_config_relative_path(org_id),
|
|
}
|
|
return payload
|
|
|
|
|
|
def apply_org_config_payload_to_config(
|
|
base_config: OPCConfig,
|
|
data: dict[str, Any],
|
|
*,
|
|
source_path: Path | None = None,
|
|
) -> OPCConfig:
|
|
merged = base_config.model_dump()
|
|
org_mapping = _company_org_payload_to_org_mapping(data, source_path=source_path)
|
|
org_mapping["organization_id"] = validate_saved_org_id(org_mapping.get("organization_id"))
|
|
org_mapping["company_profile"] = "custom"
|
|
if org_mapping.get("organization_id"):
|
|
org_mapping["organization_config_file"] = org_config_relative_path(org_mapping["organization_id"])
|
|
merged["org"] = org_mapping
|
|
config = OPCConfig.model_validate(merged)
|
|
config.org.talent_templates = []
|
|
if source_path is not None:
|
|
try:
|
|
from opc.core.employee_registry import load_company_employees
|
|
|
|
config_dir = Path(source_path).parent.parent
|
|
config.org.employees = load_company_employees(
|
|
config_dir.parent,
|
|
config.org.organization_id,
|
|
list(config.org.employees),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return config
|
|
|
|
|
|
def validate_runnable_org_config(config: OPCConfig, *, organization_id: Any = "") -> None:
|
|
"""Reject custom orgs that would silently fall back to corporate builtin roles.
|
|
|
|
Corporate can still fall back to builtin roles for legacy configs.
|
|
Saved custom orgs must carry explicit roles before activation or execution.
|
|
"""
|
|
org = config.org
|
|
profile = str(getattr(org, "company_profile", "") or "").strip().lower()
|
|
if profile != "custom":
|
|
return
|
|
|
|
org_id = validate_saved_org_id(organization_id or getattr(org, "organization_id", ""))
|
|
roles = [
|
|
role
|
|
for role in list(getattr(org, "roles", []) or [])
|
|
if str(getattr(role, "id", "") or "").strip()
|
|
]
|
|
if roles:
|
|
return
|
|
|
|
raise RunnableOrgConfigError(
|
|
f"Custom organization `{org_id}` has no roles. "
|
|
"Refusing to activate or run it with corporate fallback roles."
|
|
)
|
|
|
|
|
|
def write_org_config_payload(config_dir: Path, organization_id: Any, payload: dict[str, Any]) -> Path:
|
|
org_id = validate_saved_org_id(organization_id)
|
|
path = org_config_path(config_dir, org_id)
|
|
payload = dict(payload)
|
|
raw_employees = list(payload.get("employees", []) or [])
|
|
if raw_employees:
|
|
from opc.core.employee_registry import load_employee_registry, write_employee_registry
|
|
|
|
opc_home = Path(config_dir).parent
|
|
existing = load_employee_registry(opc_home, org_id)
|
|
write_employee_registry(opc_home, org_id, [*existing, *raw_employees])
|
|
payload["employees"] = []
|
|
payload["talent_templates"] = []
|
|
payload["organization_id"] = org_id
|
|
payload.setdefault("schema_version", ORG_CONFIG_SCHEMA_VERSION)
|
|
payload.setdefault("kind", ORG_CONFIG_KIND)
|
|
payload["metadata"] = {
|
|
**dict(payload.get("metadata", {}) or {}),
|
|
"organization_config_file": org_config_relative_path(org_id),
|
|
}
|
|
_atomic_write_text(
|
|
path,
|
|
yaml.dump(payload, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
|
)
|
|
return path
|
|
|
|
|
|
def load_org_config_payload(config_dir: Path, organization_id: Any | None = None) -> tuple[dict[str, Any], Path]:
|
|
config_dir = Path(config_dir)
|
|
org_id = validate_saved_org_id(organization_id) if organization_id else read_org_index(config_dir)
|
|
if not org_id:
|
|
raise FileNotFoundError("No active org architecture is selected.")
|
|
path = org_config_path(config_dir, org_id)
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Org architecture config does not exist: {path}")
|
|
return validate_org_config_payload(path, _read_yaml_file(path)), path
|