arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/user_data.py CHANGED
@@ -5,134 +5,362 @@ from io import BytesIO
5
5
  from zipfile import ZipFile
6
6
  import json
7
7
 
8
+ from django.apps import apps
8
9
  from django.conf import settings
9
- from django.contrib import admin
10
- from django.contrib import messages
11
- from django.contrib.contenttypes.fields import GenericForeignKey
12
- from django.contrib.contenttypes.models import ContentType
13
- from django.core.exceptions import ValidationError
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
14
13
  from django.core.management import call_command
15
- from django.db import IntegrityError, models
16
- from django.db.models.signals import post_delete, post_save
14
+ from django.db.models.signals import post_save
17
15
  from django.dispatch import receiver
16
+ from django.http import HttpResponse, HttpResponseRedirect
18
17
  from django.template.response import TemplateResponse
19
18
  from django.urls import path, reverse
20
- from django.http import HttpResponse, HttpResponseRedirect
19
+ from django.utils.functional import LazyObject
21
20
  from django.utils.translation import gettext as _
22
21
 
23
22
  from .entity import Entity
24
23
 
25
24
 
26
- class UserDatum(models.Model):
27
- """Link an :class:`Entity` instance to a user for persistence."""
28
-
29
- user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
30
- content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
31
- object_id = models.PositiveIntegerField()
32
- entity = GenericForeignKey("content_type", "object_id")
33
- created_on = models.DateTimeField(auto_now_add=True)
34
-
35
- class Meta:
36
- unique_together = ("user", "content_type", "object_id")
37
- verbose_name = "User Datum"
38
- verbose_name_plural = "User Data"
39
-
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
40
29
 
41
- # ---- Fixture utilities ---------------------------------------------------
42
30
 
43
- def _data_dir() -> Path:
44
- path = Path(settings.BASE_DIR) / "data"
45
- path.mkdir(exist_ok=True)
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)
46
59
  return path
47
60
 
48
61
 
49
62
  def _fixture_path(user, instance) -> Path:
50
- ct = ContentType.objects.get_for_model(instance)
51
- filename = f"{user.pk}_{ct.app_label}_{ct.model}_{instance.pk}.json"
52
- return _data_dir() / filename
53
-
54
-
55
- _seed_fixture_cache: dict[tuple[str, int], Path] | None = None
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
56
66
 
57
67
 
58
68
  def _seed_fixture_path(instance) -> Path | None:
59
- """Return the fixture file containing the given seed datum."""
60
- global _seed_fixture_cache
61
- if _seed_fixture_cache is None:
62
- _seed_fixture_cache = {}
63
- base = Path(settings.BASE_DIR)
64
- for path in base.rglob("fixtures/*.json"):
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:
65
129
  try:
66
- with path.open() as f:
67
- data = json.load(f)
68
- except Exception:
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
69
137
  continue
70
- for obj in data:
71
- model = obj.get("model")
72
- pk = obj.get("pk")
73
- if model and pk is not None:
74
- _seed_fixture_cache[(model, pk)] = path
75
- key = (f"{instance._meta.app_label}.{instance._meta.model_name}", instance.pk)
76
- return _seed_fixture_cache.get(key)
77
-
78
-
79
- def dump_user_fixture(instance, user) -> None:
80
- path = _fixture_path(user, instance)
81
- app_label = instance._meta.app_label
82
- model_name = instance._meta.model_name
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)
83
192
  call_command(
84
193
  "dumpdata",
85
- f"{app_label}.{model_name}",
194
+ f"{meta.app_label}.{meta.model_name}",
86
195
  indent=2,
87
196
  pks=str(instance.pk),
88
197
  output=str(path),
198
+ use_natural_foreign_keys=True,
89
199
  )
90
200
 
91
201
 
92
- def delete_user_fixture(instance, user) -> None:
93
- _fixture_path(user, instance).unlink(missing_ok=True)
94
-
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)
95
207
 
96
- # ---- Signals -------------------------------------------------------------
97
208
 
98
- @receiver(post_save)
99
- def _entity_saved(sender, instance, **kwargs):
100
- if isinstance(instance, UserDatum):
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:
101
218
  return
102
219
  try:
103
- ct = ContentType.objects.get_for_model(instance)
104
- qs = list(UserDatum.objects.filter(content_type=ct, object_id=instance.pk))
220
+ data = json.loads(content)
105
221
  except Exception:
