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