arthexis 0.1.21__py3-none-any.whl → 0.1.23__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.

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
@@ -15,15 +16,25 @@ from django.contrib import admin
15
16
  from django.contrib import messages
16
17
  from django.contrib.admin.views.decorators import staff_member_required
17
18
  from django.contrib.auth import get_user_model, login
19
+ from django.contrib.auth.decorators import login_required
18
20
  from django.contrib.auth.tokens import default_token_generator
19
21
  from django.contrib.auth.views import LoginView
20
22
  from django import forms
21
23
  from django.apps import apps as django_apps
22
24
  from utils.decorators import security_group_required
23
25
  from utils.sites import get_site
24
- from django.http import Http404, HttpResponse, JsonResponse
26
+ from django.contrib.staticfiles import finders
27
+ from django.http import (
28
+ FileResponse,
29
+ Http404,
30
+ HttpResponse,
31
+ HttpResponseForbidden,
32
+ HttpResponseRedirect,
33
+ JsonResponse,
34
+ )
25
35
  from django.shortcuts import get_object_or_404, redirect, render
26
36
  from nodes.models import Node
37
+ from nodes.utils import capture_screenshot, save_screenshot
27
38
  from django.template import loader
28
39
  from django.template.response import TemplateResponse
29
40
  from django.test import RequestFactory, signals as test_signals
@@ -33,14 +44,19 @@ from django.utils.encoding import force_bytes, force_str
33
44
  from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
34
45
  from core import mailer, public_wifi
35
46
  from core.backends import TOTP_DEVICE_NAME
36
- from django.utils.translation import gettext as _
47
+ from django.utils.translation import get_language, gettext as _
48
+
49
+ try: # pragma: no cover - compatibility shim for Django versions without constant
50
+ from django.utils.translation import LANGUAGE_SESSION_KEY
51
+ except ImportError: # pragma: no cover - fallback when constant is unavailable
52
+ LANGUAGE_SESSION_KEY = "_language"
37
53
  from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
38
54
  from django.views.decorators.http import require_POST
39
55
  from django.core.cache import cache
40
56
  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
57
+ from django.utils.cache import patch_cache_control, patch_vary_headers
58
+ from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
59
+ from django.utils.text import slugify, Truncator
44
60
  from django.core.validators import EmailValidator
45
61
  from django.db.models import Q
