arthexis 0.1.21__py3-none-any.whl → 0.1.22__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.21.dist-info → arthexis-0.1.22.dist-info}/METADATA +8 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/RECORD +31 -31
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +139 -19
- core/environment.py +2 -239
- core/models.py +419 -2
- core/system.py +76 -0
- core/tests.py +152 -8
- core/views.py +35 -1
- nodes/admin.py +148 -38
- nodes/apps.py +11 -0
- nodes/models.py +26 -6
- nodes/tests.py +214 -1
- nodes/views.py +1 -0
- ocpp/admin.py +20 -1
- ocpp/consumers.py +1 -0
- ocpp/models.py +23 -1
- ocpp/tasks.py +99 -1
- ocpp/tests.py +227 -2
- ocpp/views.py +281 -3
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +361 -22
- pages/urls.py +5 -0
- pages/views.py +264 -11
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/top_level.txt +0 -0
pages/views.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import logging
|
|
3
|
+
import mimetypes
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from types import SimpleNamespace
|
|
5
6
|
import datetime
|
|
@@ -21,9 +22,11 @@ from django import forms
|
|
|
21
22
|
from django.apps import apps as django_apps
|
|
22
23
|
from utils.decorators import security_group_required
|
|
23
24
|
from utils.sites import get_site
|
|
24
|
-
from django.
|
|
25
|
+
from django.contrib.staticfiles import finders
|
|
26
|
+
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
|
|
25
27
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
26
28
|
from nodes.models import Node
|
|
29
|
+
from nodes.utils import capture_screenshot, save_screenshot
|
|
27
30
|
from django.template import loader
|
|
28
31
|
from django.template.response import TemplateResponse
|
|
29
32
|
from django.test import RequestFactory, signals as test_signals
|
|
@@ -33,14 +36,19 @@ from django.utils.encoding import force_bytes, force_str
|
|
|
33
36
|
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
|
34
37
|
from core import mailer, public_wifi
|
|
35
38
|
from core.backends import TOTP_DEVICE_NAME
|
|
36
|
-
from django.utils.translation import gettext as _
|
|
39
|
+
from django.utils.translation import get_language, gettext as _
|
|
40
|
+
|
|
41
|
+
try: # pragma: no cover - compatibility shim for Django versions without constant
|
|
42
|
+
from django.utils.translation import LANGUAGE_SESSION_KEY
|
|
43
|
+
except ImportError: # pragma: no cover - fallback when constant is unavailable
|
|
44
|
+
LANGUAGE_SESSION_KEY = "_language"
|
|
37
45
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
38
46
|
from django.views.decorators.http import require_POST
|
|
39
47
|
from django.core.cache import cache
|
|
40
48
|
from django.views.decorators.cache import never_cache
|
|
41
|
-
from django.utils.cache import patch_vary_headers
|
|
42
|
-
from django.core.exceptions import PermissionDenied
|
|
43
|
-
from django.utils.text import slugify
|
|
49
|
+
from django.utils.cache import patch_cache_control, patch_vary_headers
|
|
50
|
+
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
|
|
51
|
+
from django.utils.text import slugify, Truncator
|
|
44
52
|
from django.core.validators import EmailValidator
|
|
45
53
|
from django.db.models import Q
|
|
46
54
|
from core.models import (
|
|
@@ -48,6 +56,7 @@ from core.models import (
|
|
|
48
56
|
ClientReport,
|
|
49
57
|
ClientReportSchedule,
|
|
50
58
|
SecurityGroup,
|
|
59
|
+
Todo,
|
|
51
60
|
)
|
|
52
61
|
|
|
53
62
|
try: # pragma: no cover - optional dependency guard
|
|
@@ -58,16 +67,34 @@ except ImportError: # pragma: no cover - handled gracefully in views
|
|
|
58
67
|
CalledProcessError = ExecutableNotFound = None
|
|
59
68
|
|
|
60
69
|
import markdown
|
|
70
|
+
from django.utils._os import safe_join
|
|
61
71
|
|
|
62
72
|
|
|
63
73
|
MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
|
|
64
74
|
|
|
75
|
+
MARKDOWN_IMAGE_PATTERN = re.compile(
|
|
76
|
+
r"(?P<prefix><img\b[^>]*\bsrc=[\"\'])(?P<scheme>(?:static|work))://(?P<path>[^\"\']+)(?P<suffix>[\"\'])",
|
|
77
|
+
re.IGNORECASE,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
ALLOWED_IMAGE_EXTENSIONS = {
|
|
81
|
+
".apng",
|
|
82
|
+
".avif",
|
|
83
|
+
".gif",
|
|
84
|
+
".jpg",
|
|
85
|
+
".jpeg",
|
|
86
|
+
".png",
|
|
87
|
+
".svg",
|
|
88
|
+
".webp",
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
|
|
66
92
|
def _render_markdown_with_toc(text: str) -> tuple[str, str]:
|
|
67
93
|
"""Render ``text`` to HTML and return the HTML and stripped TOC."""
|
|
68
94
|
|
|
69
95
|
md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
|
|
70
96
|
html = md.convert(text)
|
|
97
|
+
html = _rewrite_markdown_asset_links(html)
|
|
71
98
|
toc_html = md.toc
|
|
72
99
|
toc_html = _strip_toc_wrapper(toc_html)
|
|
73
100
|
return html, toc_html
|
|
@@ -82,6 +109,86 @@ def _strip_toc_wrapper(toc_html: str) -> str:
|
|
|
82
109
|
if toc_html.endswith("</div>"):
|
|
83
110
|
toc_html = toc_html[: -len("</div>")]
|
|
84
111
|
return toc_html.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _rewrite_markdown_asset_links(html: str) -> str:
|
|
115
|
+
"""Rewrite asset links that reference local asset schemes."""
|
|
116
|
+
|
|
117
|
+
def _replace(match: re.Match[str]) -> str:
|
|
118
|
+
scheme = match.group("scheme").lower()
|
|
119
|
+
asset_path = match.group("path").lstrip("/")
|
|
120
|
+
if not asset_path:
|
|
121
|
+
return match.group(0)
|
|
122
|
+
extension = Path(asset_path).suffix.lower()
|
|
123
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
124
|
+
return match.group(0)
|
|
125
|
+
try:
|
|
126
|
+
asset_url = reverse(
|
|
127
|
+
"pages:readme-asset",
|
|
128
|
+
kwargs={"source": scheme, "asset": asset_path},
|
|
129
|
+
)
|
|
130
|
+
except NoReverseMatch:
|
|
131
|
+
return match.group(0)
|
|
132
|
+
return f"{match.group('prefix')}{escape(asset_url)}{match.group('suffix')}"
|
|
133
|
+
|
|
134
|
+
return MARKDOWN_IMAGE_PATTERN.sub(_replace, html)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_static_asset(path: str) -> Path:
|
|
138
|
+
normalized = path.lstrip("/")
|
|
139
|
+
if not normalized:
|
|
140
|
+
raise Http404("Asset not found")
|
|
141
|
+
resolved = finders.find(normalized)
|
|
142
|
+
if not resolved:
|
|
143
|
+
raise Http404("Asset not found")
|
|
144
|
+
if isinstance(resolved, (list, tuple)):
|
|
145
|
+
resolved = resolved[0]
|
|
146
|
+
file_path = Path(resolved)
|
|
147
|
+
if file_path.is_dir():
|
|
148
|
+
raise Http404("Asset not found")
|
|
149
|
+
return file_path
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _resolve_work_asset(user, path: str) -> Path:
|
|
153
|
+
if not (user and getattr(user, "is_authenticated", False)):
|
|
154
|
+
raise PermissionDenied
|
|
155
|
+
normalized = path.lstrip("/")
|
|
156
|
+
if not normalized:
|
|
157
|
+
raise Http404("Asset not found")
|
|
158
|
+
username = getattr(user, "get_username", None)
|
|
159
|
+
if callable(username):
|
|
160
|
+
username = username()
|
|
161
|
+
else:
|
|
162
|
+
username = getattr(user, "username", "")
|
|
163
|
+
username_component = Path(str(username or user.pk)).name
|
|
164
|
+
base_work = Path(settings.BASE_DIR) / "work"
|
|
165
|
+
try:
|
|
166
|
+
user_dir = Path(safe_join(str(base_work), username_component))
|
|
167
|
+
asset_path = Path(safe_join(str(user_dir), normalized))
|
|
168
|
+
except SuspiciousFileOperation as exc:
|
|
169
|
+
logger.warning("Rejected suspicious work asset path: %s", normalized, exc_info=exc)
|
|
170
|
+
raise Http404("Asset not found") from exc
|
|
171
|
+
try:
|
|
172
|
+
user_dir_resolved = user_dir.resolve(strict=True)
|
|
173
|
+
except FileNotFoundError as exc:
|
|
174
|
+
logger.warning(
|
|
175
|
+
"Work directory missing for asset request: %s", user_dir, exc_info=exc
|
|
176
|
+
)
|
|
177
|
+
raise Http404("Asset not found") from exc
|
|
178
|
+
try:
|
|
179
|
+
asset_resolved = asset_path.resolve(strict=True)
|
|
180
|
+
except FileNotFoundError as exc:
|
|
181
|
+
raise Http404("Asset not found") from exc
|
|
182
|
+
try:
|
|
183
|
+
asset_resolved.relative_to(user_dir_resolved)
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
logger.warning(
|
|
186
|
+
"Rejected work asset outside directory: %s", asset_resolved, exc_info=exc
|
|
187
|
+
)
|
|
188
|
+
raise Http404("Asset not found") from exc
|
|
189
|
+
if asset_resolved.is_dir():
|
|
190
|
+
raise Http404("Asset not found")
|
|
191
|
+
return asset_resolved
|
|
85
192
|
from pages.utils import landing
|
|
86
193
|
from core.liveupdate import live_update
|
|
87
194
|
from django_otp import login as otp_login
|
|
@@ -487,19 +594,33 @@ def _locate_readme_document(role, doc: str | None, lang: str) -> SimpleNamespace
|
|
|
487
594
|
continue
|
|
488
595
|
candidates.append(candidate)
|
|
489
596
|
else:
|
|
597
|
+
default_readme = readme_base / "README.md"
|
|
598
|
+
root_default: Path | None = None
|
|
490
599
|
if lang:
|
|
491
600
|
candidates.append(readme_base / f"README.{lang}.md")
|
|
492
601
|
short = lang.split("-")[0]
|
|
493
602
|
if short != lang:
|
|
494
603
|
candidates.append(readme_base / f"README.{short}.md")
|
|
495
|
-
candidates.append(readme_base / "README.md")
|
|
496
604
|
if readme_base != root_base:
|
|
605
|
+
candidates.append(default_readme)
|
|
497
606
|
if lang:
|
|
498
607
|
candidates.append(root_base / f"README.{lang}.md")
|
|
499
608
|
short = lang.split("-")[0]
|
|
500
609
|
if short != lang:
|
|
501
610
|
candidates.append(root_base / f"README.{short}.md")
|
|
502
|
-
|
|
611
|
+
root_default = root_base / "README.md"
|
|
612
|
+
else:
|
|
613
|
+
root_default = default_readme
|
|
614
|
+
locale_base = root_base / "locale"
|
|
615
|
+
if locale_base.exists():
|
|
616
|
+
if lang:
|
|
617
|
+
candidates.append(locale_base / f"README.{lang}.md")
|
|
618
|
+
short = lang.split("-")[0]
|
|
619
|
+
if short != lang:
|
|
620
|
+
candidates.append(locale_base / f"README.{short}.md")
|
|
621
|
+
candidates.append(locale_base / "README.md")
|
|
622
|
+
if root_default is not None:
|
|
623
|
+
candidates.append(root_default)
|
|
503
624
|
|
|
504
625
|
readme_file = next((p for p in candidates if p.exists()), None)
|
|
505
626
|
if readme_file is None:
|
|
@@ -552,6 +673,44 @@ def _render_readme(request, role, doc: str | None = None):
|
|
|
552
673
|
return response
|
|
553
674
|
|
|
554
675
|
|
|
676
|
+
def readme_asset(request, source: str, asset: str):
|
|
677
|
+
source_normalized = (source or "").lower()
|
|
678
|
+
if source_normalized == "static":
|
|
679
|
+
file_path = _resolve_static_asset(asset)
|
|
680
|
+
elif source_normalized == "work":
|
|
681
|
+
file_path = _resolve_work_asset(getattr(request, "user", None), asset)
|
|
682
|
+
else:
|
|
683
|
+
raise Http404("Asset not found")
|
|
684
|
+
|
|
685
|
+
if not file_path.exists() or not file_path.is_file():
|
|
686
|
+
raise Http404("Asset not found")
|
|
687
|
+
|
|
688
|
+
extension = file_path.suffix.lower()
|
|
689
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
690
|
+
raise Http404("Asset not found")
|
|
691
|
+
|
|
692
|
+
try:
|
|
693
|
+
file_handle = file_path.open("rb")
|
|
694
|
+
except OSError as exc: # pragma: no cover - unexpected filesystem error
|
|
695
|
+
logger.warning("Unable to open asset %s", file_path, exc_info=exc)
|
|
696
|
+
raise Http404("Asset not found") from exc
|
|
697
|
+
|
|
698
|
+
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
699
|
+
response = FileResponse(file_handle, content_type=content_type)
|
|
700
|
+
try:
|
|
701
|
+
response["Content-Length"] = str(file_path.stat().st_size)
|
|
702
|
+
except OSError: # pragma: no cover - filesystem race
|
|
703
|
+
pass
|
|
704
|
+
|
|
705
|
+
if source_normalized == "work":
|
|
706
|
+
patch_cache_control(response, private=True, no_store=True)
|
|
707
|
+
patch_vary_headers(response, ["Cookie"])
|
|
708
|
+
else:
|
|
709
|
+
patch_cache_control(response, public=True, max_age=3600)
|
|
710
|
+
|
|
711
|
+
return response
|
|
712
|
+
|
|
713
|
+
|
|
555
714
|
class MarkdownDocumentForm(forms.Form):
|
|
556
715
|
content = forms.CharField(
|
|
557
716
|
widget=forms.Textarea(
|
|
@@ -1269,6 +1428,25 @@ def client_report(request):
|
|
|
1269
1428
|
return render(request, "pages/client_report.html", context)
|
|
1270
1429
|
|
|
1271
1430
|
|
|
1431
|
+
def _get_request_language_code(request) -> str:
|
|
1432
|
+
language_code = ""
|
|
1433
|
+
if hasattr(request, "session"):
|
|
1434
|
+
language_code = request.session.get(LANGUAGE_SESSION_KEY, "")
|
|
1435
|
+
if not language_code:
|
|
1436
|
+
cookie_name = getattr(settings, "LANGUAGE_COOKIE_NAME", "django_language")
|
|
1437
|
+
language_code = request.COOKIES.get(cookie_name, "")
|
|
1438
|
+
if not language_code:
|
|
1439
|
+
language_code = getattr(request, "LANGUAGE_CODE", "") or ""
|
|
1440
|
+
if not language_code:
|
|
1441
|
+
language_code = get_language() or ""
|
|
1442
|
+
|
|
1443
|
+
language_code = language_code.strip()
|
|
1444
|
+
if not language_code:
|
|
1445
|
+
return ""
|
|
1446
|
+
|
|
1447
|
+
return language_code.replace("_", "-").lower()[:15]
|
|
1448
|
+
|
|
1449
|
+
|
|
1272
1450
|
@require_POST
|
|
1273
1451
|
def submit_user_story(request):
|
|
1274
1452
|
throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
|
|
@@ -1290,12 +1468,12 @@ def submit_user_story(request):
|
|
|
1290
1468
|
)
|
|
1291
1469
|
|
|
1292
1470
|
data = request.POST.copy()
|
|
1293
|
-
if request.user.is_authenticated
|
|
1471
|
+
if request.user.is_authenticated:
|
|
1294
1472
|
data["name"] = request.user.get_username()[:40]
|
|
1295
1473
|
if not data.get("path"):
|
|
1296
1474
|
data["path"] = request.get_full_path()
|
|
1297
1475
|
|
|
1298
|
-
form = UserStoryForm(data)
|
|
1476
|
+
form = UserStoryForm(data, user=request.user)
|
|
1299
1477
|
if request.user.is_authenticated:
|
|
1300
1478
|
form.instance.user = request.user
|
|
1301
1479
|
|
|
@@ -1304,8 +1482,7 @@ def submit_user_story(request):
|
|
|
1304
1482
|
if request.user.is_authenticated:
|
|
1305
1483
|
story.user = request.user
|
|
1306
1484
|
story.owner = request.user
|
|
1307
|
-
|
|
1308
|
-
story.name = request.user.get_username()[:40]
|
|
1485
|
+
story.name = request.user.get_username()[:40]
|
|
1309
1486
|
if not story.name:
|
|
1310
1487
|
story.name = str(_("Anonymous"))[:40]
|
|
1311
1488
|
story.path = (story.path or request.get_full_path())[:500]
|
|
@@ -1313,7 +1490,83 @@ def submit_user_story(request):
|
|
|
1313
1490
|
story.user_agent = request.META.get("HTTP_USER_AGENT", "")
|
|
1314
1491
|
story.ip_address = client_ip or None
|
|
1315
1492
|
story.is_user_data = True
|
|
1493
|
+
language_code = _get_request_language_code(request)
|
|
1494
|
+
if language_code:
|
|
1495
|
+
story.language_code = language_code
|
|
1316
1496
|
story.save()
|
|
1497
|
+
if request.user.is_authenticated and request.user.is_superuser:
|
|
1498
|
+
comment_text = (story.comments or "").strip()
|
|
1499
|
+
prefix = "Triage "
|
|
1500
|
+
request_field = Todo._meta.get_field("request")
|
|
1501
|
+
available_length = max(request_field.max_length - len(prefix), 0)
|
|
1502
|
+
if available_length > 0 and comment_text:
|
|
1503
|
+
summary = Truncator(comment_text).chars(
|
|
1504
|
+
available_length, truncate="…"
|
|
1505
|
+
)
|
|
1506
|
+
else:
|
|
1507
|
+
summary = comment_text[:available_length]
|
|
1508
|
+
todo_request = f"{prefix}{summary}".strip()
|
|
1509
|
+
user_is_authenticated = request.user.is_authenticated
|
|
1510
|
+
node = Node.get_local()
|
|
1511
|
+
existing_todo = (
|
|
1512
|
+
Todo.objects.filter(request__iexact=todo_request, is_deleted=False)
|
|
1513
|
+
.order_by("pk")
|
|
1514
|
+
.first()
|
|
1515
|
+
)
|
|
1516
|
+
if existing_todo:
|
|
1517
|
+
update_fields: set[str] = set()
|
|
1518
|
+
if node and existing_todo.origin_node_id != node.pk:
|
|
1519
|
+
existing_todo.origin_node = node
|
|
1520
|
+
update_fields.add("origin_node")
|
|
1521
|
+
if existing_todo.original_user_id != request.user.pk:
|
|
1522
|
+
existing_todo.original_user = request.user
|
|
1523
|
+
update_fields.add("original_user")
|
|
1524
|
+
if (
|
|
1525
|
+
existing_todo.original_user_is_authenticated
|
|
1526
|
+
!= user_is_authenticated
|
|
1527
|
+
):
|
|
1528
|
+
existing_todo.original_user_is_authenticated = (
|
|
1529
|
+
user_is_authenticated
|
|
1530
|
+
)
|
|
1531
|
+
update_fields.add("original_user_is_authenticated")
|
|
1532
|
+
if not existing_todo.is_user_data:
|
|
1533
|
+
existing_todo.is_user_data = True
|
|
1534
|
+
update_fields.add("is_user_data")
|
|
1535
|
+
if update_fields:
|
|
1536
|
+
existing_todo.save(update_fields=tuple(update_fields))
|
|
1537
|
+
else:
|
|
1538
|
+
Todo.objects.create(
|
|
1539
|
+
request=todo_request,
|
|
1540
|
+
origin_node=node,
|
|
1541
|
+
original_user=request.user,
|
|
1542
|
+
original_user_is_authenticated=user_is_authenticated,
|
|
1543
|
+
is_user_data=True,
|
|
1544
|
+
)
|
|
1545
|
+
if story.take_screenshot:
|
|
1546
|
+
screenshot_url = request.META.get("HTTP_REFERER", "")
|
|
1547
|
+
parsed = urlparse(screenshot_url)
|
|
1548
|
+
if not (parsed.scheme and parsed.netloc):
|
|
1549
|
+
target_path = story.path or request.get_full_path() or "/"
|
|
1550
|
+
screenshot_url = request.build_absolute_uri(target_path)
|
|
1551
|
+
try:
|
|
1552
|
+
screenshot_path = capture_screenshot(screenshot_url)
|
|
1553
|
+
except Exception: # pragma: no cover - best effort capture
|
|
1554
|
+
logger.exception("Failed to capture screenshot for user story %s", story.pk)
|
|
1555
|
+
else:
|
|
1556
|
+
try:
|
|
1557
|
+
sample = save_screenshot(
|
|
1558
|
+
screenshot_path,
|
|
1559
|
+
method="USER_STORY",
|
|
1560
|
+
user=story.user if story.user_id else None,
|
|
1561
|
+
)
|
|
1562
|
+
except Exception: # pragma: no cover - best effort persistence
|
|
1563
|
+
logger.exception(
|
|
1564
|
+
"Failed to persist screenshot for user story %s", story.pk
|
|
1565
|
+
)
|
|
1566
|
+
else:
|
|
1567
|
+
if sample is not None:
|
|
1568
|
+
story.screenshot = sample
|
|
1569
|
+
story.save(update_fields=["screenshot"])
|
|
1317
1570
|
return JsonResponse({"success": True})
|
|
1318
1571
|
|
|
1319
1572
|
return JsonResponse({"success": False, "errors": form.errors}, status=400)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|