arthexis 0.1.9__py3-none-any.whl → 0.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- arthexis-0.1.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
pages/apps.py
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
pages/context_processors.py
CHANGED
|
@@ -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
|
|
6
|
-
from
|
|
7
|
-
from .
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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]
|