arthexis 0.1.9__py3-none-any.whl → 0.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- arthexis-0.1.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
core/environment.py
CHANGED
|
@@ -1,43 +1,60 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
|
|
5
|
-
from django.
|
|
6
|
-
from django.
|
|
7
|
-
from django.
|
|
8
|
-
from django.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.contrib import admin
|
|
6
|
+
from django.template.response import TemplateResponse
|
|
7
|
+
from django.urls import path
|
|
8
|
+
from django.utils.translation import gettext_lazy as _
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_django_settings():
|
|
12
|
+
return sorted(
|
|
13
|
+
[(name, getattr(settings, name)) for name in dir(settings) if name.isupper()]
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _environment_view(request):
|
|
18
|
+
env_vars = sorted(os.environ.items())
|
|
19
|
+
context = admin.site.each_context(request)
|
|
20
|
+
context.update(
|
|
21
|
+
{
|
|
22
|
+
"title": _("Environment"),
|
|
23
|
+
"env_vars": env_vars,
|
|
24
|
+
"environment_tasks": [],
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
return TemplateResponse(request, "admin/environment.html", context)
|
|
28
|
+
|
|
29
|
+
def _config_view(request):
|
|
30
|
+
context = admin.site.each_context(request)
|
|
31
|
+
context.update(
|
|
32
|
+
{
|
|
33
|
+
"title": _("Django Config"),
|
|
34
|
+
"django_settings": _get_django_settings(),
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
return TemplateResponse(request, "admin/config.html", context)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def patch_admin_environment_view() -> None:
|
|
41
|
+
"""Register the Environment and Config admin views on the main admin site."""
|
|
42
|
+
original_get_urls = admin.site.get_urls
|
|
43
|
+
|
|
44
|
+
def get_urls():
|
|
45
|
+
urls = original_get_urls()
|
|
46
|
+
custom = [
|
|
47
|
+
path(
|
|
48
|
+
"environment/",
|
|
49
|
+
admin.site.admin_view(_environment_view),
|
|
50
|
+
name="environment",
|
|
51
|
+
),
|
|
52
|
+
path(
|
|
53
|
+
"config/",
|
|
54
|
+
admin.site.admin_view(_config_view),
|
|
55
|
+
name="config",
|
|
56
|
+
),
|
|
57
|
+
]
|
|
58
|
+
return custom + urls
|
|
59
|
+
|
|
60
|
+
admin.site.get_urls = get_urls
|
core/fields.py
CHANGED
|
@@ -1,75 +1,168 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class
|
|
11
|
-
def
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
value
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import re
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.db.models.fields import DeferredAttribute
|
|
7
|
+
from django.utils.translation import gettext_lazy as _
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _BaseSigilDescriptor(DeferredAttribute):
|
|
11
|
+
def __set__(self, instance, value):
|
|
12
|
+
instance.__dict__[self.field.attname] = value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _CheckSigilDescriptor(_BaseSigilDescriptor):
|
|
16
|
+
def __get__(self, instance, cls=None):
|
|
17
|
+
value = super().__get__(instance, cls)
|
|
18
|
+
if instance is None:
|
|
19
|
+
return value
|
|
20
|
+
if getattr(instance, f"{self.field.name}_resolve_sigils", False):
|
|
21
|
+
return instance.resolve_sigils(self.field.name)
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _AutoSigilDescriptor(_BaseSigilDescriptor):
|
|
26
|
+
def __get__(self, instance, cls=None):
|
|
27
|
+
value = super().__get__(instance, cls)
|
|
28
|
+
if instance is None:
|
|
29
|
+
return value
|
|
30
|
+
return instance.resolve_sigils(self.field.name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _SigilBaseField:
|
|
34
|
+
def value_from_object(self, obj):
|
|
35
|
+
return obj.__dict__.get(self.attname)
|
|
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
|
+
|
|
43
|
+
|
|
44
|
+
class SigilCheckFieldMixin(_SigilBaseField):
|
|
45
|
+
descriptor_class = _CheckSigilDescriptor
|
|
46
|
+
|
|
47
|
+
def contribute_to_class(self, cls, name, private_only=False):
|
|
48
|
+
super().contribute_to_class(cls, name, private_only=private_only)
|
|
49
|
+
extra_name = f"{name}_resolve_sigils"
|
|
50
|
+
if not any(f.name == extra_name for f in cls._meta.fields):
|
|
51
|
+
cls.add_to_class(
|
|
52
|
+
extra_name,
|
|
53
|
+
models.BooleanField(
|
|
54
|
+
default=False,
|
|
55
|
+
verbose_name="Resolve [SIGILS] in templates",
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SigilAutoFieldMixin(_SigilBaseField):
|
|
61
|
+
descriptor_class = _AutoSigilDescriptor
|
|
62
|
+
|
|
63
|
+
def contribute_to_class(self, cls, name, private_only=False):
|
|
64
|
+
super().contribute_to_class(cls, name, private_only=private_only)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SigilShortCheckField(SigilCheckFieldMixin, models.CharField):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SigilLongCheckField(SigilCheckFieldMixin, models.TextField):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SigilShortAutoField(SigilAutoFieldMixin, models.CharField):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SigilLongAutoField(SigilAutoFieldMixin, models.TextField):
|
|
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/form_fields.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Custom form fields for the Arthexis admin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.core.exceptions import ValidationError
|
|
9
|
+
from django.forms.fields import FileField
|
|
10
|
+
from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
|
11
|
+
from django.utils.translation import gettext_lazy as _
|
|
12
|
+
|
|
13
|
+
from .widgets import AdminBase64FileWidget
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Base64FileField(FileField):
|
|
17
|
+
"""Form field storing uploaded files as base64 encoded strings.
|
|
18
|
+
|
|
19
|
+
The field behaves like :class:`~django.forms.FileField` from the user's
|
|
20
|
+
perspective. Uploaded files are converted to base64 and returned as text so
|
|
21
|
+
they can be stored in ``TextField`` columns. When no new file is uploaded the
|
|
22
|
+
initial base64 value is preserved, while clearing the field stores an empty
|
|
23
|
+
string.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
widget = AdminBase64FileWidget
|
|
27
|
+
default_error_messages = {
|
|
28
|
+
**FileField.default_error_messages,
|
|
29
|
+
"contradiction": _(
|
|
30
|
+
"Please either submit a file or check the clear checkbox, not both."
|
|
31
|
+
),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
download_name: str | None = None,
|
|
38
|
+
content_type: str = "application/octet-stream",
|
|
39
|
+
**kwargs: Any,
|
|
40
|
+
) -> None:
|
|
41
|
+
widget = kwargs.pop("widget", None) or self.widget()
|
|
42
|
+
if download_name:
|
|
43
|
+
widget.download_name = download_name
|
|
44
|
+
if content_type:
|
|
45
|
+
widget.content_type = content_type
|
|
46
|
+
super().__init__(widget=widget, **kwargs)
|
|
47
|
+
|
|
48
|
+
def to_python(self, data: Any) -> str | None:
|
|
49
|
+
"""Convert uploaded data to a base64 string."""
|
|
50
|
+
|
|
51
|
+
if isinstance(data, str):
|
|
52
|
+
return data
|
|
53
|
+
uploaded = super().to_python(data)
|
|
54
|
+
if uploaded is None:
|
|
55
|
+
return None
|
|
56
|
+
content = uploaded.read()
|
|
57
|
+
if hasattr(uploaded, "seek"):
|
|
58
|
+
uploaded.seek(0)
|
|
59
|
+
return base64.b64encode(content).decode("ascii")
|
|
60
|
+
|
|
61
|
+
def clean(self, data: Any, initial: str | None = None) -> str:
|
|
62
|
+
if data is FILE_INPUT_CONTRADICTION:
|
|
63
|
+
raise ValidationError(
|
|
64
|
+
self.error_messages["contradiction"], code="contradiction"
|
|
65
|
+
)
|
|
66
|
+
cleaned = super().clean(data, initial)
|
|
67
|
+
if cleaned in {None, False}:
|
|
68
|
+
return ""
|
|
69
|
+
return cleaned
|
|
70
|
+
|
|
71
|
+
def bound_data(self, data: Any, initial: str | None) -> str | None:
|
|
72
|
+
return initial
|
|
73
|
+
|
|
74
|
+
def has_changed(self, initial: str | None, data: Any) -> bool:
|
|
75
|
+
return not self.disabled and data is not None
|
core/github_helper.py
CHANGED
|
@@ -1,25 +1,188 @@
|
|
|
1
|
-
"""Helpers for reporting exceptions to GitHub."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
1
|
+
"""Helpers for reporting exceptions to GitHub and managing repositories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from celery import shared_task
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
15
|
+
from .models import Package
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
GITHUB_API_ROOT = "https://api.github.com"
|
|
22
|
+
REQUEST_TIMEOUT = 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitHubRepositoryError(RuntimeError):
|
|
26
|
+
"""Raised when a GitHub repository operation fails."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@shared_task
|
|
31
|
+
def report_exception_to_github(payload: dict[str, Any]) -> None:
|
|
32
|
+
"""Send exception context to the GitHub issue helper.
|
|
33
|
+
|
|
34
|
+
The task is intentionally light-weight in this repository. Deployments can
|
|
35
|
+
replace it with an implementation that forwards ``payload`` to the
|
|
36
|
+
automation responsible for creating GitHub issues.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
logger.info(
|
|
40
|
+
"Queued GitHub issue report for %s", payload.get("fingerprint", "<unknown>")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_github_token(package: Package | None) -> str:
|
|
45
|
+
"""Return the GitHub token for ``package``.
|
|
46
|
+
|
|
47
|
+
Preference is given to the release manager associated with the package.
|
|
48
|
+
When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
if package:
|
|
52
|
+
manager = getattr(package, "release_manager", None)
|
|
53
|
+
if manager:
|
|
54
|
+
token = getattr(manager, "github_token", "")
|
|
55
|
+
if token:
|
|
56
|
+
cleaned = str(token).strip()
|
|
57
|
+
if cleaned:
|
|
58
|
+
return cleaned
|
|
59
|
+
|
|
60
|
+
token = os.environ.get("GITHUB_TOKEN", "")
|
|
61
|
+
cleaned_env = token.strip() if isinstance(token, str) else str(token).strip()
|
|
62
|
+
if not cleaned_env:
|
|
63
|
+
raise GitHubRepositoryError("GitHub token is not configured")
|
|
64
|
+
return cleaned_env
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_headers(token: str) -> Mapping[str, str]:
|
|
68
|
+
return {
|
|
69
|
+
"Accept": "application/vnd.github+json",
|
|
70
|
+
"Authorization": f"token {token}",
|
|
71
|
+
"User-Agent": "arthexis-admin",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_payload(repo: str, *, private: bool, description: str | None) -> dict[str, Any]:
|
|
76
|
+
payload: dict[str, Any] = {"name": repo, "private": private}
|
|
77
|
+
if description:
|
|
78
|
+
payload["description"] = description
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _extract_error_message(response: requests.Response) -> str:
|
|
83
|
+
try:
|
|
84
|
+
data = response.json()
|
|
85
|
+
except ValueError:
|
|
86
|
+
data = {}
|
|
87
|
+
|
|
88
|
+
message = data.get("message") or response.text or "GitHub repository request failed"
|
|
89
|
+
errors = data.get("errors")
|
|
90
|
+
details: list[str] = []
|
|
91
|
+
if isinstance(errors, list):
|
|
92
|
+
for entry in errors:
|
|
93
|
+
if isinstance(entry, str):
|
|
94
|
+
details.append(entry)
|
|
95
|
+
elif isinstance(entry, Mapping):
|
|
96
|
+
text = entry.get("message") or entry.get("code")
|
|
97
|
+
if text:
|
|
98
|
+
details.append(str(text))
|
|
99
|
+
|
|
100
|
+
if details:
|
|
101
|
+
message = f"{message} ({'; '.join(details)})"
|
|
102
|
+
|
|
103
|
+
return message
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _safe_json(response: requests.Response) -> dict[str, Any]:
|
|
107
|
+
try:
|
|
108
|
+
data = response.json()
|
|
109
|
+
except ValueError:
|
|
110
|
+
data = {}
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create_repository_for_package(
|
|
115
|
+
package: Package,
|
|
116
|
+
*,
|
|
117
|
+
owner: str,
|
|
118
|
+
repo: str,
|
|
119
|
+
private: bool = False,
|
|
120
|
+
description: str | None = None,
|
|
121
|
+
) -> str:
|
|
122
|
+
"""Create a GitHub repository and return its canonical URL.
|
|
123
|
+
|
|
124
|
+
The helper attempts to create the repository under ``owner`` when provided.
|
|
125
|
+
If the authenticated token lacks access to the organization, the helper
|
|
126
|
+
falls back to creating the repository for the authenticated user. On
|
|
127
|
+
success, the GitHub HTML URL for the repository is returned.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
token = _resolve_github_token(package)
|
|
131
|
+
headers = _build_headers(token)
|
|
132
|
+
payload = _build_payload(repo, private=private, description=description)
|
|
133
|
+
|
|
134
|
+
endpoints: list[str] = []
|
|
135
|
+
owner = owner.strip()
|
|
136
|
+
if owner:
|
|
137
|
+
endpoints.append(f"{GITHUB_API_ROOT}/orgs/{owner}/repos")
|
|
138
|
+
endpoints.append(f"{GITHUB_API_ROOT}/user/repos")
|
|
139
|
+
|
|
140
|
+
last_error: str | None = None
|
|
141
|
+
|
|
142
|
+
for index, endpoint in enumerate(endpoints):
|
|
143
|
+
try:
|
|
144
|
+
response = requests.post(
|
|
145
|
+
endpoint,
|
|
146
|
+
json=payload,
|
|
147
|
+
headers=headers,
|
|
148
|
+
timeout=REQUEST_TIMEOUT,
|
|
149
|
+
)
|
|
150
|
+
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
151
|
+
logger.exception(
|
|
152
|
+
"GitHub repository creation request failed for %s/%s", owner, repo
|
|
153
|
+
)
|
|
154
|
+
raise GitHubRepositoryError(str(exc)) from exc
|
|
155
|
+
|
|
156
|
+
if 200 <= response.status_code < 300:
|
|
157
|
+
data = _safe_json(response)
|
|
158
|
+
html_url = data.get("html_url")
|
|
159
|
+
if html_url:
|
|
160
|
+
return html_url
|
|
161
|
+
|
|
162
|
+
resolved_owner = (
|
|
163
|
+
data.get("owner", {}).get("login")
|
|
164
|
+
if isinstance(data.get("owner"), Mapping)
|
|
165
|
+
else owner
|
|
166
|
+
)
|
|
167
|
+
resolved_owner = (resolved_owner or owner).strip("/")
|
|
168
|
+
return f"https://github.com/{resolved_owner}/{repo}"
|
|
169
|
+
|
|
170
|
+
message = _extract_error_message(response)
|
|
171
|
+
logger.error(
|
|
172
|
+
"GitHub repository creation failed for %s/%s (%s): %s",
|
|
173
|
+
owner or "<user>",
|
|
174
|
+
repo,
|
|
175
|
+
response.status_code,
|
|
176
|
+
message,
|
|
177
|
+
)
|
|
178
|
+
last_error = message
|
|
179
|
+
|
|
180
|
+
# If we're attempting to create within an organization and receive a
|
|
181
|
+
# not found or forbidden error, fall back to creating for the
|
|
182
|
+
# authenticated user.
|
|
183
|
+
if index == 0 and owner and response.status_code in {403, 404}:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
raise GitHubRepositoryError(last_error or "GitHub repository creation failed")
|