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/apps.py CHANGED
@@ -1,10 +1,45 @@
1
- from django.apps import AppConfig
2
-
3
-
4
- class PagesConfig(AppConfig):
5
- default_auto_field = "django.db.models.BigAutoField"
6
- name = "pages"
7
- verbose_name = "7. Experience"
8
-
9
- def ready(self): # pragma: no cover - import for side effects
10
- from . import checks # noqa: F401
1
+ import logging
2
+
3
+ from django.apps import AppConfig
4
+ from django.db import DatabaseError
5
+ from django.db.backends.signals import connection_created
6
+
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class PagesConfig(AppConfig):
12
+ default_auto_field = "django.db.models.BigAutoField"
13
+ name = "pages"
14
+ verbose_name = "7. Experience"
15
+ _view_history_purged = False
16
+
17
+ def ready(self): # pragma: no cover - import for side effects
18
+ from . import checks # noqa: F401
19
+ from . import site_config
20
+
21
+ site_config.ready()
22
+ connection_created.connect(
23
+ self._handle_connection_created,
24
+ dispatch_uid="pages_view_history_connection_created",
25
+ weak=False,
26
+ )
27
+
28
+ def _handle_connection_created(self, sender, connection, **kwargs):
29
+ if self._view_history_purged:
30
+ return
31
+ self._view_history_purged = True
32
+ self._purge_view_history()
33
+
34
+ def _purge_view_history(self, days: int = 15) -> None:
35
+ """Remove stale :class:`pages.models.ViewHistory` entries."""
36
+
37
+ from .models import ViewHistory
38
+
39
+ try:
40
+ deleted = ViewHistory.purge_older_than(days=days)
41
+ except DatabaseError:
42
+ logger.debug("Skipping view history purge; database unavailable", exc_info=True)
43
+ else:
44
+ if deleted:
45
+ logger.info("Purged %s view history entries older than %s days", deleted, days)
pages/checks.py CHANGED
@@ -1,40 +1,40 @@
1
- import inspect
2
-
3
- from django.core.checks import Warning, register
4
- from django.urls.resolvers import URLPattern, URLResolver
5
-
6
- from config import urls as project_urls
7
-
8
-
9
- def _collect_checks(resolver: URLResolver, errors: list, prefix: str = ""):
10
- for pattern in resolver.url_patterns:
11
- if isinstance(pattern, URLResolver):
12
- _collect_checks(pattern, errors, prefix + pattern.pattern._route)
13
- elif isinstance(pattern, URLPattern):
14
- view = pattern.callback
15
- if getattr(view, "landing", False):
16
- sig = inspect.signature(view)
17
- params = list(sig.parameters.values())
18
- if params and params[0].name == "request":
19
- params = params[1:]
20
- has_required = any(
21
- p.default is inspect._empty
22
- and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
23
- for p in params
24
- )
25
- if has_required:
26
- errors.append(
27
- Warning(
28
- f'Landing view "{view.__module__}.{view.__name__}" requires URL parameters and cannot be a landing page.',
29
- id="pages.W001",
30
- )
31
- )
32
-
33
-
34
- @register()
35
- def landing_views_have_no_args(app_configs, **kwargs):
36
- errors: list = []
37
- for p in project_urls.urlpatterns:
38
- if isinstance(p, URLResolver):
39
- _collect_checks(p, errors, p.pattern._route)
40
- return errors
1
+ import inspect
2
+
3
+ from django.core.checks import Warning, register
4
+ from django.urls.resolvers import URLPattern, URLResolver
5
+
6
+ from config import urls as project_urls
7
+
8
+
9
+ def _collect_checks(resolver: URLResolver, errors: list, prefix: str = ""):
10
+ for pattern in resolver.url_patterns:
11
+ if isinstance(pattern, URLResolver):
12
+ _collect_checks(pattern, errors, prefix + pattern.pattern._route)
13
+ elif isinstance(pattern, URLPattern):
14
+ view = pattern.callback
15
+ if getattr(view, "landing", False):
16
+ sig = inspect.signature(view)
17
+ params = list(sig.parameters.values())
18
+ if params and params[0].name == "request":
19
+ params = params[1:]
20
+ has_required = any(
21
+ p.default is inspect._empty
22
+ and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
23
+ for p in params
24
+ )
25
+ if has_required:
26
+ errors.append(
27
+ Warning(
28
+ f'Landing view "{view.__module__}.{view.__name__}" requires URL parameters and cannot be a landing page.',
29
+ id="pages.W001",
30
+ )
31
+ )
32
+
33
+
34
+ @register()
35
+ def landing_views_have_no_args(app_configs, **kwargs):
36
+ errors: list = []
37
+ for p in project_urls.urlpatterns:
38
+ if isinstance(p, URLResolver):
39
+ _collect_checks(p, errors, p.pattern._route)
40
+ return errors
@@ -1,85 +1,151 @@
1
- from utils.sites import get_site
2
- from django.urls import Resolver404, resolve
3
- from django.conf import settings
4
- from pathlib import Path
5
- from types import SimpleNamespace
6
- from nodes.models import Node
7
- from .models import Module
8
-
9
- _favicon_path = Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon.txt"
10
- try:
11
- _DEFAULT_FAVICON = f"data:image/png;base64,{_favicon_path.read_text().strip()}"
12
- except OSError:
13
- _DEFAULT_FAVICON = ""
14
-
15
-
16
- def nav_links(request):
17
- """Provide navigation links for the current site."""
18
- site = get_site(request)
19
- node = Node.get_local()
20
- role = node.role if node else None
21
- if role:
22
- modules = (
23
- Module.objects.filter(node_role=role, is_deleted=False)
24
- .select_related("application")
25
- .prefetch_related("landings")
26
- )
27
- else:
28
- modules = []
29
-
30
- valid_modules = []
31
- current_module = None
32
- for module in modules:
33
- landings = []
34
- for landing in module.landings.filter(enabled=True):
35
- try:
36
- match = resolve(landing.path)
37
- except Resolver404:
38
- continue
39
- view_func = match.func
40
- requires_login = getattr(view_func, "login_required", False) or hasattr(
41
- view_func, "login_url"
42
- )
43
- staff_only = getattr(view_func, "staff_required", False)
44
- if requires_login and not request.user.is_authenticated:
45
- continue
46
- if staff_only and not request.user.is_staff:
47
- continue
48
- landings.append(landing)
49
- if landings:
50
- module.enabled_landings = landings
51
- valid_modules.append(module)
52
- if request.path.startswith(module.path):
53
- if current_module is None or len(module.path) > len(
54
- current_module.path
55
- ):
56
- current_module = module
57
-
58
- datasette_lock = Path(settings.BASE_DIR) / "locks" / "datasette.lck"
59
- if datasette_lock.exists():
60
- datasette_module = SimpleNamespace(
61
- menu_label="Data",
62
- path="/data/",
63
- enabled_landings=[SimpleNamespace(path="/data/", label="Datasette")],
64
- )
65
- valid_modules.append(datasette_module)
66
-
67
- valid_modules.sort(key=lambda m: m.menu_label.lower())
68
-
69
- if current_module and current_module.favicon:
70
- favicon_url = current_module.favicon.url
71
- else:
72
- favicon_url = None
73
- if site:
74
- try:
75
- if site.badge.favicon:
76
- favicon_url = site.badge.favicon.url
77
- except Exception:
78
- pass
79
- if not favicon_url:
80
- favicon_url = _DEFAULT_FAVICON
81
-
82
- return {
83
- "nav_modules": valid_modules,
84
- "favicon_url": favicon_url,
85
- }
1
+ from utils.sites import get_site
2
+ from django.urls import Resolver404, resolve
3
+ from django.conf import settings
4
+ from pathlib import Path
5
+ from nodes.models import Node
6
+ from core.models import Reference
7
+ from core.reference_utils import filter_visible_references
8
+ from .models import Module
9
+
10
+ _FAVICON_DIR = Path(settings.BASE_DIR) / "pages" / "fixtures" / "data"
11
+ _FAVICON_FILENAMES = {
12
+ "default": "favicon.txt",
13
+ "Watchtower": "favicon_watchtower.txt",
14
+ "Constellation": "favicon_watchtower.txt",
15
+ "Control": "favicon_control.txt",
16
+ "Satellite": "favicon_satellite.txt",
17
+ }
18
+
19
+
20
+ def _load_favicon(filename: str) -> str:
21
+ path = _FAVICON_DIR / filename
22
+ try:
23
+ return f"data:image/png;base64,{path.read_text().strip()}"
24
+ except OSError:
25
+ return ""
26
+
27
+
28
+ _DEFAULT_FAVICON = _load_favicon(_FAVICON_FILENAMES["default"])
29
+ _ROLE_FAVICONS = {
30
+ role: (_load_favicon(filename) or _DEFAULT_FAVICON)
31
+ for role, filename in _FAVICON_FILENAMES.items()
32
+ if role != "default"
33
+ }
34
+
35
+
36
+ def nav_links(request):
37
+ """Provide navigation links for the current site."""
38
+ site = get_site(request)
39
+ node = Node.get_local()
40
+ role = node.role if node else None
41
+ if role:
42
+ modules = (
43
+ Module.objects.filter(node_role=role, is_deleted=False)
44
+ .select_related("application")
45
+ .prefetch_related("landings")
46
+ )
47
+ else:
48
+ modules = []
49
+
50
+ valid_modules = []
51
+ current_module = None
52
+ user = getattr(request, "user", None)
53
+ user_is_authenticated = getattr(user, "is_authenticated", False)
54
+ user_is_superuser = getattr(user, "is_superuser", False)
55
+ if user_is_authenticated:
56
+ user_group_names = set(user.groups.values_list("name", flat=True))
57
+ else:
58
+ user_group_names = set()
59
+ for module in modules:
60
+ landings = []
61
+ for landing in module.landings.filter(enabled=True):
62
+ try:
63
+ match = resolve(landing.path)
64
+ except Resolver404:
65
+ continue
66
+ view_func = match.func
67
+ requires_login = bool(getattr(view_func, "login_required", False))
68
+ if not requires_login and hasattr(view_func, "login_url"):
69
+ requires_login = True
70
+ staff_only = getattr(view_func, "staff_required", False)
71
+ required_groups = getattr(
72
+ view_func, "required_security_groups", frozenset()
73
+ )
74
+ blocked_reason = None
75
+ if required_groups:
76
+ requires_login = True
77
+ if not user_is_authenticated:
78
+ blocked_reason = "login"
79
+ elif not user_is_superuser and not (
80
+ user_group_names & set(required_groups)
81
+ ):
82
+ blocked_reason = "permission"
83
+ elif requires_login and not user_is_authenticated:
84
+ blocked_reason = "login"
85
+
86
+ if staff_only and not getattr(request.user, "is_staff", False):
87
+ if blocked_reason != "login":
88
+ blocked_reason = "permission"
89
+
90
+ landing.nav_is_locked = bool(blocked_reason)
91
+ landing.nav_lock_reason = blocked_reason
92
+ landings.append(landing)
93
+ if landings:
94
+ normalized_module_path = module.path.rstrip("/") or "/"
95
+ if normalized_module_path == "/read":
96
+ primary_landings = [
97
+ landing
98
+ for landing in landings
99
+ if landing.path.rstrip("/") == normalized_module_path
100
+ ]
101
+ if primary_landings:
102
+ landings = primary_landings
103
+ else:
104
+ landings = [landings[0]]
105
+ app_name = getattr(module.application, "name", "").lower()
106
+ if app_name == "awg":
107
+ module.menu = "Calculators"
108
+ elif module.path.rstrip("/").lower() == "/man":
109
+ module.menu = "Manual"
110
+ module.enabled_landings = landings
111
+ valid_modules.append(module)
112
+ if request.path.startswith(module.path):
113
+ if current_module is None or len(module.path) > len(
114
+ current_module.path
115
+ ):
116
+ current_module = module
117
+
118
+
119
+ valid_modules.sort(key=lambda m: m.menu_label.lower())
120
+
121
+ if current_module and current_module.favicon:
122
+ favicon_url = current_module.favicon.url
123
+ else:
124
+ favicon_url = None
125
+ if site:
126
+ try:
127
+ if site.badge.favicon:
128
+ favicon_url = site.badge.favicon.url
129
+ except Exception:
130
+ pass
131
+ if not favicon_url:
132
+ role_name = getattr(getattr(node, "role", None), "name", "")
133
+ favicon_url = _ROLE_FAVICONS.get(role_name, _DEFAULT_FAVICON) or _DEFAULT_FAVICON
134
+
135
+ header_refs_qs = (
136
+ Reference.objects.filter(show_in_header=True)
137
+ .exclude(value="")
138
+ .prefetch_related("roles", "features", "sites")
139
+ )
140
+ header_references = filter_visible_references(
141
+ header_refs_qs,
142
+ request=request,
143
+ site=site,
144
+ node=node,
145
+ )
146
+
147
+ return {
148
+ "nav_modules": valid_modules,
149
+ "favicon_url": favicon_url,
150
+ "header_references": header_references,
151
+ }
pages/defaults.py ADDED
@@ -0,0 +1,13 @@
1
+ """Default configuration for the pages application."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Dict
5
+
6
+ DEFAULT_APPLICATION_DESCRIPTIONS: Dict[str, str] = {
7
+ "awg": "Power, Energy and Cost calculations.",
8
+ "core": "Support for Business Processes and monetization.",
9
+ "ocpp": "Compatibility with Standards and Good Practices.",
10
+ "nodes": "System and Node-level operations.",
11
+ "pages": "User QA, Continuity Design and Chaos Testing.",
12
+ "teams": "Identity, Entitlements and Access Controls.",
13
+ }
pages/forms.py ADDED
@@ -0,0 +1,221 @@
1
+ """Forms for the pages app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django import forms
6
+ from django.contrib.auth import authenticate
7
+ from django.contrib.auth.forms import AuthenticationForm
8
+ from django.core.exceptions import ValidationError
9
+ from django.utils.translation import gettext_lazy as _
10
+ from django.views.decorators.debug import sensitive_variables
11
+
12
+ from core.form_fields import Base64FileField
13
+
14
+ from .models import UserManual, UserStory
15
+
16
+
17
+ class AuthenticatorLoginForm(AuthenticationForm):
18
+ """Authentication form that supports password or authenticator codes."""
19
+
20
+ otp_token = forms.CharField(
21
+ label=_("Authenticator code"),
22
+ required=False,
23
+ widget=forms.TextInput(
24
+ attrs={
25
+ "autocomplete": "one-time-code",
26
+ "inputmode": "numeric",
27
+ "pattern": "[0-9]*",
28
+ }
29
+ ),
30
+ )
31
+ auth_method = forms.CharField(required=False, widget=forms.HiddenInput(), initial="password")
32
+
33
+ error_messages = {
34
+ **AuthenticationForm.error_messages,
35
+ "invalid_token": _("The authenticator code is invalid or has expired."),
36
+ "token_required": _("Enter the code from your authenticator app."),
37
+ "password_required": _("Enter your password."),
38
+ }
39
+
40
+ def __init__(self, request=None, *args, **kwargs):
41
+ super().__init__(request=request, *args, **kwargs)
42
+ self.fields["password"].required = False
43
+ self.fields["otp_token"].strip = True
44
+ self.fields["auth_method"].initial = "password"
45
+ self.verified_device = None
46
+
47
+ def get_invalid_token_error(self) -> ValidationError:
48
+ return ValidationError(self.error_messages["invalid_token"], code="invalid_token")
49
+
50
+ def get_token_required_error(self) -> ValidationError:
51
+ return ValidationError(self.error_messages["token_required"], code="token_required")
52
+
53
+ def get_password_required_error(self) -> ValidationError:
54
+ return ValidationError(self.error_messages["password_required"], code="password_required")
55
+
56
+ @sensitive_variables()
57
+ def clean(self):
58
+ username = self.cleaned_data.get("username")
59
+ method = (self.cleaned_data.get("auth_method") or "password").lower()
60
+ if method not in {"password", "otp"}:
61
+ method = "password"
62
+ self.cleaned_data["auth_method"] = method
63
+
64
+ if username is not None:
65
+ if method == "otp":
66
+ token = (self.cleaned_data.get("otp_token") or "").strip().replace(" ", "")
67
+ if not token:
68
+ raise self.get_token_required_error()
69
+ self.user_cache = authenticate(
70
+ self.request,
71
+ username=username,
72
+ otp_token=token,
73
+ )
74
+ if self.user_cache is None:
75
+ raise self.get_invalid_token_error()
76
+ self.cleaned_data["otp_token"] = token
77
+ self.verified_device = getattr(self.user_cache, "otp_device", None)
78
+ else:
79
+ password = self.cleaned_data.get("password")
80
+ if not password:
81
+ raise self.get_password_required_error()
82
+ self.user_cache = authenticate(
83
+ self.request, username=username, password=password
84
+ )
85
+ if self.user_cache is None:
86
+ raise self.get_invalid_login_error()
87
+ self.confirm_login_allowed(self.user_cache)
88
+
89
+ return self.cleaned_data
90
+
91
+ def get_verified_device(self):
92
+ return self.verified_device
93
+
94
+
95
+ class AuthenticatorEnrollmentForm(forms.Form):
96
+ """Form used to confirm a pending authenticator enrollment."""
97
+
98
+ token = forms.CharField(
99
+ label=_("Authenticator code"),
100
+ min_length=6,
101
+ max_length=8,
102
+ widget=forms.TextInput(
103
+ attrs={
104
+ "autocomplete": "one-time-code",
105
+ "inputmode": "numeric",
106
+ "pattern": "[0-9]*",
107
+ }
108
+ ),
109
+ )
110
+
111
+ error_messages = {
112
+ "invalid_token": _("The provided code is invalid or has expired."),
113
+ "missing_device": _("Generate a new authenticator secret before confirming it."),
114
+ }
115
+
116
+ def __init__(self, *args, device=None, **kwargs):
117
+ self.device = device
118
+ super().__init__(*args, **kwargs)
119
+
120
+ def clean_token(self):
121
+ token = (self.cleaned_data.get("token") or "").strip().replace(" ", "")
122
+ if not token:
123
+ raise forms.ValidationError(self.error_messages["invalid_token"], code="invalid_token")
124
+ if self.device is None:
125
+ raise forms.ValidationError(self.error_messages["missing_device"], code="missing_device")
126
+ try:
127
+ verified = self.device.verify_token(token)
128
+ except Exception:
129
+ verified = False
130
+ if not verified:
131
+ raise forms.ValidationError(self.error_messages["invalid_token"], code="invalid_token")
132
+ return token
133
+
134
+ def get_verified_device(self):
135
+ return self.device
136
+
137
+
138
+ _manual_pdf_field = UserManual._meta.get_field("content_pdf")
139
+
140
+
141
+ class UserManualAdminForm(forms.ModelForm):
142
+ content_pdf = Base64FileField(
143
+ label=_manual_pdf_field.verbose_name,
144
+ help_text=_manual_pdf_field.help_text,
145
+ required=not _manual_pdf_field.blank,
146
+ content_type="application/pdf",
147
+ download_name="manual.pdf",
148
+ )
149
+
150
+ class Meta:
151
+ model = UserManual
152
+ fields = "__all__"
153
+
154
+ def __init__(self, *args, **kwargs):
155
+ super().__init__(*args, **kwargs)
156
+ instance = getattr(self, "instance", None)
157
+ slug = getattr(instance, "slug", "")
158
+ if slug:
159
+ self.fields["content_pdf"].widget.download_name = f"{slug}.pdf"
160
+ self.fields["content_pdf"].widget.attrs.setdefault(
161
+ "accept", "application/pdf"
162
+ )
163
+
164
+
165
+ class UserStoryForm(forms.ModelForm):
166
+ class Meta:
167
+ model = UserStory
168
+ fields = ("name", "rating", "comments", "take_screenshot", "path")
169
+ widgets = {
170
+ "path": forms.HiddenInput(),
171
+ "comments": forms.Textarea(attrs={"rows": 4, "maxlength": 400}),
172
+ }
173
+
174
+ def __init__(self, *args, user=None, **kwargs):
175
+ self.user = user
176
+ super().__init__(*args, **kwargs)
177
+
178
+ if user is not None and user.is_authenticated:
179
+ name_field = self.fields["name"]
180
+ name_field.required = False
181
+ name_field.label = _("Username")
182
+ name_field.initial = (user.get_username() or "")[:40]
183
+ name_field.widget.attrs.update(
184
+ {
185
+ "maxlength": 40,
186
+ "readonly": "readonly",
187
+ }
188
+ )
189
+ else:
190
+ self.fields["name"] = forms.EmailField(
191
+ label=_("Email address"),
192
+ max_length=40,
193
+ required=True,
194
+ widget=forms.EmailInput(
195
+ attrs={
196
+ "maxlength": 40,
197
+ "placeholder": _("name@example.com"),
198
+ "autocomplete": "email",
199
+ "inputmode": "email",
200
+ }
201
+ ),
202
+ )
203
+ self.fields["take_screenshot"].initial = True
204
+ self.fields["rating"].widget = forms.RadioSelect(
205
+ choices=[(i, str(i)) for i in range(1, 6)]
206
+ )
207
+
208
+ def clean_comments(self):
209
+ comments = (self.cleaned_data.get("comments") or "").strip()
210
+ if len(comments) > 400:
211
+ raise forms.ValidationError(
212
+ _("Feedback must be 400 characters or fewer."), code="max_length"
213
+ )
214
+ return comments
215
+
216
+ def clean_name(self):
217
+ if self.user is not None and self.user.is_authenticated:
218
+ return (self.user.get_username() or "")[:40]
219
+
220
+ name = (self.cleaned_data.get("name") or "").strip()
221
+ return name[:40]