jmri_server.services.bean_transfer
BeanTransferService — copy bean definitions between profiles.
The user story: "I set up a staging layout on my dev profile; I want
that bean set on production too." Without this service, the operator
has to shell in and copy PanelPro.xml by hand, plus edit out the
beans they DON'T want to overwrite.
Wire shape (a rewrite-specific extension — JMRI Java doesn't have a
beanTransfer topic)::
method=list
→ {"profiles": [
{"id": ..., "name": ..., "path": ..., "isActive": bool,
"counts": {"turnout": N, "sensor": N, "light": N}},
], "types": ["turnout", "sensor", "light"]}
method=post, data={"action": "preview", "from": <profile_id>,
"to": <profile_id>, "types": ["turnout", ...]}
→ {"counts": {"turnout": N, "sensor": N, "light": N},
"willOverwrite": {"turnout": N, ...}}
method=post, data={"action": "apply", "from": <profile_id>,
"to": <profile_id>, "types": [...]}
→ {"copied": {"turnout": N, "sensor": N, "light": N},
"targetIsActive": bool}
An apply against the active profile also re-populates the live bean managers (so a Copy to production is visible immediately, not just after a profile switch).
1"""``BeanTransferService`` — copy bean definitions between profiles. 2 3The user story: "I set up a staging layout on my dev profile; I want 4that bean set on production too." Without this service, the operator 5has to shell in and copy ``PanelPro.xml`` by hand, plus edit out the 6beans they DON'T want to overwrite. 7 8Wire shape (a rewrite-specific extension — JMRI Java doesn't have a 9``beanTransfer`` topic):: 10 11 method=list 12 → {"profiles": [ 13 {"id": ..., "name": ..., "path": ..., "isActive": bool, 14 "counts": {"turnout": N, "sensor": N, "light": N}}, 15 ], "types": ["turnout", "sensor", "light"]} 16 17 method=post, data={"action": "preview", "from": <profile_id>, 18 "to": <profile_id>, "types": ["turnout", ...]} 19 → {"counts": {"turnout": N, "sensor": N, "light": N}, 20 "willOverwrite": {"turnout": N, ...}} 21 22 method=post, data={"action": "apply", "from": <profile_id>, 23 "to": <profile_id>, "types": [...]} 24 → {"copied": {"turnout": N, "sensor": N, "light": N}, 25 "targetIsActive": bool} 26 27An apply against the active profile also re-populates the live bean 28managers (so a Copy to production is visible immediately, not just 29after a profile switch). 30""" 31from __future__ import annotations 32 33import logging 34from typing import Any 35 36from jmri_core.persistence.profile import Profile 37from jmri_core.persistence.profile_registry import ProfileRef, ProfileRegistry 38 39from jmri_server.config_store import ConfigStore 40from jmri_server.auth.models import Role 41from jmri_server.service import JsonService, JsonServiceError 42 43_log = logging.getLogger(__name__) 44 45SUPPORTED_TYPES = ("turnout", "sensor", "light") 46 47 48def _profile_from_ref(registry: ProfileRegistry, ref: ProfileRef) -> Profile: 49 return ref.to_profile(registry.settings_root) 50 51 52def _open_store(profile: Profile) -> ConfigStore: 53 """Read the profile's ``PanelPro.xml`` into a fresh 54 :class:`ConfigStore`. Missing / never-saved files parse as an 55 empty config — the caller gets empty iterators for each bean 56 type without raising.""" 57 return ConfigStore(profile=profile) 58 59 60def _count(store: ConfigStore, kind: str) -> int: 61 if kind == "turnout": 62 return sum(1 for _ in store.turnouts()) 63 if kind == "sensor": 64 return sum(1 for _ in store.sensors()) 65 if kind == "light": 66 return sum(1 for _ in store.lights()) 67 return 0 68 69 70def _existing_names(store: ConfigStore, kind: str) -> set[str]: 71 """System names already present in *store* for the given bean 72 kind. Used to compute how many of a would-be transfer would 73 OVERWRITE vs. add fresh, so preview can warn honestly.""" 74 if kind == "turnout": 75 return {r.system_name for r in store.turnouts()} 76 if kind == "sensor": 77 return {r.system_name for r in store.sensors()} 78 if kind == "light": 79 return {r.system_name for r in store.lights()} 80 return set() 81 82 83def _copy_kind( 84 src: ConfigStore, dst: ConfigStore, kind: str, 85) -> int: 86 """Upsert every row of ``kind`` from *src* into *dst*. Returns 87 the number of rows copied. Auto-preserves *dst*'s extra_attribs 88 for rows that already exist there — the same logic ``upsert_*`` 89 uses on the bean-service edit path.""" 90 n = 0 91 if kind == "turnout": 92 for row in src.turnouts(): 93 dst.upsert_turnout(row) 94 n += 1 95 elif kind == "sensor": 96 for row in src.sensors(): 97 dst.upsert_sensor(row) 98 n += 1 99 elif kind == "light": 100 for row in src.lights(): 101 dst.upsert_light(row) 102 n += 1 103 return n 104 105 106class BeanTransferService(JsonService): 107 """Cross-profile bean transfer. Read-only ``list`` + 108 ``post`` with ``action=preview|apply``.""" 109 110 topic = "beanTransfer" 111 # ``list`` is safe for any role. ``post`` writes across 112 # profiles — admin-only, since a compromised operator token 113 # shouldn't be able to leak or overwrite the production 114 # profile's beans from a staging session. 115 REQUIRED_ROLE = { 116 "list": Role.VIEWER, 117 "post": Role.ADMIN, 118 } 119 120 async def on_list( 121 self, data: dict[str, Any] | None, 122 ) -> dict[str, Any]: 123 registry = self._registry_or_none() 124 profiles_out: list[dict[str, Any]] = [] 125 if registry is not None: 126 active = registry.active() 127 active_id = active.id if active else None 128 for ref in registry.profiles: 129 profile = _profile_from_ref(registry, ref) 130 store = _open_store(profile) 131 profiles_out.append({ 132 "id": ref.id, 133 "name": ref.name, 134 "path": str(profile.profile_dir), 135 "isActive": ref.id == active_id, 136 "counts": { 137 kind: _count(store, kind) 138 for kind in SUPPORTED_TYPES 139 }, 140 }) 141 return { 142 "profiles": profiles_out, 143 "types": list(SUPPORTED_TYPES), 144 } 145 146 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 147 data = data or {} 148 action = data.get("action") 149 if action == "preview": 150 return await self._preview(data) 151 if action == "apply": 152 return await self._apply(data) 153 raise JsonServiceError( 154 400, f"unknown beanTransfer action: {action!r}", 155 ) 156 157 # ---- helpers ----------------------------------------------------- 158 159 def _registry_or_none(self) -> ProfileRegistry | None: 160 registry = getattr(self.ctx, "profile_registry", None) 161 return registry if isinstance(registry, ProfileRegistry) else None 162 163 def _require_registry(self) -> ProfileRegistry: 164 registry = self._registry_or_none() 165 if registry is None: 166 raise JsonServiceError( 167 503, 168 "no profile registry loaded (no profiles.xml at settings root)", 169 ) 170 return registry 171 172 def _resolve_pair( 173 self, data: dict[str, Any], 174 ) -> tuple[Profile, Profile, ProfileRef, ProfileRef, list[str]]: 175 registry = self._require_registry() 176 src_id = data.get("from") 177 dst_id = data.get("to") 178 if not isinstance(src_id, str) or not isinstance(dst_id, str): 179 raise JsonServiceError( 180 400, "beanTransfer requires 'from' and 'to' profile ids", 181 ) 182 if src_id == dst_id: 183 raise JsonServiceError( 184 400, "beanTransfer 'from' and 'to' must differ", 185 ) 186 src_ref = registry.by_id(src_id) 187 if src_ref is None: 188 raise JsonServiceError( 189 404, f"no source profile with id {src_id!r}", 190 ) 191 dst_ref = registry.by_id(dst_id) 192 if dst_ref is None: 193 raise JsonServiceError( 194 404, f"no target profile with id {dst_id!r}", 195 ) 196 raw_types = data.get("types") 197 if raw_types is None: 198 types = list(SUPPORTED_TYPES) 199 elif isinstance(raw_types, list): 200 types = [t for t in raw_types if isinstance(t, str)] 201 else: 202 raise JsonServiceError( 203 400, "'types' must be an array of bean-kind strings", 204 ) 205 unknown = [t for t in types if t not in SUPPORTED_TYPES] 206 if unknown: 207 raise JsonServiceError( 208 400, 209 f"unsupported bean types: {unknown!r}. " 210 f"Supported: {list(SUPPORTED_TYPES)}", 211 ) 212 if not types: 213 raise JsonServiceError( 214 400, "'types' must not be empty", 215 ) 216 return ( 217 _profile_from_ref(registry, src_ref), 218 _profile_from_ref(registry, dst_ref), 219 src_ref, 220 dst_ref, 221 types, 222 ) 223 224 async def _preview(self, data: dict[str, Any]) -> dict[str, Any]: 225 src, dst, _sref, _dref, types = self._resolve_pair(data) 226 src_store = _open_store(src) 227 dst_store = _open_store(dst) 228 counts: dict[str, int] = {} 229 will_overwrite: dict[str, int] = {} 230 for kind in types: 231 src_names = _existing_names(src_store, kind) 232 counts[kind] = len(src_names) 233 will_overwrite[kind] = len( 234 src_names & _existing_names(dst_store, kind) 235 ) 236 return {"counts": counts, "willOverwrite": will_overwrite} 237 238 async def _apply(self, data: dict[str, Any]) -> dict[str, Any]: 239 registry = self._require_registry() 240 src, dst, _sref, dst_ref, types = self._resolve_pair(data) 241 src_store = _open_store(src) 242 active = registry.active() 243 target_is_active = active is not None and dst_ref.id == active.id 244 245 # If the target is the active profile, write through the 246 # ctx.config_store the live server holds — otherwise a fresh 247 # instance we throw away, since no in-memory managers are 248 # attached to the target's beans. 249 if target_is_active and isinstance(self.ctx.config_store, ConfigStore): 250 dst_store = self.ctx.config_store 251 else: 252 dst_store = _open_store(dst) 253 254 copied: dict[str, int] = {} 255 for kind in types: 256 copied[kind] = _copy_kind(src_store, dst_store, kind) 257 258 # Re-populate live managers when we've just written into the 259 # active profile. Otherwise the caller would have to switch 260 # profile (or restart) to see the new beans in the running 261 # connection. 262 if target_is_active: 263 from jmri_server.app import _restore_beans_from_config # noqa: PLC0415 264 await _restore_beans_from_config( 265 self.ctx.connection, dst_store, 266 ) 267 268 return {"copied": copied, "targetIsActive": target_is_active}
SUPPORTED_TYPES =
('turnout', 'sensor', 'light')
107class BeanTransferService(JsonService): 108 """Cross-profile bean transfer. Read-only ``list`` + 109 ``post`` with ``action=preview|apply``.""" 110 111 topic = "beanTransfer" 112 # ``list`` is safe for any role. ``post`` writes across 113 # profiles — admin-only, since a compromised operator token 114 # shouldn't be able to leak or overwrite the production 115 # profile's beans from a staging session. 116 REQUIRED_ROLE = { 117 "list": Role.VIEWER, 118 "post": Role.ADMIN, 119 } 120 121 async def on_list( 122 self, data: dict[str, Any] | None, 123 ) -> dict[str, Any]: 124 registry = self._registry_or_none() 125 profiles_out: list[dict[str, Any]] = [] 126 if registry is not None: 127 active = registry.active() 128 active_id = active.id if active else None 129 for ref in registry.profiles: 130 profile = _profile_from_ref(registry, ref) 131 store = _open_store(profile) 132 profiles_out.append({ 133 "id": ref.id, 134 "name": ref.name, 135 "path": str(profile.profile_dir), 136 "isActive": ref.id == active_id, 137 "counts": { 138 kind: _count(store, kind) 139 for kind in SUPPORTED_TYPES 140 }, 141 }) 142 return { 143 "profiles": profiles_out, 144 "types": list(SUPPORTED_TYPES), 145 } 146 147 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 148 data = data or {} 149 action = data.get("action") 150 if action == "preview": 151 return await self._preview(data) 152 if action == "apply": 153 return await self._apply(data) 154 raise JsonServiceError( 155 400, f"unknown beanTransfer action: {action!r}", 156 ) 157 158 # ---- helpers ----------------------------------------------------- 159 160 def _registry_or_none(self) -> ProfileRegistry | None: 161 registry = getattr(self.ctx, "profile_registry", None) 162 return registry if isinstance(registry, ProfileRegistry) else None 163 164 def _require_registry(self) -> ProfileRegistry: 165 registry = self._registry_or_none() 166 if registry is None: 167 raise JsonServiceError( 168 503, 169 "no profile registry loaded (no profiles.xml at settings root)", 170 ) 171 return registry 172 173 def _resolve_pair( 174 self, data: dict[str, Any], 175 ) -> tuple[Profile, Profile, ProfileRef, ProfileRef, list[str]]: 176 registry = self._require_registry() 177 src_id = data.get("from") 178 dst_id = data.get("to") 179 if not isinstance(src_id, str) or not isinstance(dst_id, str): 180 raise JsonServiceError( 181 400, "beanTransfer requires 'from' and 'to' profile ids", 182 ) 183 if src_id == dst_id: 184 raise JsonServiceError( 185 400, "beanTransfer 'from' and 'to' must differ", 186 ) 187 src_ref = registry.by_id(src_id) 188 if src_ref is None: 189 raise JsonServiceError( 190 404, f"no source profile with id {src_id!r}", 191 ) 192 dst_ref = registry.by_id(dst_id) 193 if dst_ref is None: 194 raise JsonServiceError( 195 404, f"no target profile with id {dst_id!r}", 196 ) 197 raw_types = data.get("types") 198 if raw_types is None: 199 types = list(SUPPORTED_TYPES) 200 elif isinstance(raw_types, list): 201 types = [t for t in raw_types if isinstance(t, str)] 202 else: 203 raise JsonServiceError( 204 400, "'types' must be an array of bean-kind strings", 205 ) 206 unknown = [t for t in types if t not in SUPPORTED_TYPES] 207 if unknown: 208 raise JsonServiceError( 209 400, 210 f"unsupported bean types: {unknown!r}. " 211 f"Supported: {list(SUPPORTED_TYPES)}", 212 ) 213 if not types: 214 raise JsonServiceError( 215 400, "'types' must not be empty", 216 ) 217 return ( 218 _profile_from_ref(registry, src_ref), 219 _profile_from_ref(registry, dst_ref), 220 src_ref, 221 dst_ref, 222 types, 223 ) 224 225 async def _preview(self, data: dict[str, Any]) -> dict[str, Any]: 226 src, dst, _sref, _dref, types = self._resolve_pair(data) 227 src_store = _open_store(src) 228 dst_store = _open_store(dst) 229 counts: dict[str, int] = {} 230 will_overwrite: dict[str, int] = {} 231 for kind in types: 232 src_names = _existing_names(src_store, kind) 233 counts[kind] = len(src_names) 234 will_overwrite[kind] = len( 235 src_names & _existing_names(dst_store, kind) 236 ) 237 return {"counts": counts, "willOverwrite": will_overwrite} 238 239 async def _apply(self, data: dict[str, Any]) -> dict[str, Any]: 240 registry = self._require_registry() 241 src, dst, _sref, dst_ref, types = self._resolve_pair(data) 242 src_store = _open_store(src) 243 active = registry.active() 244 target_is_active = active is not None and dst_ref.id == active.id 245 246 # If the target is the active profile, write through the 247 # ctx.config_store the live server holds — otherwise a fresh 248 # instance we throw away, since no in-memory managers are 249 # attached to the target's beans. 250 if target_is_active and isinstance(self.ctx.config_store, ConfigStore): 251 dst_store = self.ctx.config_store 252 else: 253 dst_store = _open_store(dst) 254 255 copied: dict[str, int] = {} 256 for kind in types: 257 copied[kind] = _copy_kind(src_store, dst_store, kind) 258 259 # Re-populate live managers when we've just written into the 260 # active profile. Otherwise the caller would have to switch 261 # profile (or restart) to see the new beans in the running 262 # connection. 263 if target_is_active: 264 from jmri_server.app import _restore_beans_from_config # noqa: PLC0415 265 await _restore_beans_from_config( 266 self.ctx.connection, dst_store, 267 ) 268 269 return {"copied": copied, "targetIsActive": target_is_active}
Cross-profile bean transfer. Read-only list +
post with action=preview|apply.
async def
on_list(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
121 async def on_list( 122 self, data: dict[str, Any] | None, 123 ) -> dict[str, Any]: 124 registry = self._registry_or_none() 125 profiles_out: list[dict[str, Any]] = [] 126 if registry is not None: 127 active = registry.active() 128 active_id = active.id if active else None 129 for ref in registry.profiles: 130 profile = _profile_from_ref(registry, ref) 131 store = _open_store(profile) 132 profiles_out.append({ 133 "id": ref.id, 134 "name": ref.name, 135 "path": str(profile.profile_dir), 136 "isActive": ref.id == active_id, 137 "counts": { 138 kind: _count(store, kind) 139 for kind in SUPPORTED_TYPES 140 }, 141 }) 142 return { 143 "profiles": profiles_out, 144 "types": list(SUPPORTED_TYPES), 145 }
async def
on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
147 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 148 data = data or {} 149 action = data.get("action") 150 if action == "preview": 151 return await self._preview(data) 152 if action == "apply": 153 return await self._apply(data) 154 raise JsonServiceError( 155 400, f"unknown beanTransfer action: {action!r}", 156 )