arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
core/apps.py CHANGED
@@ -104,7 +104,11 @@ class CoreConfig(AppConfig):
104
104
 
105
105
  lock = Path(settings.BASE_DIR) / "locks" / "celery.lck"
106
106
 
107
+ from django.db.backends.signals import connection_created
108
+
107
109
  if lock.exists():
110
+ from .auto_upgrade import ensure_auto_upgrade_periodic_task
111
+ from django.db import DEFAULT_DB_ALIAS, connections
108
112
 
109
113
  def ensure_email_collector_task(**kwargs):
110
114
  try: # pragma: no cover - optional dependency
@@ -131,8 +135,32 @@ class CoreConfig(AppConfig):
131
135
  pass
132
136
 
133
137
  post_migrate.connect(ensure_email_collector_task, sender=self)
138
+ post_migrate.connect(ensure_auto_upgrade_periodic_task, sender=self)
134
139
 
135
- from django.db.backends.signals import connection_created
140
+ auto_upgrade_dispatch_uid = "core.apps.ensure_auto_upgrade_periodic_task"
141
+
142
+ def ensure_auto_upgrade_on_connection(**kwargs):
143
+ connection = kwargs.get("connection")
144
+ if connection is not None and connection.alias != "default":
145
+ return
146
+
147
+ try:
148
+ ensure_auto_upgrade_periodic_task()
149
+ finally:
150
+ connection_created.disconnect(
151
+ receiver=ensure_auto_upgrade_on_connection,
152
+ dispatch_uid=auto_upgrade_dispatch_uid,
153
+ )
154
+
155
+ connection_created.connect(
156
+ ensure_auto_upgrade_on_connection,
157
+ dispatch_uid=auto_upgrade_dispatch_uid,
158
+ weak=False,
159
+ )
160
+
161
+ default_connection = connections[DEFAULT_DB_ALIAS]
162
+ if default_connection.connection is not None:
163
+ ensure_auto_upgrade_on_connection(connection=default_connection)
136
164
 
137
165
  def enable_sqlite_wal(**kwargs):
138
166
  connection = kwargs.get("connection")
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
@@ -4,10 +4,65 @@ import contextlib
4
4
  import ipaddress
5
5
  import socket
6
6
 
7
+ from django.conf import settings
7
8
  from django.contrib.auth import get_user_model
8
9
  from django.contrib.auth.backends import ModelBackend
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
9
13
 
10
14
  from .models import EnergyAccount
15
+ from . import temp_passwords
16
+
17
+
18
+ TOTP_DEVICE_NAME = "authenticator"
19
+
20
+
21
+ class TOTPBackend(ModelBackend):
22
+ """Authenticate using a TOTP code from an enrolled authenticator app."""
23
+
24
+ def authenticate(self, request, username=None, otp_token=None, **kwargs):
25
+ if not username or otp_token in (None, ""):
26
+ return None
27
+
28
+ token = str(otp_token).strip().replace(" ", "")
29
+ if not token:
30
+ return None
31
+
32
+ UserModel = get_user_model()
33
+ try:
34
+ user = UserModel._default_manager.get_by_natural_key(username)
35
+ except UserModel.DoesNotExist:
36
+ return None
37
+
38
+ if not user.is_active:
39
+ return None
40
+
41
+ device_qs = TOTPDevice.objects.filter(user=user, confirmed=True)
42
+ if TOTP_DEVICE_NAME:
43
+ device_qs = device_qs.filter(name=TOTP_DEVICE_NAME)
44
+
45
+ device = device_qs.order_by("-id").first()
46
+ if device is None:
47
+ return None
48
+
49
+ try:
50
+ verified = device.verify_token(token)
51
+ except Exception:
52
+ return None
53
+
54
+ if not verified:
55
+ return None
56
+
57
+ user.otp_device = device
58
+ return user
59
+
60
+ def get_user(self, user_id):
61
+ UserModel = get_user_model()
62
+ try:
63
+ return UserModel._default_manager.get(pk=user_id)
64
+ except UserModel.DoesNotExist:
65
+ return None
11
66
 
12
67
 
13
68
  class RFIDBackend:
@@ -69,15 +124,33 @@ def _collect_local_ip_addresses():
69
124
  class LocalhostAdminBackend(ModelBackend):
70
125
  """Allow default admin credentials only from local networks."""
71
126
 
72
- _ALLOWED_NETWORKS = [
127
+ _ALLOWED_NETWORKS = (
73
128
  ipaddress.ip_network("::1/128"),
74
129
  ipaddress.ip_network("127.0.0.0/8"),
130
+ ipaddress.ip_network("10.42.0.0/16"),
75
131
  ipaddress.ip_network("192.168.0.0/16"),
76
- ]
132
+ )
133
+ _CONTROL_ALLOWED_NETWORKS = (ipaddress.ip_network("10.0.0.0/8"),)
77
134
  _LOCAL_IPS = _collect_local_ip_addresses()
