jmri_server.services.config_profile

ConfigProfileService — CRUD over <settings>/profiles.xml.

Without this, adding a new profile means hand-editing profiles.xml and hoping the syntax matches JMRI's exact expectations — an alpha tester ran into exactly that failure mode. This service performs the register / unregister mechanically, and mints an empty PanelPro.xml for freshly-created profiles so the Copy-beans-between-profiles flow sees them as valid targets on the next reload.

Wire shape (rewrite extension — JMRI Java has a configProfile topic but with a different mutation contract; we lean on our own ProfileRegistry for simplicity)::

method=list
    → array of {id, name, path, isActive}

method=put, data={"name": "prod"}          # id auto-generated
method=put, data={"name": "prod", "id": "..."}
    → the new profile projection

method=delete, data={"id": "..."}
    → {"id": ..., "deleted": true}

Delete only unregisters the profile — the on-disk directory is left alone. Reason: a user delete of the wrong profile through the UI would otherwise be non-recoverable. Removing the dir is a manual follow-up.

  1"""``ConfigProfileService`` — CRUD over ``<settings>/profiles.xml``.
  2
  3Without this, adding a new profile means hand-editing
  4``profiles.xml`` and hoping the syntax matches JMRI's exact
  5expectations — an alpha tester ran into exactly that failure mode.
  6This service performs the register / unregister mechanically, and
  7mints an empty ``PanelPro.xml`` for freshly-created profiles so the
  8Copy-beans-between-profiles flow sees them as valid targets on the
  9next reload.
 10
 11Wire shape (rewrite extension — JMRI Java has a ``configProfile``
 12topic but with a different mutation contract; we lean on our own
 13:class:`ProfileRegistry` for simplicity)::
 14
 15    method=list
 16        → array of {id, name, path, isActive}
 17
 18    method=put, data={"name": "prod"}          # id auto-generated
 19    method=put, data={"name": "prod", "id": "..."}
 20        → the new profile projection
 21
 22    method=delete, data={"id": "..."}
 23        → {"id": ..., "deleted": true}
 24
 25Delete only unregisters the profile — the on-disk directory is left
 26alone. Reason: a user delete of the wrong profile through the UI
 27would otherwise be non-recoverable. Removing the dir is a manual
 28follow-up.
 29"""
 30from __future__ import annotations
 31
 32import logging
 33import re
 34import uuid
 35from typing import Any
 36
 37from jmri_core.persistence.profile import Profile
 38from jmri_core.persistence.profile_registry import (
 39    ProfileRef,
 40    ProfileRegistry,
 41    save_profile_registry,
 42)
 43
 44from jmri_server.auth.models import Role
 45from jmri_server.service import JsonService, JsonServiceError
 46
 47_log = logging.getLogger(__name__)
 48
 49# Same character class as roster / logixng system names — filesystem
 50# safe on Windows (no colon), no path separators.
 51_SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
 52
 53
 54def _project(ref: ProfileRef, registry: ProfileRegistry) -> dict[str, Any]:
 55    active = registry.active()
 56    profile = ref.to_profile(registry.settings_root)
 57    return {
 58        "id": ref.id,
 59        "name": ref.name,
 60        "path": str(profile.profile_dir),
 61        "isActive": active is not None and active.id == ref.id,
 62    }
 63
 64
 65class ConfigProfileService(JsonService):
 66    topic = "configProfile"
 67    # Profile CRUD is admin-only — a compromised operator token
 68    # shouldn't be able to create/delete profiles or their
 69    # backing data.
 70    REQUIRED_ROLE = {
 71        "list": Role.VIEWER,
 72        "get": Role.VIEWER,
 73        "put": Role.ADMIN,
 74        "delete": Role.ADMIN,
 75        "post": Role.ADMIN,
 76    }
 77
 78    async def on_list(
 79        self, data: dict[str, Any] | None,
 80    ) -> list[dict[str, Any]]:
 81        registry = self._registry_or_none()
 82        if registry is None:
 83            return []
 84        return [_project(ref, registry) for ref in registry.profiles]
 85
 86    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 87        data = data or {}
 88        name = data.get("name")
 89        if not isinstance(name, str) or not name:
 90            raise JsonServiceError(400, "configProfile.put requires 'name'")
 91        if not _SAFE_NAME_RE.match(name):
 92            raise JsonServiceError(
 93                400,
 94                "profile name must match [A-Za-z0-9._-]+ "
 95                "(filesystem-safe, no colon or path separators)",
 96            )
 97        registry = self._require_registry()
 98        # Reject duplicates by name — same-name entries would collide
 99        # on disk since we store per-name subdirectories.
