arthexis 0.1.20__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.20.dist-info → arthexis-0.1.22.dist-info}/METADATA +10 -11
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/RECORD +34 -36
- config/asgi.py +1 -15
- config/settings.py +4 -26
- config/urls.py +5 -1
- core/admin.py +140 -252
- core/apps.py +0 -6
- core/environment.py +2 -220
- core/models.py +425 -77
- core/system.py +76 -0
- core/tests.py +153 -15
- core/views.py +35 -97
- nodes/admin.py +165 -32
- nodes/apps.py +11 -0
- nodes/models.py +26 -6
- nodes/tests.py +263 -1
- nodes/views.py +61 -1
- ocpp/admin.py +68 -7
- ocpp/consumers.py +1 -0
- ocpp/models.py +71 -1
- ocpp/tasks.py +99 -1
- ocpp/tests.py +310 -2
- ocpp/views.py +365 -5
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/context_processors.py +0 -12
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +361 -63
- pages/urls.py +5 -1
- pages/views.py +264 -16
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/WHEEL +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/top_level.txt +0 -0
ocpp/views.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import uuid
|
|
3
|
-
from datetime import datetime, timedelta, timezone as dt_timezone
|
|
3
|
+
from datetime import datetime, time, timedelta, timezone as dt_timezone
|
|
4
4
|
from types import SimpleNamespace
|
|
5
5
|
|
|
6
6
|
from django.http import Http404, HttpResponse, JsonResponse
|
|
7
7
|
from django.http.request import split_domain_port
|
|
8
8
|
from django.views.decorators.csrf import csrf_exempt
|
|
9
9
|
from django.shortcuts import get_object_or_404, render, resolve_url
|
|
10
|
+
from django.template.loader import render_to_string
|
|
10
11
|
from django.core.paginator import Paginator
|
|
11
12
|
from django.contrib.auth.decorators import login_required
|
|
12
13
|
from django.contrib.auth.views import redirect_to_login
|
|
@@ -14,7 +15,7 @@ from django.utils.translation import gettext_lazy as _, gettext, ngettext
|
|
|
14
15
|
from django.utils.text import slugify
|
|
15
16
|
from django.urls import NoReverseMatch, reverse
|
|
16
17
|
from django.conf import settings
|
|
17
|
-
from django.utils import translation, timezone
|
|
18
|
+
from django.utils import translation, timezone, formats
|
|
18
19
|
from django.core.exceptions import ValidationError
|
|
19
20
|
|
|
20
21
|
from asgiref.sync import async_to_sync
|
|
@@ -26,6 +27,8 @@ from nodes.models import Node
|
|
|
26
27
|
from pages.utils import landing
|
|
27
28
|
from core.liveupdate import live_update
|
|
28
29
|
|
|
30
|
+
from django.utils.dateparse import parse_datetime
|
|
31
|
+
|
|
29
32
|
from . import store
|
|
30
33
|
from .models import Transaction, Charger, DataTransferMessage, RFID
|
|
31
34
|
from .evcs import (
|
|
@@ -336,6 +339,272 @@ def _connector_overview(
|
|
|
336
339
|
return overview
|
|
337
340
|
|
|
338
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
|
+
|
|
339
608
|
def _live_sessions(charger: Charger) -> list[tuple[Charger, Transaction]]:
|
|
340
609
|
"""Return active sessions grouped by connector for the charger."""
|
|
341
610
|
|
|
@@ -752,8 +1021,48 @@ def dashboard(request):
|
|
|
752
1021
|
request.get_full_path(), login_url=reverse("pages:login")
|
|
753
1022
|
)
|
|
754
1023
|
is_watchtower = role_name in {"Watchtower", "Constellation"}
|
|
755
|
-
|
|
756
|
-
|
|
1024
|
+
visible_chargers = (
|
|
1025
|
+
_visible_chargers(request.user)
|
|
1026
|
+
.select_related("location")
|
|
1027
|
+
.order_by("charger_id", "connector_id")
|
|
1028
|
+
)
|
|
1029
|
+
stats_cache: dict[int, dict[str, float]] = {}
|
|
1030
|
+
|
|
1031
|
+
def _charger_display_name(charger: Charger) -> str:
|
|
1032
|
+
if charger.display_name:
|
|
1033
|
+
return charger.display_name
|
|
1034
|
+
if charger.location:
|
|
1035
|
+
return charger.location.name
|
|
1036
|
+
return charger.charger_id
|
|
1037
|
+
|
|
1038
|
+
today = timezone.localdate()
|
|
1039
|
+
tz = timezone.get_current_timezone()
|
|
1040
|
+
day_start = datetime.combine(today, time.min)
|
|
1041
|
+
if timezone.is_naive(day_start):
|
|
1042
|
+
day_start = timezone.make_aware(day_start, tz)
|
|
1043
|
+
day_end = day_start + timedelta(days=1)
|
|
1044
|
+
|
|
1045
|
+
def _charger_stats(charger: Charger) -> dict[str, float]:
|
|
1046
|
+
cache_key = charger.pk or id(charger)
|
|
1047
|
+
if cache_key not in stats_cache:
|
|
1048
|
+
stats_cache[cache_key] = {
|
|
1049
|
+
"total_kw": charger.total_kw,
|
|
1050
|
+
"today_kw": charger.total_kw_for_range(day_start, day_end),
|
|
1051
|
+
}
|
|
1052
|
+
return stats_cache[cache_key]
|
|
1053
|
+
|
|
1054
|
+
def _status_url(charger: Charger) -> str:
|
|
1055
|
+
return _reverse_connector_url(
|
|
1056
|
+
"charger-status",
|
|
1057
|
+
charger.charger_id,
|
|
1058
|
+
charger.connector_slug,
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
chargers: list[dict[str, object]] = []
|
|
1062
|
+
charger_groups: list[dict[str, object]] = []
|
|
1063
|
+
group_lookup: dict[str, dict[str, object]] = {}
|
|
1064
|
+
|
|
1065
|
+
for charger in visible_chargers:
|
|
757
1066
|
tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
|
|
758
1067
|
if not tx_obj:
|
|
759
1068
|
tx_obj = (
|
|
@@ -761,17 +1070,63 @@ def dashboard(request):
|
|
|
761
1070
|
.order_by("-start_time")
|
|
762
1071
|
.first()
|
|
763
1072
|
)
|
|
1073
|
+
has_session = _has_active_session(tx_obj)
|
|
764
1074
|
state, color = _charger_state(charger, tx_obj)
|
|
765
|
-
|
|
1075
|
+
if (
|
|
1076
|
+
charger.connector_id is not None
|
|
1077
|
+
and not has_session
|
|
1078
|
+
and (charger.last_status or "").strip().casefold() == "charging"
|
|
1079
|
+
):
|
|
1080
|
+
state, color = STATUS_BADGE_MAP["charging"]
|
|
1081
|
+
entry = {
|
|
1082
|
+
"charger": charger,
|
|
1083
|
+
"state": state,
|
|
1084
|
+
"color": color,
|
|
1085
|
+
"display_name": _charger_display_name(charger),
|
|
1086
|
+
"stats": _charger_stats(charger),
|
|
1087
|
+
"status_url": _status_url(charger),
|
|
1088
|
+
}
|
|
1089
|
+
chargers.append(entry)
|
|
1090
|
+
if charger.connector_id is None:
|
|
1091
|
+
group = {"parent": entry, "children": []}
|
|
1092
|
+
charger_groups.append(group)
|
|
1093
|
+
group_lookup[charger.charger_id] = group
|
|
1094
|
+
else:
|
|
1095
|
+
group = group_lookup.get(charger.charger_id)
|
|
1096
|
+
if group is None:
|
|
1097
|
+
group = {"parent": None, "children": []}
|
|
1098
|
+
charger_groups.append(group)
|
|
1099
|
+
group_lookup[charger.charger_id] = group
|
|
1100
|
+
group["children"].append(entry)
|
|
1101
|
+
|
|
1102
|
+
for group in charger_groups:
|
|
1103
|
+
parent_entry = group.get("parent")
|
|
1104
|
+
if not parent_entry or not group["children"]:
|
|
1105
|
+
continue
|
|
1106
|
+
connector_statuses = [
|
|
1107
|
+
(child["charger"].last_status or "").strip().casefold()
|
|
1108
|
+
for child in group["children"]
|
|
1109
|
+
if child["charger"].connector_id is not None
|
|
1110
|
+
]
|
|
1111
|
+
if connector_statuses and all(status == "charging" for status in connector_statuses):
|
|
1112
|
+
label, badge_color = STATUS_BADGE_MAP["charging"]
|
|
1113
|
+
parent_entry["state"] = label
|
|
1114
|
+
parent_entry["color"] = badge_color
|
|
766
1115
|
scheme = "wss" if request.is_secure() else "ws"
|
|
767
1116
|
host = request.get_host()
|
|
768
1117
|
ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
|
|
769
1118
|
context = {
|
|
770
1119
|
"chargers": chargers,
|
|
1120
|
+
"charger_groups": charger_groups,
|
|
771
1121
|
"show_demo_notice": is_watchtower,
|
|
772
1122
|
"demo_ws_url": ws_url,
|
|
773
1123
|
"ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
|
|
774
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})
|
|
775
1130
|
return render(request, "ocpp/dashboard.html", context)
|
|
776
1131
|
|
|
777
1132
|
|
|
@@ -1132,6 +1487,9 @@ def charger_status(request, cid, connector=None):
|
|
|
1132
1487
|
connector_overview = [
|
|
1133
1488
|
item for item in overview if item["charger"].connector_id is not None
|
|
1134
1489
|
]
|
|
1490
|
+
usage_timeline, usage_timeline_window = _usage_timeline(
|
|
1491
|
+
charger, connector_overview
|
|
1492
|
+
)
|
|
1135
1493
|
search_url = _reverse_connector_url("charger-session-search", cid, connector_slug)
|
|
1136
1494
|
configuration_url = None
|
|
1137
1495
|
if request.user.is_staff:
|
|
@@ -1198,6 +1556,8 @@ def charger_status(request, cid, connector=None):
|
|
|
1198
1556
|
"pagination_query": pagination_query,
|
|
1199
1557
|
"session_query": session_query,
|
|
1200
1558
|
"chart_should_animate": chart_should_animate,
|
|
1559
|
+
"usage_timeline": usage_timeline,
|
|
1560
|
+
"usage_timeline_window": usage_timeline_window,
|
|
1201
1561
|
},
|
|
1202
1562
|
)
|
|
1203
1563
|
|
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)
|