arthexis 0.1.8__py3-none-any.whl → 0.1.9__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 (81) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.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 +133 -16
  10. config/urls.py +65 -6
  11. core/admin.py +1226 -191
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1071 -264
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +358 -63
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +7 -3
  42. core/workgroup_views.py +43 -6
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +1 -1
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.8.dist-info/RECORD +0 -80
  77. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  78. config/workgroup_app.py +0 -7
  79. core/checks.py +0 -29
  80. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  81. {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
core/user_data.py CHANGED
@@ -5,134 +5,268 @@ 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
21
19
  from django.utils.translation import gettext as _
22
20
 
23
21
  from .entity import Entity
24
22
 
25
23
 
26
- class UserDatum(models.Model):
27
- """Link an :class:`Entity` instance to a user for persistence."""
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
+
28
29
 
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)
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
34
39
 
35
- class Meta:
36
- unique_together = ("user", "content_type", "object_id")
37
- verbose_name = "User Datum"
38
- verbose_name_plural = "User Data"
39
40
 
41
+ def _user_allows_user_data(user) -> bool:
42
+ return bool(user) and not getattr(user, "is_profile_restricted", False)
40
43
 
41
- # ---- Fixture utilities ---------------------------------------------------
42
44
 
43
- def _data_dir() -> Path:
44
- path = Path(settings.BASE_DIR) / "data"
45
- path.mkdir(exist_ok=True)
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)
46
51
  return path
47
52
 
48
53
 
49
54
  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
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
56
58
 
57
59
 
58
60
  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"):
65
- try:
66
- with path.open() as f:
67
- data = json.load(f)
68
- except Exception:
69
- 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
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)
83
134
  call_command(
84
135
  "dumpdata",
85
- f"{app_label}.{model_name}",
136
+ f"{meta.app_label}.{meta.model_name}",
86
137
  indent=2,
87
138
  pks=str(instance.pk),
88
139
  output=str(path),
140
+ use_natural_foreign_keys=True,
89
141
  )
90
142
 
91
143
 
92
- def delete_user_fixture(instance, user) -> None:
93
- _fixture_path(user, instance).unlink(missing_ok=True)
94
-
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)
95
149
 
96
- # ---- Signals -------------------------------------------------------------
97
150
 
98
- @receiver(post_save)
99
- def _entity_saved(sender, instance, **kwargs):
100
- if isinstance(instance, UserDatum):
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:
101
160
  return
102
161
  try:
103
- ct = ContentType.objects.get_for_model(instance)
104
- qs = list(UserDatum.objects.filter(content_type=ct, object_id=instance.pk))
162
+ data = json.loads(content)
105
163
  except Exception:
106
164
  return
107
- for ud in qs:
108
- dump_user_fixture(instance, ud.user)
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)
109
181
 
110
182
 
111
- @receiver(post_delete)
112
- def _entity_deleted(sender, instance, **kwargs):
113
- if isinstance(instance, UserDatum):
114
- return
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
115
187
  try:
116
- ct = ContentType.objects.get_for_model(instance)
117
- qs = list(UserDatum.objects.filter(content_type=ct, object_id=instance.pk))
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")
118
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:
119
239
  return
120
- for ud in qs:
121
- delete_user_fixture(instance, ud.user)
122
- ud.delete()
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
123
247
 
124
248
 
125
- @receiver(post_save, sender=UserDatum)
126
- def _userdatum_saved(sender, instance, **kwargs):
127
- dump_user_fixture(instance.entity, instance.user)
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)
128
257
 
129
258
 
130
- @receiver(post_delete, sender=UserDatum)
131
- def _userdatum_deleted(sender, instance, **kwargs):
132
- delete_user_fixture(instance.entity, instance.user)
259
+ @receiver(user_logged_in)
260
+ def _on_login(sender, request, user, **kwargs):
261
+ load_user_fixtures(user, include_shared=not _shared_fixtures_loaded)
133
262
 
134
263
 
135
- # ---- Admin integration ---------------------------------------------------
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
+
136
270
 
137
271
  class UserDatumAdminMixin(admin.ModelAdmin):
138
272
  """Mixin adding a *User Datum* checkbox to change forms."""
@@ -140,19 +274,27 @@ class UserDatumAdminMixin(admin.ModelAdmin):
140
274
  def render_change_form(
141
275
  self, request, context, add=False, change=False, form_url="", obj=None
142
276
  ):
143
- context["show_user_datum"] = True
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)
144
281
  context["show_save_as_copy"] = issubclass(self.model, Entity) or hasattr(
145
282
  self.model, "clone"
146
283
  )
147
284
  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()
285
+ context["is_user_datum"] = getattr(obj, "is_user_data", False)
286
+ context["is_seed_datum"] = getattr(obj, "is_seed_data", False)
152
287
  else:
153
288
  context["is_user_datum"] = False
289
+ context["is_seed_datum"] = False
154
290
  return super().render_change_form(request, context, add, change, form_url, obj)
