arthexis 0.1.13__py3-none-any.whl → 0.1.14__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.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
core/user_data.py CHANGED
@@ -1,633 +1,641 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
- from io import BytesIO
5
- from zipfile import ZipFile
6
- import json
7
-
8
- from django.apps import apps
9
- from django.conf import settings
10
- from django.contrib import admin, messages
11
- from django.contrib.auth import get_user_model
12
- from django.contrib.auth.signals import user_logged_in
13
- from django.core.management import call_command
14
- from django.db.models.signals import post_save
15
- from django.dispatch import receiver
16
- from django.http import HttpResponse, HttpResponseRedirect
17
- from django.template.response import TemplateResponse
18
- from django.urls import path, reverse
19
- from django.utils.functional import LazyObject
20
- from django.utils.translation import gettext as _
21
-
22
- from .entity import Entity
23
-
24
-
25
- def _data_root(user=None) -> Path:
26
- path = Path(getattr(user, "data_path", "") or Path(settings.BASE_DIR) / "data")
27
- path.mkdir(parents=True, exist_ok=True)
28
- return path
29
-
30
-
31
- def _username_for(user) -> str:
32
- username = ""
33
- if hasattr(user, "get_username"):
34
- username = user.get_username()
35
- if not username and hasattr(user, "username"):
36
- username = user.username
37
- if not username and getattr(user, "pk", None):
38
- username = str(user.pk)
39
- return username
40
-
41
-
42
- def _user_allows_user_data(user) -> bool:
43
- if not user:
44
- return False
45
- username = _username_for(user)
46
- UserModel = get_user_model()
47
- system_username = getattr(UserModel, "SYSTEM_USERNAME", "")
48
- if system_username and username == system_username:
49
- return True
50
- return not getattr(user, "is_profile_restricted", False)
51
-
52
-
53
- def _data_dir(user) -> Path:
54
- username = _username_for(user)
55
- if not username:
56
- raise ValueError("Cannot determine username for fixture directory")
57
- path = _data_root(user) / username
58
- path.mkdir(parents=True, exist_ok=True)
59
- return path
60
-
61
-
62
- def _fixture_path(user, instance) -> Path:
63
- model_meta = instance._meta.concrete_model._meta
64
- filename = f"{model_meta.app_label}_{model_meta.model_name}_{instance.pk}.json"
65
- return _data_dir(user) / filename
66
-
67
-
68
- def _seed_fixture_path(instance) -> Path | None:
69
- label = f"{instance._meta.app_label}.{instance._meta.model_name}"
70
- base = Path(settings.BASE_DIR)
71
- for path in base.glob("**/fixtures/*.json"):
72
- try:
73
- data = json.loads(path.read_text(encoding="utf-8"))
74
- except Exception:
75
- continue
76
- if not isinstance(data, list) or not data:
77
- continue
78
- obj = data[0]
79
- if obj.get("model") != label:
80
- continue
81
- pk = obj.get("pk")
82
- if pk is not None and pk == instance.pk:
83
- return path
84
- fields = obj.get("fields", {}) or {}
85
- comparable_fields = {
86
- key: value
87
- for key, value in fields.items()
88
- if key not in {"is_seed_data", "is_deleted", "is_user_data"}
89
- }
90
- if comparable_fields:
91
- match = True
92
- for field_name, value in comparable_fields.items():
93
- if not hasattr(instance, field_name):
94
- match = False
95
- break
96
- if getattr(instance, field_name) != value:
97
- match = False
98
- break
99
- if match:
100
- return path
101
- return None
102
-
103
-
104
- def _coerce_user(candidate, user_model):
105
- if candidate is None:
106
- return None
107
- if isinstance(candidate, user_model):
108
- return candidate
109
- if isinstance(candidate, LazyObject):
110
- try:
111
- candidate._setup()
112
- except Exception:
113
- return None
114
- return _coerce_user(candidate._wrapped, user_model)
115
- return None
116
-
117
-
118
- def _select_fixture_user(candidate, user_model):
119
- user = _coerce_user(candidate, user_model)
120
- visited: set[int] = set()
121
- while user is not None:
122
- identifier = user.pk or id(user)
123
- if identifier in visited:
124
- break
125
- visited.add(identifier)
126
- username = _username_for(user)
127
- admin_username = getattr(user_model, "ADMIN_USERNAME", "")
128
- if admin_username and username == admin_username:
129
- try:
130
- delegate = getattr(user, "operate_as", None)
131
- except user_model.DoesNotExist:
132
- delegate = None
133
- else:
134
- delegate = _coerce_user(delegate, user_model)
135
- if delegate is not None and delegate is not user:
136
- user = delegate
137
- continue
138
- if _user_allows_user_data(user):
139
- return user
140
- try:
141
- delegate = getattr(user, "operate_as", None)
142
- except user_model.DoesNotExist:
143
- delegate = None
144
- user = _coerce_user(delegate, user_model)
145
- return None
146
-
147
-
148
- def _resolve_fixture_user(instance, fallback=None):
149
- UserModel = get_user_model()
150
- owner = getattr(instance, "user", None)
151
- selected = _select_fixture_user(owner, UserModel)
152
- if selected is not None:
153
- return selected
154
- if hasattr(instance, "owner"):
155
- try:
156
- owner_value = instance.owner
157
- except Exception:
158
- owner_value = None
159
- else:
160
- selected = _select_fixture_user(owner_value, UserModel)
161
- if selected is not None:
162
- return selected
163
- selected = _select_fixture_user(fallback, UserModel)
164
- if selected is not None:
165
- return selected
166
- return fallback
167
-
168
-
169
- def dump_user_fixture(instance, user=None) -> None:
170
- model = instance._meta.concrete_model
171
- UserModel = get_user_model()
172
- if issubclass(UserModel, Entity) and isinstance(instance, UserModel):
173
- return
174
- target_user = user or _resolve_fixture_user(instance)
175
- if target_user is None:
176
- return
177
- allow_user_data = _user_allows_user_data(target_user)
178
- if not allow_user_data:
179
- is_user_data = getattr(instance, "is_user_data", False)
180
- if not is_user_data and instance.pk:
181
- stored_flag = (
182
- type(instance)
183
- .all_objects.filter(pk=instance.pk)
184
- .values_list("is_user_data", flat=True)
185
- .first()
186
- )
187
- is_user_data = bool(stored_flag)
188
- if not is_user_data:
189
- return
190
- meta = model._meta
191
- path = _fixture_path(target_user, instance)
192
- call_command(
193
- "dumpdata",
194
- f"{meta.app_label}.{meta.model_name}",
195
- indent=2,
196
- pks=str(instance.pk),
197
- output=str(path),
198
- use_natural_foreign_keys=True,
199
- )
200
-
201
-
202
- def delete_user_fixture(instance, user=None) -> None:
203
- target_user = user or _resolve_fixture_user(instance)
204
- if target_user is None:
205
- return
206
- _fixture_path(target_user, instance).unlink(missing_ok=True)
207
-
208
-
209
- def _mark_fixture_user_data(path: Path) -> None:
210
- try:
211
- content = path.read_text(encoding="utf-8")
212
- except UnicodeDecodeError:
213
- try:
214
- content = path.read_bytes().decode("latin-1")
215
- except Exception:
216
- return
217
- except Exception:
218
- return
219
- try:
220
- data = json.loads(content)
221
- except Exception:
222
- return
223
- if not isinstance(data, list):
224
- return
225
- for obj in data:
226
- label = obj.get("model")
227
- if not label:
228
- continue
229
- try:
230
- model = apps.get_model(label)
231
- except LookupError:
232
- continue
233
- if not issubclass(model, Entity):
234
- continue
235
- pk = obj.get("pk")
236
- if pk is None:
237
- continue
238
- model.all_objects.filter(pk=pk).update(is_user_data=True)
239
-
240
-
241
- def _fixture_targets_installed_apps(data) -> bool:
242
- """Return ``True`` when *data* only targets installed apps and models."""
243
-
244
- if not isinstance(data, list):
245
- return True
246
-
247
- labels = {
248
- obj.get("model")
249
- for obj in data
250
- if isinstance(obj, dict) and obj.get("model")
251
- }
252
-
253
- for label in labels:
254
- if not isinstance(label, str):
255
- continue
256
- if "." not in label:
257
- continue
258
- app_label, model_name = label.split(".", 1)
259
- if not app_label or not model_name:
260
- continue
261
- if not apps.is_installed(app_label):
262
- return False
263
- try:
264
- apps.get_model(label)
265
- except LookupError:
266
- return False
267
-
268
- return True
269
-
270
-
271
- def _load_fixture(path: Path, *, mark_user_data: bool = True) -> bool:
272
- """Load a fixture from *path* and optionally flag loaded entities."""
273
-
274
- text = None
275
- try:
276
- text = path.read_text(encoding="utf-8")
277
- except UnicodeDecodeError:
278
- try:
279
- text = path.read_bytes().decode("latin-1")
280
- except Exception:
281
- return False
282
- path.write_text(text, encoding="utf-8")
283
- except Exception:
284
- # Continue without cached text so ``call_command`` can surface the
285
- # underlying error just as before.
286
- pass
287
-
288
- if text is not None:
289
- try:
290
- data = json.loads(text)
291
- except Exception:
292
- data = None
293
- else:
294
- if isinstance(data, list):
295
- if not data:
296
- path.unlink(missing_ok=True)
297
- return False
298
- if not _fixture_targets_installed_apps(data):
299
- return False
300
-
301
- try:
302
- call_command("loaddata", str(path), ignorenonexistent=True)
303
- except Exception:
304
- return False
305
-
306
- if mark_user_data:
307
- _mark_fixture_user_data(path)
308
-
309
- return True
310
-
311
-
312
- def _fixture_sort_key(path: Path) -> tuple[int, str]:
313
- parts = path.name.split("_", 2)
314
- model_part = parts[1].lower() if len(parts) >= 2 else ""
315
- is_user = model_part == "user"
316
- return (0 if is_user else 1, path.name)
317
-
318
-
319
- def _is_user_fixture(path: Path) -> bool:
320
- parts = path.name.split("_", 2)
321
- return len(parts) >= 2 and parts[1].lower() == "user"
322
-
323
-
324
- def _get_request_ip(request) -> str:
325
- """Return the best-effort client IP for ``request``."""
326
-
327
- if request is None:
328
- return ""
329
-
330
- meta = getattr(request, "META", None)
331
- if not getattr(meta, "get", None):
332
- return ""
333
-
334
- forwarded = meta.get("HTTP_X_FORWARDED_FOR")
335
- if forwarded:
336
- for value in str(forwarded).split(","):
337
- candidate = value.strip()
338
- if candidate:
339
- return candidate
340
-
341
- remote = meta.get("REMOTE_ADDR")
342
- if remote:
343
- return str(remote).strip()
344
-
345
- return ""
346
-
347
-
348
- _shared_fixtures_loaded = False
349
-
350
-
351
- def load_shared_user_fixtures(*, force: bool = False, user=None) -> None:
352
- global _shared_fixtures_loaded
353
- if _shared_fixtures_loaded and not force:
354
- return
355
- root = _data_root(user)
356
- paths = sorted(root.glob("*.json"), key=_fixture_sort_key)
357
- for path in paths:
358
- if _is_user_fixture(path):
359
- continue
360
- _load_fixture(path)
361
- _shared_fixtures_loaded = True
362
-
363
-
364
- def load_user_fixtures(user, *, include_shared: bool = False) -> None:
365
- if include_shared:
366
- load_shared_user_fixtures(user=user)
367
- paths = sorted(_data_dir(user).glob("*.json"), key=_fixture_sort_key)
368
- for path in paths:
369
- if _is_user_fixture(path):
370
- continue
371
- _load_fixture(path)
372
-
373
-
374
- @receiver(user_logged_in)
375
- def _on_login(sender, request, user, **kwargs):
376
- load_user_fixtures(user, include_shared=not _shared_fixtures_loaded)
377
-
378
- if not (
379
- getattr(user, "is_staff", False) or getattr(user, "is_superuser", False)
380
- ):
381
- return
382
-
383
- username = _username_for(user) or "unknown"
384
- ip_address = _get_request_ip(request) or "unknown"
385
-
386
- from nodes.models import NetMessage
387
-
388
- NetMessage.broadcast(subject=f"login {username}", body=f"@ {ip_address}")
389
-
390
-
391
- @receiver(post_save, sender=settings.AUTH_USER_MODEL)
392
- def _on_user_created(sender, instance, created, **kwargs):
393
- if created:
394
- load_shared_user_fixtures(force=True, user=instance)
395
- load_user_fixtures(instance)
396
-
397
-
398
- class UserDatumAdminMixin(admin.ModelAdmin):
399
- """Mixin adding a *User Datum* checkbox to change forms."""
400
-
401
- def render_change_form(
402
- self, request, context, add=False, change=False, form_url="", obj=None
403
- ):
404
- context["show_user_datum"] = issubclass(self.model, Entity)
405
- context["show_seed_datum"] = issubclass(self.model, Entity)
406
- context["show_save_as_copy"] = issubclass(self.model, Entity) or hasattr(
407
- self.model, "clone"
408
- )
409
- if obj is not None:
410
- context["is_user_datum"] = getattr(obj, "is_user_data", False)
411
- context["is_seed_datum"] = getattr(obj, "is_seed_data", False)
412
- else:
413
- context["is_user_datum"] = False
414
- context["is_seed_datum"] = False
415
- return super().render_change_form(request, context, add, change, form_url, obj)
416
-
417
-
418
- class EntityModelAdmin(UserDatumAdminMixin, admin.ModelAdmin):
419
- """ModelAdmin base class for :class:`Entity` models."""
420
-
421
- change_form_template = "admin/user_datum_change_form.html"
422
-
423
- def save_model(self, request, obj, form, change):
424
- copied = "_saveacopy" in request.POST
425
- if copied:
426
- obj = obj.clone() if hasattr(obj, "clone") else obj
427
- obj.pk = None
428
- form.instance = obj
429
- try:
430
- super().save_model(request, obj, form, False)
431
- except Exception:
432
- messages.error(
433
- request,
434
- _("Unable to save copy. Adjust unique fields and try again."),
435
- )
436
- raise
437
- else:
438
- super().save_model(request, obj, form, change)
439
- if isinstance(obj, Entity):
440
- type(obj).all_objects.filter(pk=obj.pk).update(
441
- is_seed_data=obj.is_seed_data, is_user_data=obj.is_user_data
442
- )
443
- if copied:
444
- return
445
- if getattr(self, "_skip_entity_user_datum", False):
446
- return
447
-
448
- target_user = _resolve_fixture_user(obj, request.user)
449
- allow_user_data = _user_allows_user_data(target_user)
450
- if request.POST.get("_user_datum") == "on":
451
- if allow_user_data:
452
- if not obj.is_user_data:
453
- type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=True)
454
- obj.is_user_data = True
455
- dump_user_fixture(obj, target_user)
456
- handler = getattr(self, "user_datum_saved", None)
457
- if callable(handler):
458
- handler(request, obj)
459
- path = _fixture_path(target_user, obj)
460
- self.message_user(request, f"User datum saved to {path}")
461
- else:
462
- if obj.is_user_data:
463
- type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
464
- obj.is_user_data = False
465
- delete_user_fixture(obj, target_user)
466
- messages.warning(
467
- request,
468
- _("User data is not available for this account."),
469
- )
470
- elif obj.is_user_data:
471
- type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
472
- obj.is_user_data = False
473
- delete_user_fixture(obj, target_user)
474
- handler = getattr(self, "user_datum_deleted", None)
475
- if callable(handler):
476
- handler(request, obj)
477
-
478
-
479
- def patch_admin_user_datum() -> None:
480
- """Mixin all registered entity admin classes and future registrations."""
481
-
482
- if getattr(admin.site, "_user_datum_patched", False):
483
- return
484
-
485
- def _patched(admin_class):
486
- template = (
487
- getattr(admin_class, "change_form_template", None)
488
- or EntityModelAdmin.change_form_template
489
- )
490
- return type(
491
- f"Patched{admin_class.__name__}",
492
- (EntityModelAdmin, admin_class),
493
- {"change_form_template": template},
494
- )
495
-
496
- for model, model_admin in list(admin.site._registry.items()):
497
- if issubclass(model, Entity) and not isinstance(model_admin, EntityModelAdmin):
498
- admin.site.unregister(model)
499
- admin.site.register(model, _patched(model_admin.__class__))
500
-
501
- original_register = admin.site.register
502
-
503
- def register(model_or_iterable, admin_class=None, **options):
504
- models = model_or_iterable
505
- if not isinstance(models, (list, tuple, set)):
506
- models = [models]
507
- admin_class = admin_class or admin.ModelAdmin
508
- patched_class = admin_class
509
- for model in models:
510
- if issubclass(model, Entity) and not issubclass(
511
- patched_class, EntityModelAdmin
512
- ):
513
- patched_class = _patched(patched_class)
514
- return original_register(model_or_iterable, patched_class, **options)
515
-
516
- admin.site.register = register
517
- admin.site._user_datum_patched = True
518
-
519
-
520
- def _iter_entity_admin_models():
521
- """Yield registered :class:`Entity` admin models without proxy duplicates."""
522
-
523
- seen: set[type] = set()
524
- for model, model_admin in admin.site._registry.items():
525
- if not issubclass(model, Entity):
526
- continue
527
- concrete_model = model._meta.concrete_model
528
- if concrete_model in seen:
529
- continue
530
- seen.add(concrete_model)
531
- yield model, model_admin
532
-
533
-
534
- def _seed_data_view(request):
535
- sections = []
536
- for model, model_admin in _iter_entity_admin_models():
537
- objs = model.objects.filter(is_seed_data=True)
538
- if not objs.exists():
539
- continue
540
- items = []
541
- for obj in objs:
542
- url = reverse(
543
- f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
544
- args=[obj.pk],
545
- )
546
- fixture = _seed_fixture_path(obj)
547
- items.append({"url": url, "label": str(obj), "fixture": fixture})
548
- sections.append({"opts": model._meta, "items": items})
549
- context = admin.site.each_context(request)
550
- context.update({"title": _("Seed Data"), "sections": sections})
551
- return TemplateResponse(request, "admin/data_list.html", context)
552
-
553
-
554
- def _user_data_view(request):
555
- sections = []
556
- for model, model_admin in _iter_entity_admin_models():
557
- objs = model.objects.filter(is_user_data=True)
558
- if not objs.exists():
559
- continue
560
- items = []
561
- for obj in objs:
562
- url = reverse(
563
- f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
564
- args=[obj.pk],
565
- )
566
- fixture = _fixture_path(request.user, obj)
567
- items.append({"url": url, "label": str(obj), "fixture": fixture})
568
- sections.append({"opts": model._meta, "items": items})
569
- context = admin.site.each_context(request)
570
- context.update(
571
- {"title": _("User Data"), "sections": sections, "import_export": True}
572
- )
573
- return TemplateResponse(request, "admin/data_list.html", context)
574
-
575
-
576
- def _user_data_export(request):
577
- buffer = BytesIO()
578
- with ZipFile(buffer, "w") as zf:
579
- for path in _data_dir(request.user).glob("*.json"):
580
- zf.write(path, arcname=path.name)
581
- buffer.seek(0)
582
- response = HttpResponse(buffer.getvalue(), content_type="application/zip")
583
- response["Content-Disposition"] = (
584
- f"attachment; filename=user_data_{request.user.pk}.zip"
585
- )
586
- return response
587
-
588
-
589
- def _user_data_import(request):
590
- if request.method == "POST" and request.FILES.get("data_zip"):
591
- with ZipFile(request.FILES["data_zip"]) as zf:
592
- paths = []
593
- data_dir = _data_dir(request.user)
594
- for name in zf.namelist():
595
- if not name.endswith(".json"):
596
- continue
597
- content = zf.read(name)
598
- target = data_dir / name
599
- with target.open("wb") as f:
600
- f.write(content)
601
- paths.append(target)
602
- if paths:
603
- for path in paths:
604
- _load_fixture(path)
605
- return HttpResponseRedirect(reverse("admin:user_data"))
606
-
607
-
608
- def patch_admin_user_data_views() -> None:
609
- original_get_urls = admin.site.get_urls
610
-
611
- def get_urls():
612
- urls = original_get_urls()
613
- custom = [
614
- path(
615
- "seed-data/", admin.site.admin_view(_seed_data_view), name="seed_data"
616
- ),
617
- path(
618
- "user-data/", admin.site.admin_view(_user_data_view), name="user_data"
619
- ),
620
- path(
621
- "user-data/export/",
622
- admin.site.admin_view(_user_data_export),
623
- name="user_data_export",
624
- ),
625
- path(
626
- "user-data/import/",
627
- admin.site.admin_view(_user_data_import),
628
- name="user_data_import",
629
- ),
630
- ]
631
- return custom + urls
632
-
633
- admin.site.get_urls = get_urls
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from io import BytesIO
5
+ from zipfile import ZipFile
6
+ import json
7
+
8
+ from django.apps import apps
9
+ from django.conf import settings
10
+ from django.contrib import admin, messages
11
+ from django.contrib.auth import get_user_model
12
+ from django.contrib.auth.signals import user_logged_in
13
+ from django.core.management import call_command
14
+ from django.db.models.signals import post_save
15
+ from django.dispatch import receiver
16
+ from django.http import HttpResponse, HttpResponseRedirect
17
+ from django.template.response import TemplateResponse
18
+ from django.urls import path, reverse
19
+ from django.utils.functional import LazyObject
20
+ from django.utils.translation import gettext as _
21
+
22
+ from .entity import Entity
23
+
24
+
25
+ def _data_root(user=None) -> Path:
26
+ path = Path(getattr(user, "data_path", "") or Path(settings.BASE_DIR) / "data")
27
+ path.mkdir(parents=True, exist_ok=True)
28
+ return path
29
+
30
+
31
+ def _username_for(user) -> str:
32
+ username = ""
33
+ if hasattr(user, "get_username"):
34
+ username = user.get_username()
35
+ if not username and hasattr(user, "username"):
36
+ username = user.username
37
+ if not username and getattr(user, "pk", None):
38
+ username = str(user.pk)
39
+ return username
40
+
41
+
42
+ def _user_allows_user_data(user) -> bool:
43
+ if not user:
44
+ return False
45
+ username = _username_for(user)
46
+ UserModel = get_user_model()
47
+ system_username = getattr(UserModel, "SYSTEM_USERNAME", "")
48
+ if system_username and username == system_username:
49
+ return True
50
+ return not getattr(user, "is_profile_restricted", False)
51
+
52
+
53
+ def _data_dir(user) -> Path:
54
+ username = _username_for(user)
55
+ if not username:
56
+ raise ValueError("Cannot determine username for fixture directory")
57
+ path = _data_root(user) / username
58
+ path.mkdir(parents=True, exist_ok=True)
59
+ return path
60
+
61
+
62
+ def _fixture_path(user, instance) -> Path:
63
+ model_meta = instance._meta.concrete_model._meta
64
+ filename = f"{model_meta.app_label}_{model_meta.model_name}_{instance.pk}.json"
65
+ return _data_dir(user) / filename
66
+
67
+
68
+ def _seed_fixture_path(instance) -> Path | None:
69
+ label = f"{instance._meta.app_label}.{instance._meta.model_name}"
70
+ base = Path(settings.BASE_DIR)
71
+ for path in base.glob("**/fixtures/*.json"):
72
+ try:
73
+ data = json.loads(path.read_text(encoding="utf-8"))
74
+ except Exception:
75
+ continue
76
+ if not isinstance(data, list) or not data:
77
+ continue
78
+ obj = data[0]
79
+ if obj.get("model") != label:
80
+ continue
81
+ pk = obj.get("pk")
82
+ if pk is not None and pk == instance.pk:
83
+ return path
84
+ fields = obj.get("fields", {}) or {}
85
+ comparable_fields = {
86
+ key: value
87
+ for key, value in fields.items()
88
+ if key not in {"is_seed_data", "is_deleted", "is_user_data"}
89
+ }
90
+ if comparable_fields:
91
+ match = True
92
+ for field_name, value in comparable_fields.items():
93
+ if not hasattr(instance, field_name):
94
+ match = False
95
+ break
96
+ if getattr(instance, field_name) != value:
97
+ match = False
98
+ break
99
+ if match:
100
+ return path
101
+ return None
102
+
103
+
104
+ def _coerce_user(candidate, user_model):
105
+ if candidate is None:
106
+ return None
107
+ if isinstance(candidate, user_model):
108
+ return candidate
109
+ if isinstance(candidate, LazyObject):
110
+ try:
111
+ candidate._setup()
112
+ except Exception:
113
+ return None
114
+ return _coerce_user(candidate._wrapped, user_model)
115
+ return None
116
+
117
+
118
+ def _select_fixture_user(candidate, user_model):
119
+ user = _coerce_user(candidate, user_model)
120
+ visited: set[int] = set()
121
+ while user is not None:
122
+ identifier = user.pk or id(user)
123
+ if identifier in visited:
124
+ break
125
+ visited.add(identifier)
126
+ username = _username_for(user)
127
+ admin_username = getattr(user_model, "ADMIN_USERNAME", "")
128
+ if admin_username and username == admin_username:
129
+ try:
130
+ delegate = getattr(user, "operate_as", None)
131
+ except user_model.DoesNotExist:
132
+ delegate = None
133
+ else:
134
+ delegate = _coerce_user(delegate, user_model)
135
+ if delegate is not None and delegate is not user:
136
+ user = delegate
137
+ continue
138
+ if _user_allows_user_data(user):
139
+ return user
140
+ try:
141
+ delegate = getattr(user, "operate_as", None)
142
+ except user_model.DoesNotExist:
143
+ delegate = None
144
+ user = _coerce_user(delegate, user_model)
145
+ return None
146
+
147
+
148
+ def _resolve_fixture_user(instance, fallback=None):
149
+ UserModel = get_user_model()
150
+ owner = getattr(instance, "user", None)
151
+ selected = _select_fixture_user(owner, UserModel)
152
+ if selected is not None:
153
+ return selected
154
+ if hasattr(instance, "owner"):
155
+ try:
156
+ owner_value = instance.owner
157
+ except Exception:
158
+ owner_value = None
159
+ else:
160
+ selected = _select_fixture_user(owner_value, UserModel)
161
+ if selected is not None:
162
+ return selected
163
+ selected = _select_fixture_user(fallback, UserModel)
164
+ if selected is not None:
165
+ return selected
166
+ return fallback
167
+
168
+
169
+ def dump_user_fixture(instance, user=None) -> None:
170
+ model = instance._meta.concrete_model
171
+ UserModel = get_user_model()
172
+ if issubclass(UserModel, Entity) and isinstance(instance, UserModel):
173
+ return
174
+ target_user = user or _resolve_fixture_user(instance)
175
+ if target_user is None:
176
+ return
177
+ allow_user_data = _user_allows_user_data(target_user)
178
+ if not allow_user_data:
179
+ is_user_data = getattr(instance, "is_user_data", False)
180
+ if not is_user_data and instance.pk:
181
+ stored_flag = (
182
+ type(instance)
183
+ .all_objects.filter(pk=instance.pk)
184
+ .values_list("is_user_data", flat=True)
185
+ .first()
186
+ )
187
+ is_user_data = bool(stored_flag)
188
+ if not is_user_data:
189
+ return
190
+ meta = model._meta
191
+ path = _fixture_path(target_user, instance)
192
+ call_command(
193
+ "dumpdata",
194
+ f"{meta.app_label}.{meta.model_name}",
195
+ indent=2,
196
+ pks=str(instance.pk),
197
+ output=str(path),
198
+ use_natural_foreign_keys=True,
199
+ )
200
+
201
+
202
+ def delete_user_fixture(instance, user=None) -> None:
203
+ target_user = user or _resolve_fixture_user(instance)
204
+ if target_user is None:
205
+ return
206
+ _fixture_path(target_user, instance).unlink(missing_ok=True)
207
+
208
+
209
+ def _mark_fixture_user_data(path: Path) -> None:
210
+ try:
211
+ content = path.read_text(encoding="utf-8")
212
+ except UnicodeDecodeError:
213
+ try:
214
+ content = path.read_bytes().decode("latin-1")
215
+ except Exception:
216
+ return
217
+ except Exception:
218
+ return
219
+ try:
220
+ data = json.loads(content)
221
+ except Exception:
222
+ return
223
+ if not isinstance(data, list):
224
+ return
225
+ for obj in data:
226
+ label = obj.get("model")
227
+ if not label:
228
+ continue
229
+ try:
230
+ model = apps.get_model(label)
231
+ except LookupError:
232
+ continue
233
+ if not issubclass(model, Entity):
234
+ continue
235
+ pk = obj.get("pk")
236
+ if pk is None:
237
+ continue
238
+ model.all_objects.filter(pk=pk).update(is_user_data=True)
239
+
240
+
241
+ def _fixture_targets_installed_apps(data) -> bool:
242
+ """Return ``True`` when *data* only targets installed apps and models."""
243
+
244
+ if not isinstance(data, list):
245
+ return True
246
+
247
+ labels = {
248
+ obj.get("model")
249
+ for obj in data
250
+ if isinstance(obj, dict) and obj.get("model")
251
+ }
252
+
253
+ for label in labels:
254
+ if not isinstance(label, str):
255
+ continue
256
+ if "." not in label:
257
+ continue
258
+ app_label, model_name = label.split(".", 1)
259
+ if not app_label or not model_name:
260
+ continue
261
+ if not apps.is_installed(app_label):
262
+ return False
263
+ try:
264
+ apps.get_model(label)
265
+ except LookupError:
266
+ return False
267
+
268
+ return True
269
+
270
+
271
+ def _load_fixture(path: Path, *, mark_user_data: bool = True) -> bool:
272
+ """Load a fixture from *path* and optionally flag loaded entities."""
273
+
274
+ text = None
275
+ try:
276
+ text = path.read_text(encoding="utf-8")
277
+ except UnicodeDecodeError:
278
+ try:
279
+ text = path.read_bytes().decode("latin-1")
280
+ except Exception:
281
+ return False
282
+ path.write_text(text, encoding="utf-8")
283
+ except Exception:
284
+ # Continue without cached text so ``call_command`` can surface the
285
+ # underlying error just as before.
286
+ pass
287
+
288
+ if text is not None:
289
+ try:
290
+ data = json.loads(text)
291
+ except Exception:
292
+ data = None
293
+ else:
294
+ if isinstance(data, list):
295
+ if not data:
296
+ path.unlink(missing_ok=True)
297
+ return False
298
+ if not _fixture_targets_installed_apps(data):
299
+ return False
300
+
301
+ try:
302
+ call_command("loaddata", str(path), ignorenonexistent=True)
303
+ except Exception:
304
+ return False
305
+
306
+ if mark_user_data:
307
+ _mark_fixture_user_data(path)
308
+
309
+ return True
310
+
311
+
312
+ def _fixture_sort_key(path: Path) -> tuple[int, str]:
313
+ parts = path.name.split("_", 2)
314
+ model_part = parts[1].lower() if len(parts) >= 2 else ""
315
+ is_user = model_part == "user"
316
+ return (0 if is_user else 1, path.name)
317
+
318
+
319
+ def _is_user_fixture(path: Path) -> bool:
320
+ parts = path.name.split("_", 2)
321
+ return len(parts) >= 2 and parts[1].lower() == "user"
322
+
323
+
324
+ def _get_request_ip(request) -> str:
325
+ """Return the best-effort client IP for ``request``."""
326
+
327
+ if request is None:
328
+ return ""
329
+
330
+ meta = getattr(request, "META", None)
331
+ if not getattr(meta, "get", None):
332
+ return ""
333
+
334
+ forwarded = meta.get("HTTP_X_FORWARDED_FOR")
335
+ if forwarded:
336
+ for value in str(forwarded).split(","):
337
+ candidate = value.strip()
338
+ if candidate:
339
+ return candidate
340
+
341
+ remote = meta.get("REMOTE_ADDR")
342
+ if remote:
343
+ return str(remote).strip()
344
+
345
+ return ""
346
+
347
+
348
+ _shared_fixtures_loaded = False
349
+
350
+
351
+ def load_shared_user_fixtures(*, force: bool = False, user=None) -> None:
352
+ global _shared_fixtures_loaded
353
+ if _shared_fixtures_loaded and not force:
354
+ return
355
+ root = _data_root(user)
356
+ paths = sorted(root.glob("*.json"), key=_fixture_sort_key)
357
+ for path in paths:
358
+ if _is_user_fixture(path):
359
+ continue
360
+ _load_fixture(path)
361
+ _shared_fixtures_loaded = True
362
+
363
+
364
+ def load_user_fixtures(user, *, include_shared: bool = False) -> None:
365
+ if include_shared:
366
+ load_shared_user_fixtures(user=user)
367
+ paths = sorted(_data_dir(user).glob("*.json"), key=_fixture_sort_key)
368
+ for path in paths:
369
+ if _is_user_fixture(path):
370
+ continue
371
+ _load_fixture(path)
372
+
373
+
374
+ @receiver(user_logged_in)
375
+ def _on_login(sender, request, user, **kwargs):
376
+ load_user_fixtures(user, include_shared=not _shared_fixtures_loaded)
377
+
378
+ if not (
379
+ getattr(user, "is_staff", False) or getattr(user, "is_superuser", False)
380
+ ):
381
+ return
382
+
383
+ username = _username_for(user) or "unknown"
384
+ ip_address = _get_request_ip(request) or "unknown"
385
+
386
+ from nodes.models import NetMessage
387
+
388
+ NetMessage.broadcast(subject=f"login {username}", body=f"@ {ip_address}")
389
+
390
+
391
+ @receiver(post_save, sender=settings.AUTH_USER_MODEL)
392
+ def _on_user_created(sender, instance, created, **kwargs):
393
+ if created:
394
+ load_shared_user_fixtures(force=True, user=instance)
395
+ load_user_fixtures(instance)
396
+
397
+
398
+ class UserDatumAdminMixin(admin.ModelAdmin):
399
+ """Mixin adding a *User Datum* checkbox to change forms."""
400
+
401
+ def render_change_form(
402
+ self, request, context, add=False, change=False, form_url="", obj=None
403
+ ):
404
+ supports_user_datum = issubclass(self.model, Entity) or getattr(
405
+ self.model, "supports_user_datum", False
406
+ )
407
+ supports_seed_datum = issubclass(self.model, Entity) or getattr(
408
+ self.model, "supports_seed_datum", supports_user_datum
409
+ )
410
+ context["show_user_datum"] = supports_user_datum
411
+ context["show_seed_datum"] = supports_seed_datum
412
+ context["show_save_as_copy"] = (
413
+ issubclass(self.model, Entity)
414
+ or getattr(self.model, "supports_save_as_copy", False)
415
+ or hasattr(self.model, "clone")
416
+ )
417
+ if obj is not None:
418
+ context["is_user_datum"] = getattr(obj, "is_user_data", False)
419
+ context["is_seed_datum"] = getattr(obj, "is_seed_data", False)
420
+ else:
421
+ context["is_user_datum"] = False
422
+ context["is_seed_datum"] = False
423
+ return super().render_change_form(request, context, add, change, form_url, obj)
424
+
425
+
426
+ class EntityModelAdmin(UserDatumAdminMixin, admin.ModelAdmin):
427
+ """ModelAdmin base class for :class:`Entity` models."""
428
+
429
+ change_form_template = "admin/user_datum_change_form.html"
430
+
431
+ def save_model(self, request, obj, form, change):
432
+ copied = "_saveacopy" in request.POST
433
+ if copied:
434
+ obj = obj.clone() if hasattr(obj, "clone") else obj
435
+ obj.pk = None
436
+ form.instance = obj
437
+ try:
438
+ super().save_model(request, obj, form, False)
439
+ except Exception:
440
+ messages.error(
441
+ request,
442
+ _("Unable to save copy. Adjust unique fields and try again."),
443
+ )
444
+ raise
445
+ else:
446
+ super().save_model(request, obj, form, change)
447
+ if isinstance(obj, Entity):
448
+ type(obj).all_objects.filter(pk=obj.pk).update(
449
+ is_seed_data=obj.is_seed_data, is_user_data=obj.is_user_data
450
+ )
451
+ if copied:
452
+ return
453
+ if getattr(self, "_skip_entity_user_datum", False):
454
+ return
455
+
456
+ target_user = _resolve_fixture_user(obj, request.user)
457
+ allow_user_data = _user_allows_user_data(target_user)
458
+ if request.POST.get("_user_datum") == "on":
459
+ if allow_user_data:
460
+ if not obj.is_user_data:
461
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=True)
462
+ obj.is_user_data = True
463
+ dump_user_fixture(obj, target_user)
464
+ handler = getattr(self, "user_datum_saved", None)
465
+ if callable(handler):
466
+ handler(request, obj)
467
+ path = _fixture_path(target_user, obj)
468
+ self.message_user(request, f"User datum saved to {path}")
469
+ else:
470
+ if obj.is_user_data:
471
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
472
+ obj.is_user_data = False
473
+ delete_user_fixture(obj, target_user)
474
+ messages.warning(
475
+ request,
476
+ _("User data is not available for this account."),
477
+ )
478
+ elif obj.is_user_data:
479
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
480
+ obj.is_user_data = False
481
+ delete_user_fixture(obj, target_user)
482
+ handler = getattr(self, "user_datum_deleted", None)
483
+ if callable(handler):
484
+ handler(request, obj)
485
+
486
+
487
+ def patch_admin_user_datum() -> None:
488
+ """Mixin all registered entity admin classes and future registrations."""
489
+
490
+ if getattr(admin.site, "_user_datum_patched", False):
491
+ return
492
+
493
+ def _patched(admin_class):
494
+ template = (
495
+ getattr(admin_class, "change_form_template", None)
496
+ or EntityModelAdmin.change_form_template
497
+ )
498
+ return type(
499
+ f"Patched{admin_class.__name__}",
500
+ (EntityModelAdmin, admin_class),
501
+ {"change_form_template": template},
502
+ )
503
+
504
+ for model, model_admin in list(admin.site._registry.items()):
505
+ if issubclass(model, Entity) and not isinstance(model_admin, EntityModelAdmin):
506
+ admin.site.unregister(model)
507
+ admin.site.register(model, _patched(model_admin.__class__))
508
+
509
+ original_register = admin.site.register
510
+
511
+ def register(model_or_iterable, admin_class=None, **options):
512
+ models = model_or_iterable
513
+ if not isinstance(models, (list, tuple, set)):
514
+ models = [models]
515
+ admin_class = admin_class or admin.ModelAdmin
516
+ patched_class = admin_class
517
+ for model in models:
518
+ if issubclass(model, Entity) and not issubclass(
519
+ patched_class, EntityModelAdmin
520
+ ):
521
+ patched_class = _patched(patched_class)
522
+ return original_register(model_or_iterable, patched_class, **options)
523
+
524
+ admin.site.register = register
525
+ admin.site._user_datum_patched = True
526
+
527
+
528
+ def _iter_entity_admin_models():
529
+ """Yield registered :class:`Entity` admin models without proxy duplicates."""
530
+
531
+ seen: set[type] = set()
532
+ for model, model_admin in admin.site._registry.items():
533
+ if not issubclass(model, Entity):
534
+ continue
535
+ concrete_model = model._meta.concrete_model
536
+ if concrete_model in seen:
537
+ continue
538
+ seen.add(concrete_model)
539
+ yield model, model_admin
540
+
541
+
542
+ def _seed_data_view(request):
543
+ sections = []
544
+ for model, model_admin in _iter_entity_admin_models():
545
+ objs = model.objects.filter(is_seed_data=True)
546
+ if not objs.exists():
547
+ continue
548
+ items = []
549
+ for obj in objs:
550
+ url = reverse(
551
+ f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
552
+ args=[obj.pk],
553
+ )
554
+ fixture = _seed_fixture_path(obj)
555
+ items.append({"url": url, "label": str(obj), "fixture": fixture})
556
+ sections.append({"opts": model._meta, "items": items})
557
+ context = admin.site.each_context(request)
558
+ context.update({"title": _("Seed Data"), "sections": sections})
559
+ return TemplateResponse(request, "admin/data_list.html", context)
560
+
561
+
562
+ def _user_data_view(request):
563
+ sections = []
564
+ for model, model_admin in _iter_entity_admin_models():
565
+ objs = model.objects.filter(is_user_data=True)
566
+ if not objs.exists():
567
+ continue
568
+ items = []
569
+ for obj in objs:
570
+ url = reverse(
571
+ f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
572
+ args=[obj.pk],
573
+ )
574
+ fixture = _fixture_path(request.user, obj)
575
+ items.append({"url": url, "label": str(obj), "fixture": fixture})
576
+ sections.append({"opts": model._meta, "items": items})
577
+ context = admin.site.each_context(request)
578
+ context.update(
579
+ {"title": _("User Data"), "sections": sections, "import_export": True}
580
+ )
581
+ return TemplateResponse(request, "admin/data_list.html", context)
582
+
583
+
584
+ def _user_data_export(request):
585
+ buffer = BytesIO()
586
+ with ZipFile(buffer, "w") as zf:
587
+ for path in _data_dir(request.user).glob("*.json"):
588
+ zf.write(path, arcname=path.name)
589
+ buffer.seek(0)
590
+ response = HttpResponse(buffer.getvalue(), content_type="application/zip")
591
+ response["Content-Disposition"] = (
592
+ f"attachment; filename=user_data_{request.user.pk}.zip"
593
+ )
594
+ return response
595
+
596
+
597
+ def _user_data_import(request):
598
+ if request.method == "POST" and request.FILES.get("data_zip"):
599
+ with ZipFile(request.FILES["data_zip"]) as zf:
600
+ paths = []
601
+ data_dir = _data_dir(request.user)
602
+ for name in zf.namelist():
603
+ if not name.endswith(".json"):
604
+ continue
605
+ content = zf.read(name)
606
+ target = data_dir / name
607
+ with target.open("wb") as f:
608
+ f.write(content)
609
+ paths.append(target)
610
+ if paths:
611
+ for path in paths:
612
+ _load_fixture(path)
613
+ return HttpResponseRedirect(reverse("admin:user_data"))
614
+
615
+
616
+ def patch_admin_user_data_views() -> None:
617
+ original_get_urls = admin.site.get_urls
618
+
619
+ def get_urls():
620
+ urls = original_get_urls()
621
+ custom = [
622
+ path(
623
+ "seed-data/", admin.site.admin_view(_seed_data_view), name="seed_data"
624
+ ),
625
+ path(
626
+ "user-data/", admin.site.admin_view(_user_data_view), name="user_data"
627
+ ),
628
+ path(
629
+ "user-data/export/",
630
+ admin.site.admin_view(_user_data_export),
631
+ name="user_data_export",
632
+ ),
633
+ path(
634
+ "user-data/import/",
635
+ admin.site.admin_view(_user_data_import),
636
+ name="user_data_import",
637
+ ),
638
+ ]
639
+ return custom + urls
640
+
641
+ admin.site.get_urls = get_urls