106
222
  return
107
- for ud in qs:
108
- dump_user_fixture(instance, ud.user)
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)
109
239
 
110
240
 
111
- @receiver(post_delete)
112
- def _entity_deleted(sender, instance, **kwargs):
113
- if isinstance(instance, UserDatum):
114
- return
241
+ def _load_fixture(path: Path, *, mark_user_data: bool = True) -> bool:
242
+ """Load a fixture from *path* and optionally flag loaded entities."""
243
+
244
+ text = None
115
245
  try:
116
- ct = ContentType.objects.get_for_model(instance)
117
- qs = list(UserDatum.objects.filter(content_type=ct, object_id=instance.pk))
246
+ text = path.read_text(encoding="utf-8")
247
+ except UnicodeDecodeError:
248
+ try:
249
+ text = path.read_bytes().decode("latin-1")
250
+ except Exception:
251
+ return False
252
+ path.write_text(text, encoding="utf-8")
118
253
  except Exception:
254
+ # Continue without cached text so ``call_command`` can surface the
255
+ # underlying error just as before.
256
+ pass
257
+
258
+ if text is not None:
259
+ try:
260
+ data = json.loads(text)
261
+ except Exception:
262
+ data = None
263
+ else:
264
+ if isinstance(data, list) and not data:
265
+ path.unlink(missing_ok=True)
266
+ return False
267
+
268
+ try:
269
+ call_command("loaddata", str(path), ignorenonexistent=True)
270
+ except Exception:
271
+ return False
272
+
273
+ if mark_user_data:
274
+ _mark_fixture_user_data(path)
275
+
276
+ return True
277
+
278
+
279
+ def _fixture_sort_key(path: Path) -> tuple[int, str]:
280
+ parts = path.name.split("_", 2)
281
+ model_part = parts[1].lower() if len(parts) >= 2 else ""
282
+ is_user = model_part == "user"
283
+ return (0 if is_user else 1, path.name)
284
+
285
+
286
+ def _is_user_fixture(path: Path) -> bool:
287
+ parts = path.name.split("_", 2)
288
+ return len(parts) >= 2 and parts[1].lower() == "user"
289
+
290
+
291
+ def _get_request_ip(request) -> str:
292
+ """Return the best-effort client IP for ``request``."""
293
+
294
+ if request is None:
295
+ return ""
296
+
297
+ meta = getattr(request, "META", None)
298
+ if not getattr(meta, "get", None):
299
+ return ""
300
+
301
+ forwarded = meta.get("HTTP_X_FORWARDED_FOR")
302
+ if forwarded:
303
+ for value in str(forwarded).split(","):
304
+ candidate = value.strip()
305
+ if candidate:
306
+ return candidate
307
+
308
+ remote = meta.get("REMOTE_ADDR")
309
+ if remote:
310
+ return str(remote).strip()
311
+
312
+ return ""
313
+
314
+
315
+ _shared_fixtures_loaded = False
316
+
317
+
318
+ def load_shared_user_fixtures(*, force: bool = False, user=None) -> None:
319
+ global _shared_fixtures_loaded
320
+ if _shared_fixtures_loaded and not force:
321
+ return
322
+ root = _data_root(user)
323
+ paths = sorted(root.glob("*.json"), key=_fixture_sort_key)
324
+ for path in paths:
325
+ if _is_user_fixture(path):
326
+ continue
327
+ _load_fixture(path)
328
+ _shared_fixtures_loaded = True
329
+
330
+
331
+ def load_user_fixtures(user, *, include_shared: bool = False) -> None:
332
+ if include_shared:
333
+ load_shared_user_fixtures(user=user)
334
+ paths = sorted(_data_dir(user).glob("*.json"), key=_fixture_sort_key)
335
+ for path in paths:
336
+ if _is_user_fixture(path):
337
+ continue
338
+ _load_fixture(path)
339
+
340
+
341
+ @receiver(user_logged_in)
342
+ def _on_login(sender, request, user, **kwargs):
343
+ load_user_fixtures(user, include_shared=not _shared_fixtures_loaded)
344
+
345
+ if not (
346
+ getattr(user, "is_staff", False) or getattr(user, "is_superuser", False)
347
+ ):
119
348
  return
120
- for ud in qs:
121
- delete_user_fixture(instance, ud.user)
122
- ud.delete()
123
349
 
350
+ username = _username_for(user) or "unknown"
351
+ ip_address = _get_request_ip(request) or "unknown"
124
352
 