46
62
  from core.models import (
@@ -48,6 +64,7 @@ from core.models import (
48
64
  ClientReport,
49
65
  ClientReportSchedule,
50
66
  SecurityGroup,
67
+ Todo,
51
68
  )
52
69
 
53
70
  try: # pragma: no cover - optional dependency guard
@@ -58,16 +75,34 @@ except ImportError: # pragma: no cover - handled gracefully in views
58
75
  CalledProcessError = ExecutableNotFound = None
59
76
 
60
77
  import markdown
78
+ from django.utils._os import safe_join
61
79
 
62
80
 
63
81
  MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
64
82
 
83
+ MARKDOWN_IMAGE_PATTERN = re.compile(
84
+ r"(?P<prefix><img\b[^>]*\bsrc=[\"\'])(?P<scheme>(?:static|work))://(?P<path>[^\"\']+)(?P<suffix>[\"\'])",
85
+ re.IGNORECASE,
86
+ )
87
+
88
+ ALLOWED_IMAGE_EXTENSIONS = {
89
+ ".apng",
90
+ ".avif",
91
+ ".gif",
92
+ ".jpg",
93
+ ".jpeg",
94
+ ".png",
95
+ ".svg",
96
+ ".webp",
97
+ }
98
+
65
99
 
66
100
  def _render_markdown_with_toc(text: str) -> tuple[str, str]:
67
101
  """Render ``text`` to HTML and return the HTML and stripped TOC."""
68
102
 
69
103
  md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
70
104
  html = md.convert(text)
105
+ html = _rewrite_markdown_asset_links(html)
71
106
  toc_html = md.toc
72
107
  toc_html = _strip_toc_wrapper(toc_html)
73
108
  return html, toc_html
@@ -82,6 +117,86 @@ def _strip_toc_wrapper(toc_html: str) -> str:
82
117
  if toc_html.endswith("</div>"):
83
118
  toc_html = toc_html[: -len("</div>")]
84
119
  return toc_html.strip()
120
+
121
+
122
+ def _rewrite_markdown_asset_links(html: str) -> str:
123
+ """Rewrite asset links that reference local asset schemes."""
124
+
125
+ def _replace(match: re.Match[str]) -> str:
126
+ scheme = match.group("scheme").lower()
127
+ asset_path = match.group("path").lstrip("/")
128
+ if not asset_path:
129
+ return match.group(0)
130
+ extension = Path(asset_path).suffix.lower()
131
+ if extension not in ALLOWED_IMAGE_EXTENSIONS:
132
+ return match.group(0)
133
+ try:
134
+ asset_url = reverse(
135
+ "pages:readme-asset",
136
+ kwargs={"source": scheme, "asset": asset_path},
137
+ )
138
+ except NoReverseMatch:
139
+ return match.group(0)
140
+ return f"{match.group('prefix')}{escape(asset_url)}{match.group('suffix')}"
141
+
142
+ return MARKDOWN_IMAGE_PATTERN.sub(_replace, html)
143
+
144
+
145
+ def _resolve_static_asset(path: str) -> Path:
146
+ normalized = path.lstrip("/")
147
+ if not normalized:
148
+ raise Http404("Asset not found")
149
+ resolved = finders.find(normalized)
150
+ if not resolved:
151
+ raise Http404("Asset not found")
152
+ if isinstance(resolved, (list, tuple)):
153
+ resolved = resolved[0]
154
+ file_path = Path(resolved)
155
+ if file_path.is_dir():
156
+ raise Http404("Asset not found")
157
+ return file_path
158
+
159
+
160
+ def _resolve_work_asset(user, path: str) -> Path:
161
+ if not (user and getattr(user, "is_authenticated", False)):
162
+ raise PermissionDenied
163
+ normalized = path.lstrip("/")
164
+ if not normalized:
165
+ raise Http404("Asset not found")
166
+ username = getattr(user, "get_username", None)
167
+ if callable(username):
168
+ username = username()
169
+ else:
170
+ username = getattr(user, "username", "")
171
+ username_component = Path(str(username or user.pk)).name
172
+ base_work = Path(settings.BASE_DIR) / "work"
173
+ try:
174
+ user_dir = Path(safe_join(str(base_work), username_component))
175
+ asset_path = Path(safe_join(str(user_dir), normalized))
176
+ except SuspiciousFileOperation as exc:
177
+ logger.warning("Rejected suspicious work asset path: %s", normalized, exc_info=exc)
178
+ raise Http404("Asset not found") from exc
179
+ try:
180
+ user_dir_resolved = user_dir.resolve(strict=True)
181
+ except FileNotFoundError as exc:
182
+ logger.warning(
183
+ "Work directory missing for asset request: %s", user_dir, exc_info=exc
184
+ )
185
+ raise Http404("Asset not found") from exc
186
+ try:
187
+ asset_resolved = asset_path.resolve(strict=True)
188
+ except FileNotFoundError as exc:
189
+ raise Http404("Asset not found") from exc
190
+ try:
191
+ asset_resolved.relative_to(user_dir_resolved)
192
+ except ValueError as exc:
193
+ logger.warning(
194
+ "Rejected work asset outside directory: %s", asset_resolved, exc_info=exc
195
+ )
196
+ raise Http404("Asset not found") from exc
197
+ if asset_resolved.is_dir():
198
+ raise Http404("Asset not found")
199
+ return asset_resolved
85
200
  from pages.utils import landing
86
201
  from core.liveupdate import live_update
87
202
  from django_otp import login as otp_login
@@ -487,19 +602,33 @@ def _locate_readme_document(role, doc: str | None, lang: str) -> SimpleNamespace
487
602
  continue
488
603
  candidates.append(candidate)
489
604
  else:
605
+ default_readme = readme_base / "README.md"
606
+ root_default: Path | None = None
490
607
  if lang:
491
608
  candidates.append(readme_base / f"README.{lang}.md")
492
609
  short = lang.split("-")[0]
493
610
  if short != lang:
494
611
  candidates.append(readme_base / f"README.{short}.md")
495
- candidates.append(readme_base / "README.md")
496
612
  if readme_base != root_base:
613
+ candidates.append(default_readme)
497
614
  if lang:
498
615
  candidates.append(root_base / f"README.{lang}.md")
499
616
  short = lang.split("-")[0]
500
617
  if short != lang:
501
618
  candidates.append(root_base / f"README.{short}.md")
502
- candidates.append(root_base / "README.md")
619
+ root_default = root_base / "README.md"
620
+ else:
621
+ root_default = default_readme
622
+ locale_base = root_base / "locale"
623
+ if locale_base.exists():
624
+ if lang:
625
+ candidates.append(locale_base / f"README.{lang}.md")
626
+ short = lang.split("-")[0]
627
+ if short != lang:
628
+ candidates.append(locale_base / f"README.{short}.md")
629
+ candidates.append(locale_base / "README.md")
630
+ if root_default is not None:
631
+ candidates.append(root_default)
503
632
 
504
633
  readme_file = next((p for p in candidates if p.exists()), None)
505
634
  if readme_file is None:
@@ -552,6 +681,44 @@ def _render_readme(request, role, doc: str | None = None):
552
681
  return response
553
682
 
554
683
 
684
+ def readme_asset(request, source: str, asset: str):
685
+ source_normalized = (source or "").lower()
686
+ if source_normalized == "static":
687
+ file_path = _resolve_static_asset(asset)
688
+ elif source_normalized == "work":
689
+ file_path = _resolve_work_asset(getattr(request, "user", None), asset)
690
+ else:
691
+ raise Http404("Asset not found")
692
+
693
+ if not file_path.exists() or not file_path.is_file():
694
+ raise Http404("Asset not found")
695
+
696
+ extension = file_path.suffix.lower()
697
+ if extension not in ALLOWED_IMAGE_EXTENSIONS:
698
+ raise Http404("Asset not found")
699
+
700
+ try:
701
+ file_handle = file_path.open("rb")
702
+ except OSError as exc: # pragma: no cover - unexpected filesystem error
703
+ logger.warning("Unable to open asset %s", file_path, exc_info=exc)
704
+ raise Http404("Asset not found") from exc
705
+
706
+ content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
707
+ response = FileResponse(file_handle, content_type=content_type)
708
+ try:
709
+ response["Content-Length"] = str(file_path.stat().st_size)
710
+ except OSError: # pragma: no cover - filesystem race
711
+ pass
712
+
713
+ if source_normalized == "work":
714
+ patch_cache_control(response, private=True, no_store=True)
715
+ patch_vary_headers(response, ["Cookie"])
716
+ else:
717
+ patch_cache_control(response, public=True, max_age=3600)
718
+
719
+ return response
720
+
721
+
555
722
  class MarkdownDocumentForm(forms.Form):
556
723
  content = forms.CharField(
557
724
  widget=forms.Textarea(
@@ -1121,10 +1288,10 @@ class ClientReportForm(forms.Form):
1121
1288
  initial=ClientReportSchedule.PERIODICITY_NONE,
1122
1289
  help_text=_("Defines how often the report should be generated automatically."),
1123
1290
  )
1124
- disable_emails = forms.BooleanField(
1125
- label=_("Disable email delivery"),
1291
+ enable_emails = forms.BooleanField(
1292
+ label=_("Enable email delivery"),
1126
1293
  required=False,
1127
- help_text=_("Generate files without sending emails."),
1294
+ help_text=_("Send the report via email to the recipients listed above."),
1128
1295
  )
1129
1296
 
1130
1297
  def __init__(self, *args, request=None, **kwargs):
@@ -1227,12 +1394,17 @@ def client_report(request):
1227
1394
  owner = form.cleaned_data.get("owner")
1228
1395
  if not owner and request.user.is_authenticated:
1229
1396
  owner = request.user
1397
+ enable_emails = form.cleaned_data.get("enable_emails", False)
1398
+ disable_emails = not enable_emails
1399
+ recipients = (
1400
+ form.cleaned_data.get("destinations") if enable_emails else []
1401
+ )
1230
1402
  report = ClientReport.generate(
1231
1403
  form.cleaned_data["start"],
1232
1404
  form.cleaned_data["end"],
1233
1405
  owner=owner,
1234
- recipients=form.cleaned_data.get("destinations"),
1235
- disable_emails=form.cleaned_data.get("disable_emails", False),
1406
+ recipients=recipients,
1407
+ disable_emails=disable_emails,
1236
1408
  )
1237
1409
  report.store_local_copy()
1238
1410
  recurrence = form.cleaned_data.get("recurrence")
@@ -1241,8 +1413,8 @@ def client_report(request):
1241
1413
  owner=owner,
1242
1414
  created_by=request.user if request.user.is_authenticated else None,
1243
1415
  periodicity=recurrence,
1244
- email_recipients=form.cleaned_data.get("destinations", []),
1245
- disable_emails=form.cleaned_data.get("disable_emails", False),
1416
+ email_recipients=recipients,
1417
+ disable_emails=disable_emails,
1246
1418
  )
1247
1419
  report.schedule = schedule
1248
1420
  report.save(update_fields=["schedule"])
@@ -1252,6 +1424,27 @@ def client_report(request):
1252
1424
  "Client report schedule created; future reports will be generated automatically."
1253
1425
  ),
1254
1426
  )