78
135
 
136
+ def _iter_allowed_networks(self):
137
+ yield from self._ALLOWED_NETWORKS
138
+ if getattr(settings, "NODE_ROLE", "") == "Control":
139
+ yield from self._CONTROL_ALLOWED_NETWORKS
140
+
79
141
  def authenticate(self, request, username=None, password=None, **kwargs):
80
142
  if username == "admin" and password == "admin" and request is not None:
143
+ try:
144
+ host = request.get_host()
145
+ except DisallowedHost:
146
+ return None
147
+ host, _port = split_domain_port(host)
148
+ if host.startswith("[") and host.endswith("]"):
149
+ host = host[1:-1]
150
+ try:
151
+ ipaddress.ip_address(host)
152
+ except ValueError:
153
+ return None
81
154
  forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
82
155
  if forwarded:
83
156
  remote = forwarded.split(",")[0].strip()
@@ -87,7 +160,7 @@ class LocalhostAdminBackend(ModelBackend):
87
160
  ip = ipaddress.ip_address(remote)
88
161
  except ValueError:
89
162
  return None
90
- allowed = any(ip in net for net in self._ALLOWED_NETWORKS)
163
+ allowed = any(ip in net for net in self._iter_allowed_networks())
91
164
  if not allowed and ip in self._LOCAL_IPS:
92
165
  allowed = True
93
166
  if not allowed:
@@ -100,6 +173,8 @@ class LocalhostAdminBackend(ModelBackend):
100
173
  "is_superuser": True,
101
174
  },
102
175
  )
176
+ if not created and not user.is_active:
177
+ return None
103
178
  arthexis_user = (
104
179
  User.all_objects.filter(username="arthexis").exclude(pk=user.pk).first()
105
180
  )
@@ -122,3 +197,40 @@ class LocalhostAdminBackend(ModelBackend):
122
197
  return User.all_objects.get(pk=user_id)
123
198
  except User.DoesNotExist:
124
199
  return None
200
+
201
+
202
+ class TempPasswordBackend(ModelBackend):
203
+ """Authenticate using a temporary password stored in a lockfile."""
204
+
205
+ def authenticate(self, request, username=None, password=None, **kwargs):
206
+ if not username or not password:
207
+ return None
208
+
209
+ UserModel = get_user_model()
210
+ manager = getattr(UserModel, "all_objects", UserModel._default_manager)
211
+ try:
212
+ user = manager.get_by_natural_key(username)
213
+ except UserModel.DoesNotExist:
214
+ return None
215
+
216
+ entry = temp_passwords.load_temp_password(user.username)
217
+ if entry is None:
218
+ return None
219
+ if entry.is_expired:
220
+ temp_passwords.discard_temp_password(user.username)
221
+ return None
222
+ if not entry.check_password(password):
223
+ return None
224
+
225
+ if not user.is_active:
226
+ user.is_active = True
227
+ user.save(update_fields=["is_active"])
228
+ return user
229
+
230
+ def get_user(self, user_id):
231
+ UserModel = get_user_model()
232
+ manager = getattr(UserModel, "all_objects", UserModel._default_manager)
233
+ try:
234
+ return manager.get(pk=user_id)
235
+ except UserModel.DoesNotExist:
236
+ return None
core/environment.py CHANGED
@@ -9,22 +9,35 @@ from django.urls import path
9
9
  from django.utils.translation import gettext_lazy as _
10
10
 
11
11
 
12
- def _environment_view(request):
13
- env_vars = sorted(os.environ.items())
14
- django_settings = sorted(
12
+ def _get_django_settings():
13
+ return sorted(
15
14
  [(name, getattr(settings, name)) for name in dir(settings) if name.isupper()]
16
15
  )
16
+
17
+
18
+ def _environment_view(request):
19
+ env_vars = sorted(os.environ.items())
17
20
  context = admin.site.each_context(request)
18
21
  context.update(
19
22
  {
20
- "title": _("Environment"),
23
+ "title": _("Environ"),
21
24
  "env_vars": env_vars,
22
- "django_settings": django_settings,
23
25
  }
24
26
  )
25
27
  return TemplateResponse(request, "admin/environment.html", context)
26
28
 
27
29
 
30
+ def _config_view(request):
31
+ context = admin.site.each_context(request)
32
+ context.update(
33
+ {
34
+ "title": _("Config"),
35
+ "django_settings": _get_django_settings(),
36
+ }
37
+ )
38
+ return TemplateResponse(request, "admin/config.html", context)
39
+
40
+
28
41
  def patch_admin_environment_view() -> None:
29
42
  """Add custom admin view for environment information."""
30
43
  original_get_urls = admin.site.get_urls
@@ -37,6 +50,11 @@ def patch_admin_environment_view() -> None:
37
50
  admin.site.admin_view(_environment_view),
38
51
  name="environment",
39
52
  ),