125
- @receiver(post_save, sender=UserDatum)
126
- def _userdatum_saved(sender, instance, **kwargs):
127
- dump_user_fixture(instance.entity, instance.user)
353
+ from nodes.models import NetMessage
128
354
 
355
+ NetMessage.broadcast(subject=f"login {username}", body=f"@ {ip_address}")
129
356
 
130
- @receiver(post_delete, sender=UserDatum)
131
- def _userdatum_deleted(sender, instance, **kwargs):
132
- delete_user_fixture(instance.entity, instance.user)
133
357
 
358
+ @receiver(post_save, sender=settings.AUTH_USER_MODEL)
359
+ def _on_user_created(sender, instance, created, **kwargs):
360
+ if created:
361
+ load_shared_user_fixtures(force=True, user=instance)
362
+ load_user_fixtures(instance)
134
363
 
135
- # ---- Admin integration ---------------------------------------------------
136
364
 
137
365
  class UserDatumAdminMixin(admin.ModelAdmin):
138
366
  """Mixin adding a *User Datum* checkbox to change forms."""
@@ -140,19 +368,25 @@ class UserDatumAdminMixin(admin.ModelAdmin):
140
368
  def render_change_form(
141
369
  self, request, context, add=False, change=False, form_url="", obj=None
142
370
  ):
143
- context["show_user_datum"] = True
371
+ context["show_user_datum"] = issubclass(self.model, Entity)
372
+ context["show_seed_datum"] = issubclass(self.model, Entity)
144
373
  context["show_save_as_copy"] = issubclass(self.model, Entity) or hasattr(
145
374
  self.model, "clone"
146
375
  )
147
376
  if obj is not None:
148
- ct = ContentType.objects.get_for_model(obj)
149
- context["is_user_datum"] = UserDatum.objects.filter(
150
- user=request.user, content_type=ct, object_id=obj.pk
151
- ).exists()
377
+ context["is_user_datum"] = getattr(obj, "is_user_data", False)
378
+ context["is_seed_datum"] = getattr(obj, "is_seed_data", False)
152
379
  else:
153
380
  context["is_user_datum"] = False
381
+ context["is_seed_datum"] = False
154
382
  return super().render_change_form(request, context, add, change, form_url, obj)
155
383
 
384
+
385
+ class EntityModelAdmin(UserDatumAdminMixin, admin.ModelAdmin):
386
+ """ModelAdmin base class for :class:`Entity` models."""
387
+
388
+ change_form_template = "admin/user_datum_change_form.html"
389
+
156
390
  def save_model(self, request, obj, form, change):
157
391
  copied = "_saveacopy" in request.POST
158
392
  if copied:
@@ -161,57 +395,96 @@ class UserDatumAdminMixin(admin.ModelAdmin):
161
395
  form.instance = obj
162
396
  try:
163
397
  super().save_model(request, obj, form, False)
164
- except IntegrityError:
165
- self.message_user(
398
+ except Exception:
399
+ messages.error(
166
400
  request,
167
401
  _("Unable to save copy. Adjust unique fields and try again."),
168
- messages.ERROR,
169
402
  )
170
- raise ValidationError("save_as_copy")
403
+ raise
171
404
  else:
172
405
  super().save_model(request, obj, form, change)
406
+ if isinstance(obj, Entity):
407
+ type(obj).all_objects.filter(pk=obj.pk).update(
408
+ is_seed_data=obj.is_seed_data, is_user_data=obj.is_user_data
409
+ )
173
410
  if copied:
174
411
  return
175
- ct = ContentType.objects.get_for_model(obj)
412
+ if getattr(self, "_skip_entity_user_datum", False):
413
+ return
414
+
415
+ target_user = _resolve_fixture_user(obj, request.user)
416
+ allow_user_data = _user_allows_user_data(target_user)
176
417
  if request.POST.get("_user_datum") == "on":
177
- UserDatum.objects.get_or_create(
178
- user=request.user, content_type=ct, object_id=obj.pk
179
- )
180
- dump_user_fixture(obj, request.user)
181
- path = _fixture_path(request.user, obj)
182
- self.message_user(request, f"User datum saved to {path}")
183
- else:
184
- qs = UserDatum.objects.filter(
185
- user=request.user, content_type=ct, object_id=obj.pk
186
- )
187
- if qs.exists():
188
- qs.delete()
189
- delete_user_fixture(obj, request.user)
418
+ if allow_user_data:
419
+ if not obj.is_user_data:
420
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=True)
421
+ obj.is_user_data = True
422
+ dump_user_fixture(obj, target_user)
423
+ handler = getattr(self, "user_datum_saved", None)
424
+ if callable(handler):
425
+ handler(request, obj)
426
+ path = _fixture_path(target_user, obj)
427
+ self.message_user(request, f"User datum saved to {path}")
428
+ else:
429
+ if obj.is_user_data:
430
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
431
+ obj.is_user_data = False
432
+ delete_user_fixture(obj, target_user)
433
+ messages.warning(
434
+ request,
435
+ _("User data is not available for this account."),
436
+ )
437
+ elif obj.is_user_data:
438
+ type(obj).all_objects.filter(pk=obj.pk).update(is_user_data=False)
439
+ obj.is_user_data = False
440
+ delete_user_fixture(obj, target_user)
441
+ handler = getattr(self, "user_datum_deleted", None)
442
+ if callable(handler):
443
+ handler(request, obj)
190
444
 
