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)
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.
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)
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}