arthexis 0.1.14__py3-none-any.whl → 0.1.16__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/tasks.py CHANGED
@@ -1,11 +1,17 @@
1
1
  import logging
2
- from datetime import timedelta
2
+ from datetime import date, datetime, time, timedelta
3
+ from pathlib import Path
3
4
 
4
5
  from celery import shared_task
6
+ from django.conf import settings
7
+ from django.contrib.auth import get_user_model
5
8
  from django.utils import timezone
6
9
  from django.db.models import Q
7
10
 
8
- from .models import MeterValue
11
+ from core import mailer
12
+ from nodes.models import Node
13
+
14
+ from .models import MeterValue, Transaction
9
15
 
10
16
  logger = logging.getLogger(__name__)
11
17
 
@@ -29,3 +35,151 @@ def purge_meter_values() -> int:
29
35
 
30
36
  # Backwards compatibility alias
31
37
  purge_meter_readings = purge_meter_values
38
+
39
+
40
+ def _resolve_report_window() -> tuple[datetime, datetime, date]:
41
+ """Return the start/end datetimes for today's reporting window."""
42
+
43
+ current_tz = timezone.get_current_timezone()
44
+ today = timezone.localdate()
45
+ start = timezone.make_aware(datetime.combine(today, time.min), current_tz)
46
+ end = start + timedelta(days=1)
47
+ return start, end, today
48
+
49
+
50
+ def _session_report_recipients() -> list[str]:
51
+ """Return the list of recipients for the daily session report."""
52
+
53
+ User = get_user_model()
54
+ recipients = list(
55
+ User.objects.filter(is_superuser=True)
56
+ .exclude(email="")
57
+ .values_list("email", flat=True)
58
+ )
59
+ if recipients:
60
+ return recipients
61
+
62
+ fallback = getattr(settings, "DEFAULT_FROM_EMAIL", "").strip()
63
+ return [fallback] if fallback else []
64
+
65
+
66
+ def _format_duration(delta: timedelta | None) -> str:
67
+ """Return a compact string for ``delta`` or ``"in progress"``."""
68
+
69
+ if delta is None:
70
+ return "in progress"
71
+ total_seconds = int(delta.total_seconds())
72
+ hours, remainder = divmod(total_seconds, 3600)
73
+ minutes, seconds = divmod(remainder, 60)
74
+ parts: list[str] = []
75
+ if hours:
76
+ parts.append(f"{hours}h")
77
+ if minutes:
78
+ parts.append(f"{minutes}m")
79
+ if seconds or not parts:
80
+ parts.append(f"{seconds}s")
81
+ return " ".join(parts)
82
+
83
+
84
+ def _format_charger(transaction: Transaction) -> str:
85
+ """Return a human friendly label for ``transaction``'s charger."""
86
+
87
+ charger = transaction.charger
88
+ if charger is None:
89
+ return "Unknown charger"
90
+ for attr in ("display_name", "name", "charger_id"):
91
+ value = getattr(charger, attr, "")
92
+ if value:
93
+ return str(value)
94
+ return str(charger)
95
+
96
+
97
+ @shared_task
98
+ def send_daily_session_report() -> int:
99
+ """Send a summary of today's OCPP sessions when email is available."""
100
+
101
+ if not mailer.can_send_email():
102
+ logger.info("Skipping OCPP session report: email not configured")
103
+ return 0
104
+
105
+ celery_lock = Path(settings.BASE_DIR) / "locks" / "celery.lck"
106
+ if not celery_lock.exists():
107
+ logger.info("Skipping OCPP session report: celery feature disabled")
108
+ return 0
109
+
110
+ recipients = _session_report_recipients()
111
+ if not recipients:
112
+ logger.info("Skipping OCPP session report: no recipients found")
113
+ return 0
114
+
115
+ start, end, today = _resolve_report_window()
116
+ transactions = list(
117
+ Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
118
+ .select_related("charger", "account")
119
+ .order_by("start_time")
120
+ )
121
+ if not transactions:
122
+ logger.info("No OCPP sessions recorded on %s", today.isoformat())
123
+ return 0
124
+
125
+ total_energy = sum(transaction.kw for transaction in transactions)
126
+ lines = [
127
+ f"OCPP session report for {today.isoformat()}",
128
+ "",
129
+ f"Total sessions: {len(transactions)}",
130
+ f"Total energy: {total_energy:.2f} kWh",
131
+ "",
132
+ ]
133
+
134
+ for index, transaction in enumerate(transactions, start=1):
135
+ start_local = timezone.localtime(transaction.start_time)
136
+ stop_local = (
137
+ timezone.localtime(transaction.stop_time)
138
+ if transaction.stop_time
139
+ else None
140
+ )
141
+ duration = _format_duration(
142
+ stop_local - start_local if stop_local else None
143
+ )
144
+ account = transaction.account.name if transaction.account else "N/A"
145
+ connector = (
146
+ f"Connector {transaction.connector_id}" if transaction.connector_id else None
147
+ )
148
+ lines.append(f"{index}. {_format_charger(transaction)}")
149
+ lines.append(f" Account: {account}")
150
+ if transaction.rfid:
151
+ lines.append(f" RFID: {transaction.rfid}")
152
+ if connector:
153
+ lines.append(f" {connector}")
154
+ lines.append(
155
+ " Start: "
156
+ f"{start_local.strftime('%H:%M:%S %Z')}"
157
+ )
158
+ if stop_local:
159
+ lines.append(
160
+ " Stop: "
161
+ f"{stop_local.strftime('%H:%M:%S %Z')} ({duration})"
162
+ )
163
+ else:
164
+ lines.append(" Stop: in progress")
165
+ lines.append(f" Energy: {transaction.kw:.2f} kWh")
166
+ lines.append("")
167
+
168
+ subject = f"OCPP session report for {today.isoformat()}"
169
+ body = "\n".join(lines).strip()
170
+
171
+ node = Node.get_local()
172
+ if node is not None:
173
+ node.send_mail(subject, body, recipients)
174
+ else:
175
+ mailer.send(
176
+ subject,
177
+ body,
178
+ recipients,
179
+ getattr(settings, "DEFAULT_FROM_EMAIL", None),
180
+ )
181
+
182
+ logger.info(
183
+ "Sent OCPP session report for %s to %s", today.isoformat(), ", ".join(recipients)
184
+ )
185
+ return len(transactions)
ocpp/test_rfid.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import io
2
2
  import json