100        for existing in registry.profiles:
101            if existing.name == name:
102                raise JsonServiceError(
103                    409, f"profile with name {name!r} already exists",
104                )
105        # Auto-generate an id if the caller didn't specify one. JMRI's
106        # own convention is ``<name>.<8-hex>``; matching that keeps
107        # profiles.xml recognizable by a JMRI Java install if the user
108        # ever imports our settings back into it.
109        raw_id = data.get("id")
110        if isinstance(raw_id, str) and raw_id:
111            if registry.by_id(raw_id) is not None:
112                raise JsonServiceError(
113                    409, f"profile id {raw_id!r} already exists",
114                )
115            profile_id = raw_id
116        else:
117            profile_id = f"{name}.{uuid.uuid4().hex[:8]}"
118        ref = ProfileRef(
119            id=profile_id,
120            path=f"settings:{name}/",
121            name=name,
122        )
123        registry.profiles.append(ref)
124        # Persist profiles.xml before touching the filesystem so a
125        # save-failure doesn't leave orphan directories. Create the
126        # profile's dir + an empty PanelPro.xml afterwards.
127        save_profile_registry(registry)
128        profile = ref.to_profile(registry.settings_root)
129        profile.ensure()
130        _write_empty_config(profile)
131        return _project(ref, registry)
132
133    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
134        data = data or {}
135        profile_id = data.get("id")
136        if not isinstance(profile_id, str) or not profile_id:
137            raise JsonServiceError(400, "configProfile.delete requires 'id'")
138        registry = self._require_registry()
139        ref = registry.by_id(profile_id)
140        if ref is None:
141            raise JsonServiceError(
142                404, f"no profile with id {profile_id!r}",
143            )
144        active = registry.active()
145        if active is not None and active.id == profile_id:
146            raise JsonServiceError(
147                409,
148                "can't delete the active profile; switch to another profile first",
149            )
150        registry.profiles = [p for p in registry.profiles if p.id != profile_id]
151        save_profile_registry(registry)
152        return {"id": profile_id, "deleted": True}
153
154    # ---- helpers ----------------------------------------------------
155
156    def _registry_or_none(self) -> ProfileRegistry | None:
157        registry = getattr(self.ctx, "profile_registry", None)
158        return registry if isinstance(registry, ProfileRegistry) else None
159
160    def _require_registry(self) -> ProfileRegistry:
161        registry = self._registry_or_none()
162        if registry is None:
163            raise JsonServiceError(
164                503,
165                "no profile registry loaded (no profiles.xml at settings root)",
166            )
167        return registry
168
169
170def _write_empty_config(profile: Profile) -> None:
171    """Mint a minimal ``PanelPro.xml`` so the new profile is a valid
172    target for the beanTransfer service on its first day of life.
173    Uses the same shape :func:`~jmri_core.persistence.empty_config`
174    produces so JMRI Java would accept it if imported back."""
175    from jmri_core.persistence import empty_config, save_config  # noqa: PLC0415
176
177    cfg = empty_config(doctype='layout-config SYSTEM "layout-config.dtd"')
178    save_config(profile.config_file, cfg)
class ConfigProfileService(jmri_server.service.JsonService):
 66class ConfigProfileService(JsonService):
 67    topic = "configProfile"
 68    # Profile CRUD is admin-only — a compromised operator token
 69    # shouldn't be able to create/delete profiles or their
 70    # backing data.
 71    REQUIRED_ROLE = {
 72        "list": Role.VIEWER,
 73        "get": Role.VIEWER,
 74        "put": Role.ADMIN,
 75        "delete": Role.ADMIN,
 76        "post": Role.ADMIN,
 77    }
 78
 79    async def on_list(
 80        self, data: dict[str, Any] | None,
 81    ) -> list[dict[str, Any]]:
 82        registry = self._registry_or_none()
 83        if registry is None:
 84            return []
 85        return [_project(ref, registry) for ref in registry.profiles]
 86
 87    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 88        data = data or {}
 89        name = data.get("name")
 90        if not isinstance(name, str) or not name:
 91            raise JsonServiceError(400, "configProfile.put requires 'name'")
 92        if not _SAFE_NAME_RE.match(name):
 93            raise JsonServiceError(
 94                400,
 95                "profile name must match [A-Za-z0-9._-]+ "
 96                "(filesystem-safe, no colon or path separators)",
 97            )
 98        registry = self._require_registry()
 99        # Reject duplicates by name — same-name entries would collide
