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.
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {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
|
|
11
|
-
from django.contrib.
|
|
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
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
51
|
-
filename = f"{
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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"] =
|
|
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
|
-
|
|
149
|
-
context["
|
|
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
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
202
|
-
or
|
|
454
|
+
getattr(admin_class, "change_form_template", None)
|
|
455
|
+
or EntityModelAdmin.change_form_template
|
|
203
456
|
)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
attrs,
|
|
457
|
+
return type(
|
|
458
|
+
f"Patched{admin_class.__name__}",
|
|
459
|
+
(EntityModelAdmin, admin_class),
|
|
460
|
+
{"change_form_template": template},
|
|
209
461
|
)
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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(
|
|
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(
|
|
557
|
+
f.write(content)
|
|
296
558
|
paths.append(target)
|
|
297
559
|
if paths:
|
|
298
|
-
|
|
299
|
-
|
|
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(
|
|
319
|
-
|
|
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),
|