arthexis 0.1.8__py3-none-any.whl → 0.1.10__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.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/admin_history.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from django.contrib.admin.options import ModelAdmin
|
|
3
|
+
from django.contrib.admin.models import LogEntry
|
|
4
|
+
from django.utils.encoding import smart_str
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def patch_admin_history():
|
|
8
|
+
if getattr(ModelAdmin, "_history_patched", False):
|
|
9
|
+
return
|
|
10
|
+
|
|
11
|
+
def construct_change_message(self, request, form, formsets, add=False):
|
|
12
|
+
fields = []
|
|
13
|
+
if add:
|
|
14
|
+
for name, value in form.cleaned_data.items():
|
|
15
|
+
fields.append({"field": name, "old": None, "new": smart_str(value)})
|
|
16
|
+
if not fields:
|
|
17
|
+
return ""
|
|
18
|
+
return json.dumps({"added": fields})
|
|
19
|
+
for name in form.changed_data:
|
|
20
|
+
fields.append(
|
|
21
|
+
{
|
|
22
|
+
"field": name,
|
|
23
|
+
"old": smart_str(form.initial.get(name)),
|
|
24
|
+
"new": smart_str(form.cleaned_data.get(name)),
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
if not fields:
|
|
28
|
+
return ""
|
|
29
|
+
return json.dumps({"changed": fields})
|
|
30
|
+
|
|
31
|
+
def get_change_message(self):
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(self.change_message)
|
|
34
|
+
except Exception:
|
|
35
|
+
return self.change_message
|
|
36
|
+
if isinstance(data, dict):
|
|
37
|
+
if "added" in data:
|
|
38
|
+
parts = [f"{d['field']}='{d['new']}'" for d in data["added"]]
|
|
39
|
+
return "Added " + ", ".join(parts)
|
|
40
|
+
if "changed" in data:
|
|
41
|
+
parts = [
|
|
42
|
+
f"{d['field']}: '{d['old']}' -> '{d['new']}'"
|
|
43
|
+
for d in data["changed"]
|
|
44
|
+
]
|
|
45
|
+
return "Changed " + ", ".join(parts)
|
|
46
|
+
return self.change_message
|
|
47
|
+
|
|
48
|
+
ModelAdmin.construct_change_message = construct_change_message
|
|
49
|
+
LogEntry.get_change_message = get_change_message
|
|
50
|
+
ModelAdmin._history_patched = True
|
core/admindocs.py
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import inspect
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.contrib import admin
|
|
2
7
|
from django.core.management import get_commands, load_command_class
|
|
3
|
-
from django.contrib.admindocs.views import
|
|
8
|
+
from django.contrib.admindocs.views import (
|
|
9
|
+
BaseAdminDocsView,
|
|
10
|
+
user_has_model_view_permission,
|
|
11
|
+
)
|
|
12
|
+
from django.urls import NoReverseMatch, reverse
|
|
4
13
|
|
|
5
14
|
|
|
6
15
|
class CommandsView(BaseAdminDocsView):
|
|
@@ -42,3 +51,101 @@ class CommandsView(BaseAdminDocsView):
|
|
|
42
51
|
}
|
|
43
52
|
)
|
|
44
53
|
return super().get_context_data(**{**kwargs, "commands": commands})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OrderedModelIndexView(BaseAdminDocsView):
|
|
57
|
+
template_name = "admin_doc/model_index.html"
|
|
58
|
+
|
|
59
|
+
GROUP_OVERRIDES = {
|
|
60
|
+
"ocpp.location": "core",
|
|
61
|
+
"core.rfid": "ocpp",
|
|
62
|
+
"core.package": "teams",
|
|
63
|
+
"core.packagerelease": "teams",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def _get_docs_app_config(self, meta):
|
|
67
|
+
override_label = self.GROUP_OVERRIDES.get(meta.label_lower)
|
|
68
|
+
if override_label:
|
|
69
|
+
return apps.get_app_config(override_label)
|
|
70
|
+
return meta.app_config
|
|
71
|
+
|
|
72
|
+
def get_context_data(self, **kwargs):
|
|
73
|
+
models = []
|
|
74
|
+
for m in apps.get_models():
|
|
75
|
+
if user_has_model_view_permission(self.request.user, m._meta):
|
|
76
|
+
meta = m._meta
|
|
77
|
+
meta.docstring = inspect.getdoc(m) or ""
|
|
78
|
+
app_config = self._get_docs_app_config(meta)
|
|
79
|
+
models.append(
|
|
80
|
+
SimpleNamespace(
|
|
81
|
+
app_label=meta.app_label,
|
|
82
|
+
model_name=meta.model_name,
|
|
83
|
+
object_name=meta.object_name,
|
|
84
|
+
docstring=meta.docstring,
|
|
85
|
+
app_config=app_config,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
models.sort(key=lambda m: str(m.app_config.verbose_name))
|
|
89
|
+
return super().get_context_data(**{**kwargs, "models": models})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ModelGraphIndexView(BaseAdminDocsView):
|
|
93
|
+
template_name = "admin_doc/model_graphs.html"
|
|
94
|
+
|
|
95
|
+
def get_context_data(self, **kwargs):
|
|
96
|
+
sections = {}
|
|
97
|
+
user = self.request.user
|
|
98
|
+
|
|
99
|
+
for model in admin.site._registry:
|
|
100
|
+
meta = model._meta
|
|
101
|
+
if not user_has_model_view_permission(user, meta):
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
app_config = apps.get_app_config(meta.app_label)
|
|
105
|
+
section = sections.setdefault(
|
|
106
|
+
app_config.label,
|
|
107
|
+
{
|
|
108
|
+
"app_label": app_config.label,
|
|
109
|
+
"verbose_name": str(app_config.verbose_name),
|
|
110
|
+
"models": [],
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
section["models"].append(
|
|
115
|
+
{
|
|
116
|
+
"object_name": meta.object_name,
|
|
117
|
+
"verbose_name": str(meta.verbose_name),
|
|
118
|
+
"doc_url": reverse(
|
|
119
|
+
"django-admindocs-models-detail",
|
|
120
|
+
kwargs={
|
|
121
|
+
"app_label": meta.app_label,
|
|
122
|
+
"model_name": meta.model_name,
|
|
123
|
+
},
|
|
124
|
+
),
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
graph_sections = []
|
|
129
|
+
for section in sections.values():
|
|
130
|
+
section_models = section["models"]
|
|
131
|
+
section_models.sort(key=lambda model: model["verbose_name"])
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
app_list_url = reverse("admin:app_list", args=[section["app_label"]])
|
|
135
|
+
except NoReverseMatch:
|
|
136
|
+
app_list_url = ""
|
|
137
|
+
|
|
138
|
+
graph_sections.append(
|
|
139
|
+
{
|
|
140
|
+
**section,
|
|
141
|
+
"graph_url": reverse(
|
|
142
|
+
"admin-model-graph", args=[section["app_label"]]
|
|
143
|
+
),
|
|
144
|
+
"app_list_url": app_list_url,
|
|
145
|
+
"model_count": len(section_models),
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
graph_sections.sort(key=lambda section: section["verbose_name"])
|
|
150
|
+
|
|
151
|
+
return super().get_context_data(**{**kwargs, "sections": graph_sections})
|
core/apps.py
CHANGED
|
@@ -1,22 +1,43 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
from django.apps import AppConfig
|
|
2
4
|
from django.utils.translation import gettext_lazy as _
|
|
3
5
|
|
|
4
6
|
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
5
10
|
class CoreConfig(AppConfig):
|
|
6
11
|
default_auto_field = "django.db.models.BigAutoField"
|
|
7
12
|
name = "core"
|
|
8
13
|
verbose_name = _("2. Business")
|
|
9
14
|
|
|
10
15
|
def ready(self): # pragma: no cover - called by Django
|
|
16
|
+
from contextlib import suppress
|
|
17
|
+
from functools import wraps
|
|
18
|
+
import hashlib
|
|
19
|
+
import time
|
|
20
|
+
import traceback
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from django.conf import settings
|
|
11
24
|
from django.contrib.auth import get_user_model
|
|
12
25
|
from django.db.models.signals import post_migrate
|
|
26
|
+
from django.core.signals import got_request_exception
|
|
27
|
+
|
|
28
|
+
from core.github_helper import report_exception_to_github
|
|
29
|
+
from .entity import Entity
|
|
13
30
|
from .user_data import (
|
|
14
31
|
patch_admin_user_datum,
|
|
15
32
|
patch_admin_user_data_views,
|
|
16
33
|
)
|
|
17
34
|
from .system import patch_admin_system_view
|
|
18
35
|
from .environment import patch_admin_environment_view
|
|
19
|
-
from . import
|
|
36
|
+
from .sigil_builder import (
|
|
37
|
+
patch_admin_sigil_builder_view,
|
|
38
|
+
generate_model_sigils,
|
|
39
|
+
)
|
|
40
|
+
from .admin_history import patch_admin_history
|
|
20
41
|
|
|
21
42
|
def create_default_arthexis(**kwargs):
|
|
22
43
|
User = get_user_model()
|
|
@@ -29,17 +50,62 @@ class CoreConfig(AppConfig):
|
|
|
29
50
|
)
|
|
30
51
|
|
|
31
52
|
post_migrate.connect(create_default_arthexis, sender=self)
|
|
53
|
+
post_migrate.connect(generate_model_sigils, sender=self)
|
|
32
54
|
patch_admin_user_datum()
|
|
33
55
|
patch_admin_user_data_views()
|
|
34
56
|
patch_admin_system_view()
|
|
35
57
|
patch_admin_environment_view()
|
|
58
|
+
patch_admin_sigil_builder_view()
|
|
59
|
+
patch_admin_history()
|
|
36
60
|
|
|
37
|
-
from
|
|
38
|
-
|
|
61
|
+
from django.core.serializers import base as serializer_base
|
|
62
|
+
|
|
63
|
+
if not hasattr(
|
|
64
|
+
serializer_base.DeserializedObject.save, "_entity_fixture_patch"
|
|
65
|
+
):
|
|
66
|
+
original_save = serializer_base.DeserializedObject.save
|
|
67
|
+
|
|
68
|
+
@wraps(original_save)
|
|
69
|
+
def patched_save(self, save_m2m=True, using=None, **kwargs):
|
|
70
|
+
obj = self.object
|
|
71
|
+
if isinstance(obj, Entity):
|
|
72
|
+
manager = getattr(
|
|
73
|
+
type(obj), "all_objects", type(obj)._default_manager
|
|
74
|
+
)
|
|
75
|
+
if using:
|
|
76
|
+
manager = manager.db_manager(using)
|
|
77
|
+
for fields in obj._unique_field_groups():
|
|
78
|
+
lookup = {}
|
|
79
|
+
for field in fields:
|
|
80
|
+
value = getattr(obj, field.attname)
|
|
81
|
+
if value is None:
|
|
82
|
+
lookup = {}
|
|
83
|
+
break
|
|
84
|
+
lookup[field.attname] = value
|
|
85
|
+
if not lookup:
|
|
86
|
+
continue
|
|
87
|
+
existing = (
|
|
88
|
+
manager.filter(**lookup)
|
|
89
|
+
.only("pk", "is_seed_data", "is_user_data")
|
|
90
|
+
.first()
|
|
91
|
+
)
|
|
92
|
+
if existing is not None:
|
|
93
|
+
obj.pk = existing.pk
|
|
94
|
+
obj.is_seed_data = existing.is_seed_data
|
|
95
|
+
obj.is_user_data = existing.is_user_data
|
|
96
|
+
obj._state.adding = False
|
|
97
|
+
if using:
|
|
98
|
+
obj._state.db = using
|
|
99
|
+
break
|
|
100
|
+
return original_save(self, save_m2m=save_m2m, using=using, **kwargs)
|
|
101
|
+
|
|
102
|
+
patched_save._entity_fixture_patch = True
|
|
103
|
+
serializer_base.DeserializedObject.save = patched_save
|
|
39
104
|
|
|
40
105
|
lock = Path(settings.BASE_DIR) / "locks" / "celery.lck"
|
|
41
106
|
|
|
42
107
|
if lock.exists():
|
|
108
|
+
from .auto_upgrade import ensure_auto_upgrade_periodic_task
|
|
43
109
|
|
|
44
110
|
def ensure_email_collector_task(**kwargs):
|
|
45
111
|
try: # pragma: no cover - optional dependency
|
|
@@ -66,3 +132,95 @@ class CoreConfig(AppConfig):
|
|
|
66
132
|
pass
|
|
67
133
|
|
|
68
134
|
post_migrate.connect(ensure_email_collector_task, sender=self)
|
|
135
|
+
post_migrate.connect(ensure_auto_upgrade_periodic_task, sender=self)
|
|
136
|
+
ensure_auto_upgrade_periodic_task()
|
|
137
|
+
|
|
138
|
+
from django.db.backends.signals import connection_created
|
|
139
|
+
|
|
140
|
+
def enable_sqlite_wal(**kwargs):
|
|
141
|
+
connection = kwargs.get("connection")
|
|
142
|
+
if connection.vendor == "sqlite":
|
|
143
|
+
cursor = connection.cursor()
|
|
144
|
+
cursor.execute("PRAGMA journal_mode=WAL;")
|
|
145
|
+
cursor.execute("PRAGMA busy_timeout=60000;")
|
|
146
|
+
cursor.close()
|
|
147
|
+
|
|
148
|
+
connection_created.connect(enable_sqlite_wal)
|
|
149
|
+
|
|
150
|
+
def queue_github_issue(sender, request=None, **kwargs):
|
|
151
|
+
if not getattr(settings, "GITHUB_ISSUE_REPORTING_ENABLED", True):
|
|
152
|
+
return
|
|
153
|
+
if request is None:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
exception = kwargs.get("exception")
|
|
157
|
+
if exception is None:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
tb_exc = traceback.TracebackException.from_exception(exception)
|
|
162
|
+
stack = tb_exc.stack
|
|
163
|
+
top_frame = stack[-1] if stack else None
|
|
164
|
+
fingerprint_parts = [
|
|
165
|
+
exception.__class__.__module__,
|
|
166
|
+
exception.__class__.__name__,
|
|
167
|
+
]
|
|
168
|
+
if top_frame:
|
|
169
|
+
fingerprint_parts.extend(
|
|
170
|
+
[
|
|
171
|
+
top_frame.filename,
|
|
172
|
+
str(top_frame.lineno),
|
|
173
|
+
top_frame.name,
|
|
174
|
+
]
|
|
175
|
+
)
|
|
176
|
+
fingerprint = hashlib.sha256(
|
|
177
|
+
"|".join(fingerprint_parts).encode("utf-8")
|
|
178
|
+
).hexdigest()
|
|
179
|
+
|
|
180
|
+
cooldown = getattr(settings, "GITHUB_ISSUE_REPORTING_COOLDOWN", 3600)
|
|
181
|
+
lock_dir = Path(settings.BASE_DIR) / "locks" / "github-issues"
|
|
182
|
+
fingerprint_path = None
|
|
183
|
+
now = time.time()
|
|
184
|
+
|
|
185
|
+
with suppress(OSError):
|
|
186
|
+
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
fingerprint_path = lock_dir / fingerprint
|
|
188
|
+
if fingerprint_path.exists():
|
|
189
|
+
age = now - fingerprint_path.stat().st_mtime
|
|
190
|
+
if age < cooldown:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
if fingerprint_path is not None:
|
|
194
|
+
with suppress(OSError):
|
|
195
|
+
fingerprint_path.write_text(str(now))
|
|
196
|
+
|
|
197
|
+
user_repr = None
|
|
198
|
+
user = getattr(request, "user", None)
|
|
199
|
+
if user is not None:
|
|
200
|
+
try:
|
|
201
|
+
if getattr(user, "is_authenticated", False):
|
|
202
|
+
user_repr = user.get_username()
|
|
203
|
+
else:
|
|
204
|
+
user_repr = "anonymous"
|
|
205
|
+
except Exception: # pragma: no cover - defensive
|
|
206
|
+
user_repr = str(user)
|
|
207
|
+
|
|
208
|
+
payload = {
|
|
209
|
+
"path": getattr(request, "path", None),
|
|
210
|
+
"method": getattr(request, "method", None),
|
|
211
|
+
"user": user_repr,
|
|
212
|
+
"active_app": getattr(request, "active_app", None),
|
|
213
|
+
"fingerprint": fingerprint,
|
|
214
|
+
"exception_class": f"{exception.__class__.__module__}.{exception.__class__.__name__}",
|
|
215
|
+
"traceback": "".join(tb_exc.format()),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
report_exception_to_github.delay(payload)
|
|
219
|
+
except Exception: # pragma: no cover - defensive
|
|
220
|
+
logger.exception("Failed to queue GitHub issue from request exception")
|
|
221
|
+
|
|
222
|
+
got_request_exception.connect(
|
|
223
|
+
queue_github_issue,
|
|
224
|
+
dispatch_uid="core.github_issue_reporter",
|
|
225
|
+
weak=False,
|
|
226
|
+
)
|
core/auto_upgrade.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Helpers for managing the auto-upgrade scheduler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
AUTO_UPGRADE_TASK_NAME = "auto-upgrade-check"
|
|
11
|
+
AUTO_UPGRADE_TASK_PATH = "core.tasks.check_github_updates"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_auto_upgrade_periodic_task(
|
|
15
|
+
sender=None, *, base_dir: Path | None = None, **kwargs
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Ensure the auto-upgrade periodic task exists.
|
|
18
|
+
|
|
19
|
+
The function is signal-safe so it can be wired to Django's
|
|
20
|
+
``post_migrate`` hook. When called directly the ``sender`` and
|
|
21
|
+
``**kwargs`` parameters are ignored.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
del sender, kwargs # Unused when invoked as a Django signal handler.
|
|
25
|
+
|
|
26
|
+
if base_dir is None:
|
|
27
|
+
base_dir = Path(settings.BASE_DIR)
|
|
28
|
+
else:
|
|
29
|
+
base_dir = Path(base_dir)
|
|
30
|
+
|
|
31
|
+
lock_dir = base_dir / "locks"
|
|
32
|
+
mode_file = lock_dir / "auto_upgrade.lck"
|
|
33
|
+
if not mode_file.exists():
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
try: # pragma: no cover - optional dependency failures
|
|
37
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
38
|
+
from django.db.utils import OperationalError, ProgrammingError
|
|
39
|
+
except Exception:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
mode = mode_file.read_text().strip() or "version"
|
|
43
|
+
interval_minutes = 5 if mode == "latest" else 10
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
47
|
+
every=interval_minutes, period=IntervalSchedule.MINUTES
|
|
48
|
+
)
|
|
49
|
+
PeriodicTask.objects.update_or_create(
|
|
50
|
+
name=AUTO_UPGRADE_TASK_NAME,
|
|
51
|
+
defaults={
|
|
52
|
+
"interval": schedule,
|
|
53
|
+
"task": AUTO_UPGRADE_TASK_PATH,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
except (OperationalError, ProgrammingError): # pragma: no cover - DB not ready
|
|
57
|
+
return
|
core/backends.py
CHANGED
|
@@ -1,12 +1,69 @@
|
|
|
1
1
|
"""Custom authentication backends for the core app."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
4
|
+
import ipaddress
|
|
5
|
+
import socket
|
|
6
|
+
|
|
7
|
+
from django.conf import settings
|
|
3
8
|
from django.contrib.auth import get_user_model
|
|
4
9
|
from django.contrib.auth.backends import ModelBackend
|
|
5
|
-
import
|
|
10
|
+
from django.core.exceptions import DisallowedHost
|
|
11
|
+
from django.http.request import split_domain_port
|
|
12
|
+
from django_otp.plugins.otp_totp.models import TOTPDevice
|
|
6
13
|
|
|
7
14
|
from .models import EnergyAccount
|
|
8
15
|
|
|
9
16
|
|
|
17
|
+
TOTP_DEVICE_NAME = "authenticator"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TOTPBackend(ModelBackend):
|
|
21
|
+
"""Authenticate using a TOTP code from an enrolled authenticator app."""
|
|
22
|
+
|
|
23
|
+
def authenticate(self, request, username=None, otp_token=None, **kwargs):
|
|
24
|
+
if not username or otp_token in (None, ""):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
token = str(otp_token).strip().replace(" ", "")
|
|
28
|
+
if not token:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
UserModel = get_user_model()
|
|
32
|
+
try:
|
|
33
|
+
user = UserModel._default_manager.get_by_natural_key(username)
|
|
34
|
+
except UserModel.DoesNotExist:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
if not user.is_active:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
device_qs = TOTPDevice.objects.filter(user=user, confirmed=True)
|
|
41
|
+
if TOTP_DEVICE_NAME:
|
|
42
|
+
device_qs = device_qs.filter(name=TOTP_DEVICE_NAME)
|
|
43
|
+
|
|
44
|
+
device = device_qs.order_by("-id").first()
|
|
45
|
+
if device is None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
verified = device.verify_token(token)
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
if not verified:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
user.otp_device = device
|
|
57
|
+
return user
|
|
58
|
+
|
|
59
|
+
def get_user(self, user_id):
|
|
60
|
+
UserModel = get_user_model()
|
|
61
|
+
try:
|
|
62
|
+
return UserModel._default_manager.get(pk=user_id)
|
|
63
|
+
except UserModel.DoesNotExist:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
10
67
|
class RFIDBackend:
|
|
11
68
|
"""Authenticate using a user's RFID."""
|
|
12
69
|
|
|
@@ -32,19 +89,67 @@ class RFIDBackend:
|
|
|
32
89
|
return None
|
|
33
90
|
|
|
34
91
|
|
|
92
|
+
def _collect_local_ip_addresses():
|
|
93
|
+
"""Return IP addresses assigned to the current machine."""
|
|
94
|
+
|
|
95
|
+
hosts = {socket.gethostname().strip()}
|
|
96
|
+
with contextlib.suppress(Exception):
|
|
97
|
+
hosts.add(socket.getfqdn().strip())
|
|
98
|
+
|
|
99
|
+
addresses = set()
|
|
100
|
+
for host in filter(None, hosts):
|
|
101
|
+
with contextlib.suppress(OSError):
|
|
102
|
+
_, _, ip_list = socket.gethostbyname_ex(host)
|
|
103
|
+
for candidate in ip_list:
|
|
104
|
+
with contextlib.suppress(ValueError):
|
|
105
|
+
addresses.add(ipaddress.ip_address(candidate))
|
|
106
|
+
with contextlib.suppress(OSError):
|
|
107
|
+
for info in socket.getaddrinfo(host, None, family=socket.AF_UNSPEC):
|
|
108
|
+
sockaddr = info[-1]
|
|
109
|
+
if not sockaddr:
|
|
110
|
+
continue
|
|
111
|
+
raw_address = sockaddr[0]
|
|
112
|
+
if isinstance(raw_address, bytes):
|
|
113
|
+
with contextlib.suppress(UnicodeDecodeError):
|
|
114
|
+
raw_address = raw_address.decode()
|
|
115
|
+
if isinstance(raw_address, str):
|
|
116
|
+
if "%" in raw_address:
|
|
117
|
+
raw_address = raw_address.split("%", 1)[0]
|
|
118
|
+
with contextlib.suppress(ValueError):
|
|
119
|
+
addresses.add(ipaddress.ip_address(raw_address))
|
|
120
|
+
return tuple(sorted(addresses, key=str))
|
|
121
|
+
|
|
122
|
+
|
|
35
123
|
class LocalhostAdminBackend(ModelBackend):
|
|
36
124
|
"""Allow default admin credentials only from local networks."""
|
|
37
125
|
|
|
38
|
-
_ALLOWED_NETWORKS =
|
|
126
|
+
_ALLOWED_NETWORKS = (
|
|
39
127
|
ipaddress.ip_network("::1/128"),
|
|
40
128
|
ipaddress.ip_network("127.0.0.0/8"),
|
|
41
|
-
ipaddress.ip_network("192.168.0.0/16"),
|
|
42
|
-
ipaddress.ip_network("172.16.0.0/12"),
|
|
43
129
|
ipaddress.ip_network("10.42.0.0/16"),
|
|
44
|
-
|
|
130
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
131
|
+
)
|
|
132
|
+
_CONTROL_ALLOWED_NETWORKS = (ipaddress.ip_network("10.0.0.0/8"),)
|
|
133
|
+
_LOCAL_IPS = _collect_local_ip_addresses()
|
|
134
|
+
|
|
135
|
+
def _iter_allowed_networks(self):
|
|
136
|
+
yield from self._ALLOWED_NETWORKS
|
|
137
|
+
if getattr(settings, "NODE_ROLE", "") == "Control":
|
|
138
|
+
yield from self._CONTROL_ALLOWED_NETWORKS
|
|
45
139
|
|
|
46
140
|
def authenticate(self, request, username=None, password=None, **kwargs):
|
|
47
141
|
if username == "admin" and password == "admin" and request is not None:
|
|
142
|
+
try:
|
|
143
|
+
host = request.get_host()
|
|
144
|
+
except DisallowedHost:
|
|
145
|
+
return None
|
|
146
|
+
host, _port = split_domain_port(host)
|
|
147
|
+
if host.startswith("[") and host.endswith("]"):
|
|
148
|
+
host = host[1:-1]
|
|
149
|
+
try:
|
|
150
|
+
ipaddress.ip_address(host)
|
|
151
|
+
except ValueError:
|
|
152
|
+
return None
|
|
48
153
|
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
49
154
|
if forwarded:
|
|
50
155
|
remote = forwarded.split(",")[0].strip()
|
|
@@ -54,7 +159,9 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
54
159
|
ip = ipaddress.ip_address(remote)
|
|
55
160
|
except ValueError:
|
|
56
161
|
return None
|
|
57
|
-
allowed = any(ip in net for net in self.
|
|
162
|
+
allowed = any(ip in net for net in self._iter_allowed_networks())
|
|
163
|
+
if not allowed and ip in self._LOCAL_IPS:
|
|
164
|
+
allowed = True
|
|
58
165
|
if not allowed:
|
|
59
166
|
return None
|
|
60
167
|
User = get_user_model()
|
|
@@ -65,11 +172,21 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
65
172
|
"is_superuser": True,
|
|
66
173
|
},
|
|
67
174
|
)
|
|
175
|
+
if not created and not user.is_active:
|
|
176
|
+
return None
|
|
177
|
+
arthexis_user = (
|
|
178
|
+
User.all_objects.filter(username="arthexis").exclude(pk=user.pk).first()
|
|
179
|
+
)
|
|
68
180
|
if created:
|
|
181
|
+
if arthexis_user and user.operate_as_id is None:
|
|
182
|
+
user.operate_as = arthexis_user
|
|
69
183
|
user.set_password("admin")
|
|
70
184
|
user.save()
|
|
71
185
|
elif not user.check_password("admin"):
|
|
72
186
|
return None
|
|
187
|
+
elif arthexis_user and user.operate_as_id is None:
|
|
188
|
+
user.operate_as = arthexis_user
|
|
189
|
+
user.save(update_fields=["operate_as"])
|
|
73
190
|
return user
|
|
74
191
|
return super().authenticate(request, username, password, **kwargs)
|
|
75
192
|
|
|
@@ -79,4 +196,3 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
79
196
|
return User.all_objects.get(pk=user_id)
|
|
80
197
|
except User.DoesNotExist:
|
|
81
198
|
return None
|
|
82
|
-
|