1427
+ if disable_emails:
1428
+ messages.success(
1429
+ request,
1430
+ _(
1431
+ "Consumer report generated. The download will begin automatically."
1432
+ ),
1433
+ )
1434
+ redirect_url = f"{reverse('pages:client-report')}?download={report.pk}"
1435
+ return HttpResponseRedirect(redirect_url)
1436
+ download_url = None
1437
+ download_param = request.GET.get("download")
1438
+ if download_param and request.user.is_authenticated:
1439
+ try:
1440
+ download_id = int(download_param)
1441
+ except (TypeError, ValueError):
1442
+ download_id = None
1443
+ if download_id:
1444
+ download_url = reverse(
1445
+ "pages:client-report-download", args=[download_id]
1446
+ )
1447
+
1255
1448
  try:
1256
1449
  login_url = reverse("pages:login")
1257
1450
  except NoReverseMatch:
@@ -1265,10 +1458,71 @@ def client_report(request):
1265
1458
  "report": report,
1266
1459
  "schedule": schedule,
1267
1460
  "login_url": login_url,
1461
+ "download_url": download_url,
1462
+ "previous_reports": _client_report_history(request),
1268
1463
  }
1269
1464
  return render(request, "pages/client_report.html", context)
1270
1465
 
1271
1466
 
