remoteRF-server-testing 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. remoteRF_server/__init__.py +0 -0
  2. remoteRF_server/common/__init__.py +0 -0
  3. remoteRF_server/common/grpc/__init__.py +1 -0
  4. remoteRF_server/common/grpc/grpc_host_pb2.py +63 -0
  5. remoteRF_server/common/grpc/grpc_host_pb2_grpc.py +97 -0
  6. remoteRF_server/common/grpc/grpc_pb2.py +59 -0
  7. remoteRF_server/common/grpc/grpc_pb2_grpc.py +97 -0
  8. remoteRF_server/common/idl/__init__.py +1 -0
  9. remoteRF_server/common/idl/device_schema.py +39 -0
  10. remoteRF_server/common/idl/pluto_schema.py +174 -0
  11. remoteRF_server/common/idl/schema.py +358 -0
  12. remoteRF_server/common/utils/__init__.py +6 -0
  13. remoteRF_server/common/utils/ansi_codes.py +120 -0
  14. remoteRF_server/common/utils/api_token.py +21 -0
  15. remoteRF_server/common/utils/db_connection.py +35 -0
  16. remoteRF_server/common/utils/db_location.py +24 -0
  17. remoteRF_server/common/utils/list_string.py +5 -0
  18. remoteRF_server/common/utils/process_arg.py +80 -0
  19. remoteRF_server/drivers/__init__.py +0 -0
  20. remoteRF_server/drivers/adalm_pluto/__init__.py +0 -0
  21. remoteRF_server/drivers/adalm_pluto/pluto_remote_server.py +105 -0
  22. remoteRF_server/host/__init__.py +0 -0
  23. remoteRF_server/host/host_auth_token.py +292 -0
  24. remoteRF_server/host/host_directory_store.py +142 -0
  25. remoteRF_server/host/host_tunnel_server.py +1388 -0
  26. remoteRF_server/server/__init__.py +0 -0
  27. remoteRF_server/server/acc_perms.py +317 -0
  28. remoteRF_server/server/cert_provider.py +184 -0
  29. remoteRF_server/server/device_manager.py +688 -0
  30. remoteRF_server/server/grpc_server.py +1023 -0
  31. remoteRF_server/server/reservation.py +811 -0
  32. remoteRF_server/server/rpc_manager.py +104 -0
  33. remoteRF_server/server/user_group_cli.py +723 -0
  34. remoteRF_server/server/user_group_handler.py +1120 -0
  35. remoteRF_server/serverrf_cli.py +1377 -0
  36. remoteRF_server/tools/__init__.py +191 -0
  37. remoteRF_server/tools/gen_certs.py +274 -0
  38. remoteRF_server/tools/gist_status.py +139 -0
  39. remoteRF_server/tools/gist_status_testing.py +67 -0
  40. remoterf_server_testing-0.0.0.dist-info/METADATA +612 -0
  41. remoterf_server_testing-0.0.0.dist-info/RECORD +44 -0
  42. remoterf_server_testing-0.0.0.dist-info/WHEEL +5 -0
  43. remoterf_server_testing-0.0.0.dist-info/entry_points.txt +2 -0
  44. remoterf_server_testing-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,723 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional
3
+ import csv
4
+ import json
5
+ import os
6
+ import re
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from .user_group_handler import user_group_handler
11
+
12
+ from .user_group_handler import user_group_handler
13
+
14
+ CANCEL = {"c", "e", "exit", "q", "quit"}
15
+
16
+ def make_user_group_local(session) -> None:
17
+ def _prompt_str(msg: str) -> Optional[str]:
18
+ s = (session.prompt(msg) or "").strip()
19
+ if s.lower() in CANCEL:
20
+ return None
21
+ return s
22
+
23
+ def _prompt_yes_no(msg: str, default_no: bool = True) -> bool:
24
+ s = (session.prompt(msg) or "").strip().lower()
25
+ if not s:
26
+ return not default_no
27
+ return s in {"y", "yes"}
28
+
29
+ def _prompt_int(msg: str, *, default: Optional[int] = None, min_v: Optional[int] = 0) -> Optional[int]:
30
+ while True:
31
+ s = (session.prompt(msg) or "").strip()
32
+ if s.lower() in CANCEL:
33
+ return None
34
+ if not s:
35
+ if default is None:
36
+ print("Please enter a value (or 'c' to cancel).")
37
+ continue
38
+ val = default
39
+ else:
40
+ try:
41
+ val = int(s)
42
+ except ValueError:
43
+ print("Please enter an integer (or 'c' to cancel).")
44
+ continue
45
+
46
+ if min_v is not None and val < min_v:
47
+ print(f"Value must be >= {min_v}.")
48
+ continue
49
+ return val
50
+
51
+ def _prompt_int_list(msg: str) -> Optional[List[int]]:
52
+ while True:
53
+ s = (session.prompt(msg) or "").strip()
54
+ if s.lower() in CANCEL:
55
+ return None
56
+
57
+ if not s:
58
+ print("Please enter at least one device id (e.g. '0,1,2') or 'c' to cancel.")
59
+ continue
60
+
61
+ # Allow both commas and whitespace
62
+ parts = [p for p in s.replace(",", " ").split() if p]
63
+ try:
64
+ vals = [int(p) for p in parts]
65
+ except ValueError:
66
+ print("Invalid list. Use e.g. '0,1,2' or '0 1 2'.")
67
+ continue
68
+
69
+ if any(v < 0 for v in vals):
70
+ print("Device IDs must be >= 0.")
71
+ continue
72
+
73
+ # Dedup while preserving order
74
+ seen = set()
75
+ out = []
76
+ for v in vals:
77
+ if v not in seen:
78
+ seen.add(v)
79
+ out.append(v)
80
+
81
+ if len(out) == 0:
82
+ print("Please enter at least one device id (or 'c' to cancel).")
83
+ continue
84
+
85
+ return out
86
+
87
+ # ---- Group name ----
88
+ group_name = _prompt_str("Enter group name (c to cancel): ")
89
+ if not group_name:
90
+ return
91
+
92
+ if not _prompt_yes_no(f"Confirm group name '{group_name}'? (y/n): ", default_no=True):
93
+ return
94
+
95
+ # ---- Parameters ----
96
+ devices_whitelist = _prompt_int_list("Devices whitelist (e.g. 0,1,2) (c to cancel): ")
97
+ if devices_whitelist is None:
98
+ return
99
+
100
+ max_reservation_time_sec = _prompt_int("Max reservation time (sec) [1800] (c to cancel): ", default=1800, min_v=1)
101
+ if max_reservation_time_sec is None:
102
+ return
103
+
104
+ max_reservations = _prompt_int("Max concurrent reservations [3] (c to cancel): ", default=3, min_v=1)
105
+ if max_reservations is None:
106
+ return
107
+
108
+ lifetime_sec = _prompt_int("Group lifetime (sec) [0=forever] (c to cancel): ", default=0, min_v=0)
109
+ if lifetime_sec is None:
110
+ return
111
+
112
+ # ---- Final confirmation ----
113
+ wl_str = ",".join(map(str, devices_whitelist))
114
+ print("\nAbout to create user group with:")
115
+ print(f" group_name : {group_name}")
116
+ print(f" devices_whitelist : {wl_str}")
117
+ print(f" max_reservation_time_sec : {max_reservation_time_sec}")
118
+ print(f" max_reservations : {max_reservations}")
119
+ print(f" lifetime_sec : {lifetime_sec}")
120
+
121
+ if not _prompt_yes_no("Proceed? (y/n): ", default_no=True):
122
+ return
123
+
124
+ # ---- Create ----
125
+ ok = user_group_handler.create_user_group(
126
+ group_name=group_name,
127
+ devices_whitelist=devices_whitelist,
128
+ max_reservation_time_sec=max_reservation_time_sec,
129
+ max_reservations=max_reservations,
130
+ lifetime_sec=lifetime_sec,
131
+ )
132
+
133
+ if ok:
134
+ print("User group created.")
135
+ else:
136
+ print("Failed to create user group.")
137
+
138
+ def remove_user_group_local(session) -> None:
139
+
140
+ def _prompt_int(msg: str) -> Optional[int]:
141
+ while True:
142
+ s = (session.prompt(msg) or "").strip()
143
+ if s.lower() in CANCEL:
144
+ return None
145
+ try:
146
+ v = int(s)
147
+ except ValueError:
148
+ print("Please enter an integer group ID (or 'c' to cancel).")
149
+ continue
150
+ if v < 0:
151
+ print("Group ID must be >= 0.")
152
+ continue
153
+ return v
154
+
155
+ def _prompt_yes_no(msg: str) -> bool:
156
+ s = (session.prompt(msg) or "").strip().lower()
157
+ return s in {"y", "yes"}
158
+
159
+ group_id = _prompt_int("Enter group ID to delete (c to cancel): ")
160
+ if group_id is None:
161
+ return
162
+
163
+ if not _prompt_yes_no(f"Confirm delete group_id={group_id}? (y/n): "):
164
+ return
165
+
166
+ ok = user_group_handler.delete_user_group(group_id=group_id)
167
+
168
+ if ok:
169
+ print(f"Deleted user group {group_id}.")
170
+ else:
171
+ print(f"Failed to delete user group {group_id}.")
172
+
173
+ def list_user_groups_local(session) -> None:
174
+
175
+ def _prompt_yes_no(msg: str, default_no: bool = True) -> bool:
176
+ s = (session.prompt(msg) or "").strip().lower()
177
+ if not s:
178
+ return not default_no
179
+ return s in {"y", "yes"}
180
+
181
+ # include_users = _prompt_yes_no("Include users in output? (y/n) [n]: ", default_no=True)
182
+ include_users = False
183
+
184
+ groups: List[Dict[str, Any]] = user_group_handler.get_all_user_groups(include_users=include_users) or []
185
+
186
+ if not groups:
187
+ print("No user groups found.")
188
+ return
189
+
190
+ # Print a compact table-like view.
191
+ def _fmt(v: Any) -> str:
192
+ if v is None:
193
+ return ""
194
+ if isinstance(v, (list, tuple)):
195
+ return ",".join(str(x) for x in v)
196
+ if isinstance(v, dict):
197
+ return str(v)
198
+ return str(v)
199
+
200
+ # Prefer common keys if present
201
+ preferred = [
202
+ "group_id",
203
+ "id",
204
+ "group_name",
205
+ "name",
206
+ "devices_whitelist",
207
+ "max_reservation_time_sec",
208
+ "max_reservations",
209
+ "lifetime_sec",
210
+ "created_at",
211
+ "users",
212
+ ]
213
+
214
+ # Choose columns: preferred keys that exist in at least one record, then any remaining keys.
215
+ present = set().union(*(g.keys() for g in groups))
216
+ columns = [k for k in preferred if k in present]
217
+ columns += [k for k in sorted(present) if k not in columns]
218
+
219
+ # If not including users, drop users column if present to keep output clean.
220
+ if not include_users and "users" in columns:
221
+ columns.remove("users")
222
+
223
+ # Compute widths (cap long values)
224
+ def _cap(s: str, n: int = 48) -> str:
225
+ return s if len(s) <= n else s[: n - 3] + "..."
226
+
227
+ widths = {c: len(c) for c in columns}
228
+ rows: List[List[str]] = []
229
+ for g in groups:
230
+ row = []
231
+ for c in columns:
232
+ val = _fmt(g.get(c, ""))
233
+ val = _cap(val)
234
+ widths[c] = max(widths[c], len(val))
235
+ row.append(val)
236
+ rows.append(row)
237
+
238
+ # Header
239
+ header = " ".join(c.ljust(widths[c]) for c in columns)
240
+ sep = " ".join("-" * widths[c] for c in columns)
241
+ print(header)
242
+ print(sep)
243
+
244
+ # Rows
245
+ for row in rows:
246
+ print(" ".join(row[i].ljust(widths[columns[i]]) for i in range(len(columns))))
247
+
248
+ # If include_users, print expanded user lists (if present) for readability
249
+ # if include_users:
250
+ # # Find a reasonable key name for users if exists
251
+ # user_key = "users" if any("users" in g for g in groups) else None
252
+ # if user_key:
253
+ # print("\nUsers per group:")
254
+ # for g in groups:
255
+ # gid = g.get("group_id", g.get("id", "?"))
256
+ # name = g.get("group_name", g.get("name", ""))
257
+ # users = g.get(user_key, [])
258
+ # if not users:
259
+ # continue
260
+ # print(f" {gid} {name}:")
261
+ # if isinstance(users, list):
262
+ # for u in users:
263
+ # print(f" - {u}")
264
+ # else:
265
+ # print(f" - {users}")
266
+
267
+ def edit_user_group_local(session) -> None:
268
+
269
+ def _prompt_raw(msg: str) -> Optional[str]:
270
+ s = (session.prompt(msg) or "").strip()
271
+ if s.lower() in CANCEL:
272
+ return None
273
+ return s
274
+
275
+ def _prompt_int_optional(msg: str, *, min_v: Optional[int] = 0) -> Optional[Optional[int]]:
276
+ s = _prompt_raw(msg)
277
+ if s is None:
278
+ return None # cancelled
279
+ if s == "":
280
+ return "" # sentinel for unchanged
281
+ try:
282
+ v = int(s)
283
+ except ValueError:
284
+ print("Please enter an integer, or press Enter to keep unchanged, or 'c' to cancel.")
285
+ return _prompt_int_optional(msg, min_v=min_v)
286
+ if min_v is not None and v < min_v:
287
+ print(f"Value must be >= {min_v}.")
288
+ return _prompt_int_optional(msg, min_v=min_v)
289
+ return v
290
+
291
+ def _prompt_devices_optional(msg: str) -> Optional[object]:
292
+ s = _prompt_raw(msg)
293
+ if s is None:
294
+ return None # cancelled
295
+ if s == "":
296
+ return "" # unchanged
297
+ if s.lower() in {"all", "*"}:
298
+ return []
299
+ parts = [p for p in s.replace(",", " ").split() if p]
300
+ try:
301
+ vals = [int(p) for p in parts]
302
+ except ValueError:
303
+ print("Invalid list. Use e.g. '0,1,2' or '0 1 2' or 'all'.")
304
+ return _prompt_devices_optional(msg)
305
+ if any(v < 0 for v in vals):
306
+ print("Device IDs must be >= 0.")
307
+ return _prompt_devices_optional(msg)
308
+ # dedup preserve order
309
+ seen = set()
310
+ out: List[int] = []
311
+ for v in vals:
312
+ if v not in seen:
313
+ seen.add(v)
314
+ out.append(v)
315
+ return out
316
+
317
+ def _prompt_yes_no(msg: str) -> bool:
318
+ s = (session.prompt(msg) or "").strip().lower()
319
+ return s in {"y", "yes"}
320
+
321
+ # ---- Required: group_id ----
322
+ gid_raw = _prompt_raw("Enter group ID to edit (c to cancel): ")
323
+ if gid_raw is None:
324
+ return
325
+ try:
326
+ group_id = int(gid_raw)
327
+ except ValueError:
328
+ print("Group ID must be an integer.")
329
+ return
330
+ if group_id < 0:
331
+ print("Group ID must be >= 0.")
332
+ return
333
+
334
+ # ---- Optional fields ----
335
+ group_name = _prompt_raw("New group name (Enter to keep unchanged, c to cancel): ")
336
+ if group_name is None:
337
+ return
338
+ if group_name == "":
339
+ group_name = None
340
+
341
+ devices_whitelist = _prompt_devices_optional(
342
+ "New devices whitelist (e.g. 0,1,2 | 'all' => [] | Enter unchanged, c cancel): "
343
+ )
344
+ if devices_whitelist is None:
345
+ return
346
+ if devices_whitelist == "":
347
+ devices_whitelist = None # unchanged
348
+
349
+ max_res_time = _prompt_int_optional("New max reservation time sec (Enter unchanged, c cancel): ", min_v=1)
350
+ if max_res_time is None:
351
+ return
352
+ if max_res_time == "":
353
+ max_res_time = None
354
+
355
+ max_reservations = _prompt_int_optional("New max reservations (Enter unchanged, c cancel): ", min_v=1)
356
+ if max_reservations is None:
357
+ return
358
+ if max_reservations == "":
359
+ max_reservations = None
360
+
361
+ lifetime_sec = _prompt_int_optional("New lifetime sec (0=forever) (Enter unchanged, c cancel): ", min_v=0)
362
+ if lifetime_sec is None:
363
+ return
364
+ if lifetime_sec == "":
365
+ lifetime_sec = None
366
+
367
+ # Ensure at least one change
368
+ if (
369
+ group_name is None
370
+ and devices_whitelist is None
371
+ and max_res_time is None
372
+ and max_reservations is None
373
+ and lifetime_sec is None
374
+ ):
375
+ print("No changes specified. Nothing to do.")
376
+ return
377
+
378
+ # ---- Summary + confirm ----
379
+ def _fmt_devices(v: Optional[List[int]]) -> str:
380
+ if v is None:
381
+ return "(unchanged)"
382
+ return "ALL([])" if len(v) == 0 else ",".join(map(str, v))
383
+
384
+ print("\nAbout to edit user group:")
385
+ print(f" group_id : {group_id}")
386
+ print(f" group_name : {group_name if group_name is not None else '(unchanged)'}")
387
+ print(f" devices_whitelist : {_fmt_devices(devices_whitelist)}")
388
+ print(f" max_reservation_time_sec: {max_res_time if max_res_time is not None else '(unchanged)'}")
389
+ print(f" max_reservations : {max_reservations if max_reservations is not None else '(unchanged)'}")
390
+ print(f" lifetime_sec : {lifetime_sec if lifetime_sec is not None else '(unchanged)'}")
391
+
392
+ if not _prompt_yes_no("Proceed? (y/n): "):
393
+ return
394
+
395
+ ok = user_group_handler.edit_user_group(
396
+ group_id=group_id,
397
+ group_name=group_name,
398
+ devices_whitelist=devices_whitelist, # type: ignore[arg-type]
399
+ max_reservation_time_sec=max_res_time, # type: ignore[arg-type]
400
+ max_reservations=max_reservations, # type: ignore[arg-type]
401
+ lifetime_sec=lifetime_sec, # type: ignore[arg-type]
402
+ )
403
+
404
+ if ok:
405
+ print("User group updated.")
406
+ else:
407
+ print("Failed to update user group.")
408
+
409
+ def make_enrollment_codes_local(session) -> None:
410
+
411
+ def _prompt_raw(msg: str) -> Optional[str]:
412
+ s = (session.prompt(msg) or "").strip()
413
+ if s.lower() in CANCEL:
414
+ return None
415
+ return s
416
+
417
+ def _prompt_yes_no(msg: str, default_no: bool = True) -> bool:
418
+ s = (session.prompt(msg) or "").strip().lower()
419
+ if not s:
420
+ return not default_no
421
+ return s in {"y", "yes"}
422
+
423
+ def _prompt_int(msg: str, *, default: Optional[int] = None, min_v: Optional[int] = 0) -> Optional[int]:
424
+ while True:
425
+ s = _prompt_raw(msg)
426
+ if s is None:
427
+ return None
428
+ if s == "":
429
+ if default is None:
430
+ print("Please enter a value (or 'c' to cancel).")
431
+ continue
432
+ v = default
433
+ else:
434
+ try:
435
+ v = int(s)
436
+ except ValueError:
437
+ print("Please enter an integer (or 'c' to cancel).")
438
+ continue
439
+ if min_v is not None and v < min_v:
440
+ print(f"Value must be >= {min_v}.")
441
+ continue
442
+ return v
443
+
444
+ count = _prompt_int("How many enrollment codes? (c to cancel): ", min_v=1)
445
+ if count is None:
446
+ return
447
+
448
+ group_id = _prompt_int("Group ID to attach codes to? (c to cancel): ", min_v=0)
449
+ if group_id is None:
450
+ return
451
+
452
+ char_count = _prompt_int("Characters per code name? [10] (c to cancel): ", default=10, min_v=4)
453
+ if char_count is None:
454
+ return
455
+
456
+ try:
457
+ code_names: List[str] = user_group_handler.generate_random_enrollment_code_names(
458
+ count=count,
459
+ char_count=char_count,
460
+ ) or []
461
+ except Exception as e:
462
+ print(f"Failed to generate code names: {e}")
463
+ return
464
+
465
+ if not code_names:
466
+ print("No codes generated.")
467
+ return
468
+
469
+ print("\nGenerated code names:")
470
+ for i, name in enumerate(code_names, 1):
471
+ print(f" {i:>2}) {name}")
472
+
473
+ if not _prompt_yes_no("\nUse these codes? (y/n): ", default_no=True):
474
+ return
475
+
476
+ uses = _prompt_int("Uses per code? [1] (c to cancel): ", default=1, min_v=1)
477
+ if uses is None:
478
+ return
479
+
480
+ duration_sec = _prompt_int(
481
+ "Duration in seconds? [3600] (0=never expires) (c to cancel): ",
482
+ default=3600,
483
+ min_v=0,
484
+ )
485
+ if duration_sec is None:
486
+ return
487
+
488
+ print("\nAbout to create enrollment codes with:")
489
+ print(f" group_id : {group_id}")
490
+ print(f" count : {len(code_names)}")
491
+ print(f" uses : {uses}")
492
+ print(f" duration_sec : {duration_sec}")
493
+
494
+ if not _prompt_yes_no("Proceed? (y/n): ", default_no=True):
495
+ return
496
+
497
+ result = user_group_handler.create_enrollment_codes(
498
+ code_names=code_names,
499
+ group_id=group_id,
500
+ uses=uses,
501
+ duration_sec=duration_sec,
502
+ )
503
+ print("\nCreated enrollment codes:")
504
+ print(result)
505
+
506
+ def remove_enrollment_code_local(session) -> None:
507
+
508
+ def _prompt(msg: str):
509
+ s = (session.prompt(msg) or "").strip()
510
+ return None if s.lower() in CANCEL else s
511
+
512
+ def _yes(msg: str) -> bool:
513
+ return (session.prompt(msg) or "").strip().lower() in {"y", "yes"}
514
+
515
+ mode = _prompt("Delete by (n)ame or (g)roup_id? [n] (c to cancel): ")
516
+ if mode is None:
517
+ return
518
+ mode = (mode or "n").lower()
519
+
520
+ if mode.startswith("n"):
521
+ code_name = _prompt("Enter code_name (c to cancel): ")
522
+ if not code_name:
523
+ return
524
+ if not _yes(f"Delete enrollment code '{code_name}'? (y/n): "):
525
+ return
526
+ ok = user_group_handler.delete_enrollment_code_name(code_name=code_name)
527
+ print("Deleted." if ok else "Not found / failed.")
528
+ return
529
+
530
+ if mode.startswith("g"):
531
+ gid_s = _prompt("Enter group_id to delete ALL codes for that group (c to cancel): ")
532
+ if not gid_s:
533
+ return
534
+ try:
535
+ group_id = int(gid_s)
536
+ except ValueError:
537
+ print("group_id must be an integer.")
538
+ return
539
+ if group_id < 0:
540
+ print("group_id must be >= 0.")
541
+ return
542
+ if not _yes(f"Delete ALL enrollment codes for group_id={group_id}? (y/n): "):
543
+ return
544
+ ok = user_group_handler.delete_enrollment_code_id(group_id=group_id)
545
+ print("Deleted." if ok else "None deleted / failed.")
546
+ return
547
+
548
+ print("Unknown option. Use 'n' or 'g'.")
549
+
550
+ def list_enrollment_codes_local(session) -> None:
551
+ rows: List[Dict[str, Any]] = user_group_handler.get_all_enrollment_codes() or []
552
+
553
+ if not rows:
554
+ print("No enrollment codes found.")
555
+ return
556
+
557
+ preferred = [
558
+ "code_name",
559
+ "group_id",
560
+ "uses",
561
+ "uses_left",
562
+ "remaining_uses",
563
+ "duration_sec",
564
+ "expires_at",
565
+ "created_at",
566
+ ]
567
+ present = set().union(*(r.keys() for r in rows))
568
+ cols = [c for c in preferred if c in present] + [c for c in sorted(present) if c not in preferred]
569
+
570
+ def _fmt(v: Any) -> str:
571
+ if v is None:
572
+ return ""
573
+ if isinstance(v, (list, tuple)):
574
+ return ",".join(str(x) for x in v)
575
+ return str(v)
576
+
577
+ def _cap(s: str, n: int = 48) -> str:
578
+ return s if len(s) <= n else s[: n - 3] + "..."
579
+
580
+ # Compute widths
581
+ widths = {c: len(c) for c in cols}
582
+ table: List[List[str]] = []
583
+ for r in rows:
584
+ row = []
585
+ for c in cols:
586
+ cell = _cap(_fmt(r.get(c, "")))
587
+ widths[c] = max(widths[c], len(cell))
588
+ row.append(cell)
589
+ table.append(row)
590
+
591
+ # Print
592
+ header = " ".join(c.ljust(widths[c]) for c in cols)
593
+ sep = " ".join("-" * widths[c] for c in cols)
594
+ print(header)
595
+ print(sep)
596
+ for row in table:
597
+ print(" ".join(row[i].ljust(widths[cols[i]]) for i in range(len(cols))))
598
+
599
+ def _resolve_download_dir() -> Path:
600
+ """
601
+ Best-effort Downloads folder resolution:
602
+ 1) XDG_DOWNLOAD_DIR env (if set)
603
+ 2) ~/.config/user-dirs.dirs XDG_DOWNLOAD_DIR (Linux desktops)
604
+ 3) ~/Downloads
605
+ 4) fallback: cwd
606
+ """
607
+ # 1) Env override (rare, but cheap to support)
608
+ xdg_env = os.getenv("XDG_DOWNLOAD_DIR")
609
+ if xdg_env:
610
+ p = Path(os.path.expandvars(xdg_env)).expanduser()
611
+ return p
612
+
613
+ # 2) user-dirs.dirs (common on Linux w/ xdg-user-dirs)
614
+ try:
615
+ user_dirs = Path.home() / ".config" / "user-dirs.dirs"
616
+ if user_dirs.exists():
617
+ txt = user_dirs.read_text(encoding="utf-8", errors="ignore")
618
+ m = re.search(r'^XDG_DOWNLOAD_DIR\s*=\s*"(.*)"\s*$', txt, flags=re.MULTILINE)
619
+ if m:
620
+ raw = m.group(1)
621
+ raw = raw.replace("$HOME", str(Path.home()))
622
+ p = Path(os.path.expandvars(raw)).expanduser()
623
+ return p
624
+ except Exception:
625
+ pass
626
+
627
+ # 3) Default
628
+ p = Path.home() / "Downloads"
629
+ return p
630
+
631
+
632
+ def _coerce_csv_cell(v: Any) -> str:
633
+ if v is None:
634
+ return ""
635
+ if isinstance(v, (dict, list, tuple)):
636
+ # stable, readable cell content
637
+ return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
638
+ return str(v)
639
+
640
+
641
+ def _write_csv(
642
+ rows: List[Dict[str, Any]],
643
+ out_path: Path,
644
+ *,
645
+ preferred_columns: Optional[List[str]] = None,
646
+ ) -> None:
647
+ if not rows:
648
+ raise ValueError("No rows to write.")
649
+
650
+ preferred_columns = preferred_columns or []
651
+
652
+ present = set().union(*(r.keys() for r in rows))
653
+ columns = [c for c in preferred_columns if c in present]
654
+ columns += [c for c in sorted(present) if c not in columns]
655
+
656
+ out_path.parent.mkdir(parents=True, exist_ok=True)
657
+
658
+ with out_path.open("w", newline="", encoding="utf-8") as f:
659
+ w = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore")
660
+ w.writeheader()
661
+ for r in rows:
662
+ w.writerow({c: _coerce_csv_cell(r.get(c)) for c in columns})
663
+
664
+
665
+ def export_user_groups_csv_local() -> Optional[Path]:
666
+ # include_users=True so CSV truly contains "everything"
667
+ groups: List[Dict[str, Any]] = user_group_handler.get_all_user_groups(include_users=True) or []
668
+ if not groups:
669
+ print("No user groups found.")
670
+ return None
671
+
672
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
673
+ out_dir = _resolve_download_dir()
674
+ out_path = out_dir / f"remoterf_user_groups_{ts}.csv"
675
+
676
+ preferred = [
677
+ "group_id", "id",
678
+ "group_name", "name",
679
+ "devices_whitelist",
680
+ "max_reservation_time_sec",
681
+ "max_reservations",
682
+ "lifetime_sec",
683
+ "created_at",
684
+ "users",
685
+ ]
686
+
687
+ try:
688
+ _write_csv(groups, out_path, preferred_columns=preferred)
689
+ print(f"Exported user groups CSV -> {out_path}")
690
+ return out_path
691
+ except Exception as e:
692
+ print(f"Failed to export user groups CSV: {e}")
693
+ return None
694
+
695
+
696
+ def export_enrollment_codes_csv_local() -> Optional[Path]:
697
+ rows: List[Dict[str, Any]] = user_group_handler.get_all_enrollment_codes() or []
698
+ if not rows:
699
+ print("No enrollment codes found.")
700
+ return None
701
+
702
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
703
+ out_dir = _resolve_download_dir()
704
+ out_path = out_dir / f"remoterf_enrollment_codes_{ts}.csv"
705
+
706
+ preferred = [
707
+ "code_name",
708
+ "group_id",
709
+ "uses",
710
+ "uses_left",
711
+ "remaining_uses",
712
+ "duration_sec",
713
+ "expires_at",
714
+ "created_at",
715
+ ]
716
+
717
+ try:
718
+ _write_csv(rows, out_path, preferred_columns=preferred)
719
+ print(f"Exported enrollment codes CSV -> {out_path}")
720
+ return out_path
721
+ except Exception as e:
722
+ print(f"Failed to export enrollment codes CSV: {e}")
723
+ return None