100        # on disk since we store per-name subdirectories.
101        for existing in registry.profiles:
102            if existing.name == name:
103                raise JsonServiceError(
104                    409, f"profile with name {name!r} already exists",
105                )
106        # Auto-generate an id if the caller didn't specify one. JMRI's
107        # own convention is ``<name>.<8-hex>``; matching that keeps
108        # profiles.xml recognizable by a JMRI Java install if the user
109        # ever imports our settings back into it.
110        raw_id = data.get("id")
111        if isinstance(raw_id, str) and raw_id:
112            if registry.by_id(raw_id) is not None:
113                raise JsonServiceError(
114                    409, f"profile id {raw_id!r} already exists",
115                )
116            profile_id = raw_id
117        else:
118            profile_id = f"{name}.{uuid.uuid4().hex[:8]}"
119        ref = ProfileRef(
120            id=profile_id,
121            path=f"settings:{name}/",
122            name=name,
123        )
124        registry.profiles.append(ref)
125        # Persist profiles.xml before touching the filesystem so a
126        # save-failure doesn't leave orphan directories. Create the
127        # profile's dir + an empty PanelPro.xml afterwards.
128        save_profile_registry(registry)
129        profile = ref.to_profile(registry.settings_root)
130        profile.ensure()
131        _write_empty_config(profile)
132        return _project(ref, registry)
133
134    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
135        data = data or {}
136        profile_id = data.get("id")
137        if not isinstance(profile_id, str) or not profile_id:
138            raise JsonServiceError(400, "configProfile.delete requires 'id'")
139        registry = self._require_registry()
140        ref = registry.by_id(profile_id)
141        if ref is None:
142            raise JsonServiceError(
143                404, f"no profile with id {profile_id!r}",
144            )
145        active = registry.active()
146        if active is not None and active.id == profile_id:
147            raise JsonServiceError(
148                409,
149                "can't delete the active profile; switch to another profile first",
150            )
151        registry.profiles = [p for p in registry.profiles if p.id != profile_id]
152        save_profile_registry(registry)
153        return {"id": profile_id, "deleted": True}
154
155    # ---- helpers ----------------------------------------------------
156
157    def _registry_or_none(self) -> ProfileRegistry | None:
158        registry = getattr(self.ctx, "profile_registry", None)
159        return registry if isinstance(registry, ProfileRegistry) else None
160
161    def _require_registry(self) -> ProfileRegistry:
162        registry = self._registry_or_none()
163        if registry is None:
164            raise JsonServiceError(
165                503,
166                "no profile registry loaded (no profiles.xml at settings root)",
167            )
168        return registry

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'configProfile'
REQUIRED_ROLE = {'list': <Role.VIEWER: 'viewer'>, 'get': <Role.VIEWER: 'viewer'>, 'put': <Role.ADMIN: 'admin'>, 'delete': <Role.ADMIN: 'admin'>, 'post': <Role.ADMIN: 'admin'>}
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
79    async def on_list(
80        self, data: dict[str, Any] | None,
81    ) -> list[dict[str, Any]]:
82        registry = self._registry_or_none()
83        if registry is None:
84            return []
85        return [_project(ref, registry) for ref in registry.profiles]
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
 87    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
 88        data = data or {}
 89        name = data.get("name")
 90        if not isinstance(name, str) or not name:
 91            raise JsonServiceError(400, "configProfile.put requires 'name'")
 92        if not _SAFE_NAME_RE.match(name):
 93            raise JsonServiceError(
 94                400,
 95                "profile name must match [A-Za-z0-9._-]+ "
 96                "(filesystem-safe, no colon or path separators)",
 97            )
 98        registry = self._require_registry()
 99        # Reject duplicates by name — same-name entries would collide
100        # on disk since we store per-name subdirectories.
101        for existing in registry.profiles:
102            if existing.name == name:
103                raise JsonServiceError(
104                    409, f"profile with name {name!r} already exists",
105                )
106        # Auto-generate an id if the caller didn't specify one. JMRI's
107        # own convention is ``<name>.<8-hex>``; matching that keeps
108        # profiles.xml recognizable by a JMRI Java install if the user
109        # ever imports our settings back into it.
110        raw_id = data.get("id")
111        if isinstance(raw_id, str) and raw_id:
112            if registry.by_id(raw_id) is not None:
113                raise JsonServiceError(
114                    409, f"profile id {raw_id!r} already exists",
115                )
116            profile_id = raw_id
117        else:
118            profile_id = f"{name}.{uuid.uuid4().hex[:8]}"
119        ref = ProfileRef(
120            id=profile_id,
121            path=f"settings:{name}/",
122            name=name,
123        )
124        registry.profiles.append(ref)
125        # Persist profiles.xml before touching the filesystem so a
126        # save-failure doesn't leave orphan directories. Create the
127        # profile's dir + an empty PanelPro.xml afterwards.
128        save_profile_registry(registry)
129        profile = ref.to_profile(registry.settings_root)
130        profile.ensure()
131        _write_empty_config(profile)
132        return _project(ref, registry)
async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
134    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
135        data = data or {}
136        profile_id = data.get("id")
137        if not isinstance(profile_id, str) or not profile_id:
138            raise JsonServiceError(400, "configProfile.delete requires 'id'")
139        registry = self._require_registry()
140        ref = registry.by_id(profile_id)
141        if ref is None:
142            raise JsonServiceError(
143                404, f"no profile with id {profile_id!r}",
144            )
145        active = registry.active()
146        if active is not None and active.id == profile_id:
147            raise JsonServiceError(
148                409,
149                "can't delete the active profile; switch to another profile first",
150            )
151        registry.profiles = [p for p in registry.profiles if p.id != profile_id]
152        save_profile_registry(registry)
153        return {"id": profile_id, "deleted": True}