1467
+ @login_required
1468
+ def client_report_download(request, report_id: int):
1469
+ report = get_object_or_404(ClientReport, pk=report_id)
1470
+ if not request.user.is_staff and report.owner_id != request.user.pk:
1471
+ return HttpResponseForbidden(
1472
+ _("You do not have permission to download this report.")
1473
+ )
1474
+ pdf_path = report.ensure_pdf()
1475
+ if not pdf_path.exists():
1476
+ raise Http404(_("Report file unavailable."))
1477
+ filename = f"consumer-report-{report.start_date}-{report.end_date}.pdf"
1478
+ response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
1479
+ response["Content-Disposition"] = f'attachment; filename="{filename}"'
1480
+ return response
1481
+
1482
+
1483
+ def _client_report_history(request, limit: int = 20):
1484
+ if not request.user.is_authenticated:
1485
+ return []
1486
+ qs = ClientReport.objects.order_by("-created_on")
1487
+ if not request.user.is_staff:
1488
+ qs = qs.filter(owner=request.user)
1489
+ history = []
1490
+ for report in qs[:limit]:
1491
+ totals = report.rows_for_display.get("totals", {})
1492
+ history.append(
1493
+ {
1494
+ "instance": report,
1495
+ "download_url": reverse("pages:client-report-download", args=[report.pk]),
1496
+ "email_enabled": not report.disable_emails,
1497
+ "recipients": report.recipients or [],
1498
+ "totals": {
1499
+ "total_kw": totals.get("total_kw", 0.0),
1500
+ "total_kw_period": totals.get("total_kw_period", 0.0),
1501
+ },
1502
+ }
1503
+ )
1504
+ return history
1505
+
1506
+
1507
+ def _get_request_language_code(request) -> str:
1508
+ language_code = ""
1509
+ if hasattr(request, "session"):
1510
+ language_code = request.session.get(LANGUAGE_SESSION_KEY, "")
1511
+ if not language_code:
1512
+ cookie_name = getattr(settings, "LANGUAGE_COOKIE_NAME", "django_language")
1513
+ language_code = request.COOKIES.get(cookie_name, "")
1514
+ if not language_code:
1515
+ language_code = getattr(request, "LANGUAGE_CODE", "") or ""
1516
+ if not language_code:
1517
+ language_code = get_language() or ""
1518
+
1519
+ language_code = language_code.strip()
1520
+ if not language_code:
1521
+ return ""
1522
+
1523
+ return language_code.replace("_", "-").lower()[:15]
1524
+
1525
+
1272
1526
  @require_POST
1273
1527
  def submit_user_story(request):
1274
1528
  throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
@@ -1290,12 +1544,12 @@ def submit_user_story(request):
1290
1544
  )
1291
1545
 
1292
1546
  data = request.POST.copy()
1293
- if request.user.is_authenticated and not data.get("name"):
1547
+ if request.user.is_authenticated:
1294
1548
  data["name"] = request.user.get_username()[:40]
1295
1549
  if not data.get("path"):
1296
1550
  data["path"] = request.get_full_path()
1297
1551
 