3
3
  import os
4
+ import subprocess
4
5
  import sys
5
6
  import types
6
7
  from datetime import datetime, timezone as dt_timezone
@@ -394,10 +395,11 @@ class ValidateRfidValueTests(SimpleTestCase):
394
395
 
395
396
  @patch("ocpp.rfid.reader.timezone.now")
396
397
  @patch("ocpp.rfid.reader.notify_async")
398
+ @patch("ocpp.rfid.reader.subprocess.Popen")
397
399
  @patch("ocpp.rfid.reader.subprocess.run")
398
400
  @patch("ocpp.rfid.reader.RFID.register_scan")
399
401
  def test_external_command_success(
400
- self, mock_register, mock_run, mock_notify, mock_now
402
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
401
403
  ):
402
404
  fake_now = object()
403
405
  mock_now.return_value = fake_now
@@ -414,6 +416,7 @@ class ValidateRfidValueTests(SimpleTestCase):
414
416
  mock_run.return_value = types.SimpleNamespace(
415
417
  returncode=0, stdout="ok\n", stderr=""
416
418
  )
419
+ mock_popen.return_value = object()
417
420
 
418
421
  result = validate_rfid_value("abcd1234")
419
422
 
@@ -424,6 +427,7 @@ class ValidateRfidValueTests(SimpleTestCase):
424
427
  env = run_kwargs.get("env", {})
425
428
  self.assertEqual(env.get("RFID_VALUE"), "ABCD1234")
