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