1298
- form = UserStoryForm(data)
1552
+ form = UserStoryForm(data, user=request.user)
1299
1553
  if request.user.is_authenticated:
1300
1554
  form.instance.user = request.user
1301
1555
 
@@ -1304,8 +1558,7 @@ def submit_user_story(request):
1304
1558
  if request.user.is_authenticated:
1305
1559
  story.user = request.user
1306
1560
  story.owner = request.user
1307
- if not story.name:
1308
- story.name = request.user.get_username()[:40]
1561
+ story.name = request.user.get_username()[:40]
1309
1562
  if not story.name:
1310
1563
  story.name = str(_("Anonymous"))[:40]
1311
1564
  story.path = (story.path or request.get_full_path())[:500]
@@ -1313,7 +1566,83 @@ def submit_user_story(request):
1313
1566
  story.user_agent = request.META.get("HTTP_USER_AGENT", "")
1314
1567
  story.ip_address = client_ip or None
1315
1568
  story.is_user_data = True
1569
+ language_code = _get_request_language_code(request)
1570
+ if language_code:
1571
+ story.language_code = language_code
1316
1572
  story.save()
1573
+ if request.user.is_authenticated and request.user.is_superuser:
1574
+ comment_text = (story.comments or "").strip()
1575
+ prefix = "Triage "
1576
+ request_field = Todo._meta.get_field("request")
1577
+ available_length = max(request_field.max_length - len(prefix), 0)
1578
+ if available_length > 0 and comment_text:
1579
+ summary = Truncator(comment_text).chars(
1580
+ available_length, truncate="…"
1581
+ )
1582
+ else:
1583
+ summary = comment_text[:available_length]
1584
+ todo_request = f"{prefix}{summary}".strip()
1585
+ user_is_authenticated = request.user.is_authenticated
1586
+ node = Node.get_local()
1587
+ existing_todo = (
1588
+ Todo.objects.filter(request__iexact=todo_request, is_deleted=False)
1589
+ .order_by("pk")
1590
+ .first()
1591
+ )
1592
+ if existing_todo:
1593
+ update_fields: set[str] = set()
1594
+ if node and existing_todo.origin_node_id != node.pk:
1595
+ existing_todo.origin_node = node
1596
+ update_fields.add("origin_node")
1597
+ if existing_todo.original_user_id != request.user.pk:
1598
+ existing_todo.original_user = request.user
1599
+ update_fields.add("original_user")
1600
+ if (
1601
+ existing_todo.original_user_is_authenticated
1602
+ != user_is_authenticated
1603
+ ):
1604
+ existing_todo.original_user_is_authenticated = (
1605
+ user_is_authenticated
1606
+ )
1607
+ update_fields.add("original_user_is_authenticated")
1608
+ if not existing_todo.is_user_data:
1609
+ existing_todo.is_user_data = True
1610
+ update_fields.add("is_user_data")
1611
+ if update_fields:
1612
+ existing_todo.save(update_fields=tuple(update_fields))
1613
+ else:
1614
+ Todo.objects.create(
1615
+ request=todo_request,
1616
+ origin_node=node,
1617
+ original_user=request.user,
1618
+ original_user_is_authenticated=user_is_authenticated,
1619
+ is_user_data=True,
1620
+ )
1621
+ if story.take_screenshot:
1622
+ screenshot_url = request.META.get("HTTP_REFERER", "")
1623
+ parsed = urlparse(screenshot_url)
1624
+ if not (parsed.scheme and parsed.netloc):
1625
+ target_path = story.path or request.get_full_path() or "/"
1626
+ screenshot_url = request.build_absolute_uri(target_path)
1627
+ try:
1628
+ screenshot_path = capture_screenshot(screenshot_url)
1629
+ except Exception: # pragma: no cover - best effort capture
1630
+ logger.exception("Failed to capture screenshot for user story %s", story.pk)
1631
+ else:
1632
+ try:
1633
+ sample = save_screenshot(
1634
+ screenshot_path,
1635
+ method="USER_STORY",
1636
+ user=story.user if story.user_id else None,
1637
+ )
1638
+ except Exception: # pragma: no cover - best effort persistence
1639
+ logger.exception(
1640
+ "Failed to persist screenshot for user story %s", story.pk
1641
+ )
1642
+ else:
1643
+ if sample is not None:
1644
+ story.screenshot = sample
1645
+ story.save(update_fields=["screenshot"])
1317
1646
  return JsonResponse({"success": True})
1318
1647
 
1319
1648
  return JsonResponse({"success": False, "errors": form.errors}, status=400)