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')
class BeanTransferService(jmri_server.service.JsonService):
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.

topic = 'beanTransfer'
REQUIRED_ROLE = {'list': <Role.VIEWER: 'viewer'>, 'post': <Role.ADMIN: 'admin'>}
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        )