426
429
  self.assertEqual(env.get("RFID_LABEL_ID"), "1")
430
+ mock_popen.assert_not_called()
427
431
  mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
428
432
  tag.save.assert_called_once_with(update_fields=["last_seen_on"])
429
433
  self.assertTrue(result["allowed"])
@@ -436,10 +440,11 @@ class ValidateRfidValueTests(SimpleTestCase):
436
440
 
437
441
  @patch("ocpp.rfid.reader.timezone.now")
438
442
  @patch("ocpp.rfid.reader.notify_async")
443
+ @patch("ocpp.rfid.reader.subprocess.Popen")
439
444
  @patch("ocpp.rfid.reader.subprocess.run")
440
445
  @patch("ocpp.rfid.reader.RFID.register_scan")
441
446
  def test_external_command_failure_blocks_tag(
442
- self, mock_register, mock_run, mock_notify, mock_now
447
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
443
448
  ):
444
449
  fake_now = object()
445
450
  mock_now.return_value = fake_now
@@ -456,6 +461,7 @@ class ValidateRfidValueTests(SimpleTestCase):
456
461
  mock_run.return_value = types.SimpleNamespace(
457
462
  returncode=1, stdout="", stderr="failure"
458
463
  )
464
+ mock_popen.return_value = object()
459
465
 
460
466
  result = validate_rfid_value("ffff")
461
467
 
@@ -469,6 +475,42 @@ class ValidateRfidValueTests(SimpleTestCase):
469
475
  self.assertEqual(output.get("stdout"), "")
470
476
  self.assertEqual(output.get("stderr"), "failure")
471
477
  self.assertEqual(output.get("error"), "")
478
+ mock_popen.assert_not_called()
479
+
480
+ @patch("ocpp.rfid.reader.timezone.now")
481
+ @patch("ocpp.rfid.reader.notify_async")
482
+ @patch("ocpp.rfid.reader.subprocess.Popen")
483
+ @patch("ocpp.rfid.reader.subprocess.run")
484
+ @patch("ocpp.rfid.reader.RFID.register_scan")
485
+ def test_post_command_runs_after_success(
486
+ self, mock_register, mock_run, mock_popen, mock_notify, mock_now
487
+ ):
488
+ fake_now = object()
489
+ mock_now.return_value = fake_now
490
+ tag = MagicMock()
491
+ tag.pk = 3
492
+ tag.label_id = 3
493
+ tag.allowed = True
494
+ tag.external_command = ""
495
+ tag.post_auth_command = "echo done"
496
+ tag.color = "B"
497
+ tag.released = False
498
+ tag.reference = None
499
+ tag.kind = RFID.CLASSIC
500
+ mock_register.return_value = (tag, False)
501
+ result = validate_rfid_value("abcdef")
502
+
503
+ mock_run.assert_not_called()
504
+ mock_popen.assert_called_once()
505
+ args, kwargs = mock_popen.call_args
506
+ self.assertEqual(args[0], "echo done")
507
+ env = kwargs.get("env", {})
508
+ self.assertEqual(env.get("RFID_VALUE"), "ABCDEF")
509
+ self.assertEqual(env.get("RFID_LABEL_ID"), "3")
510
+ self.assertIs(kwargs.get("stdout"), subprocess.DEVNULL)
511
+ self.assertIs(kwargs.get("stderr"), subprocess.DEVNULL)
512
+ self.assertTrue(result["allowed"])
513
+ mock_notify.assert_called_once_with("RFID 3 OK", "ABCDEF B")
472
514
 
473
515
 
474
516
  class CardTypeDetectionTests(TestCase):
ocpp/tests.py CHANGED
@@ -40,6 +40,7 @@ from django.test import (
40
40
  TestCase,
41
41
  override_settings,
42
42
  )
43
+ from django.conf import settings
43
44
  from unittest import skip
44
45
  from contextlib import suppress
45
46
  from types import SimpleNamespace
