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.
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/METADATA +9 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/RECORD +33 -33
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +224 -32
- core/environment.py +2 -239
- core/models.py +903 -65
- core/release.py +0 -5
- core/system.py +76 -0
- core/tests.py +181 -9
- core/user_data.py +42 -2
- core/views.py +68 -27
- nodes/admin.py +211 -60
- nodes/apps.py +11 -0
- nodes/models.py +35 -7
- nodes/tests.py +288 -1
- nodes/views.py +101 -48
- ocpp/admin.py +32 -2
- ocpp/consumers.py +1 -0
- ocpp/models.py +52 -3
- ocpp/tasks.py +99 -1
- ocpp/tests.py +350 -2
- ocpp/views.py +300 -6
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +386 -28
- pages/urls.py +10 -0
- pages/views.py +347 -18
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.23.dist-info}/top_level.txt +0 -0
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
{
|
|
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 =
|
|
866
|
-
|
|
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
|
-
|
|
872
|
-
fav.
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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]
|