arthexis 0.1.8__py3-none-any.whl → 0.1.9__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.9.dist-info}/METADATA +42 -4
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.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 +133 -16
- config/urls.py +65 -6
- core/admin.py +1226 -191
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- 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 +1071 -264
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +358 -63
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- 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 +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +1 -1
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- 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.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.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,13 +50,57 @@ 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
|
|
|
@@ -66,3 +131,93 @@ class CoreConfig(AppConfig):
|
|
|
66
131
|
pass
|
|
67
132
|
|
|
68
133
|
post_migrate.connect(ensure_email_collector_task, sender=self)
|
|
134
|
+
|
|
135
|
+
from django.db.backends.signals import connection_created
|
|
136
|
+
|
|
137
|
+
def enable_sqlite_wal(**kwargs):
|
|
138
|
+
connection = kwargs.get("connection")
|
|
139
|
+
if connection.vendor == "sqlite":
|
|
140
|
+
cursor = connection.cursor()
|
|
141
|
+
cursor.execute("PRAGMA journal_mode=WAL;")
|
|
142
|
+
cursor.execute("PRAGMA busy_timeout=60000;")
|
|
143
|
+
cursor.close()
|
|
144
|
+
|
|
145
|
+
connection_created.connect(enable_sqlite_wal)
|
|
146
|
+
|
|
147
|
+
def queue_github_issue(sender, request=None, **kwargs):
|
|
148
|
+
if not getattr(settings, "GITHUB_ISSUE_REPORTING_ENABLED", True):
|
|
149
|
+
return
|
|
150
|
+
if request is None:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
exception = kwargs.get("exception")
|
|
154
|
+
if exception is None:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
tb_exc = traceback.TracebackException.from_exception(exception)
|
|
159
|
+
stack = tb_exc.stack
|
|
160
|
+
top_frame = stack[-1] if stack else None
|
|
161
|
+
fingerprint_parts = [
|
|
162
|
+
exception.__class__.__module__,
|
|
163
|
+
exception.__class__.__name__,
|
|
164
|
+
]
|
|
165
|
+
if top_frame:
|
|
166
|
+
fingerprint_parts.extend(
|
|
167
|
+
[
|
|
168
|
+
top_frame.filename,
|
|
169
|
+
str(top_frame.lineno),
|
|
170
|
+
top_frame.name,
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
fingerprint = hashlib.sha256(
|
|
174
|
+
"|".join(fingerprint_parts).encode("utf-8")
|
|
175
|
+
).hexdigest()
|
|
176
|
+
|
|
177
|
+
cooldown = getattr(settings, "GITHUB_ISSUE_REPORTING_COOLDOWN", 3600)
|
|
178
|
+
lock_dir = Path(settings.BASE_DIR) / "locks" / "github-issues"
|
|
179
|
+
fingerprint_path = None
|
|
180
|
+
now = time.time()
|
|
181
|
+
|
|
182
|
+
with suppress(OSError):
|
|
183
|
+
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
fingerprint_path = lock_dir / fingerprint
|
|
185
|
+
if fingerprint_path.exists():
|
|
186
|
+
age = now - fingerprint_path.stat().st_mtime
|
|
187
|
+
if age < cooldown:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if fingerprint_path is not None:
|
|
191
|
+
with suppress(OSError):
|
|
192
|
+
fingerprint_path.write_text(str(now))
|
|
193
|
+
|
|
194
|
+
user_repr = None
|
|
195
|
+
user = getattr(request, "user", None)
|
|
196
|
+
if user is not None:
|
|
197
|
+
try:
|
|
198
|
+
if getattr(user, "is_authenticated", False):
|
|
199
|
+
user_repr = user.get_username()
|
|
200
|
+
else:
|
|
201
|
+
user_repr = "anonymous"
|
|
202
|
+
except Exception: # pragma: no cover - defensive
|
|
203
|
+
user_repr = str(user)
|
|
204
|
+
|
|
205
|
+
payload = {
|
|
206
|
+
"path": getattr(request, "path", None),
|
|
207
|
+
"method": getattr(request, "method", None),
|
|
208
|
+
"user": user_repr,
|
|
209
|
+
"active_app": getattr(request, "active_app", None),
|
|
210
|
+
"fingerprint": fingerprint,
|
|
211
|
+
"exception_class": f"{exception.__class__.__module__}.{exception.__class__.__name__}",
|
|
212
|
+
"traceback": "".join(tb_exc.format()),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
report_exception_to_github.delay(payload)
|
|
216
|
+
except Exception: # pragma: no cover - defensive
|
|
217
|
+
logger.exception("Failed to queue GitHub issue from request exception")
|
|
218
|
+
|
|
219
|
+
got_request_exception.connect(
|
|
220
|
+
queue_github_issue,
|
|
221
|
+
dispatch_uid="core.github_issue_reporter",
|
|
222
|
+
weak=False,
|
|
223
|
+
)
|
core/backends.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""Custom authentication backends for the core app."""
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
4
|
+
import ipaddress
|
|
5
|
+
import socket
|
|
6
|
+
|
|
3
7
|
from django.contrib.auth import get_user_model
|
|
4
8
|
from django.contrib.auth.backends import ModelBackend
|
|
5
|
-
import ipaddress
|
|
6
9
|
|
|
7
10
|
from .models import EnergyAccount
|
|
8
11
|
|
|
@@ -32,6 +35,37 @@ class RFIDBackend:
|
|
|
32
35
|
return None
|
|
33
36
|
|
|
34
37
|
|
|
38
|
+
def _collect_local_ip_addresses():
|
|
39
|
+
"""Return IP addresses assigned to the current machine."""
|
|
40
|
+
|
|
41
|
+
hosts = {socket.gethostname().strip()}
|
|
42
|
+
with contextlib.suppress(Exception):
|
|
43
|
+
hosts.add(socket.getfqdn().strip())
|
|
44
|
+
|
|
45
|
+
addresses = set()
|
|
46
|
+
for host in filter(None, hosts):
|
|
47
|
+
with contextlib.suppress(OSError):
|
|
48
|
+
_, _, ip_list = socket.gethostbyname_ex(host)
|
|
49
|
+
for candidate in ip_list:
|
|
50
|
+
with contextlib.suppress(ValueError):
|
|
51
|
+
addresses.add(ipaddress.ip_address(candidate))
|
|
52
|
+
with contextlib.suppress(OSError):
|
|
53
|
+
for info in socket.getaddrinfo(host, None, family=socket.AF_UNSPEC):
|
|
54
|
+
sockaddr = info[-1]
|
|
55
|
+
if not sockaddr:
|
|
56
|
+
continue
|
|
57
|
+
raw_address = sockaddr[0]
|
|
58
|
+
if isinstance(raw_address, bytes):
|
|
59
|
+
with contextlib.suppress(UnicodeDecodeError):
|
|
60
|
+
raw_address = raw_address.decode()
|
|
61
|
+
if isinstance(raw_address, str):
|
|
62
|
+
if "%" in raw_address:
|
|
63
|
+
raw_address = raw_address.split("%", 1)[0]
|
|
64
|
+
with contextlib.suppress(ValueError):
|
|
65
|
+
addresses.add(ipaddress.ip_address(raw_address))
|
|
66
|
+
return tuple(sorted(addresses, key=str))
|
|
67
|
+
|
|
68
|
+
|
|
35
69
|
class LocalhostAdminBackend(ModelBackend):
|
|
36
70
|
"""Allow default admin credentials only from local networks."""
|
|
37
71
|
|
|
@@ -39,9 +73,8 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
39
73
|
ipaddress.ip_network("::1/128"),
|
|
40
74
|
ipaddress.ip_network("127.0.0.0/8"),
|
|
41
75
|
ipaddress.ip_network("192.168.0.0/16"),
|
|
42
|
-
ipaddress.ip_network("172.16.0.0/12"),
|
|
43
|
-
ipaddress.ip_network("10.42.0.0/16"),
|
|
44
76
|
]
|
|
77
|
+
_LOCAL_IPS = _collect_local_ip_addresses()
|
|
45
78
|
|
|
46
79
|
def authenticate(self, request, username=None, password=None, **kwargs):
|
|
47
80
|
if username == "admin" and password == "admin" and request is not None:
|
|
@@ -55,6 +88,8 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
55
88
|
except ValueError:
|
|
56
89
|
return None
|
|
57
90
|
allowed = any(ip in net for net in self._ALLOWED_NETWORKS)
|
|
91
|
+
if not allowed and ip in self._LOCAL_IPS:
|
|
92
|
+
allowed = True
|
|
58
93
|
if not allowed:
|
|
59
94
|
return None
|
|
60
95
|
User = get_user_model()
|
|
@@ -65,11 +100,19 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
65
100
|
"is_superuser": True,
|
|
66
101
|
},
|
|
67
102
|
)
|
|
103
|
+
arthexis_user = (
|
|
104
|
+
User.all_objects.filter(username="arthexis").exclude(pk=user.pk).first()
|
|
105
|
+
)
|
|
68
106
|
if created:
|
|
107
|
+
if arthexis_user and user.operate_as_id is None:
|
|
108
|
+
user.operate_as = arthexis_user
|
|
69
109
|
user.set_password("admin")
|
|
70
110
|
user.save()
|
|
71
111
|
elif not user.check_password("admin"):
|
|
72
112
|
return None
|
|
113
|
+
elif arthexis_user and user.operate_as_id is None:
|
|
114
|
+
user.operate_as = arthexis_user
|
|
115
|
+
user.save(update_fields=["operate_as"])
|
|
73
116
|
return user
|
|
74
117
|
return super().authenticate(request, username, password, **kwargs)
|
|
75
118
|
|
|
@@ -79,4 +122,3 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
79
122
|
return User.all_objects.get(pk=user_id)
|
|
80
123
|
except User.DoesNotExist:
|
|
81
124
|
return None
|
|
82
|
-
|
core/entity.py
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import copy
|
|
2
2
|
import logging
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
3
|
|
|
6
|
-
from django.apps import apps
|
|
7
|
-
from django.conf import settings
|
|
8
|
-
from django.db import models
|
|
9
4
|
from django.contrib.auth.models import UserManager as DjangoUserManager
|
|
5
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
6
|
+
from django.db import models
|
|
10
7
|
|
|
11
8
|
logger = logging.getLogger(__name__)
|
|
12
9
|
|
|
@@ -27,17 +24,14 @@ class EntityManager(models.Manager):
|
|
|
27
24
|
|
|
28
25
|
class EntityUserManager(DjangoUserManager):
|
|
29
26
|
def get_queryset(self):
|
|
30
|
-
return (
|
|
31
|
-
EntityQuerySet(self.model, using=self._db)
|
|
32
|
-
.filter(is_deleted=False)
|
|
33
|
-
.exclude(username="admin")
|
|
34
|
-
)
|
|
27
|
+
return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
|
|
35
28
|
|
|
36
29
|
|
|
37
30
|
class Entity(models.Model):
|
|
38
31
|
"""Base model providing seed data tracking and soft deletion."""
|
|
39
32
|
|
|
40
33
|
is_seed_data = models.BooleanField(default=False, editable=False)
|
|
34
|
+
is_user_data = models.BooleanField(default=False, editable=False)
|
|
41
35
|
is_deleted = models.BooleanField(default=False, editable=False)
|
|
42
36
|
|
|
43
37
|
objects = EntityManager()
|
|
@@ -60,11 +54,66 @@ class Entity(models.Model):
|
|
|
60
54
|
pass
|
|
61
55
|
else:
|
|
62
56
|
self.is_seed_data = old.is_seed_data
|
|
57
|
+
self.is_user_data = old.is_user_data
|
|
63
58
|
super().save(*args, **kwargs)
|
|
64
59
|
|
|
60
|
+
@classmethod
|
|
61
|
+
def _unique_field_groups(cls):
|
|
62
|
+
"""Return concrete field tuples enforcing uniqueness for this model."""
|
|
63
|
+
|
|
64
|
+
opts = cls._meta
|
|
65
|
+
groups: list[tuple[models.Field, ...]] = []
|
|
66
|
+
|
|
67
|
+
for field in opts.concrete_fields:
|
|
68
|
+
if field.unique and not field.primary_key:
|
|
69
|
+
groups.append((field,))
|
|
70
|
+
|
|
71
|
+
for unique in opts.unique_together:
|
|
72
|
+
fields: list[models.Field] = []
|
|
73
|
+
for name in unique:
|
|
74
|
+
try:
|
|
75
|
+
field = opts.get_field(name)
|
|
76
|
+
except FieldDoesNotExist:
|
|
77
|
+
fields = []
|
|
78
|
+
break
|
|
79
|
+
if not getattr(field, "concrete", False) or field.primary_key:
|
|
80
|
+
fields = []
|
|
81
|
+
break
|
|
82
|
+
fields.append(field)
|
|
83
|
+
if fields:
|
|
84
|
+
groups.append(tuple(fields))
|
|
85
|
+
|
|
86
|
+
for constraint in opts.constraints:
|
|
87
|
+
if not isinstance(constraint, models.UniqueConstraint):
|
|
88
|
+
continue
|
|
89
|
+
if not constraint.fields or constraint.condition is not None:
|
|
90
|
+
continue
|
|
91
|
+
fields = []
|
|
92
|
+
for name in constraint.fields:
|
|
93
|
+
try:
|
|
94
|
+
field = opts.get_field(name)
|
|
95
|
+
except FieldDoesNotExist:
|
|
96
|
+
fields = []
|
|
97
|
+
break
|
|
98
|
+
if not getattr(field, "concrete", False) or field.primary_key:
|
|
99
|
+
fields = []
|
|
100
|
+
break
|
|
101
|
+
fields.append(field)
|
|
102
|
+
if fields:
|
|
103
|
+
groups.append(tuple(fields))
|
|
104
|
+
|
|
105
|
+
unique_groups: list[tuple[models.Field, ...]] = []
|
|
106
|
+
seen: set[tuple[str, ...]] = set()
|
|
107
|
+
for fields in groups:
|
|
108
|
+
key = tuple(field.attname for field in fields)
|
|
109
|
+
if key in seen:
|
|
110
|
+
continue
|
|
111
|
+
seen.add(key)
|
|
112
|
+
unique_groups.append(fields)
|
|
113
|
+
return unique_groups
|
|
114
|
+
|
|
65
115
|
def resolve_sigils(self, field: str) -> str:
|
|
66
116
|
"""Return ``field`` value with [ROOT.KEY] tokens resolved."""
|
|
67
|
-
# Find field ignoring case
|
|
68
117
|
name = field.lower()
|
|
69
118
|
fobj = next((f for f in self._meta.fields if f.name.lower() == name), None)
|
|
70
119
|
if not fobj:
|
|
@@ -72,44 +121,9 @@ class Entity(models.Model):
|
|
|
72
121
|
value = self.__dict__.get(fobj.attname, "")
|
|
73
122
|
if value is None:
|
|
74
123
|
return ""
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
pattern = re.compile(r"\[([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\]")
|
|
78
|
-
SigilRoot = apps.get_model("core", "SigilRoot")
|
|
124
|
+
from .sigil_resolver import resolve_sigils as _resolve
|
|
79
125
|
|
|
80
|
-
|
|
81
|
-
root_name, key = match.group(1), match.group(2)
|
|
82
|
-
try:
|
|
83
|
-
root = SigilRoot.objects.get(prefix__iexact=root_name)
|
|
84
|
-
if root.context_type == SigilRoot.Context.CONFIG:
|
|
85
|
-
if root.prefix.upper() == "ENV":
|
|
86
|
-
if key in os.environ:
|
|
87
|
-
return os.environ[key]
|
|
88
|
-
logger.warning(
|
|
89
|
-
"Missing environment variable for sigil [%s.%s]",
|
|
90
|
-
root_name,
|
|
91
|
-
key,
|
|
92
|
-
)
|
|
93
|
-
return match.group(0)
|
|
94
|
-
if root.prefix.upper() == "SYS":
|
|
95
|
-
if hasattr(settings, key):
|
|
96
|
-
return str(getattr(settings, key))
|
|
97
|
-
logger.warning(
|
|
98
|
-
"Missing settings attribute for sigil [%s.%s]",
|
|
99
|
-
root_name,
|
|
100
|
-
key,
|
|
101
|
-
)
|
|
102
|
-
return match.group(0)
|
|
103
|
-
logger.warning(
|
|
104
|
-
"Unresolvable sigil [%s.%s]: unsupported context", root_name, key
|
|
105
|
-
)
|
|
106
|
-
except SigilRoot.DoesNotExist:
|
|
107
|
-
logger.warning("Unknown sigil root [%s]", root_name)
|
|
108
|
-
except Exception:
|
|
109
|
-
logger.exception("Error resolving sigil [%s.%s]", root_name, key)
|
|
110
|
-
return match.group(0)
|
|
111
|
-
|
|
112
|
-
return pattern.sub(repl, text)
|
|
126
|
+
return _resolve(str(value), current=self)
|
|
113
127
|
|
|
114
128
|
def delete(self, using=None, keep_parents=False):
|
|
115
129
|
if self.is_seed_data:
|
core/fields.py
CHANGED
|
@@ -29,6 +29,12 @@ class _SigilBaseField:
|
|
|
29
29
|
def value_from_object(self, obj):
|
|
30
30
|
return obj.__dict__.get(self.attname)
|
|
31
31
|
|
|
32
|
+
def pre_save(self, model_instance, add):
|
|
33
|
+
# ``models.Field.pre_save`` uses ``getattr`` which would resolve the
|
|
34
|
+
# sigil descriptor. Persist the raw database value instead so env-based
|
|
35
|
+
# placeholders remain intact when editing through admin forms.
|
|
36
|
+
return self.value_from_object(model_instance)
|
|
37
|
+
|
|
32
38
|
|
|
33
39
|
class SigilCheckFieldMixin(_SigilBaseField):
|
|
34
40
|
descriptor_class = _CheckSigilDescriptor
|
|
@@ -67,4 +73,3 @@ class SigilShortAutoField(SigilAutoFieldMixin, models.CharField):
|
|
|
67
73
|
|
|
68
74
|
class SigilLongAutoField(SigilAutoFieldMixin, models.TextField):
|
|
69
75
|
pass
|
|
70
|
-
|
core/github_helper.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Helpers for reporting exceptions to GitHub."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from celery import shared_task
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@shared_task
|
|
15
|
+
def report_exception_to_github(payload: dict[str, Any]) -> None:
|
|
16
|
+
"""Send exception context to the GitHub issue helper.
|
|
17
|
+
|
|
18
|
+
The task is intentionally light-weight in this repository. Deployments can
|
|
19
|
+
replace it with an implementation that forwards ``payload`` to the
|
|
20
|
+
automation responsible for creating GitHub issues.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
logger.info(
|
|
24
|
+
"Queued GitHub issue report for %s", payload.get("fingerprint", "<unknown>")
|
|
25
|
+
)
|