@@ -79,7 +80,7 @@ from .simulator import SimulatorConfig, ChargePointSimulator
79
80
  from .evcs import simulate, SimulatorState, _simulators
80
81
  import re
81
82
  from datetime import datetime, timedelta, timezone as dt_timezone
82
- from .tasks import purge_meter_readings
83
+ from .tasks import purge_meter_readings, send_daily_session_report
83
84
  from django.db import close_old_connections
84
85
  from django.db.utils import OperationalError
85
86
  from urllib.parse import unquote, urlparse
@@ -2981,6 +2982,46 @@ class SimulatorAdminTests(TransactionTestCase):
2981
2982
 
2982
2983
  await communicator.disconnect()
2983
2984
 
2985
+ async def test_heartbeat_refreshes_aggregate_after_connector_status(self):
2986
+ store.ip_connections.clear()
2987
+ store.connections.clear()
2988
+ await database_sync_to_async(Charger.objects.create)(charger_id="HBAGG")
2989
+ communicator = WebsocketCommunicator(application, "/HBAGG/")
2990
+ connect_result = await communicator.connect()
2991
+ self.assertTrue(connect_result[0], connect_result)
2992
+
2993
+ status_payload = {
2994
+ "connectorId": 2,
2995
+ "status": "Faulted",
2996
+ "errorCode": "ReaderFailure",
2997
+ }
2998
+ await communicator.send_json_to(
2999
+ [2, "1", "StatusNotification", status_payload]
3000
+ )
3001
+ await communicator.receive_json_from()
3002
+
3003
+ aggregate = await database_sync_to_async(Charger.objects.get)(
3004
+ charger_id="HBAGG", connector_id=None
3005
+ )
3006
+ connector = await database_sync_to_async(Charger.objects.get)(
3007
+ charger_id="HBAGG", connector_id=2
3008
+ )
3009
+ previous_heartbeat = aggregate.last_heartbeat
3010
+
3011
+ await communicator.send_json_to([2, "2", "Heartbeat", {}])
3012
+ await communicator.receive_json_from()
3013
+
3014
+ await database_sync_to_async(aggregate.refresh_from_db)()
3015
+ await database_sync_to_async(connector.refresh_from_db)()
3016
+
3017
+ self.assertIsNotNone(aggregate.last_heartbeat)
3018
+ if previous_heartbeat:
3019
+ self.assertNotEqual(aggregate.last_heartbeat, previous_heartbeat)
3020
+ if connector.last_heartbeat:
3021
+ self.assertNotEqual(aggregate.last_heartbeat, connector.last_heartbeat)
3022
+
3023
+ await communicator.disconnect()
3024
+
2984
3025
 
2985
3026
  class ChargerLocationTests(TestCase):
2986
3027
  def test_lat_lon_fields_saved(self):
@@ -3676,6 +3717,75 @@ class PurgeMeterReadingsTaskTests(TestCase):
3676
3717
  self.assertTrue(MeterReading.objects.filter(pk=reading.pk).exists())
3677
3718
 
3678
3719
 