191
445
 
192
446
  def patch_admin_user_datum() -> None:
193
- """Mixin all registered admin classes."""
194
- for model, model_admin in list(admin.site._registry.items()):
195
- if model is UserDatum:
196
- continue
197
- if isinstance(model_admin, UserDatumAdminMixin):
198
- continue
199
- admin.site.unregister(model)
447
+ """Mixin all registered entity admin classes and future registrations."""
448
+
449
+ if getattr(admin.site, "_user_datum_patched", False):
450
+ return
451
+
452
+ def _patched(admin_class):
200
453
  template = (
201
- getattr(model_admin, "change_form_template", None)
202
- or "admin/user_datum_change_form.html"
454
+ getattr(admin_class, "change_form_template", None)
455
+ or EntityModelAdmin.change_form_template
203
456
  )
204
- attrs = {"change_form_template": template}
205
- Patched = type(
206
- f"Patched{model_admin.__class__.__name__}",
207
- (UserDatumAdminMixin, model_admin.__class__),
208
- attrs,
457
+ return type(
458
+ f"Patched{admin_class.__name__}",
459
+ (EntityModelAdmin, admin_class),
460
+ {"change_form_template": template},
209
461
  )
210
- admin.site.register(model, Patched)
462
+
463
+ for model, model_admin in list(admin.site._registry.items()):
464
+ if issubclass(model, Entity) and not isinstance(model_admin, EntityModelAdmin):
465
+ admin.site.unregister(model)
466
+ admin.site.register(model, _patched(model_admin.__class__))
467
+
468
+ original_register = admin.site.register
469
+
470
+ def register(model_or_iterable, admin_class=None, **options):
471
+ models = model_or_iterable
472
+ if not isinstance(models, (list, tuple, set)):
473
+ models = [models]
474
+ admin_class = admin_class or admin.ModelAdmin
475
+ patched_class = admin_class
476
+ for model in models:
477
+ if issubclass(model, Entity) and not issubclass(
478
+ patched_class, EntityModelAdmin
479
+ ):
480
+ patched_class = _patched(patched_class)
481
+ return original_register(model_or_iterable, patched_class, **options)
482
+
483
+ admin.site.register = register
484
+ admin.site._user_datum_patched = True
211
485
 
212
486
 
213
487
  def _seed_data_view(request):
214
- """Display all entities marked as seed data."""
215
488
  sections = []
216
489
  for model, model_admin in admin.site._registry.items():
217
490
  if not issubclass(model, Entity):
@@ -225,14 +498,8 @@ def _seed_data_view(request):
225
498
  f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
226
499
  args=[obj.pk],
227
500
  )
228
- fixture_path = _seed_fixture_path(obj)
229
- items.append(
230
- {
231
- "url": url,
232
- "label": str(obj),
233
- "fixture": fixture_path.name if fixture_path else "",
234
- }
235
- )
501
+ fixture = _seed_fixture_path(obj)
502
+ items.append({"url": url, "label": str(obj), "fixture": fixture})
236
503
  sections.append({"opts": model._meta, "items": items})
237
504
  context = admin.site.each_context(request)
238
505
  context.update({"title": _("Seed Data"), "sections": sections})
@@ -240,38 +507,33 @@ def _seed_data_view(request):
240
507
 
241
508
 
