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/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
|
@@ -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):
|
|
@@ -29,6 +34,12 @@ class _SigilBaseField:
|
|
|
29
34
|
def value_from_object(self, obj):
|
|
30
35
|
return obj.__dict__.get(self.attname)
|
|
31
36
|
|
|
37
|
+
def pre_save(self, model_instance, add):
|
|
38
|
+
# ``models.Field.pre_save`` uses ``getattr`` which would resolve the
|
|
39
|
+
# sigil descriptor. Persist the raw database value instead so env-based
|
|
40
|
+
# placeholders remain intact when editing through admin forms.
|
|
41
|
+
return self.value_from_object(model_instance)
|
|
42
|
+
|
|
32
43
|
|
|
33
44
|
class SigilCheckFieldMixin(_SigilBaseField):
|
|
34
45
|
descriptor_class = _CheckSigilDescriptor
|
|
@@ -68,3 +79,90 @@ class SigilShortAutoField(SigilAutoFieldMixin, models.CharField):
|
|
|
68
79
|
class SigilLongAutoField(SigilAutoFieldMixin, models.TextField):
|
|
69
80
|
pass
|
|
70
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/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
|
+
)
|
core/github_issues.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, Mapping
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .models import Package, PackageRelease
|
|
13
|
+
from .release import DEFAULT_PACKAGE
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
19
|
+
LOCK_DIR = BASE_DIR / "locks" / "github-issues"
|
|
20
|
+
LOCK_TTL = timedelta(hours=1)
|
|
21
|
+
REQUEST_TIMEOUT = 10
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_repository() -> tuple[str, str]:
|
|
25
|
+
"""Return the ``(owner, repo)`` tuple for the active package."""
|
|
26
|
+
|
|
27
|
+
package = Package.objects.filter(is_active=True).first()
|
|
28
|
+
repository_url = (
|
|
29
|
+
package.repository_url
|
|
30
|
+
if package and package.repository_url
|
|
31
|
+
else DEFAULT_PACKAGE.repository_url
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
owner: str
|
|
35
|
+
repo: str
|
|
36
|
+
|
|
37
|
+
if repository_url.startswith("git@"):
|
|
38
|
+
_, _, remainder = repository_url.partition(":")
|
|
39
|
+
path = remainder
|
|
40
|
+
else:
|
|
41
|
+
from urllib.parse import urlparse
|
|
42
|
+
|
|
43
|
+
parsed = urlparse(repository_url)
|
|
44
|
+
path = parsed.path
|
|
45
|
+
|
|
46
|
+
path = path.strip("/")
|
|
47
|
+
if path.endswith(".git"):
|
|
48
|
+
path = path[:-4]
|
|
49
|
+
|
|
50
|
+
segments = [segment for segment in path.split("/") if segment]
|
|
51
|
+
if len(segments) < 2:
|
|
52
|
+
raise ValueError(f"Invalid repository URL: {repository_url!r}")
|
|
53
|
+
|
|
54
|
+
owner, repo = segments[-2], segments[-1]
|
|
55
|
+
return owner, repo
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_github_token() -> str:
|
|
59
|
+
"""Return the configured GitHub token.
|
|
60
|
+
|
|
61
|
+
Preference is given to the latest :class:`~core.models.PackageRelease`.
|
|
62
|
+
When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
latest_release = PackageRelease.latest()
|
|
66
|
+
if latest_release:
|
|
67
|
+
token = latest_release.get_github_token()
|
|
68
|
+
if token:
|
|
69
|
+
return token
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
return os.environ["GITHUB_TOKEN"]
|
|
73
|
+
except KeyError as exc: # pragma: no cover - defensive guard
|
|
74
|
+
raise RuntimeError("GitHub token is not configured") from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ensure_lock_dir() -> None:
|
|
78
|
+
LOCK_DIR.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fingerprint_digest(fingerprint: str) -> str:
|
|
82
|
+
return hashlib.sha256(str(fingerprint).encode("utf-8")).hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fingerprint_path(fingerprint: str) -> Path:
|
|
86
|
+
return LOCK_DIR / _fingerprint_digest(fingerprint)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _has_recent_marker(lock_path: Path) -> bool:
|
|
90
|
+
if not lock_path.exists():
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
marker_age = datetime.utcnow() - datetime.utcfromtimestamp(
|
|
94
|
+
lock_path.stat().st_mtime
|
|
95
|
+
)
|
|
96
|
+
return marker_age < LOCK_TTL
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_issue_payload(
|
|
100
|
+
title: str,
|
|
101
|
+
body: str,
|
|
102
|
+
labels: Iterable[str] | None = None,
|
|
103
|
+
fingerprint: str | None = None,
|
|
104
|
+
) -> Mapping[str, object] | None:
|
|
105
|
+
"""Return an API payload for GitHub issues.
|
|
106
|
+
|
|
107
|
+
When ``fingerprint`` is provided, duplicate submissions within ``LOCK_TTL``
|
|
108
|
+
are ignored by returning ``None``. A marker is kept on disk to prevent
|
|
109
|
+
repeated reports during the cooldown window.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
payload: dict[str, object] = {"title": title, "body": body}
|
|
113
|
+
|
|
114
|
+
if labels:
|
|
115
|
+
deduped = list(dict.fromkeys(labels))
|
|
116
|
+
if deduped:
|
|
117
|
+
payload["labels"] = deduped
|
|
118
|
+
|
|
119
|
+
if fingerprint:
|
|
120
|
+
_ensure_lock_dir()
|
|
121
|
+
lock_path = _fingerprint_path(fingerprint)
|
|
122
|
+
if _has_recent_marker(lock_path):
|
|
123
|
+
logger.info("Skipping GitHub issue for active fingerprint %s", fingerprint)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
lock_path.write_text(datetime.utcnow().isoformat(), encoding="utf-8")
|
|
127
|
+
digest = _fingerprint_digest(fingerprint)
|
|
128
|
+
payload["body"] = f"{body}\n\n<!-- fingerprint:{digest} -->"
|
|
129
|
+
|
|
130
|
+
return payload
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def create_issue(
|
|
134
|
+
title: str,
|
|
135
|
+
body: str,
|
|
136
|
+
labels: Iterable[str] | None = None,
|
|
137
|
+
fingerprint: str | None = None,
|
|
138
|
+
) -> requests.Response | None:
|
|
139
|
+
"""Create a GitHub issue using the configured repository and token."""
|
|
140
|
+
|
|
141
|
+
payload = build_issue_payload(title, body, labels=labels, fingerprint=fingerprint)
|
|
142
|
+
if payload is None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
owner, repo = resolve_repository()
|
|
146
|
+
token = get_github_token()
|
|
147
|
+
|
|
148
|
+
headers = {
|
|
149
|
+
"Accept": "application/vnd.github+json",
|
|
150
|
+
"Authorization": f"token {token}",
|
|
151
|
+
"User-Agent": "arthexis-runtime-reporter",
|
|
152
|
+
}
|
|
153
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/issues"
|
|
154
|
+
|
|
155
|
+
response = requests.post(
|
|
156
|
+
url, json=payload, headers=headers, timeout=REQUEST_TIMEOUT
|
|
157
|
+
)
|
|
158
|
+
if not (200 <= response.status_code < 300):
|
|
159
|
+
logger.error(
|
|
160
|
+
"GitHub issue creation failed with status %s: %s",
|
|
161
|
+
response.status_code,
|
|
162
|
+
response.text,
|
|
163
|
+
)
|
|
164
|
+
response.raise_for_status()
|
|
165
|
+
|
|
166
|
+
logger.info(
|
|
167
|
+
"GitHub issue created for %s/%s with status %s",
|
|
168
|
+
owner,
|
|
169
|
+
repo,
|
|
170
|
+
response.status_code,
|
|
171
|
+
)
|
|
172
|
+
return response
|
core/lcd_screen.py
CHANGED
core/liveupdate.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def live_update(interval=5):
|
|
5
|
+
"""Decorator to mark function-based views for automatic refresh."""
|
|
6
|
+
|
|
7
|
+
def decorator(view):
|
|
8
|
+
@wraps(view)
|
|
9
|
+
def wrapped(request, *args, **kwargs):
|
|
10
|
+
setattr(request, "live_update_interval", interval)
|
|
11
|
+
return view(request, *args, **kwargs)
|
|
12
|
+
|
|
13
|
+
return wrapped
|
|
14
|
+
|
|
15
|
+
return decorator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LiveUpdateMixin:
|
|
19
|
+
"""Mixin to enable automatic refresh for class-based views."""
|
|
20
|
+
|
|
21
|
+
live_update_interval = 5
|
|
22
|
+
|
|
23
|
+
def dispatch(self, request, *args, **kwargs):
|
|
24
|
+
setattr(request, "live_update_interval", self.live_update_interval)
|
|
25
|
+
return super().dispatch(request, *args, **kwargs)
|
core/log_paths.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Helpers for selecting writable log directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_root() -> bool:
|
|
11
|
+
if hasattr(os, "geteuid"):
|
|
12
|
+
try:
|
|
13
|
+
return os.geteuid() == 0
|
|
14
|
+
except OSError: # pragma: no cover - defensive for unusual platforms
|
|
15
|
+
return False
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _state_home(base_home: Path) -> Path:
|
|
20
|
+
state_home = os.environ.get("XDG_STATE_HOME")
|
|
21
|
+
if state_home:
|
|
22
|
+
return Path(state_home).expanduser()
|
|
23
|
+
return base_home / ".local" / "state"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def select_log_dir(base_dir: Path) -> Path:
|
|
27
|
+
"""Choose a writable log directory for the current process."""
|
|
28
|
+
|
|
29
|
+
default = base_dir / "logs"
|
|
30
|
+
env_override = os.environ.get("ARTHEXIS_LOG_DIR")
|
|
31
|
+
is_root = _is_root()
|
|
32
|
+
sudo_user = os.environ.get("SUDO_USER")
|
|
33
|
+
|
|
34
|
+
candidates: list[Path] = []
|
|
35
|
+
if env_override:
|
|
36
|
+
candidates.append(Path(env_override).expanduser())
|
|
37
|
+
|
|
38
|
+
if is_root:
|
|
39
|
+
if not sudo_user or sudo_user == "root":
|
|
40
|
+
candidates.append(default)
|
|
41
|
+
candidates.append(Path("/var/log/arthexis"))
|
|
42
|
+
candidates.append(Path("/tmp/arthexis/logs"))
|
|
43
|
+
else:
|
|
44
|
+
home = Path.home()
|
|
45
|
+
state_home = _state_home(home)
|
|
46
|
+
candidates.extend(
|
|
47
|
+
[
|
|
48
|
+
default,
|
|
49
|
+
state_home / "arthexis" / "logs",
|
|
50
|
+
home / ".arthexis" / "logs",
|
|
51
|
+
Path("/tmp/arthexis/logs"),
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
seen: set[Path] = set()
|
|
56
|
+
ordered_candidates: list[Path] = []
|
|
57
|
+
for candidate in candidates:
|
|
58
|
+
candidate = candidate.expanduser()
|
|
59
|
+
if candidate not in seen:
|
|
60
|
+
seen.add(candidate)
|
|
61
|
+
ordered_candidates.append(candidate)
|
|
62
|
+
|
|
63
|
+
attempted: list[Path] = []
|
|
64
|
+
chosen: Path | None = None
|
|
65
|
+
for candidate in ordered_candidates:
|
|
66
|
+
attempted.append(candidate)
|
|
67
|
+
try:
|
|
68
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
except OSError:
|
|
70
|
+
continue
|
|
71
|
+
if os.access(candidate, os.W_OK | os.X_OK):
|
|
72
|
+
chosen = candidate
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
if chosen is None:
|
|
76
|
+
attempted_str = (
|
|
77
|
+
", ".join(str(path) for path in attempted) if attempted else "none"
|
|
78
|
+
)
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
f"Unable to create a writable log directory. Tried: {attempted_str}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if chosen != default:
|
|
84
|
+
if (
|
|
85
|
+
attempted
|
|
86
|
+
and attempted[0] == default
|
|
87
|
+
and not os.access(default, os.W_OK | os.X_OK)
|
|
88
|
+
):
|
|
89
|
+
print(
|
|
90
|
+
f"Log directory {default} is not writable; using {chosen}",
|
|
91
|
+
file=sys.stderr,
|
|
92
|
+
)
|
|
93
|
+
elif is_root and sudo_user and sudo_user != "root" and not env_override:
|
|
94
|
+
print(
|
|
95
|
+
f"Running with elevated privileges; writing logs to {chosen}",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
os.environ["ARTHEXIS_LOG_DIR"] = str(chosen)
|
|
100
|
+
return chosen
|
core/mailer.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Sequence
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.core.mail import EmailMessage
|
|
6
|
+
from django.utils.module_loading import import_string
|
|
7
|
+
|
|
8
|
+
try: # pragma: no cover - import should always succeed but guard defensively
|
|
9
|
+
from django.core.mail.backends.dummy import (
|
|
10
|
+
EmailBackend as DummyEmailBackend,
|
|
11
|
+
)
|
|
12
|
+
except Exception: # pragma: no cover - fallback when dummy backend unavailable
|
|
13
|
+
DummyEmailBackend = None # type: ignore[assignment]
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def send(
|
|
19
|
+
subject: str,
|
|
20
|
+
message: str,
|
|
21
|
+
recipient_list: Sequence[str],
|
|
22
|
+
from_email: str | None = None,
|
|
23
|
+
*,
|
|
24
|
+
outbox=None,
|
|
25
|
+
attachments: Sequence[tuple[str, str, str]] | None = None,
|
|
26
|
+
content_subtype: str | None = None,
|
|
27
|
+
**kwargs,
|
|
28
|
+
):
|
|
29
|
+
"""Send an email using Django's email utilities.
|
|
30
|
+
|
|
31
|
+
If ``outbox`` is provided, its connection will be used when sending.
|
|
32
|
+
"""
|
|
33
|
+
sender = (
|
|
34
|
+
from_email or getattr(outbox, "from_email", None) or settings.DEFAULT_FROM_EMAIL
|
|
35
|
+
)
|
|
36
|
+
connection = outbox.get_connection() if outbox is not None else None
|
|
37
|
+
fail_silently = kwargs.pop("fail_silently", False)
|
|
38
|
+
email = EmailMessage(
|
|
39
|
+
subject=subject,
|
|
40
|
+
body=message,
|
|
41
|
+
from_email=sender,
|
|
42
|
+
to=list(recipient_list),
|
|
43
|
+
connection=connection,
|
|
44
|
+
**kwargs,
|
|
45
|
+
)
|
|
46
|
+
if attachments:
|
|
47
|
+
for attachment in attachments:
|
|
48
|
+
if not isinstance(attachment, (list, tuple)) or len(attachment) != 3:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"attachments must contain (name, content, mimetype) tuples"
|
|
51
|
+
)
|
|
52
|
+
email.attach(*attachment)
|
|
53
|
+
if content_subtype:
|
|
54
|
+
email.content_subtype = content_subtype
|
|
55
|
+
email.send(fail_silently=fail_silently)
|
|
56
|
+
return email
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def can_send_email() -> bool:
|
|
60
|
+
"""Return ``True`` when at least one outbound email path is configured."""
|
|
61
|
+
|
|
62
|
+
from nodes.models import EmailOutbox # imported lazily to avoid circular deps
|
|
63
|
+
|
|
64
|
+
has_outbox = EmailOutbox.objects.exclude(host="").exists()
|
|
65
|
+
if has_outbox:
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
backend_path = getattr(settings, "EMAIL_BACKEND", "")
|
|
69
|
+
if not backend_path:
|
|
70
|
+
return False
|
|
71
|
+
try:
|
|
72
|
+
backend_cls = import_string(backend_path)
|
|
73
|
+
except Exception: # pragma: no cover - misconfigured backend
|
|
74
|
+
logger.warning("Email backend %s could not be imported", backend_path)
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
if DummyEmailBackend is None:
|
|
78
|
+
return True
|
|
79
|
+
try:
|
|
80
|
+
return not issubclass(backend_cls, DummyEmailBackend)
|
|
81
|
+
except TypeError: # pragma: no cover - backend not a class
|
|
82
|
+
logger.warning("Email backend %s is not a class", backend_path)
|
|
83
|
+
return False
|