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.

Files changed (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {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 core.entity import Entity
3
- from django.contrib.sites.models import Site
4
- from nodes.models import NodeRole
5
- from django.apps import apps as django_apps
6
- from django.utils.text import slugify
7
- from django.utils.translation import gettext, gettext_lazy as _
8
- from importlib import import_module
9
- from django.urls import URLPattern
10
- from django.conf import settings
11
- from django.contrib.contenttypes.models import ContentType
12
- from django.core.validators import MaxLengthValidator, MaxValueValidator, MinValueValidator
13
-
14
- from core import github_issues
15
-
16
-
17
- class ApplicationManager(models.Manager):
18
- def get_by_natural_key(self, name: str):
19
- return self.get(name=name)
20
-
21
-
22
- class Application(Entity):
23
- name = models.CharField(max_length=100, unique=True)
24
- description = models.TextField(blank=True)
25
-
26
- objects = ApplicationManager()
27
-
28
- def natural_key(self): # pragma: no cover - simple representation
29
- return (self.name,)
30
-
31
- def __str__(self) -> str: # pragma: no cover - simple representation
32
- return self.name
33
-
34
- @property
35
- def installed(self) -> bool:
36
- return django_apps.is_installed(self.name)
37
-
38
- @property
39
- def verbose_name(self) -> str:
40
- try:
41
- return django_apps.get_app_config(self.name).verbose_name
42
- except LookupError:
43
- return self.name
44
-
45
-
46
- class ModuleManager(models.Manager):
47
- def get_by_natural_key(self, role: str, path: str):
48
- return self.get(node_role__name=role, path=path)
49
-
50
-
51
- class Module(Entity):
52
- node_role = models.ForeignKey(
53
- NodeRole,
54
- on_delete=models.CASCADE,
55
- related_name="modules",
56
- )
57
- application = models.ForeignKey(
58
- Application,
59
- on_delete=models.CASCADE,
60
- related_name="modules",
61
- )
62
- path = models.CharField(
63
- max_length=100,
64
- help_text="Base path for the app, starting with /",
65
- blank=True,
66
- )
67
- menu = models.CharField(
68
- max_length=100,
69
- blank=True,
70
- help_text="Text used for the navbar pill; defaults to the application name.",
71
- )
72
- is_default = models.BooleanField(default=False)
73
- favicon = models.ImageField(upload_to="modules/favicons/", blank=True)
74
-
75
- objects = ModuleManager()
76
-
77
- class Meta:
78
- verbose_name = _("Module")
79
- verbose_name_plural = _("Modules")
80
- unique_together = ("node_role", "path")
81
-
82
- def natural_key(self): # pragma: no cover - simple representation
83
- role_name = None
84
- if getattr(self, "node_role_id", None):
85
- role_name = self.node_role.name
86
- return (role_name, self.path)
87
-
88
- natural_key.dependencies = ["nodes.NodeRole"]
89
-
90
- def __str__(self) -> str: # pragma: no cover - simple representation
91
- return f"{self.application.name} ({self.path})"
92
-
93
- @property
94
- def menu_label(self) -> str:
95
- return self.menu or self.application.name
96
-
97
- def save(self, *args, **kwargs):
98
- if not self.path:
99
- self.path = f"/{slugify(self.application.name)}/"
100
- super().save(*args, **kwargs)
101
-
102
- def create_landings(self):
103
- try:
104
- urlconf = import_module(f"{self.application.name}.urls")
105
- except Exception:
106
- try:
107
- urlconf = import_module(f"{self.application.name.lower()}.urls")
108
- except Exception:
109
- Landing.objects.get_or_create(
110
- module=self,
111
- path=self.path,
112
- defaults={"label": self.application.name},
113
- )
114
- return
115
- patterns = getattr(urlconf, "urlpatterns", [])
116
- created = False
117
- normalized_module = self.path.strip("/")
118
-
119
- def _walk(patterns, prefix=""):
120
- nonlocal created
121
- for pattern in patterns:
122
- if isinstance(pattern, URLPattern):
123
- callback = pattern.callback
124
- if getattr(callback, "landing", False):
125
- pattern_path = str(pattern.pattern)
126
- relative = f"{prefix}{pattern_path}"
127
- if normalized_module and relative.startswith(normalized_module):
128
- full_path = f"/{relative}"
129
- Landing.objects.update_or_create(
130
- module=self,
131
- path=full_path,
132
- defaults={
133
- "label": getattr(
134
- callback,
135
- "landing_label",
136
- callback.__name__.replace("_", " ").title(),
137
- )
138
- },
139
- )
140
- else:
141
- full_path = f"{self.path}{relative}"
142
- Landing.objects.get_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
- created = True
154
- else:
155
- _walk(
156
- pattern.url_patterns, prefix=f"{prefix}{str(pattern.pattern)}"
157
- )
158
-
159
- _walk(patterns)
160
-
161
- if not created:
162
- Landing.objects.get_or_create(
163
- module=self, path=self.path, defaults={"label": self.application.name}
164
- )
165
-
166
-
167
- class SiteBadge(Entity):
168
- site = models.OneToOneField(Site, on_delete=models.CASCADE, related_name="badge")
169
- badge_color = models.CharField(max_length=7, default="#28a745")
170
- favicon = models.ImageField(upload_to="sites/favicons/", blank=True)
171
- landing_override = models.ForeignKey(
172
- "Landing", null=True, blank=True, on_delete=models.SET_NULL
173
- )
174
-
175
- def __str__(self) -> str: # pragma: no cover - simple representation
176
- return f"Badge for {self.site.domain}"
177
-
178
- class Meta:
179
- verbose_name = "Site Badge"
180
- verbose_name_plural = "Site Badges"
181
-
182
-
183
- class SiteProxy(Site):
184
- class Meta:
185
- proxy = True
186
- app_label = "pages"
187
- verbose_name = "Site"
188
- verbose_name_plural = "Sites"
189
-
190
-
191
- class LandingManager(models.Manager):
192
- def get_by_natural_key(self, role: str, module_path: str, path: str):
193
- return self.get(
194
- module__node_role__name=role, module__path=module_path, path=path
195
- )
196
-
197
-
198
- class Landing(Entity):
199
- module = models.ForeignKey(
200
- Module, on_delete=models.CASCADE, related_name="landings"
201
- )
202
- path = models.CharField(max_length=200)
203
- label = models.CharField(max_length=100)
204
- enabled = models.BooleanField(default=True)
205
- description = models.TextField(blank=True)
206
-
207
- objects = LandingManager()
208
-
209
- class Meta:
210
- unique_together = ("module", "path")
211
-
212
- def __str__(self) -> str: # pragma: no cover - simple representation
213
- return f"{self.label} ({self.path})"
214
-
215
- def save(self, *args, **kwargs):
216
- existing = None
217
- if not self.pk:
218
- existing = (
219
- type(self).objects.filter(module=self.module, path=self.path).first()
220
- )
221
- if existing:
222
- self.pk = existing.pk
223
- super().save(*args, **kwargs)
224
-
225
-
226
- class UserManual(Entity):
227
- slug = models.SlugField(unique=True)
228
- title = models.CharField(max_length=200)
229
- description = models.CharField(max_length=200)
230
- languages = models.CharField(
231
- max_length=100,
232
- blank=True,
233
- default="",
234
- help_text="Comma-separated 2-letter language codes",
235
- )
236
- content_html = models.TextField()
237
- content_pdf = models.TextField(help_text="Base64 encoded PDF")
238
-
239
- class Meta:
240
- db_table = "man_usermanual"
241
- verbose_name = "User Manual"
242
- verbose_name_plural = "User Manuals"
243
-
244
- def __str__(self): # pragma: no cover - simple representation
245
- return self.title
246
-
247
- def natural_key(self): # pragma: no cover - simple representation
248
- return (self.slug,)
249
-
250
-
251
- class ViewHistory(Entity):
252
- """Record of public site visits."""
253
-
254
- path = models.CharField(max_length=500)
255
- method = models.CharField(max_length=10)
256
- status_code = models.PositiveSmallIntegerField()
257
- status_text = models.CharField(max_length=100, blank=True)
258
- error_message = models.TextField(blank=True)
259
- view_name = models.CharField(max_length=200, blank=True)
260
- visited_at = models.DateTimeField(auto_now_add=True)
261
-
262
- class Meta:
263
- ordering = ["-visited_at"]
264
- verbose_name = _("View History")
265
- verbose_name_plural = _("View Histories")
266
-
267
- def __str__(self) -> str: # pragma: no cover - simple representation
268
- return f"{self.method} {self.path} ({self.status_code})"
269
-
270
-
271
- class Favorite(Entity):
272
- user = models.ForeignKey(
273
- settings.AUTH_USER_MODEL,
274
- on_delete=models.CASCADE,
275
- related_name="favorites",
276
- )
277
- content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
278
- custom_label = models.CharField(max_length=100, blank=True)
279
- user_data = models.BooleanField(default=False)
280
-
281
- class Meta:
282
- unique_together = ("user", "content_type")
283
-
284
-
285
- class UserStory(Entity):
286
- path = models.CharField(max_length=500)
287
- name = models.CharField(max_length=40, blank=True)
288
- rating = models.PositiveSmallIntegerField(
289
- validators=[MinValueValidator(1), MaxValueValidator(5)],
290
- help_text=_("Rate your experience from 1 (lowest) to 5 (highest)."),
291
- )
292
- comments = models.TextField(
293
- validators=[MaxLengthValidator(400)],
294
- help_text=_("Share more about your experience."),
295
- )
296
- take_screenshot = models.BooleanField(
297
- default=True,
298
- help_text=_("Request a screenshot capture for this feedback."),
299
- )
300
- user = models.ForeignKey(
301
- settings.AUTH_USER_MODEL,
302
- on_delete=models.SET_NULL,
303
- blank=True,
304
- null=True,
305
- related_name="user_stories",
306
- )
307
- owner = models.ForeignKey(
308
- settings.AUTH_USER_MODEL,
309
- on_delete=models.SET_NULL,
310
- blank=True,
311
- null=True,
312
- related_name="owned_user_stories",
313
- help_text=_("Internal owner for this feedback."),
314
- )
315
- submitted_at = models.DateTimeField(auto_now_add=True)
316
- github_issue_number = models.PositiveIntegerField(
317
- blank=True,
318
- null=True,
319
- help_text=_("Number of the GitHub issue created for this feedback."),
320
- )
321
- github_issue_url = models.URLField(
322
- blank=True,
323
- help_text=_("Link to the GitHub issue created for this feedback."),
324
- )
325
-
326
- class Meta:
327
- ordering = ["-submitted_at"]
328
- verbose_name = _("User Story")
329
- verbose_name_plural = _("User Stories")
330
-
331
- def __str__(self) -> str: # pragma: no cover - simple representation
332
- display = self.name or _("Anonymous")
333
- return f"{display} ({self.rating}/5)"
334
-
335
- def get_github_issue_labels(self) -> list[str]:
336
- """Return default labels used when creating GitHub issues."""
337
-
338
- return ["feedback"]
339
-
340
- def get_github_issue_fingerprint(self) -> str | None:
341
- """Return a fingerprint used to avoid duplicate issue submissions."""
342
-
343
- if self.pk:
344
- return f"user-story:{self.pk}"
345
- return None
346
-
347
- def build_github_issue_title(self) -> str:
348
- """Return the title used for GitHub issues."""
349
-
350
- path = self.path or "/"
351
- return gettext("Feedback for %(path)s (%(rating)s/5)") % {
352
- "path": path,
353
- "rating": self.rating,
354
- }
355
-
356
- def build_github_issue_body(self) -> str:
357
- """Return the issue body summarising the feedback details."""
358
-
359
- name = self.name or gettext("Anonymous")
360
- path = self.path or "/"
361
- screenshot_requested = gettext("Yes") if self.take_screenshot else gettext("No")
362
-
363
- lines = [
364
- f"**Path:** {path}",
365
- f"**Rating:** {self.rating}/5",
366
- f"**Name:** {name}",
367
- f"**Screenshot requested:** {screenshot_requested}",
368
- ]
369
-
370
- if self.submitted_at:
371
- lines.append(f"**Submitted at:** {self.submitted_at.isoformat()}")
372
-
373
- comment = (self.comments or "").strip()
374
- if comment:
375
- lines.extend(["", comment])
376
-
377
- return "\n".join(lines).strip()
378
-
379
- def create_github_issue(self) -> str | None:
380
- """Create a GitHub issue for this feedback and store the identifiers."""
381
-
382
- if self.github_issue_url:
383
- return self.github_issue_url
384
-
385
- response = github_issues.create_issue(
386
- self.build_github_issue_title(),
387
- self.build_github_issue_body(),
388
- labels=self.get_github_issue_labels(),
389
- fingerprint=self.get_github_issue_fingerprint(),
390
- )
391
-
392
- if response is None:
393
- return None
394
-
395
- try:
396
- payload = response.json()
397
- except ValueError: # pragma: no cover - defensive guard
398
- payload = {}
399
-
400
- issue_url = payload.get("html_url")
401
- issue_number = payload.get("number")
402
-
403
- update_fields = []
404
- if issue_url and issue_url != self.github_issue_url:
405
- self.github_issue_url = issue_url
406
- update_fields.append("github_issue_url")
407
- if issue_number is not None and issue_number != self.github_issue_number:
408
- self.github_issue_number = issue_number
409
- update_fields.append("github_issue_number")
410
-
411
- if update_fields:
412
- self.save(update_fields=update_fields)
413
-
414
- return issue_url
415
-
416
-
417
- from django.db.models.signals import post_save
418
- from django.dispatch import receiver
419
-
420
-
421
- @receiver(post_save, sender=Module)
422
- def _create_landings(
423
- sender, instance, created, raw, **kwargs
424
- ): # pragma: no cover - simple handler
425
- if created and not raw:
426
- instance.create_landings()
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()