3720
+ class DailySessionReportTaskTests(TestCase):
3721
+ def setUp(self):
3722
+ super().setUp()
3723
+ self.locks_dir = Path(settings.BASE_DIR) / "locks"
3724
+ self.locks_dir.mkdir(parents=True, exist_ok=True)
3725
+ self.celery_lock = self.locks_dir / "celery.lck"
3726
+ self.celery_lock.write_text("")
3727
+ self.addCleanup(self._cleanup_lock)
3728
+
3729
+ def _cleanup_lock(self):
3730
+ try:
3731
+ self.celery_lock.unlink()
3732
+ except FileNotFoundError:
3733
+ pass
3734
+
3735
+ def test_report_sends_email_when_sessions_exist(self):
3736
+ User = get_user_model()
3737
+ User.objects.create_superuser(
3738
+ username="report-admin",
3739
+ email="report-admin@example.com",
3740
+ password="pw",
3741
+ )
3742
+ charger = Charger.objects.create(charger_id="RPT1", display_name="Pod 1")
3743
+ start = timezone.now().replace(hour=10, minute=0, second=0, microsecond=0)
3744
+ Transaction.objects.create(
3745
+ charger=charger,
3746
+ start_time=start,
3747
+ stop_time=start + timedelta(hours=1),
3748
+ meter_start=0,
3749
+ meter_stop=2500,
3750
+ connector_id=2,
3751
+ rfid="AA11",
3752
+ )
3753
+
3754
+ with patch("core.mailer.can_send_email", return_value=True), patch(
3755
+ "core.mailer.send"
3756
+ ) as mock_send:
3757
+ count = send_daily_session_report()
3758
+
3759
+ self.assertEqual(count, 1)
3760
+ self.assertTrue(mock_send.called)
3761
+ args, _kwargs = mock_send.call_args
3762
+ self.assertIn("OCPP session report", args[0])
3763
+ self.assertIn("Pod 1", args[1])
3764
+ self.assertIn("report-admin@example.com", args[2])
3765
+ self.assertGreaterEqual(len(args[2]), 1)
3766
+
3767
+ def test_report_skips_when_no_sessions(self):
3768
+ with patch("core.mailer.can_send_email", return_value=True), patch(
3769
+ "core.mailer.send"
3770
+ ) as mock_send:
3771
+ count = send_daily_session_report()
3772
+
3773
+ self.assertEqual(count, 0)
3774
+ mock_send.assert_not_called()
3775
+
3776
+ def test_report_skips_without_celery_feature(self):
3777
+ if self.celery_lock.exists():
3778
+ self.celery_lock.unlink()
3779
+
3780
+ with patch("core.mailer.can_send_email", return_value=True), patch(
3781
+ "core.mailer.send"
3782
+ ) as mock_send:
3783
+ count = send_daily_session_report()
3784
+
3785
+ self.assertEqual(count, 0)
3786
+ mock_send.assert_not_called()
3787
+
3788
+
3679
3789
  class TransactionKwTests(TestCase):
3680
3790
  def test_kw_sums_meter_readings(self):
3681
3791
  charger = Charger.objects.create(charger_id="SUM1")
pages/admin.py CHANGED
@@ -18,11 +18,14 @@ import ipaddress
18
18
  from django.apps import apps as django_apps
19
19
  from django.conf import settings
20
20
  from django.utils.translation import gettext_lazy as _, ngettext
21
+ from django.core.management import CommandError, call_command
21
22
 
22
- from nodes.models import Node
23
+ from nodes.models import Node, NodeRole
23
24
  from nodes.utils import capture_screenshot, save_screenshot
24
25
 
25
26
  from .forms import UserManualAdminForm
27
+ from .module_defaults import reload_default_modules as restore_default_modules
28
+ from .utils import landing_leads_supported
26
29
 
27
30
  from .models import (
28
31
  SiteBadge,
@@ -107,6 +110,49 @@ class SiteAdmin(DjangoSiteAdmin):
107
110
  messages.INFO,
108
111
  )
109
112
 
113
+ def _reload_site_fixtures(self, request):
114
+ fixtures_dir = Path(settings.BASE_DIR) / "core" / "fixtures"
115
+ fixture_paths = sorted(fixtures_dir.glob("references__00_site_*.json"))
116
+ sigil_fixture = fixtures_dir / "sigil_roots__site.json"
117
+ if sigil_fixture.exists():
118
+ fixture_paths.append(sigil_fixture)
119
+
120
+ if not fixture_paths:
121
+ self.message_user(request, _("No site fixtures found."), messages.WARNING)
122
+ return None
123
+
124
+ loaded = 0
125
+ for path in fixture_paths:
126
+ try:
127
+ call_command("loaddata", str(path), verbosity=0)
128
+ except CommandError as exc:
129
+ self.message_user(
130
+ request,
131
+ _("%(fixture)s: %(error)s")
132
+ % {"fixture": path.name, "error": exc},
133
+ messages.ERROR,
134
+ )
135
+ else:
136
+ loaded += 1
137
+
138
+ if loaded:
139
+ message = ngettext(
140
+ "Reloaded %(count)d site fixture.",
141
+ "Reloaded %(count)d site fixtures.",
142
+ loaded,
143
+ ) % {"count": loaded}
144
+ self.message_user(request, message, messages.SUCCESS)
145
+
146
+ return None
147
+
148
+ def reload_site_fixtures(self, request):
149
+ if request.method != "POST":
150
+ return redirect("..")
151
+
152
+ self._reload_site_fixtures(request)
153
+
154
+ return redirect("..")
155
+
110
156
  def get_urls(self):