155
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
+
156
298
  def save_model(self, request, obj, form, change):
157
299
  copied = "_saveacopy" in request.POST
158
300
  if copied:
@@ -161,57 +303,93 @@ class UserDatumAdminMixin(admin.ModelAdmin):
161
303
  form.instance = obj
162
304
  try:
163
305
  super().save_model(request, obj, form, False)
164
- except IntegrityError:
165
- self.message_user(
306
+ except Exception:
307
+ messages.error(
166
308
  request,
167
309
  _("Unable to save copy. Adjust unique fields and try again."),
168
- messages.ERROR,
169
310
  )
170
- raise ValidationError("save_as_copy")
311
+ raise
171
312
  else:
172
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
+ )
173
318
  if copied:
174
319
  return
175
- ct = ContentType.objects.get_for_model(obj)
320
+ target_user = _resolve_fixture_user(obj, request.user)
321
+ allow_user_data = _user_allows_user_data(target_user)
176
322
  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)
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)
190
349
 
191
350
 
192
351
  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)
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):
200
358
  template = (
201
- getattr(model_admin, "change_form_template", None)
202
- or "admin/user_datum_change_form.html"
359
+ getattr(admin_class, "change_form_template", None)
360
+ or EntityModelAdmin.change_form_template
203
361
  )
204
- attrs = {"change_form_template": template}
205
- Patched = type(
206
- f"Patched{model_admin.__class__.__name__}",
207
- (UserDatumAdminMixin, model_admin.__class__),
208
- attrs,
362
+ return type(
363
+ f"Patched{admin_class.__name__}",
364
+ (EntityModelAdmin, admin_class),
365
+ {"change_form_template": template},
209
366
  )
210
- admin.site.register(model, Patched)
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
211
390
 
212
391
 
213
392
  def _seed_data_view(request):
214
- """Display all entities marked as seed data."""
215
393
  sections = []
216
394
  for model, model_admin in admin.site._registry.items():
217
395
  if not issubclass(model, Entity):
@@ -225,14 +403,8 @@ def _seed_data_view(request):
225
403
  f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
226
404
  args=[obj.pk],
227
405
  )
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
- )
406
+ fixture = _seed_fixture_path(obj)
407
+ items.append({"url": url, "label": str(obj), "fixture": fixture})
236
408
  sections.append({"opts": model._meta, "items": items})
237
409
  context = admin.site.each_context(request)
238
410
  context.update({"title": _("Seed Data"), "sections": sections})
@@ -240,38 +412,33 @@ def _seed_data_view(request):
240
412
 
241
413
 
242
414
  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()]
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})
259
431
  context = admin.site.each_context(request)
260
432
  context.update(
261
- {
262
- "title": _("User Data"),
263
- "sections": section_list,
264
- "import_export": True,
265
- }
433
+ {"title": _("User Data"), "sections": sections, "import_export": True}
266
434
  )
267
435
  return TemplateResponse(request, "admin/data_list.html", context)
268
436
 
269
437
 
270
438
  def _user_data_export(request):
271
- """Return a zip file containing all fixtures for the current user."""
272
439
  buffer = BytesIO()
273
440
  with ZipFile(buffer, "w") as zf:
274
- for path in _data_dir().glob(f"{request.user.pk}_*.json"):
441
+ for path in _data_dir(request.user).glob("*.json"):
275
442
  zf.write(path, arcname=path.name)
276
443
  buffer.seek(0)
277
444
  response = HttpResponse(buffer.getvalue(), content_type="application/zip")
@@ -282,41 +449,36 @@ def _user_data_export(request):
282
449
 
283
450
 
284
451
  def _user_data_import(request):
285
- """Import fixtures from an uploaded zip file."""
286
452
  if request.method == "POST" and request.FILES.get("data_zip"):
287
453
  with ZipFile(request.FILES["data_zip"]) as zf:
288
454
  paths = []
289
- data_dir = _data_dir()
455
+ data_dir = _data_dir(request.user)
290
456
  for name in zf.namelist():
291
457
  if not name.endswith(".json"):
292
458
  continue
459
+ content = zf.read(name)
293
460
  target = data_dir / name
294
461
  with target.open("wb") as f:
295
- f.write(zf.read(name))
462
+ f.write(content)
296
463
  paths.append(target)
297
464
  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
465
+ for path in paths:
466
+ _load_fixture(path)
308
467
  return HttpResponseRedirect(reverse("admin:user_data"))
309
468
 
310
469
 
311
470
  def patch_admin_user_data_views() -> None:
312
- """Add custom admin views for seed and user data listings."""
313
471
  original_get_urls = admin.site.get_urls
314
472
 
315
473
  def get_urls():
316
474
  urls = original_get_urls()
317
475
  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"),
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
+ ),
320
482
  path(
321
483
  "user-data/export/",
322
484
  admin.site.admin_view(_user_data_export),