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.

ocpp/views.py CHANGED
@@ -1,13 +1,13 @@
1
1
  import json
2
2
  import uuid
3
- from datetime import datetime, timedelta, timezone as dt_timezone
4
- from datetime import datetime, time, timedelta
3
+ from datetime import datetime, time, timedelta, timezone as dt_timezone
5
4
  from types import SimpleNamespace
6
5
 
7
6
  from django.http import Http404, HttpResponse, JsonResponse
8
7
  from django.http.request import split_domain_port
9
8
  from django.views.decorators.csrf import csrf_exempt
10
9
  from django.shortcuts import get_object_or_404, render, resolve_url
10
+ from django.template.loader import render_to_string
11
11
  from django.core.paginator import Paginator
12
12
  from django.contrib.auth.decorators import login_required
13
13
  from django.contrib.auth.views import redirect_to_login
@@ -15,7 +15,7 @@ from django.utils.translation import gettext_lazy as _, gettext, ngettext
15
15
  from django.utils.text import slugify
16
16
  from django.urls import NoReverseMatch, reverse
17
17
  from django.conf import settings
18
- from django.utils import translation, timezone
18
+ from django.utils import translation, timezone, formats
19
19
  from django.core.exceptions import ValidationError
20
20
 
21
21
  from asgiref.sync import async_to_sync
@@ -27,6 +27,8 @@ from nodes.models import Node
27
27
  from pages.utils import landing
28
28
  from core.liveupdate import live_update
29
29
 
30
+ from django.utils.dateparse import parse_datetime
31
+
30
32
  from . import store
31
33
  from .models import Transaction, Charger, DataTransferMessage, RFID