111
157
  urls = super().get_urls()
112
158
  custom = [
@@ -114,7 +160,12 @@ class SiteAdmin(DjangoSiteAdmin):
114
160
  "register-current/",
115
161
  self.admin_site.admin_view(self.register_current),
116
162
  name="pages_siteproxy_register_current",
117
- )
163
+ ),
164
+ path(
165
+ "reload-site-fixtures/",
166
+ self.admin_site.admin_view(self.reload_site_fixtures),
167
+ name="pages_siteproxy_reload_site_fixtures",
168
+ ),
118
169
  ]
119
170
  return custom + urls
120
171
 
@@ -179,16 +230,118 @@ class ApplicationAdmin(EntityModelAdmin):
179
230
  class LandingInline(admin.TabularInline):
180
231
  model = Landing
181
232
  extra = 0
182
- fields = ("path", "label", "enabled", "description")
233
+ fields = ("path", "label", "enabled")
234
+ show_change_link = True
235
+
236
+
237
+ @admin.register(Landing)
238
+ class LandingAdmin(EntityModelAdmin):
239
+ list_display = ("label", "path", "module", "enabled")
240
+ list_filter = ("enabled", "module__node_role", "module__application")
241
+ search_fields = (
242
+ "label",
243
+ "path",
244
+ "description",
245
+ "module__path",
246
+ "module__application__name",
247
+ "module__node_role__name",
248
+ )
249
+ fields = ("module", "path", "label", "enabled", "description")
250
+ list_select_related = ("module", "module__application", "module__node_role")
183
251
 
184
252
 
185
253
  @admin.register(Module)
186
254
  class ModuleAdmin(EntityModelAdmin):
255
+ change_list_template = "admin/pages/module/change_list.html"
187
256
  list_display = ("application", "node_role", "path", "menu", "is_default")
188
257
  list_filter = ("node_role", "application")
189
258
  fields = ("node_role", "application", "path", "menu", "is_default", "favicon")
190
259
  inlines = [LandingInline]
191
260
 
