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