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,1120 @@
1
+ import sqlite3
2
+ from datetime import datetime
3
+ import time
4
+ import sys
5
+ import json
6
+ import threading
7
+ from pathlib import Path
8
+ from functools import wraps
9
+ from typing import Optional, Tuple, List
10
+
11
+ from ..common.utils import *
12
+
13
+ import os
14
+ import sys
15
+
16
+ class UserGroupHandler:
17
+ '''
18
+ Handles user groups and enrollment codes.
19
+ create_user_group -> bool
20
+ delete_user_group -> bool
21
+ edit_user_group -> bool
22
+ get_all_user_groups -> list[dict]
23
+ create_enrollment_codes -> str
24
+ generate_random_enrollment_code_names -> list[str]
25
+ delete_enrollment_code (by code_name) -> bool
26
+ delete_enrollment_code (by group_id) -> bool
27
+ get_all_enrollment_codes -> list[dict]
28
+ enroll_user_with_code -> bool
29
+ get_users_devices -> list[int]
30
+ get_users_max_reservation_time -> tuple[bool, int]
31
+ get_users_max_reservations -> tuple[bool, int]
32
+ '''
33
+
34
+ def __init__(self):
35
+ self.filepath = get_db_dir() / 'usergroups.db'
36
+
37
+ # print("DB path:", self.filepath)
38
+ # print("Parent exists:", self.filepath.parent.exists(), "is_dir:", self.filepath.parent.is_dir())
39
+ # print("Writable:", os.access(self.filepath.parent, os.W_OK))
40
+
41
+ self.db = sqlite3.connect(self.filepath)
42
+ self.cursor = self.db.cursor()
43
+
44
+ self.cursor.execute('''
45
+ CREATE TABLE IF NOT EXISTS UserGroups (
46
+ group_id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ group_name TEXT NOT NULL UNIQUE,
48
+ devices_whitelist TEXT NOT NULL DEFAULT '[]', -- JSON array, e.g. "[0,1,2]"
49
+ max_reservation_time_sec INTEGER NOT NULL,
50
+ max_reservations INTEGER NOT NULL,
51
+ lifetime_sec INTEGER NOT NULL,
52
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
53
+ );
54
+ ''')
55
+
56
+ self.cursor.execute('''
57
+ CREATE TABLE IF NOT EXISTS UsersLookup (
58
+ user_id INTEGER NOT NULL,
59
+ group_id INTEGER NOT NULL,
60
+ PRIMARY KEY (user_id, group_id)
61
+ );
62
+ ''')
63
+
64
+ self.cursor.execute('''
65
+ CREATE TABLE IF NOT EXISTS EnrollmentCodes (
66
+ code_id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ code_name TEXT NOT NULL UNIQUE,
68
+ group_id INTEGER NOT NULL,
69
+ uses INTEGER NOT NULL,
70
+ duration_sec INTEGER NOT NULL,
71
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
72
+ );
73
+ ''')
74
+
75
+ self.cursor.close()
76
+ self.db.commit()
77
+ self.db.close()
78
+ self.lock = threading.RLock()
79
+
80
+ @db_connection
81
+ def create_user_group(
82
+ self,
83
+ *,
84
+ group_name: str,
85
+ devices_whitelist: list[int],
86
+ max_reservation_time_sec: int,
87
+ max_reservations: int,
88
+ lifetime_sec: int,
89
+ ) -> bool:
90
+ try:
91
+ if not isinstance(group_name, str) or not group_name.strip():
92
+ return False
93
+
94
+ if devices_whitelist is None:
95
+ devices_whitelist = []
96
+ if not isinstance(devices_whitelist, (list, tuple)):
97
+ return False
98
+
99
+ try:
100
+ devices_whitelist = sorted({int(d) for d in devices_whitelist})
101
+ except Exception:
102
+ return False
103
+
104
+ try:
105
+ max_reservation_time_sec = int(max_reservation_time_sec)
106
+ max_reservations = int(max_reservations)
107
+ lifetime_sec = int(lifetime_sec)
108
+ except Exception:
109
+ return False
110
+
111
+ if max_reservation_time_sec <= 0:
112
+ return False
113
+ if max_reservations < 0:
114
+ return False
115
+ if lifetime_sec < 0:
116
+ return False
117
+
118
+ devices_json = json.dumps(devices_whitelist)
119
+
120
+ # ---- insert ----
121
+ self.cursor.execute(
122
+ """
123
+ INSERT INTO UserGroups
124
+ (group_name, devices_whitelist, max_reservation_time_sec, max_reservations, lifetime_sec, created_at)
125
+ VALUES (?, ?, ?, ?, ?, ?)
126
+ """,
127
+ (
128
+ group_name.strip(),
129
+ devices_json,
130
+ max_reservation_time_sec,
131
+ max_reservations,
132
+ lifetime_sec,
133
+ datetime.now(),
134
+ ),
135
+ )
136
+ return True
137
+
138
+ except Exception:
139
+ return False
140
+
141
+ @db_connection
142
+ def get_groups_user_in(self, *, uid: int) -> list[str]:
143
+ try:
144
+ uid = int(uid)
145
+ if uid < 0:
146
+ return []
147
+
148
+ # 1) Fetch group_ids the user is in
149
+ self.cursor.execute(
150
+ "SELECT group_id FROM UsersLookup WHERE user_id = ?",
151
+ (uid,),
152
+ )
153
+ gid_rows = self.cursor.fetchall() or []
154
+ group_ids = []
155
+ for r in gid_rows:
156
+ if not r or r[0] is None:
157
+ continue
158
+ try:
159
+ gid = int(r[0])
160
+ except Exception:
161
+ continue
162
+ if gid > 0:
163
+ group_ids.append(gid)
164
+
165
+ if not group_ids:
166
+ return []
167
+
168
+ # de-dupe, stable order
169
+ seen_gid = set()
170
+ group_ids_unique = []
171
+ for gid in group_ids:
172
+ if gid in seen_gid:
173
+ continue
174
+ seen_gid.add(gid)
175
+ group_ids_unique.append(gid)
176
+
177
+ # 2) Fetch group names from UserGroups using those ids
178
+ placeholders = ",".join(["?"] * len(group_ids_unique))
179
+ self.cursor.execute(
180
+ f"""
181
+ SELECT group_name
182
+ FROM UserGroups
183
+ WHERE group_id IN ({placeholders})
184
+ ORDER BY group_name ASC
185
+ """,
186
+ tuple(group_ids_unique),
187
+ )
188
+ rows = self.cursor.fetchall() or []
189
+
190
+ # de-dupe defensively, preserve order
191
+ out: list[str] = []
192
+ seen_name: set[str] = set()
193
+ for r in rows:
194
+ if not r or r[0] is None:
195
+ continue
196
+ name = str(r[0]).strip()
197
+ if not name or name in seen_name:
198
+ continue
199
+ seen_name.add(name)
200
+ out.append(name)
201
+
202
+ return out
203
+ except Exception:
204
+ return []
205
+
206
+
207
+
208
+ @db_connection
209
+ def delete_user_group(
210
+ self,
211
+ *,
212
+ group_id: int
213
+ ) -> bool:
214
+ try:
215
+ try:
216
+ group_id = int(group_id)
217
+ except Exception:
218
+ return False
219
+ if group_id <= 0:
220
+ return False
221
+
222
+ self.cursor.execute("SELECT 1 FROM UserGroups WHERE group_id = ? LIMIT 1", (group_id,))
223
+ if self.cursor.fetchone() is None:
224
+ return False
225
+
226
+ # cleanup
227
+ self.cursor.execute("DELETE FROM EnrollmentCodes WHERE group_id = ?", (group_id,))
228
+ self.cursor.execute("DELETE FROM UsersLookup WHERE group_id = ?", (group_id,))
229
+ self.cursor.execute("DELETE FROM UserGroups WHERE group_id = ?", (group_id,))
230
+
231
+ return True
232
+ except Exception:
233
+ return False
234
+
235
+ @db_connection
236
+ def edit_user_group(
237
+ self,
238
+ *,
239
+ group_id: int,
240
+ group_name: str = None,
241
+ devices_whitelist: list[int] = None,
242
+ max_reservation_time_sec: int = None,
243
+ max_reservations: int = None,
244
+ lifetime_sec: int = None
245
+ ) -> bool:
246
+ try:
247
+ try:
248
+ group_id = int(group_id)
249
+ except Exception:
250
+ return False
251
+ if group_id <= 0:
252
+ return False
253
+
254
+ self.cursor.execute("SELECT 1 FROM UserGroups WHERE group_id = ? LIMIT 1", (group_id,))
255
+ if self.cursor.fetchone() is None:
256
+ return False
257
+
258
+ # dynamic update
259
+ sets = []
260
+ params = []
261
+
262
+ if group_name is not None:
263
+ group_name = str(group_name).strip()
264
+ if group_name == "":
265
+ return False
266
+ sets.append("group_name = ?")
267
+ params.append(group_name)
268
+
269
+ if devices_whitelist is not None:
270
+ try:
271
+ wl = [int(x) for x in list(devices_whitelist)]
272
+ except Exception:
273
+ return False
274
+ wl = sorted(set(wl))
275
+ sets.append("devices_whitelist = ?")
276
+ params.append(json.dumps(wl))
277
+
278
+ if max_reservation_time_sec is not None:
279
+ try:
280
+ v = int(max_reservation_time_sec)
281
+ except Exception:
282
+ return False
283
+ if v <= 0:
284
+ return False
285
+ sets.append("max_reservation_time_sec = ?")
286
+ params.append(v)
287
+
288
+ if max_reservations is not None:
289
+ try:
290
+ v = int(max_reservations)
291
+ except Exception:
292
+ return False
293
+ if v <= 0:
294
+ return False
295
+ sets.append("max_reservations = ?")
296
+ params.append(v)
297
+
298
+ if lifetime_sec is not None:
299
+ try:
300
+ v = int(lifetime_sec)
301
+ except Exception:
302
+ return False
303
+ if v <= 0:
304
+ return False
305
+ sets.append("lifetime_sec = ?")
306
+ params.append(v)
307
+
308
+ if not sets:
309
+ return False
310
+
311
+ params.append(group_id)
312
+ sql = f"UPDATE UserGroups SET {', '.join(sets)} WHERE group_id = ?"
313
+ self.cursor.execute(sql, tuple(params))
314
+ return True
315
+ except Exception:
316
+ return False
317
+
318
+ @db_connection
319
+ def get_all_user_groups(self, include_users: bool) -> list[dict]:
320
+ try:
321
+ include_users = bool(include_users)
322
+
323
+ self.cursor.execute("""
324
+ SELECT
325
+ group_id,
326
+ group_name,
327
+ devices_whitelist,
328
+ max_reservation_time_sec,
329
+ max_reservations,
330
+ lifetime_sec,
331
+ created_at
332
+ FROM UserGroups
333
+ ORDER BY group_id ASC
334
+ """)
335
+ rows = self.cursor.fetchall() or []
336
+
337
+ groups: list[dict] = []
338
+ gid_to_users: dict[int, list[int]] = {}
339
+
340
+ if include_users:
341
+ self.cursor.execute("""
342
+ SELECT user_id, group_id
343
+ FROM UsersLookup
344
+ """)
345
+ urows = self.cursor.fetchall() or []
346
+ for user_id, group_id in urows:
347
+ try:
348
+ gid = int(group_id)
349
+ uid = int(user_id)
350
+ except Exception:
351
+ continue
352
+ gid_to_users.setdefault(gid, []).append(uid)
353
+
354
+ # sort
355
+ for gid in gid_to_users:
356
+ gid_to_users[gid] = sorted(set(gid_to_users[gid]))
357
+
358
+ for r in rows:
359
+ try:
360
+ gid = int(r[0])
361
+ name = str(r[1])
362
+ wl_raw = r[2] if r[2] is not None else "[]"
363
+ try:
364
+ wl = json.loads(wl_raw) if isinstance(wl_raw, str) else wl_raw
365
+ wl = [int(x) for x in (wl or [])]
366
+ wl = sorted(set(wl))
367
+ except Exception:
368
+ wl = []
369
+ max_t = int(r[3])
370
+ max_r = int(r[4])
371
+ life = int(r[5])
372
+ created_at = r[6]
373
+ except Exception:
374
+ continue
375
+
376
+ entry = {
377
+ "group_id": gid,
378
+ "group_name": name,
379
+ "devices_whitelist": wl,
380
+ "max_reservation_time_sec": max_t,
381
+ "max_reservations": max_r,
382
+ "lifetime_sec": life,
383
+ "created_at": str(created_at) if created_at is not None else None,
384
+ }
385
+ if include_users:
386
+ entry["users"] = gid_to_users.get(gid, [])
387
+ groups.append(entry)
388
+
389
+ return groups
390
+ except Exception:
391
+ return []
392
+
393
+ @db_connection
394
+ def create_enrollment_codes(
395
+ self,
396
+ *,
397
+ code_names: list[str],
398
+ group_id: int,
399
+ uses: int,
400
+ duration_sec: int
401
+ ) -> str:
402
+ try:
403
+ # Basic sanitize / type normalization
404
+ if not isinstance(code_names, list) or len(code_names) == 0:
405
+ return ""
406
+
407
+ group_id = int(group_id)
408
+ uses = int(uses)
409
+ duration_sec = int(duration_sec)
410
+
411
+ if group_id < 0 or uses <= 0 or duration_sec < 0:
412
+ return ""
413
+
414
+ # Ensure group exists
415
+ self.cursor.execute("SELECT 1 FROM UserGroups WHERE group_id = ? LIMIT 1", (group_id,))
416
+ if self.cursor.fetchone() is None:
417
+ return ""
418
+
419
+ # Clean + de-dupe (preserve order)
420
+ cleaned: list[str] = []
421
+ seen: set[str] = set()
422
+ for c in code_names:
423
+ if not isinstance(c, str):
424
+ continue
425
+ c = c.strip()
426
+ if not c:
427
+ continue
428
+ if c in seen:
429
+ continue
430
+ seen.add(c)
431
+ cleaned.append(c)
432
+
433
+ if not cleaned:
434
+ return ""
435
+
436
+ # Insert many, silently skip conflicts/invalid rows
437
+ created = 0
438
+ for code in cleaned:
439
+ try:
440
+ self.cursor.execute(
441
+ """
442
+ INSERT OR IGNORE INTO EnrollmentCodes
443
+ (code_name, group_id, uses, duration_sec, created_at)
444
+ VALUES
445
+ (?, ?, ?, ?, ?)
446
+ """,
447
+ (code, group_id, uses, duration_sec, datetime.now()),
448
+ )
449
+ if getattr(self.cursor, "rowcount", 0) == 1:
450
+ created += 1
451
+ except Exception:
452
+ continue
453
+
454
+ return f"Created {created}/{len(cleaned)} enrollment codes."
455
+ except Exception:
456
+ return ""
457
+
458
+
459
+ @db_connection
460
+ def generate_random_enrollment_code_names(
461
+ self, *, count: int, char_count: int
462
+ ) -> list[str]:
463
+ try:
464
+ count = int(count)
465
+ char_count = int(char_count)
466
+
467
+ if count <= 0 or char_count <= 0:
468
+ return []
469
+
470
+ import secrets
471
+ import string
472
+
473
+ alphabet = string.ascii_uppercase + string.digits
474
+
475
+ # Pull existing codes once so we can reroll locally without hammering the DB.
476
+ try:
477
+ self.cursor.execute("SELECT code_name FROM EnrollmentCodes")
478
+ existing_rows = self.cursor.fetchall() or []
479
+ existing: set[str] = {
480
+ str(r[0]).strip() for r in existing_rows if r and r[0] is not None and str(r[0]).strip()
481
+ }
482
+ except Exception:
483
+ existing = set()
484
+
485
+ created: list[str] = []
486
+ used: set[str] = set(existing) # prevents collisions with DB + within this batch
487
+
488
+ max_attempts = max(1000, count * 200)
489
+ attempts = 0
490
+
491
+ while len(created) < count and attempts < max_attempts:
492
+ attempts += 1
493
+ code = "".join(secrets.choice(alphabet) for _ in range(char_count))
494
+ if code in used:
495
+ continue
496
+ used.add(code)
497
+ created.append(code)
498
+
499
+ return created
500
+ except Exception:
501
+ return []
502
+
503
+ @db_connection
504
+ def delete_enrollment_code_name(self, *, code_name: str) -> bool:
505
+ try:
506
+ if not isinstance(code_name, str):
507
+ return False
508
+
509
+ code_name = code_name.strip()
510
+ if not code_name:
511
+ return False
512
+
513
+ self.cursor.execute(
514
+ "SELECT 1 FROM EnrollmentCodes WHERE code_name = ? LIMIT 1",
515
+ (code_name,),
516
+ )
517
+ if self.cursor.fetchone() is None:
518
+ return False
519
+
520
+ self.cursor.execute(
521
+ "DELETE FROM EnrollmentCodes WHERE code_name = ?",
522
+ (code_name,),
523
+ )
524
+ return self.cursor.rowcount > 0
525
+ except Exception:
526
+ return False
527
+
528
+ @db_connection
529
+ def delete_enrollment_code_id(self, *, group_id: int) -> bool:
530
+ try:
531
+ group_id = int(group_id)
532
+ if group_id < 0:
533
+ return False
534
+
535
+ self.cursor.execute(
536
+ "DELETE FROM EnrollmentCodes WHERE group_id = ?",
537
+ (group_id,),
538
+ )
539
+ return self.cursor.rowcount > 0
540
+ except Exception:
541
+ return False
542
+
543
+ @db_connection
544
+ def get_all_enrollment_codes(self) -> list[dict]:
545
+ try:
546
+ self.cursor.execute(
547
+ "SELECT code_id, code_name, group_id, uses, duration_sec FROM EnrollmentCodes"
548
+ )
549
+ rows = self.cursor.fetchall() or []
550
+
551
+ out: list[dict] = []
552
+ for r in rows:
553
+ try:
554
+ out.append(
555
+ {
556
+ "code_id": int(r[0]),
557
+ "code_name": str(r[1]),
558
+ "group_id": int(r[2]),
559
+ "uses": int(r[3]),
560
+ "duration_sec": int(r[4]),
561
+ }
562
+ )
563
+ except Exception:
564
+ continue
565
+
566
+ return out
567
+ except Exception:
568
+ return []
569
+
570
+ @db_connection
571
+ def validate_enrollment_code(self, *, code_name: str) -> bool:
572
+ # Check if enrollment code exists and has remaining uses
573
+ try:
574
+ if not isinstance(code_name, str):
575
+ return False
576
+ code_name = code_name.strip()
577
+ if not code_name:
578
+ return False
579
+
580
+ self.cursor.execute(
581
+ "SELECT uses FROM EnrollmentCodes WHERE code_name = ? LIMIT 1",
582
+ (code_name,),
583
+ )
584
+ row = self.cursor.fetchone()
585
+ if row is None:
586
+ return False
587
+
588
+ try:
589
+ uses_left = int(row[0])
590
+ except Exception:
591
+ return False
592
+
593
+ return uses_left > 0
594
+ except Exception:
595
+ return False
596
+
597
+ @db_connection
598
+ def enroll_user_with_code(self, *, uid: int, code_name: str) -> bool:
599
+ # Add into users table, and decrement uses in enrollment codes table
600
+ try:
601
+ uid = int(uid)
602
+ code_name = str(code_name).strip()
603
+ if uid < 0 or not code_name:
604
+ return False
605
+
606
+ # look up code + ensure it exists and has remaining uses
607
+ self.cursor.execute(
608
+ "SELECT group_id, uses FROM EnrollmentCodes WHERE code_name = ? LIMIT 1",
609
+ (code_name,),
610
+ )
611
+ row = self.cursor.fetchone()
612
+ if row is None:
613
+ return False
614
+
615
+ group_id = int(row[0])
616
+ uses_left = int(row[1])
617
+ if group_id < 0 or uses_left <= 0:
618
+ return False
619
+
620
+ # ensure group exists
621
+ self.cursor.execute("SELECT 1 FROM UserGroups WHERE group_id = ? LIMIT 1", (group_id,))
622
+ if self.cursor.fetchone() is None:
623
+ return False
624
+
625
+ # insert enrollment
626
+ self.cursor.execute(
627
+ "SELECT 1 FROM UsersLookup WHERE user_id = ? AND group_id = ? LIMIT 1",
628
+ (uid, group_id),
629
+ )
630
+ if self.cursor.fetchone() is None:
631
+ self.cursor.execute(
632
+ "INSERT INTO UsersLookup (user_id, group_id) VALUES (?, ?)",
633
+ (uid, group_id),
634
+ )
635
+
636
+ # decrement uses (guard again in SQL)
637
+ self.cursor.execute(
638
+ "UPDATE EnrollmentCodes SET uses = uses - 1 WHERE code_name = ? AND uses > 0",
639
+ (code_name,),
640
+ )
641
+ if self.cursor.rowcount <= 0:
642
+ return False
643
+
644
+ # delete code if exhausted
645
+ self.cursor.execute(
646
+ "DELETE FROM EnrollmentCodes WHERE code_name = ? AND uses <= 0",
647
+ (code_name,),
648
+ )
649
+
650
+ return True
651
+ except Exception:
652
+ return False
653
+
654
+ @db_connection
655
+ def get_users_devices(self, *, uid: int) -> list[int]:
656
+ # union of all devices whitelists from all groups the user is in
657
+ try:
658
+ uid = int(uid)
659
+ if uid < 0:
660
+ return []
661
+
662
+ # get all group_ids the user is enrolled in
663
+ self.cursor.execute(
664
+ "SELECT group_id FROM UsersLookup WHERE user_id = ?",
665
+ (uid,),
666
+ )
667
+ group_rows = self.cursor.fetchall()
668
+ if not group_rows:
669
+ return []
670
+
671
+ group_ids = [int(r[0]) for r in group_rows if r and r[0] is not None]
672
+ if not group_ids:
673
+ return []
674
+
675
+ # fetch whitelists for those groups
676
+ placeholders = ",".join(["?"] * len(group_ids))
677
+ self.cursor.execute(
678
+ f"SELECT devices_whitelist FROM UserGroups WHERE group_id IN ({placeholders})",
679
+ tuple(group_ids),
680
+ )
681
+ rows = self.cursor.fetchall()
682
+ if not rows:
683
+ return []
684
+
685
+ out_set: set[int] = set()
686
+ for (wl_str,) in rows:
687
+ if wl_str is None:
688
+ continue
689
+ try:
690
+ parsed = json.loads(wl_str) if isinstance(wl_str, str) else []
691
+ if isinstance(parsed, list):
692
+ for x in parsed:
693
+ try:
694
+ out_set.add(int(x))
695
+ except Exception:
696
+ pass
697
+ except Exception:
698
+ try:
699
+ parts = [p.strip() for p in str(wl_str).split(",")]
700
+ for p in parts:
701
+ if p != "":
702
+ out_set.add(int(p))
703
+ except Exception:
704
+ pass
705
+
706
+ return sorted(out_set)
707
+ except Exception:
708
+ return []
709
+
710
+
711
+ @db_connection
712
+ def get_users_max_reservation_time(self, *, uid: int, device_id: int) -> tuple[bool, int]:
713
+ # if the same device shows up in multiple groups, return the highest max_reservation_time_sec
714
+ try:
715
+ import json
716
+
717
+ uid = int(uid)
718
+ device_id = int(device_id)
719
+ if uid < 0 or device_id < 0:
720
+ return (False, 0)
721
+
722
+ # find all groups the user is in
723
+ self.cursor.execute("SELECT group_id FROM UsersLookup WHERE user_id = ?", (uid,))
724
+ group_rows = self.cursor.fetchall()
725
+ if not group_rows:
726
+ return (False, 0)
727
+
728
+ group_ids = [int(r[0]) for r in group_rows if r and r[0] is not None]
729
+ if not group_ids:
730
+ return (False, 0)
731
+
732
+ placeholders = ",".join(["?"] * len(group_ids))
733
+ self.cursor.execute(
734
+ f"SELECT devices_whitelist, max_reservation_time_sec FROM UserGroups WHERE group_id IN ({placeholders})",
735
+ tuple(group_ids),
736
+ )
737
+ rows = self.cursor.fetchall()
738
+ if not rows:
739
+ return (False, 0)
740
+
741
+ allowed = False
742
+ best = 0
743
+
744
+ for wl_str, max_time in rows:
745
+ # parse whitelist (JSON array preferred, tolerate fallback)
746
+ whitelist: list[int] = []
747
+ if wl_str is not None:
748
+ try:
749
+ parsed = json.loads(wl_str) if isinstance(wl_str, str) else []
750
+ if isinstance(parsed, list):
751
+ whitelist = [int(x) for x in parsed if str(x).strip() != ""]
752
+ except Exception:
753
+ try:
754
+ whitelist = [int(p.strip()) for p in str(wl_str).split(",") if p.strip() != ""]
755
+ except Exception:
756
+ whitelist = []
757
+
758
+ if device_id in whitelist:
759
+ allowed = True
760
+ try:
761
+ t = int(max_time)
762
+ except Exception:
763
+ t = 0
764
+ if t > best:
765
+ best = t
766
+
767
+ return (allowed, best if allowed else 0)
768
+ except Exception:
769
+ return (False, 0)
770
+
771
+ @db_connection
772
+ def get_users_max_reservations(self, *, uid: int, device_id: int) -> tuple[bool, int]:
773
+ # if the same device shows up in multiple groups, return the highest max_reservations
774
+ try:
775
+ import json
776
+
777
+ uid = int(uid)
778
+ device_id = int(device_id)
779
+ if uid < 0 or device_id < 0:
780
+ return (False, 0)
781
+
782
+ # find all groups the user is in
783
+ self.cursor.execute("SELECT group_id FROM UsersLookup WHERE user_id = ?", (uid,))
784
+ group_rows = self.cursor.fetchall()
785
+ if not group_rows:
786
+ return (False, 0)
787
+
788
+ group_ids = [int(r[0]) for r in group_rows if r and r[0] is not None]
789
+ if not group_ids:
790
+ return (False, 0)
791
+
792
+ placeholders = ",".join(["?"] * len(group_ids))
793
+ self.cursor.execute(
794
+ f"SELECT devices_whitelist, max_reservations FROM UserGroups WHERE group_id IN ({placeholders})",
795
+ tuple(group_ids),
796
+ )
797
+ rows = self.cursor.fetchall()
798
+ if not rows:
799
+ return (False, 0)
800
+
801
+ allowed = False
802
+ best = 0
803
+
804
+ for wl_str, max_res in rows:
805
+ # parse whitelist (JSON array preferred, tolerate fallback)
806
+ whitelist: list[int] = []
807
+ if wl_str is not None:
808
+ try:
809
+ parsed = json.loads(wl_str) if isinstance(wl_str, str) else []
810
+ if isinstance(parsed, list):
811
+ whitelist = [int(x) for x in parsed if str(x).strip() != ""]
812
+ except Exception:
813
+ try:
814
+ whitelist = [int(p.strip()) for p in str(wl_str).split(",") if p.strip() != ""]
815
+ except Exception:
816
+ whitelist = []
817
+
818
+ if device_id in whitelist:
819
+ allowed = True
820
+ try:
821
+ n = int(max_res)
822
+ except Exception:
823
+ n = 0
824
+ if n > best:
825
+ best = n
826
+
827
+ return (allowed, best if allowed else 0)
828
+ except Exception:
829
+ return (False, 0)
830
+
831
+ def _parse_whitelist(self, wl_str) -> set[int]:
832
+ try:
833
+ if wl_str is None:
834
+ return set()
835
+ if isinstance(wl_str, str):
836
+ parsed = json.loads(wl_str)
837
+ if isinstance(parsed, list):
838
+ return {int(x) for x in parsed}
839
+ return set()
840
+ except Exception:
841
+ # tolerate old formats like "1,2,3"
842
+ try:
843
+ return {int(p.strip()) for p in str(wl_str).split(",") if p.strip() != ""}
844
+ except Exception:
845
+ return set()
846
+
847
+ @db_connection
848
+ def get_user_group_reservation_quota_rows(
849
+ self,
850
+ *,
851
+ uid: int,
852
+ reserved_device_ids: list[int],
853
+ ) -> list[list[int]]:
854
+ """
855
+ Returns rows: [group_id, used_reservations_in_group, max_reservations_in_group]
856
+ for every group the user is in.
857
+ """
858
+ try:
859
+ uid = int(uid)
860
+ if uid < 0:
861
+ return []
862
+
863
+ # sanitize reserved_device_ids
864
+ try:
865
+ rdevs = [int(d) for d in (reserved_device_ids or [])]
866
+ except Exception:
867
+ rdevs = []
868
+
869
+ # groups for user
870
+ self.cursor.execute("SELECT group_id FROM UsersLookup WHERE user_id = ?", (uid,))
871
+ group_rows = self.cursor.fetchall() or []
872
+ group_ids = [int(r[0]) for r in group_rows if r and r[0] is not None]
873
+ if not group_ids:
874
+ return []
875
+
876
+ placeholders = ",".join(["?"] * len(group_ids))
877
+ self.cursor.execute(
878
+ f"""
879
+ SELECT group_id, devices_whitelist, max_reservations
880
+ FROM UserGroups
881
+ WHERE group_id IN ({placeholders})
882
+ """,
883
+ tuple(group_ids),
884
+ )
885
+ rows = self.cursor.fetchall() or []
886
+
887
+ out: list[list[int]] = []
888
+ for gid, wl_str, max_res in rows:
889
+ try:
890
+ gid = int(gid)
891
+ max_res = int(max_res)
892
+ except Exception:
893
+ continue
894
+
895
+ wl = self._parse_whitelist(wl_str)
896
+ used = 0
897
+ for d in rdevs:
898
+ if d in wl:
899
+ used += 1
900
+
901
+ out.append([gid, used, max_res])
902
+
903
+ # stable order
904
+ out.sort(key=lambda x: x[0])
905
+ return out
906
+ except Exception:
907
+ return []
908
+
909
+ @db_connection
910
+ def get_group_quota_for_device(
911
+ self,
912
+ *,
913
+ uid: int,
914
+ device_id: int,
915
+ reserved_device_ids: list[int],
916
+ ) -> tuple[bool, int, int, int]:
917
+ try:
918
+ uid = int(uid)
919
+ device_id = int(device_id)
920
+ if uid < 0 or device_id < 0:
921
+ return (False, 0, 0, -1)
922
+
923
+ try:
924
+ rdevs = [int(d) for d in (reserved_device_ids or [])]
925
+ except Exception:
926
+ rdevs = []
927
+
928
+ self.cursor.execute("SELECT group_id FROM UsersLookup WHERE user_id = ?", (uid,))
929
+ group_rows = self.cursor.fetchall() or []
930
+ group_ids = [int(r[0]) for r in group_rows if r and r[0] is not None]
931
+ if not group_ids:
932
+ return (False, 0, 0, -1)
933
+
934
+ placeholders = ",".join(["?"] * len(group_ids))
935
+ self.cursor.execute(
936
+ f"""
937
+ SELECT group_id, devices_whitelist, max_reservations
938
+ FROM UserGroups
939
+ WHERE group_id IN ({placeholders})
940
+ """,
941
+ tuple(group_ids),
942
+ )
943
+ rows = self.cursor.fetchall() or []
944
+ if not rows:
945
+ return (False, 0, 0, -1)
946
+
947
+ candidates = []
948
+ for gid, wl_str, max_res in rows:
949
+ try:
950
+ gid = int(gid)
951
+ max_res = int(max_res)
952
+ except Exception:
953
+ continue
954
+
955
+ wl = self._parse_whitelist(wl_str)
956
+ if device_id not in wl:
957
+ continue
958
+
959
+ used = 0
960
+ for d in rdevs:
961
+ if d in wl:
962
+ used += 1
963
+
964
+ remaining = max_res - used # can be <= 0 (meaning full/exceeded)
965
+
966
+ # Sort key: prefer more remaining, then higher max, then fewer used, then lower gid
967
+ candidates.append((remaining, max_res, -used, -gid, used, gid))
968
+
969
+ if not candidates:
970
+ return (False, 0, 0, -1)
971
+
972
+ candidates.sort(reverse=True)
973
+ _remaining, max_res, _neg_used, _neg_gid, used, gid = candidates[0]
974
+ return (True, used, max_res, gid)
975
+
976
+ except Exception:
977
+ return (False, 0, 0, -1)
978
+
979
+ @db_connection
980
+ def check_expiration_and_cleanup(self) -> dict:
981
+ def _parse_dt(v):
982
+ if v is None:
983
+ return None
984
+ if isinstance(v, datetime):
985
+ return v
986
+ s = str(v).strip()
987
+ if not s:
988
+ return None
989
+ try:
990
+ return datetime.fromisoformat(s)
991
+ except Exception:
992
+ pass
993
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"):
994
+ try:
995
+ return datetime.strptime(s, fmt)
996
+ except Exception:
997
+ continue
998
+ return None
999
+
1000
+ try:
1001
+ now = datetime.now()
1002
+
1003
+ print("Flushing User Groups")
1004
+
1005
+ expired_group_ids: list[int] = []
1006
+ expired_code_names: list[str] = []
1007
+
1008
+ # ---- find expired groups ----
1009
+ self.cursor.execute("""
1010
+ SELECT group_id, lifetime_sec, created_at
1011
+ FROM UserGroups
1012
+ WHERE lifetime_sec > 0
1013
+ """)
1014
+ for group_id, lifetime_sec, created_at in (self.cursor.fetchall() or []):
1015
+ try:
1016
+ gid = int(group_id)
1017
+ life = int(lifetime_sec)
1018
+ except Exception:
1019
+ continue
1020
+ if gid <= 0 or life <= 0:
1021
+ continue
1022
+
1023
+ dt = _parse_dt(created_at)
1024
+ if dt is None:
1025
+ continue
1026
+
1027
+ if (now - dt).total_seconds() >= life:
1028
+ expired_group_ids.append(gid)
1029
+
1030
+ # ---- find expired codes ----
1031
+ self.cursor.execute("""
1032
+ SELECT code_name, duration_sec, created_at
1033
+ FROM EnrollmentCodes
1034
+ WHERE duration_sec > 0
1035
+ """)
1036
+ for code_name, duration_sec, created_at in (self.cursor.fetchall() or []):
1037
+ code = str(code_name).strip() if code_name is not None else ""
1038
+ try:
1039
+ dur = int(duration_sec)
1040
+ except Exception:
1041
+ continue
1042
+ if not code or dur <= 0:
1043
+ continue
1044
+
1045
+ dt = _parse_dt(created_at)
1046
+ if dt is None:
1047
+ continue
1048
+
1049
+ if (now - dt).total_seconds() >= dur:
1050
+ expired_code_names.append(code)
1051
+
1052
+ # De-dupe
1053
+ expired_group_ids = sorted(set(expired_group_ids))
1054
+ expired_code_names = sorted(set(expired_code_names))
1055
+
1056
+ codes_deleted = 0
1057
+ groups_deleted = 0
1058
+
1059
+ # ---- delete expired codes by name ----
1060
+ for code in expired_code_names:
1061
+ try:
1062
+ # reuse your existing deletion logic, but inline is cheaper
1063
+ self.cursor.execute("DELETE FROM EnrollmentCodes WHERE code_name = ?", (code,))
1064
+ if self.cursor.rowcount > 0:
1065
+ codes_deleted += 1
1066
+ except Exception:
1067
+ continue
1068
+
1069
+ # ---- delete expired groups using your existing logic (cleanup cascade) ----
1070
+ for gid in expired_group_ids:
1071
+ try:
1072
+ # Call the existing method; it will be serialized anyway, but avoid re-entering wrapper.
1073
+ # So do the same SQL your delete_user_group() does:
1074
+ self.cursor.execute("DELETE FROM EnrollmentCodes WHERE group_id = ?", (gid,))
1075
+ self.cursor.execute("DELETE FROM UsersLookup WHERE group_id = ?", (gid,))
1076
+ self.cursor.execute("DELETE FROM UserGroups WHERE group_id = ?", (gid,))
1077
+ if self.cursor.rowcount > 0:
1078
+ groups_deleted += 1
1079
+ except Exception:
1080
+ continue
1081
+
1082
+ return {
1083
+ "expired_groups_deleted": groups_deleted,
1084
+ "expired_codes_deleted": codes_deleted,
1085
+ "expired_group_ids": expired_group_ids,
1086
+ "expired_code_names": expired_code_names,
1087
+ }
1088
+
1089
+ except Exception:
1090
+ return {
1091
+ "expired_groups_deleted": 0,
1092
+ "expired_codes_deleted": 0,
1093
+ "expired_group_ids": [],
1094
+ "expired_code_names": [],
1095
+ }
1096
+
1097
+ user_group_handler = UserGroupHandler()
1098
+
1099
+ def start_usergroup_cleanup_loop(
1100
+ *,
1101
+ interval_sec: int,
1102
+ ) -> Tuple[threading.Event, threading.Thread]:
1103
+ interval_sec = int(interval_sec)
1104
+ if interval_sec <= 0:
1105
+ interval_sec = 60
1106
+
1107
+ stop_event = threading.Event()
1108
+
1109
+ def _worker():
1110
+ while not stop_event.is_set():
1111
+ try:
1112
+ user_group_handler.check_expiration_and_cleanup()
1113
+ except Exception:
1114
+ # optional: log/printf traceback here if you want
1115
+ pass
1116
+
1117
+ # interruptible sleep (wakes immediately when stop_event.set() is called)
1118
+ stop_event.wait(interval_sec)
1119
+
1120
+ threading.Thread(target=_worker, daemon=True)