53
+ path(
54
+ "config/",
55
+ admin.site.admin_view(_config_view),
56
+ name="config",
57
+ ),
40
58
  ]
41
59
  return custom + urls
42
60
 
core/fields.py CHANGED
@@ -1,5 +1,10 @@
1
+ from dataclasses import dataclass
2
+ import re
3
+ import sqlite3
4
+
1
5
  from django.db import models
2
6
  from django.db.models.fields import DeferredAttribute
7
+ from django.utils.translation import gettext_lazy as _
3
8
 
4
9
 
5
10
  class _BaseSigilDescriptor(DeferredAttribute):
@@ -73,3 +78,91 @@ class SigilShortAutoField(SigilAutoFieldMixin, models.CharField):
73
78
 
74
79
  class SigilLongAutoField(SigilAutoFieldMixin, models.TextField):
75
80
  pass
81
+
82
+
83
+ class ConditionEvaluationError(Exception):
84
+ """Raised when a condition expression cannot be evaluated."""
85
+
86
+
87
+ @dataclass
88
+ class ConditionCheckResult:
89
+ """Represents the outcome of evaluating a condition field."""
90
+
91
+ passed: bool
92
+ resolved: str
93
+ error: str | None = None
94
+
95
+
96
+ _COMMENT_PATTERN = re.compile(r"(--|/\*)")
97
+ _FORBIDDEN_KEYWORDS = re.compile(
98
+ r"\b(ATTACH|DETACH|ALTER|ANALYZE|CREATE|DROP|INSERT|UPDATE|DELETE|REPLACE|"
99
+ r"VACUUM|TRIGGER|TABLE|INDEX|VIEW|PRAGMA|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|WITH)\b",
100
+ re.IGNORECASE,
101
+ )
102
+
103
+
104
+ def _evaluate_sql_condition(expression: str) -> bool:
105
+ """Evaluate a SQL expression in an isolated SQLite connection."""
106
+
107
+ if ";" in expression:
108
+ raise ConditionEvaluationError(
109
+ _("Semicolons are not allowed in conditions."),
110
+ )
111
+ if _COMMENT_PATTERN.search(expression):
112
+ raise ConditionEvaluationError(
113
+ _("SQL comments are not allowed in conditions."),
114
+ )
115
+ match = _FORBIDDEN_KEYWORDS.search(expression)
116
+ if match:
117
+ raise ConditionEvaluationError(
118
+ _("Disallowed keyword in condition: %(keyword)s")
119
+ % {"keyword": match.group(1)},
120
+ )
121
+
122
+ try:
123
+ conn = sqlite3.connect(":memory:")
124
+ try:
125
+ conn.execute("PRAGMA trusted_schema = OFF")
126
+ conn.execute("PRAGMA foreign_keys = OFF")
127
+ try:
128
+ conn.enable_load_extension(False)
129
+ except AttributeError:
130
+ # ``enable_load_extension`` is not available on some platforms.
131
+ pass
132
+ cursor = conn.execute(
133
+ f"SELECT CASE WHEN ({expression}) THEN 1 ELSE 0 END"
134
+ )
135
+ row = cursor.fetchone()
136
+ return bool(row[0]) if row else False
137
+ finally:
138
+ conn.close()
139
+ except sqlite3.Error as exc: # pragma: no cover - exact error message varies
140
+ raise ConditionEvaluationError(str(exc)) from exc
141
+
142
+
143
+ class ConditionTextField(models.TextField):
144
+ """Field storing a conditional SQL expression resolved through [sigils]."""
145
+
146
+ def evaluate(self, instance) -> ConditionCheckResult:
147
+ """Evaluate the stored expression for ``instance``."""
148
+
149
+ value = self.value_from_object(instance)
150
+ if hasattr(instance, "resolve_sigils"):
151
+ resolved = instance.resolve_sigils(self.name)
152
+ else:
153
+ resolved = value
154
+
155
+ if resolved is None:
156
+ resolved_text = ""
157
+ else:
158
+ resolved_text = str(resolved)
159
+
160
+ resolved_text = resolved_text.strip()
161
+ if not resolved_text:
162
+ return ConditionCheckResult(True, resolved_text)
163
+
164
+ try:
165
+ passed = _evaluate_sql_condition(resolved_text)
166
+ return ConditionCheckResult(passed, resolved_text)
167
+ except ConditionEvaluationError as exc:
168
+ return ConditionCheckResult(False, resolved_text, str(exc))
core/mailer.py CHANGED
@@ -61,7 +61,9 @@ def can_send_email() -> bool:
61
61
 
62
62
  from nodes.models import EmailOutbox # imported lazily to avoid circular deps
63
63
 
64
- has_outbox = EmailOutbox.objects.exclude(host="").exists()
64
+ has_outbox = (
65
+ EmailOutbox.objects.filter(is_enabled=True).exclude(host="").exists()
66
+ )
65
67
  if has_outbox:
66
68
  return True
67
69