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.

Files changed (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -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 +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {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
- 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_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
-
13
-
14
- class ApplicationManager(models.Manager):
15
- def get_by_natural_key(self, name: str):
16
- return self.get(name=name)
17
-
18
-
19
- class Application(Entity):
20
- name = models.CharField(max_length=100, unique=True)
21
- description = models.TextField(blank=True)
22
-
23
- objects = ApplicationManager()
24
-
25
- def natural_key(self): # pragma: no cover - simple representation
26
- return (self.name,)
27
-
28
- def __str__(self) -> str: # pragma: no cover - simple representation
29
- return self.name
30
-
31
- @property
32
- def installed(self) -> bool:
33
- return django_apps.is_installed(self.name)
34
-
35
- @property
36
- def verbose_name(self) -> str:
37
- try:
38
- return django_apps.get_app_config(self.name).verbose_name
39
- except LookupError:
40
- return self.name
41
-
42
-
43
- class ModuleManager(models.Manager):
44
- def get_by_natural_key(self, role: str, path: str):
45
- return self.get(node_role__name=role, path=path)
46
-
47
-
48
- class Module(Entity):
49
- node_role = models.ForeignKey(
50
- NodeRole,
51
- on_delete=models.CASCADE,
52
- related_name="modules",
53
- )
54
- application = models.ForeignKey(
55
- Application,
56
- on_delete=models.CASCADE,
57
- related_name="modules",
58
- )
59
- path = models.CharField(
60
- max_length=100,
61
- help_text="Base path for the app, starting with /",
62
- blank=True,
63
- )
64
- menu = models.CharField(
65
- max_length=100,
66
- blank=True,
67
- help_text="Text used for the navbar pill; defaults to the application name.",
68
- )
69
- is_default = models.BooleanField(default=False)
70
- favicon = models.ImageField(upload_to="modules/favicons/", blank=True)
71
-
72
- objects = ModuleManager()
73
-
74
- class Meta:
75
- verbose_name = _("Module")
76
- verbose_name_plural = _("Modules")
77
- unique_together = ("node_role", "path")
78
-
79
- def natural_key(self): # pragma: no cover - simple representation
80
- role_name = None
81
- if getattr(self, "node_role_id", None):
82
- role_name = self.node_role.name
83
- return (role_name, self.path)
84
-
85
- natural_key.dependencies = ["nodes.NodeRole"]
86
-
87
- def __str__(self) -> str: # pragma: no cover - simple representation
88
- return f"{self.application.name} ({self.path})"
89
-
90
- @property
91
- def menu_label(self) -> str:
92
- return self.menu or self.application.name
93
-
94
- def save(self, *args, **kwargs):
95
- if not self.path:
96
- self.path = f"/{slugify(self.application.name)}/"
97
- super().save(*args, **kwargs)
98
-
99
- def create_landings(self):
100
- try:
101
- urlconf = import_module(f"{self.application.name}.urls")
102
- except Exception:
103
- try:
104
- urlconf = import_module(f"{self.application.name.lower()}.urls")
105
- except Exception:
106
- Landing.objects.get_or_create(
107
- module=self,
108
- path=self.path,
109
- defaults={"label": self.application.name},
110
- )
111
- return
112
- patterns = getattr(urlconf, "urlpatterns", [])
113
- created = False
114
-
115
- def _walk(patterns, prefix=""):
116
- nonlocal created
117
- for pattern in patterns:
118
- if isinstance(pattern, URLPattern):
119
- callback = pattern.callback
120
- if getattr(callback, "landing", False):
121
- Landing.objects.get_or_create(
122
- module=self,
123
- path=f"{self.path}{prefix}{str(pattern.pattern)}",
124
- defaults={
125
- "label": getattr(
126
- callback,
127
- "landing_label",
128
- callback.__name__.replace("_", " ").title(),
129
- )
130
- },
131
- )
132
- created = True
133
- else:
134
- _walk(
135
- pattern.url_patterns, prefix=f"{prefix}{str(pattern.pattern)}"
136
- )
137
-
138
- _walk(patterns)
139
-
140
- if not created:
141
- Landing.objects.get_or_create(
142
- module=self, path=self.path, defaults={"label": self.application.name}
143
- )
144
-
145
-
146
- class SiteBadge(Entity):
147
- site = models.OneToOneField(Site, on_delete=models.CASCADE, related_name="badge")
148
- badge_color = models.CharField(max_length=7, default="#28a745")
149
- favicon = models.ImageField(upload_to="sites/favicons/", blank=True)
150
- landing_override = models.ForeignKey(
151
- "Landing", null=True, blank=True, on_delete=models.SET_NULL
152
- )
153
-
154
- def __str__(self) -> str: # pragma: no cover - simple representation
155
- return f"Badge for {self.site.domain}"
156
-
157
- class Meta:
158
- verbose_name = "Site Badge"
159
- verbose_name_plural = "Site Badges"
160
-
161
-
162
- class SiteProxy(Site):
163
- class Meta:
164
- proxy = True
165
- app_label = "pages"
166
- verbose_name = "Site"
167
- verbose_name_plural = "Sites"
168
-
169
-
170
- class LandingManager(models.Manager):
171
- def get_by_natural_key(self, role: str, module_path: str, path: str):
172
- return self.get(
173
- module__node_role__name=role, module__path=module_path, path=path
174
- )
175
-
176
-
177
- class Landing(Entity):
178
- module = models.ForeignKey(
179
- Module, on_delete=models.CASCADE, related_name="landings"
180
- )
181
- path = models.CharField(max_length=200)
182
- label = models.CharField(max_length=100)
183
- enabled = models.BooleanField(default=True)
184
- description = models.TextField(blank=True)
185
-
186
- objects = LandingManager()
187
-
188
- class Meta:
189
- unique_together = ("module", "path")
190
-
191
- def __str__(self) -> str: # pragma: no cover - simple representation
192
- return f"{self.label} ({self.path})"
193
-
194
- def save(self, *args, **kwargs):
195
- if not self.pk:
196
- existing = (
197
- type(self).objects.filter(module=self.module, path=self.path).first()
198
- )
199
- if existing:
200
- self.pk = existing.pk
201
- super().save(*args, **kwargs)
202
-
203
- def natural_key(self): # pragma: no cover - simple representation
204
- return (self.module.node_role.name, self.module.path, self.path)
205
-
206
- natural_key.dependencies = ["nodes.NodeRole", "pages.Module"]
207
-
208
-
209
- class ViewHistory(Entity):
210
- """Record of public site visits."""
211
-
212
- path = models.CharField(max_length=500)
213
- method = models.CharField(max_length=10)
214
- status_code = models.PositiveSmallIntegerField()
215
- status_text = models.CharField(max_length=100, blank=True)
216
- error_message = models.TextField(blank=True)
217
- view_name = models.CharField(max_length=200, blank=True)
218
- visited_at = models.DateTimeField(auto_now_add=True)
219
-
220
- class Meta:
221
- ordering = ["-visited_at"]
222
- verbose_name = _("View History")
223
- verbose_name_plural = _("View Histories")
224
-
225
- def __str__(self) -> str: # pragma: no cover - simple representation
226
- return f"{self.method} {self.path} ({self.status_code})"
227
-
228
-
229
- class Favorite(Entity):
230
- user = models.ForeignKey(
231
- settings.AUTH_USER_MODEL,
232
- on_delete=models.CASCADE,
233
- related_name="favorites",
234
- )
235
- content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
236
- custom_label = models.CharField(max_length=100, blank=True)
237
- user_data = models.BooleanField(default=False)
238
-
239
- class Meta:
240
- unique_together = ("user", "content_type")
241
-
242
-
243
- from django.db.models.signals import post_save
244
- from django.dispatch import receiver
245
-
246
-
247
- @receiver(post_save, sender=Module)
248
- def _create_landings(
249
- sender, instance, created, raw, **kwargs
250
- ): # pragma: no cover - simple handler
251
- if created and not raw:
252
- instance.create_landings()
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()