arthexis 0.1.14__py3-none-any.whl → 0.1.16__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.14.dist-info → arthexis-0.1.16.dist-info}/METADATA +3 -2
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/RECORD +41 -39
- config/urls.py +5 -0
- core/admin.py +200 -9
- core/admindocs.py +44 -3
- core/apps.py +1 -1
- core/backends.py +44 -8
- core/entity.py +17 -1
- core/github_issues.py +12 -7
- core/log_paths.py +24 -10
- core/mailer.py +9 -5
- core/models.py +92 -23
- core/release.py +173 -2
- core/system.py +411 -4
- core/tasks.py +5 -1
- core/test_system_info.py +16 -0
- core/tests.py +280 -0
- core/views.py +252 -38
- nodes/admin.py +25 -1
- nodes/models.py +99 -6
- nodes/rfid_sync.py +15 -0
- nodes/tests.py +142 -3
- nodes/utils.py +3 -0
- ocpp/consumers.py +38 -0
- ocpp/models.py +19 -4
- ocpp/tasks.py +156 -2
- ocpp/test_rfid.py +44 -2
- ocpp/tests.py +111 -1
- pages/admin.py +188 -5
- pages/context_processors.py +20 -1
- pages/middleware.py +4 -0
- pages/models.py +39 -1
- pages/module_defaults.py +156 -0
- pages/tasks.py +74 -0
- pages/tests.py +629 -8
- pages/urls.py +2 -0
- pages/utils.py +11 -0
- pages/views.py +106 -38
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/WHEEL +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.14.dist-info → arthexis-0.1.16.dist-info}/top_level.txt +0 -0
core/backends.py
CHANGED
|
@@ -5,6 +5,7 @@ import ipaddress
|
|
|
5
5
|
import os
|
|
6
6
|
import socket
|
|
7
7
|
import subprocess
|
|
8
|
+
import sys
|
|
8
9
|
|
|
9
10
|
from django.conf import settings
|
|
10
11
|
from django.contrib.auth import get_user_model
|
|
@@ -42,9 +43,12 @@ class TOTPBackend(ModelBackend):
|
|
|
42
43
|
|
|
43
44
|
device_qs = TOTPDevice.objects.filter(user=user, confirmed=True)
|
|
44
45
|
if TOTP_DEVICE_NAME:
|
|
45
|
-
|
|
46
|
+
device = device_qs.filter(name=TOTP_DEVICE_NAME).order_by("-id").first()
|
|
47
|
+
else:
|
|
48
|
+
device = None
|
|
46
49
|
|
|
47
|
-
device
|
|
50
|
+
if device is None:
|
|
51
|
+
device = device_qs.order_by("-id").first()
|
|
48
52
|
if device is None:
|
|
49
53
|
return None
|
|
50
54
|
|
|
@@ -108,6 +112,19 @@ class RFIDBackend:
|
|
|
108
112
|
.first()
|
|
109
113
|
)
|
|
110
114
|
if account:
|
|
115
|
+
post_command = (getattr(tag, "post_auth_command", "") or "").strip()
|
|
116
|
+
if post_command:
|
|
117
|
+
env = os.environ.copy()
|
|
118
|
+
env["RFID_VALUE"] = rfid_value
|
|
119
|
+
env["RFID_LABEL_ID"] = str(tag.pk)
|
|
120
|
+
with contextlib.suppress(Exception):
|
|
121
|
+
subprocess.Popen(
|
|
122
|
+
post_command,
|
|
123
|
+
shell=True,
|
|
124
|
+
env=env,
|
|
125
|
+
stdout=subprocess.DEVNULL,
|
|
126
|
+
stderr=subprocess.DEVNULL,
|
|
127
|
+
)
|
|
111
128
|
return account.user
|
|
112
129
|
return None
|
|
113
130
|
|
|
@@ -167,6 +184,19 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
167
184
|
if getattr(settings, "NODE_ROLE", "") == "Control":
|
|
168
185
|
yield from self._CONTROL_ALLOWED_NETWORKS
|
|
169
186
|
|
|
187
|
+
def _is_test_environment(self, request) -> bool:
|
|
188
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
189
|
+
return True
|
|
190
|
+
if any(arg == "test" for arg in sys.argv):
|
|
191
|
+
return True
|
|
192
|
+
executable = os.path.basename(sys.argv[0]) if sys.argv else ""
|
|
193
|
+
if executable in {"pytest", "py.test"}:
|
|
194
|
+
return True
|
|
195
|
+
server_name = ""
|
|
196
|
+
if request is not None:
|
|
197
|
+
server_name = request.META.get("SERVER_NAME", "")
|
|
198
|
+
return server_name.lower() == "testserver"
|
|
199
|
+
|
|
170
200
|
def authenticate(self, request, username=None, password=None, **kwargs):
|
|
171
201
|
if username == "admin" and password == "admin" and request is not None:
|
|
172
202
|
try:
|
|
@@ -179,7 +209,8 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
179
209
|
try:
|
|
180
210
|
ipaddress.ip_address(host)
|
|
181
211
|
except ValueError:
|
|
182
|
-
|
|
212
|
+
if not self._is_test_environment(request):
|
|
213
|
+
return None
|
|
183
214
|
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
184
215
|
if forwarded:
|
|
185
216
|
remote = forwarded.split(",")[0].strip()
|
|
@@ -212,11 +243,16 @@ class LocalhostAdminBackend(ModelBackend):
|
|
|
212
243
|
user.operate_as = arthexis_user
|
|
213
244
|
user.set_password("admin")
|
|
214
245
|
user.save()
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
246
|
+
else:
|
|
247
|
+
if not user.check_password("admin"):
|
|
248
|
+
if not user.password or not user.has_usable_password():
|
|
249
|
+
user.set_password("admin")
|
|
250
|
+
user.save(update_fields=["password"])
|
|
251
|
+
else:
|
|
252
|
+
return None
|
|
253
|
+
if arthexis_user and user.operate_as_id is None:
|
|
254
|
+
user.operate_as = arthexis_user
|
|
255
|
+
user.save(update_fields=["operate_as"])
|
|
220
256
|
return user
|
|
221
257
|
return super().authenticate(request, username, password, **kwargs)
|
|
222
258
|
|
core/entity.py
CHANGED
|
@@ -4,10 +4,14 @@ import logging
|
|
|
4
4
|
from django.contrib.auth.models import UserManager as DjangoUserManager
|
|
5
5
|
from django.core.exceptions import FieldDoesNotExist
|
|
6
6
|
from django.db import models
|
|
7
|
+
from django.dispatch import Signal
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
user_data_flag_updated = Signal()
|
|
13
|
+
|
|
14
|
+
|
|
11
15
|
class EntityQuerySet(models.QuerySet):
|
|
12
16
|
def delete(self): # pragma: no cover - delegates to instance delete
|
|
13
17
|
deleted = 0
|
|
@@ -16,12 +20,24 @@ class EntityQuerySet(models.QuerySet):
|
|
|
16
20
|
deleted += 1
|
|
17
21
|
return deleted, {}
|
|
18
22
|
|
|
23
|
+
def update(self, **kwargs):
|
|
24
|
+
invalidate_user_data_cache = "is_user_data" in kwargs
|
|
25
|
+
updated = super().update(**kwargs)
|
|
26
|
+
if invalidate_user_data_cache and updated:
|
|
27
|
+
user_data_flag_updated.send(sender=self.model)
|
|
28
|
+
return updated
|
|
29
|
+
|
|
19
30
|
|
|
20
31
|
class EntityManager(models.Manager):
|
|
21
32
|
def get_queryset(self):
|
|
22
33
|
return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
|
|
23
34
|
|
|
24
35
|
|
|
36
|
+
class EntityAllManager(models.Manager):
|
|
37
|
+
def get_queryset(self):
|
|
38
|
+
return EntityQuerySet(self.model, using=self._db)
|
|
39
|
+
|
|
40
|
+
|
|
25
41
|
class EntityUserManager(DjangoUserManager):
|
|
26
42
|
def get_queryset(self):
|
|
27
43
|
return EntityQuerySet(self.model, using=self._db).filter(is_deleted=False)
|
|
@@ -35,7 +51,7 @@ class Entity(models.Model):
|
|
|
35
51
|
is_deleted = models.BooleanField(default=False, editable=False)
|
|
36
52
|
|
|
37
53
|
objects = EntityManager()
|
|
38
|
-
all_objects =
|
|
54
|
+
all_objects = EntityAllManager()
|
|
39
55
|
|
|
40
56
|
class Meta:
|
|
41
57
|
abstract = True
|
core/github_issues.py
CHANGED
|
@@ -71,13 +71,18 @@ def get_github_token() -> str:
|
|
|
71
71
|
latest_release = PackageRelease.latest()
|
|
72
72
|
if latest_release:
|
|
73
73
|
token = latest_release.get_github_token()
|
|
74
|
-
if token:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
if token is not None:
|
|
75
|
+
cleaned = token.strip() if isinstance(token, str) else str(token).strip()
|
|
76
|
+
if cleaned:
|
|
77
|
+
return cleaned
|
|
78
|
+
|
|
79
|
+
env_token = os.environ.get("GITHUB_TOKEN")
|
|
80
|
+
if env_token is not None:
|
|
81
|
+
cleaned = env_token.strip() if isinstance(env_token, str) else str(env_token).strip()
|
|
82
|
+
if cleaned:
|
|
83
|
+
return cleaned
|
|
84
|
+
|
|
85
|
+
raise RuntimeError("GitHub token is not configured")
|
|
81
86
|
|
|
82
87
|
|
|
83
88
|
def _ensure_lock_dir() -> None:
|
core/log_paths.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
+
import tempfile
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def _is_root() -> bool:
|
|
@@ -41,16 +42,29 @@ def select_log_dir(base_dir: Path) -> Path:
|
|
|
41
42
|
candidates.append(Path("/var/log/arthexis"))
|
|
42
43
|
candidates.append(Path("/tmp/arthexis/logs"))
|
|
43
44
|
else:
|
|
44
|
-
home
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
home: Path | None
|
|
46
|
+
try:
|
|
47
|
+
home = Path.home()
|
|
48
|
+
except (RuntimeError, OSError, KeyError):
|
|
49
|
+
home = None
|
|
50
|
+
|
|
51
|
+
candidates.append(default)
|
|
52
|
+
|
|
53
|
+
tmp_logs = Path(tempfile.gettempdir()) / "arthexis" / "logs"
|
|
54
|
+
|
|
55
|
+
if home is not None:
|
|
56
|
+
state_home = _state_home(home)
|
|
57
|
+
candidates.extend(
|
|
58
|
+
[
|
|
59
|
+
state_home / "arthexis" / "logs",
|
|
60
|
+
home / ".arthexis" / "logs",
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
candidates.append(tmp_logs)
|
|
65
|
+
|
|
66
|
+
candidates.append(Path("/tmp/arthexis/logs"))
|
|
67
|
+
candidates.append(tmp_logs)
|
|
54
68
|
|
|
55
69
|
seen: set[Path] = set()
|
|
56
70
|
ordered_candidates: list[Path] = []
|
core/mailer.py
CHANGED
|
@@ -45,11 +45,15 @@ def send(
|
|
|
45
45
|
)
|
|
46
46
|
if attachments:
|
|
47
47
|
for attachment in attachments:
|
|
48
|
-
if
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
if isinstance(attachment, (list, tuple)):
|
|
49
|
+
length = len(attachment)
|
|
50
|
+
if length not in {2, 3}:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"attachments must contain 2- or 3-item (name, content, mimetype) tuples"
|
|
53
|
+
)
|
|
54
|
+
email.attach(*attachment)
|
|
55
|
+
else:
|
|
56
|
+
email.attach(attachment)
|
|
53
57
|
if content_subtype:
|
|
54
58
|
email.content_subtype = content_subtype
|
|
55
59
|
email.send(fail_silently=fail_silently)
|
core/models.py
CHANGED
|
@@ -50,6 +50,7 @@ from .release import (
|
|
|
50
50
|
Credentials,
|
|
51
51
|
DEFAULT_PACKAGE,
|
|
52
52
|
RepositoryTarget,
|
|
53
|
+
GitCredentials,
|
|
53
54
|
)
|
|
54
55
|
|
|
55
56
|
|
|
@@ -585,10 +586,10 @@ class OdooProfile(Profile):
|
|
|
585
586
|
"""Return the display label for this profile."""
|
|
586
587
|
|
|
587
588
|
username = self._resolved_field_value("username")
|
|
589
|
+
if username:
|
|
590
|
+
return username
|
|
588
591
|
database = self._resolved_field_value("database")
|
|
589
|
-
|
|
590
|
-
return f"{username}@{database}"
|
|
591
|
-
return username or database or ""
|
|
592
|
+
return database or ""
|
|
592
593
|
|
|
593
594
|
def save(self, *args, **kwargs):
|
|
594
595
|
if self.pk:
|
|
@@ -1815,17 +1816,22 @@ class RFID(Entity):
|
|
|
1815
1816
|
blank=True,
|
|
1816
1817
|
help_text="Optional command executed during validation.",
|
|
1817
1818
|
)
|
|
1819
|
+
post_auth_command = models.TextField(
|
|
1820
|
+
default="",
|
|
1821
|
+
blank=True,
|
|
1822
|
+
help_text="Optional command executed after successful validation.",
|
|
1823
|
+
)
|
|
1818
1824
|
BLACK = "B"
|
|
1819
1825
|
WHITE = "W"
|
|
1820
1826
|
BLUE = "U"
|
|
1821
1827
|
RED = "R"
|
|
1822
1828
|
GREEN = "G"
|
|
1823
1829
|
COLOR_CHOICES = [
|
|
1824
|
-
(BLACK, "Black"),
|
|
1825
|
-
(WHITE, "White"),
|
|
1826
|
-
(BLUE, "Blue"),
|
|
1827
|
-
(RED, "Red"),
|
|
1828
|
-
(GREEN, "Green"),
|
|
1830
|
+
(BLACK, _("Black")),
|
|
1831
|
+
(WHITE, _("White")),
|
|
1832
|
+
(BLUE, _("Blue")),
|
|
1833
|
+
(RED, _("Red")),
|
|
1834
|
+
(GREEN, _("Green")),
|
|
1829
1835
|
]
|
|
1830
1836
|
SCAN_LABEL_STEP = 10
|
|
1831
1837
|
COPY_LABEL_STEP = 1
|
|
@@ -1837,8 +1843,8 @@ class RFID(Entity):
|
|
|
1837
1843
|
CLASSIC = "CLASSIC"
|
|
1838
1844
|
NTAG215 = "NTAG215"
|
|
1839
1845
|
KIND_CHOICES = [
|
|
1840
|
-
(CLASSIC, "MIFARE Classic"),
|
|
1841
|
-
(NTAG215, "NTAG215"),
|
|
1846
|
+
(CLASSIC, _("MIFARE Classic")),
|
|
1847
|
+
(NTAG215, _("NTAG215")),
|
|
1842
1848
|
]
|
|
1843
1849
|
kind = models.CharField(
|
|
1844
1850
|
max_length=8,
|
|
@@ -3024,6 +3030,8 @@ class ReleaseManager(Profile):
|
|
|
3024
3030
|
"pypi_username",
|
|
3025
3031
|
"pypi_token",
|
|
3026
3032
|
"github_token",
|
|
3033
|
+
"git_username",
|
|
3034
|
+
"git_password",
|
|
3027
3035
|
"pypi_password",
|
|
3028
3036
|
"pypi_url",
|
|
3029
3037
|
"secondary_pypi_url",
|
|
@@ -3038,8 +3046,32 @@ class ReleaseManager(Profile):
|
|
|
3038
3046
|
"Used before the GITHUB_TOKEN environment variable."
|
|
3039
3047
|
),
|
|
3040
3048
|
)
|
|
3049
|
+
git_username = SigilShortAutoField(
|
|
3050
|
+
"Git username",
|
|
3051
|
+
max_length=100,
|
|
3052
|
+
blank=True,
|
|
3053
|
+
help_text="Username used for Git pushes (for example, your GitHub username).",
|
|
3054
|
+
)
|
|
3055
|
+
git_password = SigilShortAutoField(
|
|
3056
|
+
"Git password/token",
|
|
3057
|
+
max_length=200,
|
|
3058
|
+
blank=True,
|
|
3059
|
+
help_text=(
|
|
3060
|
+
"Password or personal access token for HTTPS Git pushes. "
|
|
3061
|
+
"Leave blank to use the GitHub token instead."
|
|
3062
|
+
),
|
|
3063
|
+
)
|
|
3041
3064
|
pypi_password = SigilShortAutoField("PyPI password", max_length=200, blank=True)
|
|
3042
|
-
pypi_url = SigilShortAutoField(
|
|
3065
|
+
pypi_url = SigilShortAutoField(
|
|
3066
|
+
"PyPI URL",
|
|
3067
|
+
max_length=200,
|
|
3068
|
+
blank=True,
|
|
3069
|
+
help_text=(
|
|
3070
|
+
"Link to the PyPI user profile (for example, https://pypi.org/user/username/). "
|
|
3071
|
+
"Use the account's user page, not a project-specific URL. "
|
|
3072
|
+
"This value is informational and not used for uploads."
|
|
3073
|
+
),
|
|
3074
|
+
)
|
|
3043
3075
|
secondary_pypi_url = SigilShortAutoField(
|
|
3044
3076
|
"Secondary PyPI URL",
|
|
3045
3077
|
max_length=200,
|
|
@@ -3079,6 +3111,23 @@ class ReleaseManager(Profile):
|
|
|
3079
3111
|
return Credentials(username=self.pypi_username, password=self.pypi_password)
|
|
3080
3112
|
return None
|
|
3081
3113
|
|
|
3114
|
+
def to_git_credentials(self) -> GitCredentials | None:
|
|
3115
|
+
"""Return Git credentials for pushing tags."""
|
|
3116
|
+
|
|
3117
|
+
username = (self.git_username or "").strip()
|
|
3118
|
+
password_source = self.git_password or self.github_token or ""
|
|
3119
|
+
password = password_source.strip()
|
|
3120
|
+
|
|
3121
|
+
if password and not username and password_source == self.github_token:
|
|
3122
|
+
# GitHub personal access tokens require a username when used for
|
|
3123
|
+
# HTTPS pushes. Default to the recommended ``x-access-token`` so
|
|
3124
|
+
# release managers only need to provide their token.
|
|
3125
|
+
username = "x-access-token"
|
|
3126
|
+
|
|
3127
|
+
if username and password:
|
|
3128
|
+
return GitCredentials(username=username, password=password)
|
|
3129
|
+
return None
|
|
3130
|
+
|
|
3082
3131
|
|
|
3083
3132
|
class Package(Entity):
|
|
3084
3133
|
"""Package details shared across releases."""
|
|
@@ -3189,13 +3238,22 @@ class PackageRelease(Entity):
|
|
|
3189
3238
|
def dump_fixture(cls) -> None:
|
|
3190
3239
|
base = Path("core/fixtures")
|
|
3191
3240
|
base.mkdir(parents=True, exist_ok=True)
|
|
3192
|
-
for
|
|
3193
|
-
|
|
3241
|
+
existing = {path.name: path for path in base.glob("releases__*.json")}
|
|
3242
|
+
expected: set[str] = set()
|
|
3194
3243
|
for release in cls.objects.all():
|
|
3195
3244
|
name = f"releases__packagerelease_{release.version.replace('.', '_')}.json"
|
|
3196
3245
|
path = base / name
|
|
3197
3246
|
data = serializers.serialize("json", [release])
|
|
3198
|
-
|
|
3247
|
+
expected.add(name)
|
|
3248
|
+
try:
|
|
3249
|
+
current = path.read_text(encoding="utf-8")
|
|
3250
|
+
except FileNotFoundError:
|
|
3251
|
+
current = None
|
|
3252
|
+
if current != data:
|
|
3253
|
+
path.write_text(data, encoding="utf-8")
|
|
3254
|
+
for old_name, old_path in existing.items():
|
|
3255
|
+
if old_name not in expected and old_path.exists():
|
|
3256
|
+
old_path.unlink()
|
|
3199
3257
|
|
|
3200
3258
|
def __str__(self) -> str: # pragma: no cover - trivial
|
|
3201
3259
|
return f"{self.package.name} {self.version}"
|
|
@@ -3208,7 +3266,18 @@ class PackageRelease(Entity):
|
|
|
3208
3266
|
"""Return :class:`Credentials` from the associated release manager."""
|
|
3209
3267
|
manager = self.release_manager or self.package.release_manager
|
|
3210
3268
|
if manager:
|
|
3211
|
-
|
|
3269
|
+
creds = manager.to_credentials()
|
|
3270
|
+
if creds and creds.has_auth():
|
|
3271
|
+
return creds
|
|
3272
|
+
|
|
3273
|
+
token = (os.environ.get("PYPI_API_TOKEN") or "").strip()
|
|
3274
|
+
username = (os.environ.get("PYPI_USERNAME") or "").strip()
|
|
3275
|
+
password = (os.environ.get("PYPI_PASSWORD") or "").strip()
|
|
3276
|
+
|
|
3277
|
+
if token:
|
|
3278
|
+
return Credentials(token=token)
|
|
3279
|
+
if username and password:
|
|
3280
|
+
return Credentials(username=username, password=password)
|
|
3212
3281
|
return None
|
|
3213
3282
|
|
|
3214
3283
|
def get_github_token(self) -> str | None:
|
|
@@ -3224,12 +3293,8 @@ class PackageRelease(Entity):
|
|
|
3224
3293
|
manager = self.release_manager or self.package.release_manager
|
|
3225
3294
|
targets: list[RepositoryTarget] = []
|
|
3226
3295
|
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
primary_url = manager.pypi_url.strip()
|
|
3230
|
-
if not primary_url:
|
|
3231
|
-
env_primary = os.environ.get("PYPI_REPOSITORY_URL", "")
|
|
3232
|
-
primary_url = env_primary.strip()
|
|
3296
|
+
env_primary = os.environ.get("PYPI_REPOSITORY_URL", "")
|
|
3297
|
+
primary_url = env_primary.strip()
|
|
3233
3298
|
|
|
3234
3299
|
primary_creds = self.to_credentials()
|
|
3235
3300
|
targets.append(
|
|
@@ -3353,10 +3418,12 @@ class PackageRelease(Entity):
|
|
|
3353
3418
|
|
|
3354
3419
|
@classmethod
|
|
3355
3420
|
def latest(cls):
|
|
3356
|
-
"""Return the latest release by version."""
|
|
3421
|
+
"""Return the latest release by version, preferring active packages."""
|
|
3357
3422
|
from packaging.version import Version
|
|
3358
3423
|
|
|
3359
|
-
releases = list(cls.objects.
|
|
3424
|
+
releases = list(cls.objects.filter(package__is_active=True))
|
|
3425
|
+
if not releases:
|
|
3426
|
+
releases = list(cls.objects.all())
|
|
3360
3427
|
if not releases:
|
|
3361
3428
|
return None
|
|
3362
3429
|
return max(releases, key=lambda r: Version(r.version))
|
|
@@ -3544,6 +3611,8 @@ class Todo(Entity):
|
|
|
3544
3611
|
max_length=200, blank=True, default="", validators=[validate_relative_url]
|
|
3545
3612
|
)
|
|
3546
3613
|
request_details = models.TextField(blank=True, default="")
|
|
3614
|
+
generated_for_version = models.CharField(max_length=20, blank=True, default="")
|
|
3615
|
+
generated_for_revision = models.CharField(max_length=40, blank=True, default="")
|
|
3547
3616
|
done_on = models.DateTimeField(null=True, blank=True)
|
|
3548
3617
|
on_done_condition = ConditionTextField(blank=True, default="")
|
|
3549
3618
|
|
core/release.py
CHANGED
|
@@ -10,6 +10,7 @@ import time
|
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Optional, Sequence
|
|
13
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
13
14
|
|
|
14
15
|
try: # pragma: no cover - optional dependency
|
|
15
16
|
import toml # type: ignore
|
|
@@ -70,6 +71,17 @@ class Credentials:
|
|
|
70
71
|
raise ValueError("Missing PyPI credentials")
|
|
71
72
|
|
|
72
73
|
|
|
74
|
+
@dataclass
|
|
75
|
+
class GitCredentials:
|
|
76
|
+
"""Credentials used for Git operations such as pushing tags."""
|
|
77
|
+
|
|
78
|
+
username: Optional[str] = None
|
|
79
|
+
password: Optional[str] = None
|
|
80
|
+
|
|
81
|
+
def has_auth(self) -> bool:
|
|
82
|
+
return bool((self.username or "").strip() and (self.password or "").strip())
|
|
83
|
+
|
|
84
|
+
|
|
73
85
|
@dataclass
|
|
74
86
|
class RepositoryTarget:
|
|
75
87
|
"""Configuration for uploading a distribution to a repository."""
|
|
@@ -90,7 +102,7 @@ class RepositoryTarget:
|
|
|
90
102
|
|
|
91
103
|
DEFAULT_PACKAGE = Package(
|
|
92
104
|
name="arthexis",
|
|
93
|
-
description="
|
|
105
|
+
description="Power & Energy Infrastructure",
|
|
94
106
|
author="Rafael J. Guillén-Osorio",
|
|
95
107
|
email="tecnologia@gelectriic.com",
|
|
96
108
|
python_requires=">=3.10",
|
|
@@ -243,6 +255,165 @@ def _manager_credentials() -> Optional[Credentials]:
|
|
|
243
255
|
return None
|
|
244
256
|
|
|
245
257
|
|
|
258
|
+
def _manager_git_credentials(package: Optional[Package] = None) -> Optional[GitCredentials]:
|
|
259
|
+
"""Return Git credentials from the Package's release manager if available."""
|
|
260
|
+
|
|
261
|
+
try: # pragma: no cover - optional dependency
|
|
262
|
+
from core.models import Package as PackageModel
|
|
263
|
+
|
|
264
|
+
queryset = PackageModel.objects.select_related("release_manager")
|
|
265
|
+
if package is not None:
|
|
266
|
+
queryset = queryset.filter(name=package.name)
|
|
267
|
+
package_obj = queryset.first()
|
|
268
|
+
if package_obj and package_obj.release_manager:
|
|
269
|
+
creds = package_obj.release_manager.to_git_credentials()
|
|
270
|
+
if creds and creds.has_auth():
|
|
271
|
+
return creds
|
|
272
|
+
except Exception:
|
|
273
|
+
return None
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _git_authentication_missing(exc: subprocess.CalledProcessError) -> bool:
|
|
278
|
+
message = (exc.stderr or exc.stdout or "").strip().lower()
|
|
279
|
+
if not message:
|
|
280
|
+
return False
|
|
281
|
+
auth_markers = [
|
|
282
|
+
"could not read username",
|
|
283
|
+
"authentication failed",
|
|
284
|
+
"fatal: authentication failed",
|
|
285
|
+
"terminal prompts disabled",
|
|
286
|
+
]
|
|
287
|
+
return any(marker in message for marker in auth_markers)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _format_subprocess_error(exc: subprocess.CalledProcessError) -> str:
|
|
291
|
+
return (exc.stderr or exc.stdout or str(exc)).strip() or str(exc)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _git_remote_url(remote: str = "origin") -> Optional[str]:
|
|
295
|
+
proc = subprocess.run(
|
|
296
|
+
["git", "remote", "get-url", remote],
|
|
297
|
+
capture_output=True,
|
|
298
|
+
text=True,
|
|
299
|
+
check=False,
|
|
300
|
+
)
|
|
301
|
+
if proc.returncode != 0:
|
|
302
|
+
return None
|
|
303
|
+
return (proc.stdout or "").strip() or None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _git_tag_commit(tag_name: str) -> Optional[str]:
|
|
307
|
+
"""Return the commit referenced by ``tag_name`` in the local repository."""
|
|
308
|
+
|
|
309
|
+
for ref in (f"{tag_name}^{{}}", tag_name):
|
|
310
|
+
proc = subprocess.run(
|
|
311
|
+
["git", "rev-parse", ref],
|
|
312
|
+
capture_output=True,
|
|
313
|
+
text=True,
|
|
314
|
+
check=False,
|
|
315
|
+
)
|
|
316
|
+
if proc.returncode == 0:
|
|
317
|
+
commit = (proc.stdout or "").strip()
|
|
318
|
+
if commit:
|
|
319
|
+
return commit
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _git_remote_tag_commit(remote: str, tag_name: str) -> Optional[str]:
|
|
324
|
+
"""Return the commit referenced by ``tag_name`` on ``remote`` if it exists."""
|
|
325
|
+
|
|
326
|
+
proc = subprocess.run(
|
|
327
|
+
["git", "ls-remote", "--tags", remote, tag_name],
|
|
328
|
+
capture_output=True,
|
|
329
|
+
text=True,
|
|
330
|
+
check=False,
|
|
331
|
+
)
|
|
332
|
+
if proc.returncode != 0:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
commit = None
|
|
336
|
+
for line in (proc.stdout or "").splitlines():
|
|
337
|
+
parts = line.strip().split()
|
|
338
|
+
if len(parts) != 2:
|
|
339
|
+
continue
|
|
340
|
+
sha, ref = parts
|
|
341
|
+
commit = sha
|
|
342
|
+
if ref.endswith("^{}"):
|
|
343
|
+
return sha
|
|
344
|
+
return commit
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _remote_with_credentials(url: str, creds: GitCredentials) -> Optional[str]:
|
|
348
|
+
if not creds.has_auth():
|
|
349
|
+
return None
|
|
350
|
+
parsed = urlsplit(url)
|
|
351
|
+
if parsed.scheme not in {"http", "https"}:
|
|
352
|
+
return None
|
|
353
|
+
host = parsed.netloc.split("@", 1)[-1]
|
|
354
|
+
username = quote((creds.username or "").strip(), safe="")
|
|
355
|
+
password = quote((creds.password or "").strip(), safe="")
|
|
356
|
+
netloc = f"{username}:{password}@{host}"
|
|
357
|
+
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _raise_git_authentication_error(tag_name: str, exc: subprocess.CalledProcessError) -> None:
|
|
361
|
+
details = _format_subprocess_error(exc)
|
|
362
|
+
message = (
|
|
363
|
+
"Git authentication failed while pushing tag {tag}. "
|
|
364
|
+
"Configure Git credentials in the release manager profile or authenticate "
|
|
365
|
+
"locally, then rerun the publish step or push the tag manually with `git push "
|
|
366
|
+
"origin {tag}`."
|
|
367
|
+
).format(tag=tag_name)
|
|
368
|
+
if details:
|
|
369
|
+
message = f"{message} Git reported: {details}"
|
|
370
|
+
raise ReleaseError(message) from exc
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _push_tag(tag_name: str, package: Package) -> None:
|
|
374
|
+
auth_error: subprocess.CalledProcessError | None = None
|
|
375
|
+
try:
|
|
376
|
+
_run(["git", "push", "origin", tag_name])
|
|
377
|
+
return
|
|
378
|
+
except subprocess.CalledProcessError as exc:
|
|
379
|
+
remote_commit = _git_remote_tag_commit("origin", tag_name)
|
|
380
|
+
local_commit = _git_tag_commit(tag_name)
|
|
381
|
+
if remote_commit:
|
|
382
|
+
if local_commit and remote_commit == local_commit:
|
|
383
|
+
# Another process already pushed the tag; treat as success.
|
|
384
|
+
return
|
|
385
|
+
message = (
|
|
386
|
+
"Git rejected tag {tag} because it already exists on the remote. "
|
|
387
|
+
"Delete the remote tag or choose a new version before retrying."
|
|
388
|
+
).format(tag=tag_name)
|
|
389
|
+
raise ReleaseError(message) from exc
|
|
390
|
+
if not _git_authentication_missing(exc):
|
|
391
|
+
raise
|
|
392
|
+
auth_error = exc
|
|
393
|
+
|
|
394
|
+
creds = _manager_git_credentials(package)
|
|
395
|
+
if creds and creds.has_auth():
|
|
396
|
+
remote_url = _git_remote_url("origin")
|
|
397
|
+
if remote_url:
|
|
398
|
+
authed_url = _remote_with_credentials(remote_url, creds)
|
|
399
|
+
if authed_url:
|
|
400
|
+
try:
|
|
401
|
+
_run(["git", "push", authed_url, tag_name])
|
|
402
|
+
return
|
|
403
|
+
except subprocess.CalledProcessError as push_exc:
|
|
404
|
+
if not _git_authentication_missing(push_exc):
|
|
405
|
+
raise
|
|
406
|
+
auth_error = push_exc
|
|
407
|
+
# If we reach this point, the original exception is an auth error
|
|
408
|
+
if auth_error is not None:
|
|
409
|
+
_raise_git_authentication_error(tag_name, auth_error)
|
|
410
|
+
raise ReleaseError(
|
|
411
|
+
"Git authentication failed while pushing tag {tag}. Configure Git credentials and try again.".format(
|
|
412
|
+
tag=tag_name
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
246
417
|
def run_tests(
|
|
247
418
|
log_path: Optional[Path] = None,
|
|
248
419
|
command: Optional[Sequence[str]] = None,
|
|
@@ -541,7 +712,7 @@ def publish(
|
|
|
541
712
|
|
|
542
713
|
tag_name = f"v{version}"
|
|
543
714
|
_run(["git", "tag", tag_name])
|
|
544
|
-
|
|
715
|
+
_push_tag(tag_name, package)
|
|
545
716
|
return uploaded
|
|
546
717
|
|
|
547
718
|
|