arthexis 0.1.13__py3-none-any.whl → 0.1.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
- arthexis-0.1.14.dist-info/RECORD +109 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +43 -43
- config/auth_app.py +7 -7
- config/celery.py +32 -32
- config/context_processors.py +67 -69
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +25 -25
- config/offline.py +49 -49
- config/settings.py +691 -682
- config/settings_helpers.py +109 -109
- config/urls.py +171 -166
- config/wsgi.py +17 -17
- core/admin.py +3771 -2809
- core/admin_history.py +50 -50
- core/admindocs.py +151 -151
- core/apps.py +356 -272
- core/auto_upgrade.py +57 -57
- core/backends.py +265 -236
- core/changelog.py +342 -0
- core/entity.py +133 -133
- core/environment.py +61 -61
- core/fields.py +168 -168
- core/form_fields.py +75 -75
- core/github_helper.py +188 -25
- core/github_issues.py +178 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +100 -100
- core/mailer.py +85 -85
- core/middleware.py +91 -91
- core/models.py +3609 -2795
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +108 -108
- core/release.py +721 -368
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -149
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +315 -315
- core/system.py +752 -493
- core/tasks.py +408 -394
- core/temp_passwords.py +181 -181
- core/test_system_info.py +186 -139
- core/tests.py +2095 -1521
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +641 -633
- core/views.py +2175 -1417
- core/widgets.py +213 -94
- core/workgroup_urls.py +17 -17
- core/workgroup_views.py +94 -94
- nodes/admin.py +1720 -1161
- nodes/apps.py +87 -85
- nodes/backends.py +160 -160
- nodes/dns.py +203 -203
- nodes/feature_checks.py +133 -133
- nodes/lcd.py +165 -165
- nodes/models.py +1737 -1597
- nodes/reports.py +411 -411
- nodes/rfid_sync.py +195 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +46 -46
- nodes/tests.py +3810 -3116
- nodes/urls.py +15 -14
- nodes/utils.py +121 -105
- nodes/views.py +683 -619
- ocpp/admin.py +948 -948
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1565 -1459
- ocpp/evcs.py +844 -844
- ocpp/evcs_discovery.py +158 -158
- ocpp/models.py +917 -917
- ocpp/reference_utils.py +42 -42
- ocpp/routing.py +11 -11
- ocpp/simulator.py +745 -745
- ocpp/status_display.py +26 -26
- ocpp/store.py +601 -541
- ocpp/tasks.py +31 -31
- ocpp/test_export_import.py +130 -130
- ocpp/test_rfid.py +913 -702
- ocpp/tests.py +4445 -4094
- ocpp/transactions_io.py +189 -189
- ocpp/urls.py +50 -50
- ocpp/views.py +1479 -1251
- pages/admin.py +708 -539
- pages/apps.py +10 -10
- pages/checks.py +40 -40
- pages/context_processors.py +127 -119
- pages/defaults.py +13 -13
- pages/forms.py +198 -198
- pages/middleware.py +205 -153
- pages/models.py +607 -426
- pages/tests.py +2612 -2200
- pages/urls.py +25 -25
- pages/utils.py +12 -12
- pages/views.py +1165 -1128
- arthexis-0.1.13.dist-info/RECORD +0 -105
- nodes/actions.py +0 -70
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
pages/models.py
CHANGED
|
@@ -1,426 +1,607 @@
|
|
|
1
|
-
from django.db import models
|
|
2
|
-
from
|
|
3
|
-
from
|
|
4
|
-
from
|
|
5
|
-
from django.
|
|
6
|
-
from
|
|
7
|
-
from django.
|
|
8
|
-
from
|
|
9
|
-
from django.
|
|
10
|
-
from
|
|
11
|
-
from django.
|
|
12
|
-
from django.
|
|
13
|
-
|
|
14
|
-
from core import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def
|
|
32
|
-
return self.name
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return
|
|
339
|
-
|
|
340
|
-
def
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.db.models import Q
|
|
3
|
+
from core.entity import Entity
|
|
4
|
+
from core.models import Lead, SecurityGroup
|
|
5
|
+
from django.contrib.sites.models import Site
|
|
6
|
+
from nodes.models import NodeRole
|
|
7
|
+
from django.apps import apps as django_apps
|
|
8
|
+
from django.utils.text import slugify
|
|
9
|
+
from django.utils.translation import gettext, gettext_lazy as _
|
|
10
|
+
from importlib import import_module
|
|
11
|
+
from django.urls import URLPattern
|
|
12
|
+
from django.conf import settings
|
|
13
|
+
from django.contrib.contenttypes.models import ContentType
|
|
14
|
+
from django.core.validators import MaxLengthValidator, MaxValueValidator, MinValueValidator
|
|
15
|
+
from django.core.exceptions import ValidationError
|
|
16
|
+
|
|
17
|
+
from core import github_issues
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApplicationManager(models.Manager):
|
|
21
|
+
def get_by_natural_key(self, name: str):
|
|
22
|
+
return self.get(name=name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Application(Entity):
|
|
26
|
+
name = models.CharField(max_length=100, unique=True)
|
|
27
|
+
description = models.TextField(blank=True)
|
|
28
|
+
|
|
29
|
+
objects = ApplicationManager()
|
|
30
|
+
|
|
31
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
32
|
+
return (self.name,)
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
35
|
+
return self.name
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def installed(self) -> bool:
|
|
39
|
+
return django_apps.is_installed(self.name)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def verbose_name(self) -> str:
|
|
43
|
+
try:
|
|
44
|
+
return django_apps.get_app_config(self.name).verbose_name
|
|
45
|
+
except LookupError:
|
|
46
|
+
return self.name
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ModuleManager(models.Manager):
|
|
50
|
+
def get_by_natural_key(self, role: str, path: str):
|
|
51
|
+
return self.get(node_role__name=role, path=path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Module(Entity):
|
|
55
|
+
node_role = models.ForeignKey(
|
|
56
|
+
NodeRole,
|
|
57
|
+
on_delete=models.CASCADE,
|
|
58
|
+
related_name="modules",
|
|
59
|
+
)
|
|
60
|
+
application = models.ForeignKey(
|
|
61
|
+
Application,
|
|
62
|
+
on_delete=models.CASCADE,
|
|
63
|
+
related_name="modules",
|
|
64
|
+
)
|
|
65
|
+
path = models.CharField(
|
|
66
|
+
max_length=100,
|
|
67
|
+
help_text="Base path for the app, starting with /",
|
|
68
|
+
blank=True,
|
|
69
|
+
)
|
|
70
|
+
menu = models.CharField(
|
|
71
|
+
max_length=100,
|
|
72
|
+
blank=True,
|
|
73
|
+
help_text="Text used for the navbar pill; defaults to the application name.",
|
|
74
|
+
)
|
|
75
|
+
is_default = models.BooleanField(default=False)
|
|
76
|
+
favicon = models.ImageField(upload_to="modules/favicons/", blank=True)
|
|
77
|
+
|
|
78
|
+
objects = ModuleManager()
|
|
79
|
+
|
|
80
|
+
class Meta:
|
|
81
|
+
verbose_name = _("Module")
|
|
82
|
+
verbose_name_plural = _("Modules")
|
|
83
|
+
unique_together = ("node_role", "path")
|
|
84
|
+
|
|
85
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
86
|
+
role_name = None
|
|
87
|
+
if getattr(self, "node_role_id", None):
|
|
88
|
+
role_name = self.node_role.name
|
|
89
|
+
return (role_name, self.path)
|
|
90
|
+
|
|
91
|
+
natural_key.dependencies = ["nodes.NodeRole"]
|
|
92
|
+
|
|
93
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
94
|
+
return f"{self.application.name} ({self.path})"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def menu_label(self) -> str:
|
|
98
|
+
return self.menu or self.application.name
|
|
99
|
+
|
|
100
|
+
def save(self, *args, **kwargs):
|
|
101
|
+
if not self.path:
|
|
102
|
+
self.path = f"/{slugify(self.application.name)}/"
|
|
103
|
+
super().save(*args, **kwargs)
|
|
104
|
+
|
|
105
|
+
def create_landings(self):
|
|
106
|
+
try:
|
|
107
|
+
urlconf = import_module(f"{self.application.name}.urls")
|
|
108
|
+
except Exception:
|
|
109
|
+
try:
|
|
110
|
+
urlconf = import_module(f"{self.application.name.lower()}.urls")
|
|
111
|
+
except Exception:
|
|
112
|
+
Landing.objects.get_or_create(
|
|
113
|
+
module=self,
|
|
114
|
+
path=self.path,
|
|
115
|
+
defaults={"label": self.application.name},
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
patterns = getattr(urlconf, "urlpatterns", [])
|
|
119
|
+
created = False
|
|
120
|
+
normalized_module = self.path.strip("/")
|
|
121
|
+
|
|
122
|
+
def _walk(patterns, prefix=""):
|
|
123
|
+
nonlocal created
|
|
124
|
+
for pattern in patterns:
|
|
125
|
+
if isinstance(pattern, URLPattern):
|
|
126
|
+
callback = pattern.callback
|
|
127
|
+
if getattr(callback, "landing", False):
|
|
128
|
+
pattern_path = str(pattern.pattern)
|
|
129
|
+
relative = f"{prefix}{pattern_path}"
|
|
130
|
+
if normalized_module and relative.startswith(normalized_module):
|
|
131
|
+
full_path = f"/{relative}"
|
|
132
|
+
Landing.objects.update_or_create(
|
|
133
|
+
module=self,
|
|
134
|
+
path=full_path,
|
|
135
|
+
defaults={
|
|
136
|
+
"label": getattr(
|
|
137
|
+
callback,
|
|
138
|
+
"landing_label",
|
|
139
|
+
callback.__name__.replace("_", " ").title(),
|
|
140
|
+
)
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
full_path = f"{self.path}{relative}"
|
|
145
|
+
Landing.objects.get_or_create(
|
|
146
|
+
module=self,
|
|
147
|
+
path=full_path,
|
|
148
|
+
defaults={
|
|
149
|
+
"label": getattr(
|
|
150
|
+
callback,
|
|
151
|
+
"landing_label",
|
|
152
|
+
callback.__name__.replace("_", " ").title(),
|
|
153
|
+
)
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
created = True
|
|
157
|
+
else:
|
|
158
|
+
_walk(
|
|
159
|
+
pattern.url_patterns, prefix=f"{prefix}{str(pattern.pattern)}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
_walk(patterns)
|
|
163
|
+
|
|
164
|
+
if not created:
|
|
165
|
+
Landing.objects.get_or_create(
|
|
166
|
+
module=self, path=self.path, defaults={"label": self.application.name}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class SiteBadge(Entity):
|
|
171
|
+
site = models.OneToOneField(Site, on_delete=models.CASCADE, related_name="badge")
|
|
172
|
+
badge_color = models.CharField(max_length=7, default="#28a745")
|
|
173
|
+
favicon = models.ImageField(upload_to="sites/favicons/", blank=True)
|
|
174
|
+
landing_override = models.ForeignKey(
|
|
175
|
+
"Landing", null=True, blank=True, on_delete=models.SET_NULL
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
179
|
+
return f"Badge for {self.site.domain}"
|
|
180
|
+
|
|
181
|
+
class Meta:
|
|
182
|
+
verbose_name = "Site Badge"
|
|
183
|
+
verbose_name_plural = "Site Badges"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class SiteProxy(Site):
|
|
187
|
+
class Meta:
|
|
188
|
+
proxy = True
|
|
189
|
+
app_label = "pages"
|
|
190
|
+
verbose_name = "Site"
|
|
191
|
+
verbose_name_plural = "Sites"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class LandingManager(models.Manager):
|
|
195
|
+
def get_by_natural_key(self, role: str, module_path: str, path: str):
|
|
196
|
+
return self.get(
|
|
197
|
+
module__node_role__name=role, module__path=module_path, path=path
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Landing(Entity):
|
|
202
|
+
module = models.ForeignKey(
|
|
203
|
+
Module, on_delete=models.CASCADE, related_name="landings"
|
|
204
|
+
)
|
|
205
|
+
path = models.CharField(max_length=200)
|
|
206
|
+
label = models.CharField(max_length=100)
|
|
207
|
+
enabled = models.BooleanField(default=True)
|
|
208
|
+
description = models.TextField(blank=True)
|
|
209
|
+
|
|
210
|
+
objects = LandingManager()
|
|
211
|
+
|
|
212
|
+
class Meta:
|
|
213
|
+
unique_together = ("module", "path")
|
|
214
|
+
|
|
215
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
216
|
+
return f"{self.label} ({self.path})"
|
|
217
|
+
|
|
218
|
+
def save(self, *args, **kwargs):
|
|
219
|
+
existing = None
|
|
220
|
+
if not self.pk:
|
|
221
|
+
existing = (
|
|
222
|
+
type(self).objects.filter(module=self.module, path=self.path).first()
|
|
223
|
+
)
|
|
224
|
+
if existing:
|
|
225
|
+
self.pk = existing.pk
|
|
226
|
+
super().save(*args, **kwargs)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class LandingLead(Lead):
|
|
230
|
+
landing = models.ForeignKey(
|
|
231
|
+
"pages.Landing", on_delete=models.CASCADE, related_name="leads"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
class Meta:
|
|
235
|
+
verbose_name = _("Landing Lead")
|
|
236
|
+
verbose_name_plural = _("Landing Leads")
|
|
237
|
+
|
|
238
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
239
|
+
return f"{self.landing.label} ({self.path})"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class RoleLandingManager(models.Manager):
|
|
243
|
+
def get_by_natural_key(
|
|
244
|
+
self,
|
|
245
|
+
role: str | None,
|
|
246
|
+
group: str | None,
|
|
247
|
+
username: str | None,
|
|
248
|
+
module_path: str,
|
|
249
|
+
path: str,
|
|
250
|
+
):
|
|
251
|
+
filters = {
|
|
252
|
+
"landing__module__path": module_path,
|
|
253
|
+
"landing__path": path,
|
|
254
|
+
}
|
|
255
|
+
if role:
|
|
256
|
+
filters["node_role__name"] = role
|
|
257
|
+
else:
|
|
258
|
+
filters["node_role__isnull"] = True
|
|
259
|
+
if group:
|
|
260
|
+
filters["security_group__name"] = group
|
|
261
|
+
else:
|
|
262
|
+
filters["security_group__isnull"] = True
|
|
263
|
+
if username:
|
|
264
|
+
filters["user__username"] = username
|
|
265
|
+
else:
|
|
266
|
+
filters["user__isnull"] = True
|
|
267
|
+
return self.get(**filters)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class RoleLanding(Entity):
|
|
271
|
+
node_role = models.OneToOneField(
|
|
272
|
+
NodeRole,
|
|
273
|
+
on_delete=models.CASCADE,
|
|
274
|
+
related_name="default_landing",
|
|
275
|
+
null=True,
|
|
276
|
+
blank=True,
|
|
277
|
+
)
|
|
278
|
+
security_group = models.OneToOneField(
|
|
279
|
+
SecurityGroup,
|
|
280
|
+
on_delete=models.CASCADE,
|
|
281
|
+
related_name="default_landing",
|
|
282
|
+
null=True,
|
|
283
|
+
blank=True,
|
|
284
|
+
)
|
|
285
|
+
user = models.OneToOneField(
|
|
286
|
+
settings.AUTH_USER_MODEL,
|
|
287
|
+
on_delete=models.CASCADE,
|
|
288
|
+
related_name="default_landing",
|
|
289
|
+
null=True,
|
|
290
|
+
blank=True,
|
|
291
|
+
)
|
|
292
|
+
landing = models.ForeignKey(
|
|
293
|
+
Landing,
|
|
294
|
+
on_delete=models.CASCADE,
|
|
295
|
+
related_name="role_defaults",
|
|
296
|
+
)
|
|
297
|
+
priority = models.IntegerField(default=0)
|
|
298
|
+
|
|
299
|
+
objects = RoleLandingManager()
|
|
300
|
+
|
|
301
|
+
class Meta:
|
|
302
|
+
verbose_name = _("Default Landing")
|
|
303
|
+
verbose_name_plural = _("Default Landings")
|
|
304
|
+
ordering = ("-priority", "pk")
|
|
305
|
+
constraints = [
|
|
306
|
+
models.CheckConstraint(
|
|
307
|
+
name="pages_rolelanding_single_target",
|
|
308
|
+
condition=(
|
|
309
|
+
Q(
|
|
310
|
+
node_role__isnull=False,
|
|
311
|
+
security_group__isnull=True,
|
|
312
|
+
user__isnull=True,
|
|
313
|
+
)
|
|
314
|
+
| Q(
|
|
315
|
+
node_role__isnull=True,
|
|
316
|
+
security_group__isnull=False,
|
|
317
|
+
user__isnull=True,
|
|
318
|
+
)
|
|
319
|
+
| Q(
|
|
320
|
+
node_role__isnull=True,
|
|
321
|
+
security_group__isnull=True,
|
|
322
|
+
user__isnull=False,
|
|
323
|
+
)
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
329
|
+
if self.node_role_id:
|
|
330
|
+
role_name = self.node_role.name
|
|
331
|
+
elif self.security_group_id:
|
|
332
|
+
role_name = self.security_group.name
|
|
333
|
+
elif self.user_id:
|
|
334
|
+
role_name = self.user.get_username()
|
|
335
|
+
else: # pragma: no cover - guarded by constraint
|
|
336
|
+
role_name = "?"
|
|
337
|
+
landing_path = self.landing.path if self.landing_id else "?"
|
|
338
|
+
return f"{role_name} → {landing_path}"
|
|
339
|
+
|
|
340
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
341
|
+
role_name = None
|
|
342
|
+
group_name = None
|
|
343
|
+
username = None
|
|
344
|
+
if getattr(self, "node_role_id", None):
|
|
345
|
+
role_name = self.node_role.name
|
|
346
|
+
if getattr(self, "security_group_id", None):
|
|
347
|
+
group_name = self.security_group.name
|
|
348
|
+
if getattr(self, "user_id", None):
|
|
349
|
+
username = self.user.get_username()
|
|
350
|
+
landing_key = (None, None)
|
|
351
|
+
if getattr(self, "landing_id", None):
|
|
352
|
+
landing_key = (
|
|
353
|
+
self.landing.module.path if self.landing.module_id else None,
|
|
354
|
+
self.landing.path,
|
|
355
|
+
)
|
|
356
|
+
return (role_name, group_name, username) + landing_key
|
|
357
|
+
|
|
358
|
+
natural_key.dependencies = [
|
|
359
|
+
"nodes.NodeRole",
|
|
360
|
+
"core.SecurityGroup",
|
|
361
|
+
settings.AUTH_USER_MODEL,
|
|
362
|
+
"pages.Landing",
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
def clean(self):
|
|
366
|
+
super().clean()
|
|
367
|
+
targets = [
|
|
368
|
+
bool(self.node_role_id),
|
|
369
|
+
bool(self.security_group_id),
|
|
370
|
+
bool(self.user_id),
|
|
371
|
+
]
|
|
372
|
+
if sum(targets) == 0:
|
|
373
|
+
raise ValidationError(
|
|
374
|
+
{
|
|
375
|
+
"node_role": _("Select a node role, security group, or user."),
|
|
376
|
+
"security_group": _(
|
|
377
|
+
"Select a node role, security group, or user."
|
|
378
|
+
),
|
|
379
|
+
"user": _("Select a node role, security group, or user."),
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
if sum(targets) > 1:
|
|
383
|
+
raise ValidationError(
|
|
384
|
+
{
|
|
385
|
+
"node_role": _(
|
|
386
|
+
"Only one of node role, security group, or user may be set."
|
|
387
|
+
),
|
|
388
|
+
"security_group": _(
|
|
389
|
+
"Only one of node role, security group, or user may be set."
|
|
390
|
+
),
|
|
391
|
+
"user": _(
|
|
392
|
+
"Only one of node role, security group, or user may be set."
|
|
393
|
+
),
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
class UserManual(Entity):
|
|
398
|
+
class PdfOrientation(models.TextChoices):
|
|
399
|
+
LANDSCAPE = "landscape", _("Landscape")
|
|
400
|
+
PORTRAIT = "portrait", _("Portrait")
|
|
401
|
+
|
|
402
|
+
slug = models.SlugField(unique=True)
|
|
403
|
+
title = models.CharField(max_length=200)
|
|
404
|
+
description = models.CharField(max_length=200)
|
|
405
|
+
languages = models.CharField(
|
|
406
|
+
max_length=100,
|
|
407
|
+
blank=True,
|
|
408
|
+
default="",
|
|
409
|
+
help_text="Comma-separated 2-letter language codes",
|
|
410
|
+
)
|
|
411
|
+
content_html = models.TextField()
|
|
412
|
+
content_pdf = models.TextField(help_text="Base64 encoded PDF")
|
|
413
|
+
pdf_orientation = models.CharField(
|
|
414
|
+
max_length=10,
|
|
415
|
+
choices=PdfOrientation.choices,
|
|
416
|
+
default=PdfOrientation.LANDSCAPE,
|
|
417
|
+
help_text=_("Orientation used when rendering the PDF download."),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
class Meta:
|
|
421
|
+
db_table = "man_usermanual"
|
|
422
|
+
verbose_name = "User Manual"
|
|
423
|
+
verbose_name_plural = "User Manuals"
|
|
424
|
+
|
|
425
|
+
def __str__(self): # pragma: no cover - simple representation
|
|
426
|
+
return self.title
|
|
427
|
+
|
|
428
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
429
|
+
return (self.slug,)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class ViewHistory(Entity):
|
|
433
|
+
"""Record of public site visits."""
|
|
434
|
+
|
|
435
|
+
path = models.CharField(max_length=500)
|
|
436
|
+
method = models.CharField(max_length=10)
|
|
437
|
+
status_code = models.PositiveSmallIntegerField()
|
|
438
|
+
status_text = models.CharField(max_length=100, blank=True)
|
|
439
|
+
error_message = models.TextField(blank=True)
|
|
440
|
+
view_name = models.CharField(max_length=200, blank=True)
|
|
441
|
+
visited_at = models.DateTimeField(auto_now_add=True)
|
|
442
|
+
|
|
443
|
+
class Meta:
|
|
444
|
+
ordering = ["-visited_at"]
|
|
445
|
+
verbose_name = _("View History")
|
|
446
|
+
verbose_name_plural = _("View Histories")
|
|
447
|
+
|
|
448
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
449
|
+
return f"{self.method} {self.path} ({self.status_code})"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class Favorite(Entity):
|
|
453
|
+
user = models.ForeignKey(
|
|
454
|
+
settings.AUTH_USER_MODEL,
|
|
455
|
+
on_delete=models.CASCADE,
|
|
456
|
+
related_name="favorites",
|
|
457
|
+
)
|
|
458
|
+
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
459
|
+
custom_label = models.CharField(max_length=100, blank=True)
|
|
460
|
+
user_data = models.BooleanField(default=False)
|
|
461
|
+
|
|
462
|
+
class Meta:
|
|
463
|
+
unique_together = ("user", "content_type")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class UserStory(Entity):
|
|
467
|
+
path = models.CharField(max_length=500)
|
|
468
|
+
name = models.CharField(max_length=40, blank=True)
|
|
469
|
+
rating = models.PositiveSmallIntegerField(
|
|
470
|
+
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
|
471
|
+
help_text=_("Rate your experience from 1 (lowest) to 5 (highest)."),
|
|
472
|
+
)
|
|
473
|
+
comments = models.TextField(
|
|
474
|
+
validators=[MaxLengthValidator(400)],
|
|
475
|
+
help_text=_("Share more about your experience."),
|
|
476
|
+
)
|
|
477
|
+
take_screenshot = models.BooleanField(
|
|
478
|
+
default=True,
|
|
479
|
+
help_text=_("Request a screenshot capture for this feedback."),
|
|
480
|
+
)
|
|
481
|
+
user = models.ForeignKey(
|
|
482
|
+
settings.AUTH_USER_MODEL,
|
|
483
|
+
on_delete=models.SET_NULL,
|
|
484
|
+
blank=True,
|
|
485
|
+
null=True,
|
|
486
|
+
related_name="user_stories",
|
|
487
|
+
)
|
|
488
|
+
owner = models.ForeignKey(
|
|
489
|
+
settings.AUTH_USER_MODEL,
|
|
490
|
+
on_delete=models.SET_NULL,
|
|
491
|
+
blank=True,
|
|
492
|
+
null=True,
|
|
493
|
+
related_name="owned_user_stories",
|
|
494
|
+
help_text=_("Internal owner for this feedback."),
|
|
495
|
+
)
|
|
496
|
+
submitted_at = models.DateTimeField(auto_now_add=True)
|
|
497
|
+
github_issue_number = models.PositiveIntegerField(
|
|
498
|
+
blank=True,
|
|
499
|
+
null=True,
|
|
500
|
+
help_text=_("Number of the GitHub issue created for this feedback."),
|
|
501
|
+
)
|
|
502
|
+
github_issue_url = models.URLField(
|
|
503
|
+
blank=True,
|
|
504
|
+
help_text=_("Link to the GitHub issue created for this feedback."),
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
class Meta:
|
|
508
|
+
ordering = ["-submitted_at"]
|
|
509
|
+
verbose_name = _("User Story")
|
|
510
|
+
verbose_name_plural = _("User Stories")
|
|
511
|
+
|
|
512
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
513
|
+
display = self.name or _("Anonymous")
|
|
514
|
+
return f"{display} ({self.rating}/5)"
|
|
515
|
+
|
|
516
|
+
def get_github_issue_labels(self) -> list[str]:
|
|
517
|
+
"""Return default labels used when creating GitHub issues."""
|
|
518
|
+
|
|
519
|
+
return ["feedback"]
|
|
520
|
+
|
|
521
|
+
def get_github_issue_fingerprint(self) -> str | None:
|
|
522
|
+
"""Return a fingerprint used to avoid duplicate issue submissions."""
|
|
523
|
+
|
|
524
|
+
if self.pk:
|
|
525
|
+
return f"user-story:{self.pk}"
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
def build_github_issue_title(self) -> str:
|
|
529
|
+
"""Return the title used for GitHub issues."""
|
|
530
|
+
|
|
531
|
+
path = self.path or "/"
|
|
532
|
+
return gettext("Feedback for %(path)s (%(rating)s/5)") % {
|
|
533
|
+
"path": path,
|
|
534
|
+
"rating": self.rating,
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
def build_github_issue_body(self) -> str:
|
|
538
|
+
"""Return the issue body summarising the feedback details."""
|
|
539
|
+
|
|
540
|
+
name = self.name or gettext("Anonymous")
|
|
541
|
+
path = self.path or "/"
|
|
542
|
+
screenshot_requested = gettext("Yes") if self.take_screenshot else gettext("No")
|
|
543
|
+
|
|
544
|
+
lines = [
|
|
545
|
+
f"**Path:** {path}",
|
|
546
|
+
f"**Rating:** {self.rating}/5",
|
|
547
|
+
f"**Name:** {name}",
|
|
548
|
+
f"**Screenshot requested:** {screenshot_requested}",
|
|
549
|
+
]
|
|
550
|
+
|
|
551
|
+
if self.submitted_at:
|
|
552
|
+
lines.append(f"**Submitted at:** {self.submitted_at.isoformat()}")
|
|
553
|
+
|
|
554
|
+
comment = (self.comments or "").strip()
|
|
555
|
+
if comment:
|
|
556
|
+
lines.extend(["", comment])
|
|
557
|
+
|
|
558
|
+
return "\n".join(lines).strip()
|
|
559
|
+
|
|
560
|
+
def create_github_issue(self) -> str | None:
|
|
561
|
+
"""Create a GitHub issue for this feedback and store the identifiers."""
|
|
562
|
+
|
|
563
|
+
if self.github_issue_url:
|
|
564
|
+
return self.github_issue_url
|
|
565
|
+
|
|
566
|
+
response = github_issues.create_issue(
|
|
567
|
+
self.build_github_issue_title(),
|
|
568
|
+
self.build_github_issue_body(),
|
|
569
|
+
labels=self.get_github_issue_labels(),
|
|
570
|
+
fingerprint=self.get_github_issue_fingerprint(),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
if response is None:
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
try:
|
|
577
|
+
payload = response.json()
|
|
578
|
+
except ValueError: # pragma: no cover - defensive guard
|
|
579
|
+
payload = {}
|
|
580
|
+
|
|
581
|
+
issue_url = payload.get("html_url")
|
|
582
|
+
issue_number = payload.get("number")
|
|
583
|
+
|
|
584
|
+
update_fields = []
|
|
585
|
+
if issue_url and issue_url != self.github_issue_url:
|
|
586
|
+
self.github_issue_url = issue_url
|
|
587
|
+
update_fields.append("github_issue_url")
|
|
588
|
+
if issue_number is not None and issue_number != self.github_issue_number:
|
|
589
|
+
self.github_issue_number = issue_number
|
|
590
|
+
update_fields.append("github_issue_number")
|
|
591
|
+
|
|
592
|
+
if update_fields:
|
|
593
|
+
self.save(update_fields=update_fields)
|
|
594
|
+
|
|
595
|
+
return issue_url
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
from django.db.models.signals import post_save
|
|
599
|
+
from django.dispatch import receiver
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@receiver(post_save, sender=Module)
|
|
603
|
+
def _create_landings(
|
|
604
|
+
sender, instance, created, raw, **kwargs
|
|
605
|
+
): # pragma: no cover - simple handler
|
|
606
|
+
if created and not raw:
|
|
607
|
+
instance.create_landings()
|