242
509
  def _user_data_view(request):
243
- """Display all user datum entities for the current user."""
244
- sections = {}
245
- qs = UserDatum.objects.filter(user=request.user).select_related("content_type")
246
- for ud in qs:
247
- model = ud.content_type.model_class()
248
- obj = ud.entity
249
- url = reverse(
250
- f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
251
- args=[obj.pk],
252
- )
253
- section = sections.setdefault(model._meta, [])
254
- fixture = _fixture_path(request.user, obj)
255
- section.append(
256
- {"url": url, "label": str(obj), "fixture": fixture.name}
257
- )
258
- section_list = [{"opts": opts, "items": items} for opts, items in sections.items()]
510
+ sections = []
511
+ for model, model_admin in admin.site._registry.items():
512
+ if not issubclass(model, Entity):
513
+ continue
514
+ objs = model.objects.filter(is_user_data=True)
515
+ if not objs.exists():
516
+ continue
517
+ items = []
518
+ for obj in objs:
519
+ url = reverse(
520
+ f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
521
+ args=[obj.pk],
522
+ )
523
+ fixture = _fixture_path(request.user, obj)
524
+ items.append({"url": url, "label": str(obj), "fixture": fixture})
525
+ sections.append({"opts": model._meta, "items": items})
259
526
  context = admin.site.each_context(request)
260
527
  context.update(
261
- {
262
- "title": _("User Data"),
263
- "sections": section_list,
264
- "import_export": True,
265
- }
528
+ {"title": _("User Data"), "sections": sections, "import_export": True}
266
529
  )
267
530
  return TemplateResponse(request, "admin/data_list.html", context)
268
531
 
269
532
 
270
533
  def _user_data_export(request):
271
- """Return a zip file containing all fixtures for the current user."""
272
534
  buffer = BytesIO()
273
535
  with ZipFile(buffer, "w") as zf:
274
- for path in _data_dir().glob(f"{request.user.pk}_*.json"):
536
+ for path in _data_dir(request.user).glob("*.json"):
275
537
  zf.write(path, arcname=path.name)
276
538
  buffer.seek(0)
277
539
  response = HttpResponse(buffer.getvalue(), content_type="application/zip")
@@ -282,41 +544,36 @@ def _user_data_export(request):
282
544
 
283
545
 
284
546
  def _user_data_import(request):
285
- """Import fixtures from an uploaded zip file."""
286
547
  if request.method == "POST" and request.FILES.get("data_zip"):
287
548
  with ZipFile(request.FILES["data_zip"]) as zf:
288
549
  paths = []
289
- data_dir = _data_dir()
550
+ data_dir = _data_dir(request.user)
290
551
  for name in zf.namelist():
291
552
  if not name.endswith(".json"):
292
553
  continue
554
+ content = zf.read(name)
293
555
  target = data_dir / name
294
556
  with target.open("wb") as f:
295
- f.write(zf.read(name))
557
+ f.write(content)
296
558
  paths.append(target)
297
559
  if paths:
298
- call_command("loaddata", *[str(p) for p in paths])
299
- for p in paths:
300
- try:
301
- user_id, app_label, model, obj_id = p.stem.split("_", 3)
302
- ct = ContentType.objects.get_by_natural_key(app_label, model)
303
- UserDatum.objects.get_or_create(
304
- user_id=int(user_id), content_type=ct, object_id=int(obj_id)
305
- )
306
- except Exception:
307
- continue
560
+ for path in paths:
561
+ _load_fixture(path)
308
562
  return HttpResponseRedirect(reverse("admin:user_data"))
309
563
 
310
564
 
311
565
  def patch_admin_user_data_views() -> None:
312
- """Add custom admin views for seed and user data listings."""
313
566
  original_get_urls = admin.site.get_urls
314
567
 
315
568
  def get_urls():
316
569
  urls = original_get_urls()
317
570
  custom = [
318
- path("seed-data/", admin.site.admin_view(_seed_data_view), name="seed_data"),
319
- path("user-data/", admin.site.admin_view(_user_data_view), name="user_data"),
571
+ path(
572
+ "seed-data/", admin.site.admin_view(_seed_data_view), name="seed_data"
573
+ ),
574
+ path(
575
+ "user-data/", admin.site.admin_view(_user_data_view), name="user_data"
576
+ ),
320
577
  path(
321
578
  "user-data/export/",
322
579
  admin.site.admin_view(_user_data_export),