32
34
  from .evcs import (
@@ -337,6 +339,272 @@ def _connector_overview(
337
339
  return overview
338
340
 
339
341
 
342
+ def _normalize_timeline_status(value: str | None) -> str | None:
343
+ """Normalize raw charger status strings into timeline buckets."""
344
+
345
+ normalized = (value or "").strip().lower()
346
+ if not normalized:
347
+ return None
348
+ charging_states = {
349
+ "charging",
350
+ "finishing",
351
+ "suspendedev",
352
+ "suspendedevse",
353
+ "occupied",
354
+ }
355
+ available_states = {"available", "preparing", "reserved"}
356
+ offline_states = {"faulted", "unavailable", "outofservice"}
357
+ if normalized in charging_states:
358
+ return "charging"
359
+ if normalized in offline_states:
360
+ return "offline"
361
+ if normalized in available_states:
362
+ return "available"
363
+ # Treat other states as available for the initial implementation.
364
+ return "available"
365
+
366
+
367
+ def _timeline_labels() -> dict[str, str]:
368
+ """Return translated labels for timeline statuses."""
369
+
370
+ return {
371
+ "offline": gettext("Offline"),
372
+ "available": gettext("Available"),
373
+ "charging": gettext("Charging"),
374
+ }
375
+
376
+
377
+ def _format_segment_range(start: datetime, end: datetime) -> tuple[str, str]:
378
+ """Return localized display values for a timeline range."""
379
+
380
+ start_display = formats.date_format(
381
+ timezone.localtime(start), "SHORT_DATETIME_FORMAT"
382
+ )
383
+ end_display = formats.date_format(timezone.localtime(end), "SHORT_DATETIME_FORMAT")
384
+ return start_display, end_display
385
+
386
+
387
+ def _collect_status_events(
388
+ charger: Charger,
389
+ connector: Charger,
390
+ window_start: datetime,
391
+ window_end: datetime,
392
+ ) -> tuple[list[tuple[datetime, str]], tuple[datetime, str] | None]:
393
+ """Parse log entries into ordered status events for the connector."""
394
+
395
+ connector_id = connector.connector_id
396
+ serial = connector.charger_id
397
+ keys = [store.identity_key(serial, connector_id)]
398
+ if connector_id is not None:
399
+ keys.append(store.identity_key(serial, None))
400
+ keys.append(store.pending_key(serial))
401
+
402
+ seen_entries: set[str] = set()
403
+ events: list[tuple[datetime, str]] = []
404
+ latest_before_window: tuple[datetime, str] | None = None
405
+
406
+ for key in keys:
407
+ for entry in store.get_logs(key, log_type="charger"):
408
+ if entry in seen_entries:
409
+ continue
410
+ seen_entries.add(entry)
411
+ if len(entry) < 24:
412
+ continue
413
+ timestamp_raw = entry[:23]
414
+ message = entry[24:].strip()
415
+ try:
416
+ log_timestamp = datetime.strptime(
417
+ timestamp_raw, "%Y-%m-%d %H:%M:%S.%f"
418
+ ).replace(tzinfo=dt_timezone.utc)
419
+ except ValueError:
420
+ continue
421
+
422
+ event_time = log_timestamp
423
+ status_bucket: str | None = None
424
+
425
+ if message.startswith("StatusNotification processed:"):
426
+ payload_text = message.split(":", 1)[1].strip()
427
+ try:
428
+ payload = json.loads(payload_text)
429
+ except json.JSONDecodeError:
430
+ continue
431
+ target_id = payload.get("connectorId")
432
+ if connector_id is not None:
433
+ try:
434
+ normalized_target = int(target_id)
435
+ except (TypeError, ValueError):
436
+ normalized_target = None
437
+ if normalized_target not in {connector_id, None}:
438
+ continue
439
+ raw_status = payload.get("status")
440
+ status_bucket = _normalize_timeline_status(
441
+ raw_status if isinstance(raw_status, str) else None
442
+ )
443
+ payload_timestamp = payload.get("timestamp")
444
+ if isinstance(payload_timestamp, str):
445
+ parsed = parse_datetime(payload_timestamp)
446
+ if parsed is not None:
447
+ if timezone.is_naive(parsed):
448
+ parsed = timezone.make_aware(parsed, timezone=dt_timezone.utc)
449
+ event_time = parsed
450
+ elif message.startswith("Connected"):
451
+ status_bucket = "available"
452
+ elif message.startswith("Closed"):
453
+ status_bucket = "offline"
454
+
455
+ if not status_bucket:
456
+ continue
457
+
458
+ if event_time < window_start:
459
+ if (
460
+ latest_before_window is None
461
+ or event_time > latest_before_window[0]
462
+ ):
463
+ latest_before_window = (event_time, status_bucket)
464
+ continue
465
+ if event_time > window_end:
466
+ continue
467
+ events.append((event_time, status_bucket))
468
+
469
+ events.sort(key=lambda item: item[0])
470
+
471
+ deduped_events: list[tuple[datetime, str]] = []
472
+ for event_time, state in events:
473
+ if deduped_events and deduped_events[-1][1] == state:
474
+ continue
475
+ deduped_events.append((event_time, state))
476
+
477
+ return deduped_events, latest_before_window
478
+
479
+
480
+ def _usage_timeline(
481
+ charger: Charger,
482
+ connector_overview: list[dict],
483
+ *,
484
+ now: datetime | None = None,
485
+ ) -> tuple[list[dict], tuple[str, str] | None]:
486
+ """Build usage timeline data for inactive chargers."""
487
+
488
+ if now is None:
489
+ now = timezone.now()
490
+ window_end = now
491
+ window_start = now - timedelta(days=7)
492
+
493
+ if charger.connector_id is not None:
494
+ connectors = [charger]
495
+ else:
496
+ connectors = [
497
+ item["charger"]
498
+ for item in connector_overview
499
+ if item.get("charger") and item["charger"].connector_id is not None
500
+ ]
501
+ if not connectors:
502
+ connectors = [
503
+ sibling
504
+ for sibling in _connector_set(charger)
505
+ if sibling.connector_id is not None
506
+ ]
507
+
508
+ seen_ids: set[int] = set()
509
+ labels = _timeline_labels()
510
+ timeline_entries: list[dict] = []
511
+ window_display: tuple[str, str] | None = None
512
+
513
+ if window_start < window_end:
514
+ window_display = _format_segment_range(window_start, window_end)
515
+
516
+ for connector in connectors:
517
+ if connector.connector_id is None:
518
+ continue
519
+ if connector.connector_id in seen_ids:
520
+ continue
521
+ seen_ids.add(connector.connector_id)
522
+
523
+ events, prior_event = _collect_status_events(
524
+ charger, connector, window_start, window_end
525
+ )
526
+ fallback_state = _normalize_timeline_status(connector.last_status)
527
+ if fallback_state is None:
528
+ fallback_state = (
529
+ "available"
530
+ if store.is_connected(connector.charger_id, connector.connector_id)
531
+ else "offline"
532
+ )
533
+ current_state = fallback_state
534
+ if prior_event is not None:
535
+ current_state = prior_event[1]
536
+ segments: list[dict] = []
537
+ previous_time = window_start
538
+ total_seconds = (window_end - window_start).total_seconds()
539
+
540
+ for event_time, state in events:
541
+ if event_time <= window_start:
542
+ current_state = state
543
+ continue
544
+ if event_time > window_end:
545
+ break
546
+ if state == current_state:
547
+ continue
548
+ segment_start = max(previous_time, window_start)
549
+ segment_end = min(event_time, window_end)
550
+ if segment_end > segment_start:
551
+ duration = (segment_end - segment_start).total_seconds()
552
+ start_display, end_display = _format_segment_range(
553
+ segment_start, segment_end
554
+ )
555
+ segments.append(
556
+ {
557
+ "status": current_state,
558
+ "label": labels.get(current_state, current_state.title()),
559
+ "start_display": start_display,
560
+ "end_display": end_display,
561
+ "duration": max(duration, 1.0),
562
+ }
563
+ )
564
+ current_state = state
565
+ previous_time = max(event_time, window_start)
566
+
567
+ if previous_time < window_end:
568
+ segment_start = max(previous_time, window_start)
569
+ segment_end = window_end
570
+ if segment_end > segment_start:
571
+ duration = (segment_end - segment_start).total_seconds()
572
+ start_display, end_display = _format_segment_range(
573
+ segment_start, segment_end
574
+ )
575
+ segments.append(
576
+ {
577
+ "status": current_state,
578
+ "label": labels.get(current_state, current_state.title()),
579
+ "start_display": start_display,
580
+ "end_display": end_display,
581
+ "duration": max(duration, 1.0),
582
+ }
583
+ )
584
+
585
+ if not segments and total_seconds > 0:
586
+ start_display, end_display = _format_segment_range(window_start, window_end)
587
+ segments.append(
588
+ {
589
+ "status": current_state,
590
+ "label": labels.get(current_state, current_state.title()),
591
+ "start_display": start_display,
592
+ "end_display": end_display,
593
+ "duration": max(total_seconds, 1.0),
594
+ }
595
+ )
596
+
597
+ if segments:
598
+ timeline_entries.append(
599
+ {
600
+ "label": connector.connector_label,
601
+ "segments": segments,
602
+ }
603
+ )
604
+
605
+ return timeline_entries, window_display
606
+
607
+
340
608
  def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
341
609
  """Return active sessions grouped by connector for the charger."""
342
610
 
@@ -854,6 +1122,11 @@ def dashboard(request):
854
1122
  "demo_ws_url": ws_url,
855
1123
  "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
856
1124
  }
1125
+ if request.headers.get("x-requested-with") == "XMLHttpRequest" or request.GET.get("partial") == "table":
1126
+ html = render_to_string(
1127
+ "ocpp/includes/dashboard_table_rows.html", context, request=request
1128
+ )
1129
+ return JsonResponse({"html": html})
857
1130
  return render(request, "ocpp/dashboard.html", context)
858
1131
 
859
1132
 
@@ -1214,6 +1487,9 @@ def charger_status(request, cid, connector=None):
1214
1487
  connector_overview = [
1215
1488
  item for item in overview if item["charger"].connector_id is not None
1216
1489
  ]
1490
+ usage_timeline, usage_timeline_window = _usage_timeline(
1491
+ charger, connector_overview
1492
+ )
1217
1493
  search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
1218
1494
  configuration_url = None
1219
1495
  if request.user.is_staff:
@@ -1280,6 +1556,8 @@ def charger_status(request, cid, connector=None):
1280
1556
  "pagination_query": pagination_query,
1281
1557
  "session_query": session_query,
1282
1558
  "chart_should_animate": chart_should_animate,
1559
+ "usage_timeline": usage_timeline,
1560
+ "usage_timeline_window": usage_timeline_window,
1283
1561
  },