261
+ def get_urls(self):
262
+ urls = super().get_urls()
263
+ custom = [
264
+ path(
265
+ "reload-default-modules/",
266
+ self.admin_site.admin_view(self.reload_default_modules_view),
267
+ name="pages_module_reload_default_modules",
268
+ ),
269
+ ]
270
+ return custom + urls
271
+
272
+ def reload_default_modules_view(self, request):
273
+ if request.method != "POST":
274
+ return redirect("..")
275
+
276
+ summary = restore_default_modules(Application, Module, Landing, NodeRole)
277
+
278
+ if summary.roles_processed == 0:
279
+ self.message_user(
280
+ request,
281
+ _("No default modules were reloaded because the required node roles are missing."),
282
+ messages.WARNING,
283
+ )
284
+ elif summary.has_changes:
285
+ parts: list[str] = []
286
+ if summary.modules_created:
287
+ parts.append(
288
+ ngettext(
289
+ "%(count)d module created",
290
+ "%(count)d modules created",
291
+ summary.modules_created,
292
+ )
293
+ % {"count": summary.modules_created}
294
+ )
295
+ if summary.modules_updated:
296
+ parts.append(
297
+ ngettext(
298
+ "%(count)d module updated",
299
+ "%(count)d modules updated",
300
+ summary.modules_updated,
301
+ )
302
+ % {"count": summary.modules_updated}
303
+ )
304
+ if summary.landings_created:
305
+ parts.append(
306
+ ngettext(
307
+ "%(count)d landing created",
308
+ "%(count)d landings created",
309
+ summary.landings_created,
310
+ )
311
+ % {"count": summary.landings_created}
312
+ )
313
+ if summary.landings_updated:
314
+ parts.append(
315
+ ngettext(
316
+ "%(count)d landing updated",
317
+ "%(count)d landings updated",
318
+ summary.landings_updated,
319
+ )
320
+ % {"count": summary.landings_updated}
321
+ )
322
+
323
+ details = "; ".join(parts)
324
+ if details:
325
+ message = _(
326
+ "Reloaded default modules for %(roles)d role(s). %(details)s."
327
+ ) % {"roles": summary.roles_processed, "details": details}
328
+ else:
329
+ message = _(
330
+ "Reloaded default modules for %(roles)d role(s)."
331
+ ) % {"roles": summary.roles_processed}
332
+ self.message_user(request, message, messages.SUCCESS)
333
+ else:
334
+ self.message_user(
335
+ request,
336
+ _(
337
+ "Default modules are already up to date for %(roles)d role(s)."
338
+ )
339
+ % {"roles": summary.roles_processed},
340
+ messages.INFO,
341
+ )
342
+
343
+ return redirect("..")
344
+
192
345
 
193
346
  @admin.register(LandingLead)
194
347
  class LandingLeadAdmin(EntityModelAdmin):
@@ -237,6 +390,17 @@ class LandingLeadAdmin(EntityModelAdmin):
237
390
  ordering = ("-created_on",)
238
391
  date_hierarchy = "created_on"
239
392
 
393
+ def changelist_view(self, request, extra_context=None):
394
+ if not landing_leads_supported():
395
+ self.message_user(
396
+ request,
397
+ _(
398
+ "Landing leads are not being recorded because Celery is not running on this node."
399
+ ),
400
+ messages.WARNING,
401
+ )
402
+ return super().changelist_view(request, extra_context=extra_context)
403
+
240
404
  @admin.display(description=_("Landing"), ordering="landing__label")
241
405
  def landing_label(self, obj):
242
406
  return obj.landing.label
@@ -461,13 +625,22 @@ class UserStoryAdmin(EntityModelAdmin):
461
625
  "name",
462
626
  "rating",
463
627
  "path",
628
+ "status",
464
629
  "submitted_at",
465
630
  "github_issue_display",
466
631
  "take_screenshot",
467
632
  "owner",
633
+ "assign_to",
634
+ )
635
+ list_filter = ("rating", "status", "submitted_at", "take_screenshot")
636
+ search_fields = (
637
+ "name",
638
+ "comments",
639
+ "path",
640
+ "referer",
641
+ "github_issue_url",
642
+ "ip_address",
468
643
  )
469
- list_filter = ("rating", "submitted_at", "take_screenshot")
470
- search_fields = ("name", "comments", "path", "github_issue_url")
471
644
  readonly_fields = (
472
645
  "name",
473
646
  "rating",
@@ -476,6 +649,10 @@ class UserStoryAdmin(EntityModelAdmin):
476
649
  "path",
477
650
  "user",
478
651
  "owner",
652
+ "referer",
653
+ "user_agent",
654
+ "ip_address",
655
+ "created_on",
479
656
  "submitted_at",
480
657
  "github_issue_number",
481
658
  "github_issue_url",
@@ -489,6 +666,12 @@ class UserStoryAdmin(EntityModelAdmin):
489
666
  "path",
490
667
  "user",
491
668
  "owner",
669
+ "status",
670
+ "assign_to",
671
+ "referer",
672
+ "user_agent",
673
+ "ip_address",
674
+ "created_on",
492
675
  "submitted_at",
493
676
  "github_issue_number",
494
677
  "github_issue_url",