1284
1562
  )
1285
1563
 
@@ -1331,6 +1609,15 @@ def charger_session_search(request, cid, connector=None):
1331
1609
  transactions = qs.order_by("-start_time")
1332
1610
  except ValueError:
1333
1611
  transactions = []
1612
+ if transactions is not None:
1613
+ transactions = list(transactions)
1614
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1615
+ for tx in transactions:
1616
+ details = _transaction_rfid_details(tx, cache=rfid_cache)
1617
+ label_value = None
1618
+ if details:
1619
+ label_value = str(details.get("label") or "").strip() or None
1620
+ tx.rfid_label = label_value
1334
1621
  overview = _connector_overview(charger, request.user)
1335
1622
  connector_links = [
1336
1623
  {
@@ -1456,7 +1743,7 @@ def charger_log_page(request, cid, connector=None):
1456
1743
  @csrf_exempt
1457
1744
  @api_login_required
1458
1745
  def dispatch_action(request, cid, connector=None):
1459
- connector_value, _ = _normalize_connector_slug(connector)
1746
+ connector_value, _normalized_slug = _normalize_connector_slug(connector)
1460
1747
  log_key = store.identity_key(cid, connector_value)
1461
1748
  if connector_value is None:
1462
1749
  charger_obj = (
@@ -1472,11 +1759,11 @@ def dispatch_action(request, cid, connector=None):
1472
1759
  )
1473
1760
  if charger_obj is None:
1474
1761
  if connector_value is None:
1475
- charger_obj, _ = Charger.objects.get_or_create(
1762
+ charger_obj, _created = Charger.objects.get_or_create(
1476
1763
  charger_id=cid, connector_id=None
1477
1764
  )
1478
1765
  else:
1479
- charger_obj, _ = Charger.objects.get_or_create(
1766
+ charger_obj, _created = Charger.objects.get_or_create(
1480
1767
  charger_id=cid, connector_id=connector_value
1481
1768
  )
1482
1769
 
@@ -1647,6 +1934,13 @@ def dispatch_action(request, cid, connector=None):
1647
1934
  },
1648
1935
  )
1649
1936
  elif action == "reset":
1937
+ tx_obj = store.get_transaction(cid, connector_value)
1938
+ if tx_obj is not None:
1939
+ detail = _(
1940
+ "Reset is blocked while a charging session is active. "
1941
+ "Stop the session first."
1942
+ )
1943
+ return JsonResponse({"detail": detail}, status=409)
1650
1944
  message_id = uuid.uuid4().hex
1651
1945
  ocpp_action = "Reset"
1652
1946
  expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
pages/admin.py CHANGED
@@ -595,7 +595,23 @@ class ViewHistoryAdmin(EntityModelAdmin):
595
595
  )
596
596
 
597
597
  def traffic_data_view(self, request):
598
- return JsonResponse(self._build_chart_data())
598
+ return JsonResponse(
599
+ self._build_chart_data(days=self._resolve_requested_days(request))
600
+ )
601
+
602
+ def _resolve_requested_days(self, request, default: int = 30) -> int:
603
+ raw_value = request.GET.get("days")
604
+ if raw_value in (None, ""):
605
+ return default
606
+
607
+ try:
608
+ days = int(raw_value)
609
+ except (TypeError, ValueError):
610
+ return default
611
+
612
+ minimum = 1
613
+ maximum = 90
614
+ return max(minimum, min(days, maximum))
599
615
 
600
616
  def _build_chart_data(self, days: int = 30, max_pages: int = 8) -> dict:
601
617
  end_date = timezone.localdate()
@@ -689,11 +705,13 @@ class UserStoryAdmin(EntityModelAdmin):
689
705
  actions = ["create_github_issues"]
690
706
  list_display = (
691
707
  "name",
708
+ "language_code",
692
709
  "rating",
693
710
  "path",
694
711
  "status",
695
712
  "submitted_at",
696
713
  "github_issue_display",
714
+ "screenshot_display",
697
715
  "take_screenshot",
698
716
  "owner",
699
717
  "assign_to",
@@ -703,6 +721,7 @@ class UserStoryAdmin(EntityModelAdmin):
703
721
  "name",
704
722
  "comments",
705
723
  "path",
724
+ "language_code",
706
725
  "referer",
707
726
  "github_issue_url",
708
727
  "ip_address",
@@ -715,6 +734,7 @@ class UserStoryAdmin(EntityModelAdmin):
715
734
  "path",
716
735
  "user",
717
736
  "owner",
737
+ "language_code",
718
738
  "referer",
719
739
  "user_agent",
720
740
  "ip_address",
@@ -722,6 +742,7 @@ class UserStoryAdmin(EntityModelAdmin):
722
742
  "submitted_at",
723
743
  "github_issue_number",
724
744
  "github_issue_url",
745
+ "screenshot_display",
725
746
  )
726
747
  ordering = ("-submitted_at",)
727
748
  fields = (
@@ -729,7 +750,9 @@ class UserStoryAdmin(EntityModelAdmin):
729
750
  "rating",
730
751
  "comments",
731
752
  "take_screenshot",
753
+ "screenshot_display",
732
754
  "path",
755
+ "language_code",
733
756
  "user",
734
757
  "owner",
735
758
  "status",
@@ -758,6 +781,21 @@ class UserStoryAdmin(EntityModelAdmin):
758
781
  )
759
782
  if obj.github_issue_number is not None:
760
783
  return f"#{obj.github_issue_number}"
784
+ return ""
785
+
786
+ @admin.display(description=_("Screenshot"), ordering="screenshot")
787
+ def screenshot_display(self, obj):
788
+ if not obj.screenshot_id:
789
+ return ""
790
+ try:
791
+ url = reverse("admin:nodes_contentsample_change", args=[obj.screenshot_id])
792
+ except NoReverseMatch:
793
+ return obj.screenshot.path
794
+ return format_html(
795
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
796
+ url,
797
+ _("View screenshot"),
798
+ )
761
799
  return _("Not created")
762
800
 
763
801
  @admin.action(description=_("Create GitHub issues"))
@@ -842,34 +880,93 @@ def favorite_toggle(request, ct_id):
842
880
  ct = get_object_or_404(ContentType, pk=ct_id)
843
881
  fav = Favorite.objects.filter(user=request.user, content_type=ct).first()
844
882
  next_url = request.GET.get("next")
845
- if fav:
846
- return redirect(next_url or "admin:favorite_list")
847
883
  if request.method == "POST":
884
+ if fav and request.POST.get("remove"):
885
+ fav.delete()
886
+ return redirect(next_url or "admin:index")
848
887
  label = request.POST.get("custom_label", "").strip()
849
888
  user_data = request.POST.get("user_data") == "on"
850
- Favorite.objects.create(
851
- user=request.user,
852
- content_type=ct,
853
- custom_label=label,
854
- user_data=user_data,
855
- )
889
+ priority_raw = request.POST.get("priority", "").strip()
890
+ if fav:
891
+ default_priority = fav.priority
892
+ else:
893
+ default_priority = 0
894
+ if priority_raw:
895
+ try:
896
+ priority = int(priority_raw)
897
+ except (TypeError, ValueError):
898
+ priority = default_priority
899
+ else:
900
+ priority = default_priority
901
+
902
+ if fav:
903
+ update_fields = []
904
+ if fav.custom_label != label:
905
+ fav.custom_label = label
906
+ update_fields.append("custom_label")
907
+ if fav.user_data != user_data:
908
+ fav.user_data = user_data
909
+ update_fields.append("user_data")
910
+ if fav.priority != priority:
911
+ fav.priority = priority
912
+ update_fields.append("priority")
913
+ if update_fields:
914
+ fav.save(update_fields=update_fields)
915
+ else:
916
+ Favorite.objects.create(
917
+ user=request.user,
918
+ content_type=ct,
919
+ custom_label=label,
920
+ user_data=user_data,
921
+ priority=priority,
922
+ )
856
923
  return redirect(next_url or "admin:index")
857
924
  return render(
858
925
  request,
859
926
  "admin/favorite_confirm.html",
860
- {"content_type": ct, "next": next_url},
927
+ {
928
+ "content_type": ct,
929
+ "favorite": fav,
930
+ "next": next_url,
931
+ "initial_label": fav.custom_label if fav else "",
932
+ "initial_priority": fav.priority if fav else 0,
933
+ "is_checked": fav.user_data if fav else True,
934
+ },
861
935
  )
862
936
 
863
937
 
864
938
  def favorite_list(request):
865
- favorites = Favorite.objects.filter(user=request.user).select_related(
866
- "content_type"
939
+ favorites = (
940
+ Favorite.objects.filter(user=request.user)
941
+ .select_related("content_type")
942
+ .order_by("priority", "pk")
867
943
  )
868
944
  if request.method == "POST":
869
- selected = request.POST.getlist("user_data")
945
+ selected = set(request.POST.getlist("user_data"))
870
946
  for fav in favorites:
871
- fav.user_data = str(fav.pk) in selected
872
- fav.save(update_fields=["user_data"])
947
+ update_fields = []
948
+ user_selected = str(fav.pk) in selected
949
+ if fav.user_data != user_selected:
950
+ fav.user_data = user_selected
951
+ update_fields.append("user_data")
952
+
953
+ priority_raw = request.POST.get(f"priority_{fav.pk}", "").strip()
954
+ if priority_raw:
955
+ try:
956
+ priority = int(priority_raw)
957
+ except (TypeError, ValueError):
958
+ priority = fav.priority
959
+ else:
960
+ if fav.priority != priority:
961
+ fav.priority = priority
962
+ update_fields.append("priority")
963
+ else:
964
+ if fav.priority != 0:
965
+ fav.priority = 0
966
+ update_fields.append("priority")
967
+
968
+ if update_fields:
969
+ fav.save(update_fields=update_fields)
873
970
  return redirect("admin:favorite_list")
874
971
  return render(request, "admin/favorite_list.html", {"favorites": favorites})
875
972
 
pages/apps.py CHANGED
@@ -1,13 +1,45 @@
1
+ import logging
2
+
1
3
  from django.apps import AppConfig
4
+ from django.db import DatabaseError
5
+ from django.db.backends.signals import connection_created
6
+
7
+
8
+ logger = logging.getLogger(__name__)
2
9
 
3
10
 
4
11
  class PagesConfig(AppConfig):
5
12
  default_auto_field = "django.db.models.BigAutoField"
6
13
  name = "pages"
7
14
  verbose_name = "7. Experience"
15
+ _view_history_purged = False
8
16
 
9
17
  def ready(self): # pragma: no cover - import for side effects
10
18
  from . import checks # noqa: F401
11
19
  from . import site_config
12
20
 
13
21
  site_config.ready()
22
+ connection_created.connect(
23
+ self._handle_connection_created,
24
+ dispatch_uid="pages_view_history_connection_created",
25
+ weak=False,
26
+ )
27
+
28
+ def _handle_connection_created(self, sender, connection, **kwargs):
29
+ if self._view_history_purged:
30
+ return
31
+ self._view_history_purged = True
32
+ self._purge_view_history()
33
+
34
+ def _purge_view_history(self, days: int = 15) -> None:
35
+ """Remove stale :class:`pages.models.ViewHistory` entries."""
36
+
37
+ from .models import ViewHistory
38
+
39
+ try:
40
+ deleted = ViewHistory.purge_older_than(days=days)
41
+ except DatabaseError:
42
+ logger.debug("Skipping view history purge; database unavailable", exc_info=True)
43
+ else:
44
+ if deleted:
45
+ logger.info("Purged %s view history entries older than %s days", deleted, days)
pages/forms.py CHANGED
@@ -171,15 +171,35 @@ class UserStoryForm(forms.ModelForm):
171
171
  "comments": forms.Textarea(attrs={"rows": 4, "maxlength": 400}),
172
172
  }
173
173
 
174
- def __init__(self, *args, **kwargs):
174
+ def __init__(self, *args, user=None, **kwargs):
175
+ self.user = user
175
176
  super().__init__(*args, **kwargs)
176
- self.fields["name"].required = False
177
- self.fields["name"].widget.attrs.update(
178
- {
179
- "maxlength": 40,
180
- "placeholder": _("Name, email or pseudonym"),
181
- }
182
- )
177
+
178
+ if user is not None and user.is_authenticated:
179
+ name_field = self.fields["name"]
180
+ name_field.required = False
181
+ name_field.label = _("Username")
182
+ name_field.initial = (user.get_username() or "")[:40]
183
+ name_field.widget.attrs.update(
184
+ {
185
+ "maxlength": 40,
186
+ "readonly": "readonly",
187
+ }
188
+ )
189
+ else:
190
+ self.fields["name"] = forms.EmailField(
191
+ label=_("Email address"),
192
+ max_length=40,
193
+ required=True,
194
+ widget=forms.EmailInput(
195
+ attrs={
196
+ "maxlength": 40,
197
+ "placeholder": _("name@example.com"),
198
+ "autocomplete": "email",
199
+ "inputmode": "email",
200
+ }
201
+ ),
202
+ )
183
203
  self.fields["take_screenshot"].initial = True
184
204
  self.fields["rating"].widget = forms.RadioSelect(
185
205
  choices=[(i, str(i)) for i in range(1, 6)]
@@ -194,5 +214,8 @@ class UserStoryForm(forms.ModelForm):
194
214
  return comments
195
215
 
196
216
  def clean_name(self):
217
+ if self.user is not None and self.user.is_authenticated:
218
+ return (self.user.get_username() or "")[:40]
219
+
197
220
  name = (self.cleaned_data.get("name") or "").strip()
198
221
  return name[:40]