arthexis 0.1.13__py3-none-any.whl → 0.1.15__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.
Files changed (108) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
  2. arthexis-0.1.15.dist-info/RECORD +110 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3795 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +149 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3637 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +840 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +952 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2168 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2201 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1764 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3830 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +769 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +209 -153
  99. pages/models.py +643 -426
  100. pages/tasks.py +74 -0
  101. pages/tests.py +3025 -2200
  102. pages/urls.py +26 -25
  103. pages/utils.py +23 -12
  104. pages/views.py +1176 -1128
  105. arthexis-0.1.13.dist-info/RECORD +0 -105
  106. nodes/actions.py +0 -70
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
  108. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
core/tests.py CHANGED
@@ -1,1521 +1,2168 @@
1
- import os
2
-
3
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
- import django
5
-
6
- django.setup()
7
-
8
- from django.test import Client, TestCase, RequestFactory, override_settings
9
- from django.urls import reverse
10
- from django.http import HttpRequest
11
- import json
12
- from decimal import Decimal
13
- from unittest import mock
14
- from unittest.mock import patch
15
- from pathlib import Path
16
- import subprocess
17
- from glob import glob
18
- from datetime import datetime, timedelta, timezone as datetime_timezone
19
- import tempfile
20
- from urllib.parse import quote
21
-
22
- from django.utils import timezone
23
- from django.contrib.auth.models import Permission
24
- from django.contrib.messages import get_messages
25
- from .models import (
26
- User,
27
- UserPhoneNumber,
28
- EnergyAccount,
29
- ElectricVehicle,
30
- EnergyCredit,
31
- Product,
32
- Brand,
33
- EVModel,
34
- RFID,
35
- SecurityGroup,
36
- Package,
37
- PackageRelease,
38
- ReleaseManager,
39
- Todo,
40
- PublicWifiAccess,
41
- )
42
- from django.contrib.admin.sites import AdminSite
43
- from core.admin import (
44
- PackageReleaseAdmin,
45
- PackageAdmin,
46
- UserAdmin,
47
- USER_PROFILE_INLINES,
48
- )
49
- from ocpp.models import Transaction, Charger
50
-
51
- from django.core.exceptions import ValidationError
52
- from django.core.management import call_command
53
- from django.db import IntegrityError
54
- from .backends import LocalhostAdminBackend
55
- from core.views import _step_check_version, _step_promote_build, _step_publish
56
- from core import views as core_views
57
- from core import public_wifi
58
-
59
-
60
- class DefaultAdminTests(TestCase):
61
- def test_arthexis_is_default_user(self):
62
- self.assertTrue(User.objects.filter(username="arthexis").exists())
63
- self.assertFalse(User.all_objects.filter(username="admin").exists())
64
-
65
- def test_admin_created_and_local_only(self):
66
- backend = LocalhostAdminBackend()
67
- req = HttpRequest()
68
- req.META["REMOTE_ADDR"] = "127.0.0.1"
69
- user = backend.authenticate(req, username="admin", password="admin")
70
- self.assertIsNotNone(user)
71
- self.assertEqual(user.pk, 2)
72
-
73
- remote = HttpRequest()
74
- remote.META["REMOTE_ADDR"] = "10.0.0.1"
75
- self.assertIsNone(
76
- backend.authenticate(remote, username="admin", password="admin")
77
- )
78
-
79
- def test_admin_respects_forwarded_for(self):
80
- backend = LocalhostAdminBackend()
81
-
82
- req = HttpRequest()
83
- req.META["REMOTE_ADDR"] = "10.0.0.1"
84
- req.META["HTTP_X_FORWARDED_FOR"] = "127.0.0.1"
85
- self.assertIsNotNone(
86
- backend.authenticate(req, username="admin", password="admin"),
87
- "X-Forwarded-For should permit allowed IP",
88
- )
89
-
90
- blocked = HttpRequest()
91
- blocked.META["REMOTE_ADDR"] = "10.0.0.1"
92
- blocked.META["HTTP_X_FORWARDED_FOR"] = "8.8.8.8"
93
- self.assertIsNone(
94
- backend.authenticate(blocked, username="admin", password="admin")
95
- )
96
-
97
-
98
- class UserOperateAsTests(TestCase):
99
- @classmethod
100
- def setUpTestData(cls):
101
- cls.permission = Permission.objects.get(codename="view_todo")
102
-
103
- def test_staff_user_delegates_permissions(self):
104
- delegate = User.objects.create_user(username="delegate", password="secret")
105
- delegate.user_permissions.add(self.permission)
106
- operator = User.objects.create_user(
107
- username="operator", password="secret", is_staff=True
108
- )
109
- self.assertFalse(operator.has_perm("core.view_todo"))
110
- operator.operate_as = delegate
111
- operator.full_clean()
112
- operator.save()
113
- operator.refresh_from_db()
114
- self.assertTrue(operator.has_perm("core.view_todo"))
115
-
116
- def test_only_staff_may_operate_as(self):
117
- delegate = User.objects.create_user(username="delegate", password="secret")
118
- operator = User.objects.create_user(username="operator", password="secret")
119
- operator.operate_as = delegate
120
- with self.assertRaises(ValidationError):
121
- operator.full_clean()
122
-
123
- def test_non_superuser_cannot_operate_as_staff(self):
124
- staff_delegate = User.objects.create_user(
125
- username="delegate", password="secret", is_staff=True
126
- )
127
- operator = User.objects.create_user(
128
- username="operator", password="secret", is_staff=True
129
- )
130
- operator.operate_as = staff_delegate
131
- with self.assertRaises(ValidationError):
132
- operator.full_clean()
133
-
134
- def test_recursive_chain_and_cycle_detection(self):
135
- base = User.objects.create_user(username="base", password="secret")
136
- base.user_permissions.add(self.permission)
137
- middle = User.objects.create_user(
138
- username="middle", password="secret", is_staff=True
139
- )
140
- middle.operate_as = base
141
- middle.full_clean()
142
- middle.save()
143
- top = User.objects.create_superuser(
144
- username="top", email="top@example.com", password="secret"
145
- )
146
- top.operate_as = middle
147
- top.full_clean()
148
- top.save()
149
- top.refresh_from_db()
150
- self.assertTrue(top.has_perm("core.view_todo"))
151
-
152
- first = User.objects.create_superuser(
153
- username="first", email="first@example.com", password="secret"
154
- )
155
- second = User.objects.create_superuser(
156
- username="second", email="second@example.com", password="secret"
157
- )
158
- first.operate_as = second
159
- first.full_clean()
160
- first.save()
161
- second.operate_as = first
162
- second.full_clean()
163
- second.save()
164
- self.assertFalse(first._check_operate_as_chain(lambda user: False))
165
-
166
- def test_module_permissions_fall_back(self):
167
- delegate = User.objects.create_user(username="helper", password="secret")
168
- delegate.user_permissions.add(self.permission)
169
- operator = User.objects.create_user(
170
- username="mod", password="secret", is_staff=True
171
- )
172
- operator.operate_as = delegate
173
- operator.full_clean()
174
- operator.save()
175
- self.assertTrue(operator.has_module_perms("core"))
176
-
177
- def test_has_profile_via_delegate(self):
178
- delegate = User.objects.create_user(
179
- username="delegate", password="secret", is_staff=True
180
- )
181
- ReleaseManager.objects.create(user=delegate)
182
- operator = User.objects.create_superuser(
183
- username="operator",
184
- email="operator@example.com",
185
- password="secret",
186
- )
187
- operator.operate_as = delegate
188
- operator.full_clean()
189
- operator.save()
190
- profile = operator.get_profile(ReleaseManager)
191
- self.assertIsNotNone(profile)
192
- self.assertEqual(profile.user, delegate)
193
- self.assertTrue(operator.has_profile(ReleaseManager))
194
-
195
- def test_has_profile_via_group_membership(self):
196
- member = User.objects.create_user(username="member", password="secret")
197
- group = SecurityGroup.objects.create(name="Managers")
198
- group.user_set.add(member)
199
- profile = ReleaseManager.objects.create(group=group)
200
- self.assertEqual(member.get_profile(ReleaseManager), profile)
201
- self.assertTrue(member.has_profile(ReleaseManager))
202
-
203
- def test_release_manager_property_uses_delegate_profile(self):
204
- delegate = User.objects.create_user(
205
- username="delegate-property", password="secret", is_staff=True
206
- )
207
- profile = ReleaseManager.objects.create(user=delegate)
208
- operator = User.objects.create_superuser(
209
- username="operator-property",
210
- email="operator-property@example.com",
211
- password="secret",
212
- )
213
- operator.operate_as = delegate
214
- operator.full_clean()
215
- operator.save()
216
- self.assertEqual(operator.release_manager, profile)
217
-
218
-
219
- class UserPhoneNumberTests(TestCase):
220
- def test_get_phone_numbers_by_priority(self):
221
- user = User.objects.create_user(username="phone-user", password="secret")
222
- later = UserPhoneNumber.objects.create(
223
- user=user, number="+15555550101", priority=10
224
- )
225
- earlier = UserPhoneNumber.objects.create(
226
- user=user, number="+15555550100", priority=1
227
- )
228
- immediate = UserPhoneNumber.objects.create(
229
- user=user, number="+15555550099", priority=0
230
- )
231
-
232
- phones = user.get_phones_by_priority()
233
- self.assertEqual(phones, [immediate, earlier, later])
234
-
235
- def test_get_phone_numbers_by_priority_orders_by_id_when_equal(self):
236
- user = User.objects.create_user(username="phone-order", password="secret")
237
- first = UserPhoneNumber.objects.create(
238
- user=user, number="+19995550000", priority=0
239
- )
240
- second = UserPhoneNumber.objects.create(
241
- user=user, number="+19995550001", priority=0
242
- )
243
-
244
- phones = user.get_phones_by_priority()
245
- self.assertEqual(phones, [first, second])
246
-
247
- def test_get_phone_numbers_by_priority_alias(self):
248
- user = User.objects.create_user(username="phone-alias", password="secret")
249
- phone = UserPhoneNumber.objects.create(
250
- user=user, number="+14445550000", priority=3
251
- )
252
-
253
- self.assertEqual(user.get_phone_numbers_by_priority(), [phone])
254
-
255
-
256
- class ProfileValidationTests(TestCase):
257
- def test_system_user_cannot_receive_profiles(self):
258
- system_user = User.objects.get(username=User.SYSTEM_USERNAME)
259
- profile = ReleaseManager(user=system_user)
260
- with self.assertRaises(ValidationError) as exc:
261
- profile.full_clean()
262
- self.assertIn("user", exc.exception.error_dict)
263
-
264
-
265
- class UserAdminInlineTests(TestCase):
266
- def setUp(self):
267
- self.site = AdminSite()
268
- self.factory = RequestFactory()
269
- self.admin = UserAdmin(User, self.site)
270
- self.system_user = User.objects.get(username=User.SYSTEM_USERNAME)
271
- self.superuser = User.objects.create_superuser(
272
- username="inline-super",
273
- email="inline-super@example.com",
274
- password="secret",
275
- )
276
-
277
- def test_profile_inlines_hidden_for_system_user(self):
278
- request = self.factory.get("/")
279
- request.user = self.superuser
280
- system_inlines = self.admin.get_inline_instances(request, self.system_user)
281
- system_profiles = [
282
- inline
283
- for inline in system_inlines
284
- if inline.__class__ in USER_PROFILE_INLINES
285
- ]
286
- self.assertFalse(system_profiles)
287
-
288
- other_inlines = self.admin.get_inline_instances(request, self.superuser)
289
- other_profiles = [
290
- inline
291
- for inline in other_inlines
292
- if inline.__class__ in USER_PROFILE_INLINES
293
- ]
294
- self.assertEqual(len(other_profiles), len(USER_PROFILE_INLINES))
295
-
296
-
297
- class RFIDLoginTests(TestCase):
298
- def setUp(self):
299
- self.client = Client()
300
- self.user = User.objects.create_user(username="alice", password="secret")
301
- self.account = EnergyAccount.objects.create(user=self.user, name="ALICE")
302
- tag = RFID.objects.create(rfid="CARD123")
303
- self.account.rfids.add(tag)
304
-
305
- def test_rfid_login_success(self):
306
- response = self.client.post(
307
- reverse("rfid-login"),
308
- data={"rfid": "CARD123"},
309
- content_type="application/json",
310
- )
311
- self.assertEqual(response.status_code, 200)
312
- self.assertEqual(response.json()["username"], "alice")
313
-
314
- def test_rfid_login_invalid(self):
315
- response = self.client.post(
316
- reverse("rfid-login"),
317
- data={"rfid": "UNKNOWN"},
318
- content_type="application/json",
319
- )
320
- self.assertEqual(response.status_code, 401)
321
-
322
-
323
- class RFIDBatchApiTests(TestCase):
324
- def setUp(self):
325
- self.client = Client()
326
- self.user = User.objects.create_user(username="bob", password="secret")
327
- self.account = EnergyAccount.objects.create(user=self.user, name="BOB")
328
- self.client.force_login(self.user)
329
-
330
- def test_export_rfids(self):
331
- tag_black = RFID.objects.create(rfid="CARD999", custom_label="Main Tag")
332
- tag_white = RFID.objects.create(rfid="CARD998", color=RFID.WHITE)
333
- self.account.rfids.add(tag_black, tag_white)
334
- response = self.client.get(reverse("rfid-batch"))
335
- self.assertEqual(response.status_code, 200)
336
- self.assertEqual(
337
- response.json(),
338
- {
339
- "rfids": [
340
- {
341
- "rfid": "CARD999",
342
- "custom_label": "Main Tag",
343
- "energy_accounts": [self.account.id],
344
- "allowed": True,
345
- "color": "B",
346
- "released": False,
347
- }
348
- ]
349
- },
350
- )
351
-
352
- def test_export_rfids_color_filter(self):
353
- RFID.objects.create(rfid="CARD111", color=RFID.WHITE)
354
- response = self.client.get(reverse("rfid-batch"), {"color": "W"})
355
- self.assertEqual(
356
- response.json(),
357
- {
358
- "rfids": [
359
- {
360
- "rfid": "CARD111",
361
- "custom_label": "",
362
- "energy_accounts": [],
363
- "allowed": True,
364
- "color": "W",
365
- "released": False,
366
- }
367
- ]
368
- },
369
- )
370
-
371
- def test_export_rfids_released_filter(self):
372
- RFID.objects.create(rfid="CARD112", released=True)
373
- RFID.objects.create(rfid="CARD113", released=False)
374
- response = self.client.get(reverse("rfid-batch"), {"released": "true"})
375
- self.assertEqual(
376
- response.json(),
377
- {
378
- "rfids": [
379
- {
380
- "rfid": "CARD112",
381
- "custom_label": "",
382
- "energy_accounts": [],
383
- "allowed": True,
384
- "color": "B",
385
- "released": True,
386
- }
387
- ]
388
- },
389
- )
390
-
391
- def test_import_rfids(self):
392
- data = {
393
- "rfids": [
394
- {
395
- "rfid": "A1B2C3D4",
396
- "custom_label": "Imported Tag",
397
- "energy_accounts": [self.account.id],
398
- "allowed": True,
399
- "color": "W",
400
- "released": True,
401
- }
402
- ]
403
- }
404
- response = self.client.post(
405
- reverse("rfid-batch"),
406
- data=json.dumps(data),
407
- content_type="application/json",
408
- )
409
- self.assertEqual(response.status_code, 200)
410
- self.assertEqual(response.json()["imported"], 1)
411
- self.assertTrue(
412
- RFID.objects.filter(
413
- rfid="A1B2C3D4",
414
- custom_label="Imported Tag",
415
- energy_accounts=self.account,
416
- color=RFID.WHITE,
417
- released=True,
418
- ).exists()
419
- )
420
-
421
-
422
- class AllowedRFIDTests(TestCase):
423
- def setUp(self):
424
- self.user = User.objects.create_user(username="eve", password="secret")
425
- self.account = EnergyAccount.objects.create(user=self.user, name="EVE")
426
- self.rfid = RFID.objects.create(rfid="BAD123")
427
- self.account.rfids.add(self.rfid)
428
-
429
- def test_disallow_removes_and_blocks(self):
430
- self.rfid.allowed = False
431
- self.rfid.save()
432
- self.account.refresh_from_db()
433
- self.assertFalse(self.account.rfids.exists())
434
-
435
- with self.assertRaises(IntegrityError):
436
- RFID.objects.create(rfid="BAD123")
437
-
438
-
439
- class RFIDValidationTests(TestCase):
440
- def test_invalid_format_raises(self):
441
- tag = RFID(rfid="xyz")
442
- with self.assertRaises(ValidationError):
443
- tag.full_clean()
444
-
445
- def test_lowercase_saved_uppercase(self):
446
- tag = RFID.objects.create(rfid="deadbeef")
447
- self.assertEqual(tag.rfid, "DEADBEEF")
448
-
449
- def test_long_rfid_allowed(self):
450
- tag = RFID.objects.create(rfid="DEADBEEF10")
451
- self.assertEqual(tag.rfid, "DEADBEEF10")
452
-
453
- def test_find_user_by_rfid(self):
454
- user = User.objects.create_user(username="finder", password="pwd")
455
- acc = EnergyAccount.objects.create(user=user, name="FINDER")
456
- tag = RFID.objects.create(rfid="ABCD1234")
457
- acc.rfids.add(tag)
458
- found = RFID.get_account_by_rfid("abcd1234")
459
- self.assertEqual(found, acc)
460
-
461
- def test_custom_label_length(self):
462
- tag = RFID(rfid="FACE1234", custom_label="x" * 33)
463
- with self.assertRaises(ValidationError):
464
- tag.full_clean()
465
-
466
-
467
- class RFIDAssignmentTests(TestCase):
468
- def setUp(self):
469
- self.user1 = User.objects.create_user(username="user1", password="x")
470
- self.user2 = User.objects.create_user(username="user2", password="x")
471
- self.acc1 = EnergyAccount.objects.create(user=self.user1, name="USER1")
472
- self.acc2 = EnergyAccount.objects.create(user=self.user2, name="USER2")
473
- self.tag = RFID.objects.create(rfid="ABCDEF12")
474
-
475
- def test_rfid_can_only_attach_to_one_account(self):
476
- self.acc1.rfids.add(self.tag)
477
- with self.assertRaises(ValidationError):
478
- self.acc2.rfids.add(self.tag)
479
-
480
-
481
- class EnergyAccountTests(TestCase):
482
- def test_balance_calculation(self):
483
- user = User.objects.create_user(username="balance", password="x")
484
- acc = EnergyAccount.objects.create(user=user, name="BALANCE")
485
- EnergyCredit.objects.create(account=acc, amount_kw=50)
486
- charger = Charger.objects.create(charger_id="T1")
487
- Transaction.objects.create(
488
- charger=charger,
489
- account=acc,
490
- meter_start=0,
491
- meter_stop=20,
492
- start_time=timezone.now(),
493
- stop_time=timezone.now(),
494
- )
495
- self.assertEqual(acc.total_kw_spent, 20)
496
- self.assertEqual(acc.balance_kw, 30)
497
-
498
- def test_authorization_requires_positive_balance(self):
499
- user = User.objects.create_user(username="auth", password="x")
500
- acc = EnergyAccount.objects.create(user=user, name="AUTH")
501
- self.assertFalse(acc.can_authorize())
502
-
503
- EnergyCredit.objects.create(account=acc, amount_kw=5)
504
- self.assertTrue(acc.can_authorize())
505
-
506
- def test_service_account_ignores_balance(self):
507
- user = User.objects.create_user(username="service", password="x")
508
- acc = EnergyAccount.objects.create(
509
- user=user, service_account=True, name="SERVICE"
510
- )
511
- self.assertTrue(acc.can_authorize())
512
-
513
- def test_account_without_user(self):
514
- acc = EnergyAccount.objects.create(name="NOUSER")
515
- tag = RFID.objects.create(rfid="NOUSER1")
516
- acc.rfids.add(tag)
517
- self.assertIsNone(acc.user)
518
- self.assertTrue(acc.rfids.filter(rfid="NOUSER1").exists())
519
-
520
-
521
- class ElectricVehicleTests(TestCase):
522
- def test_account_can_have_multiple_vehicles(self):
523
- user = User.objects.create_user(username="cars", password="x")
524
- acc = EnergyAccount.objects.create(user=user, name="CARS")
525
- tesla = Brand.objects.create(name="Tesla")
526
- nissan = Brand.objects.create(name="Nissan")
527
- model_s = EVModel.objects.create(brand=tesla, name="Model S")
528
- leaf = EVModel.objects.create(brand=nissan, name="Leaf")
529
- ElectricVehicle.objects.create(
530
- account=acc, brand=tesla, model=model_s, vin="VIN12345678901234"
531
- )
532
- ElectricVehicle.objects.create(
533
- account=acc, brand=nissan, model=leaf, vin="VIN23456789012345"
534
- )
535
- self.assertEqual(acc.vehicles.count(), 2)
536
-
537
-
538
- class AddressTests(TestCase):
539
- def test_invalid_municipality_state(self):
540
- addr = Address(
541
- street="Main",
542
- number="1",
543
- municipality="Monterrey",
544
- state=Address.State.COAHUILA,
545
- postal_code="00000",
546
- )
547
- with self.assertRaises(ValidationError):
548
- addr.full_clean()
549
-
550
- def test_user_link(self):
551
- addr = Address.objects.create(
552
- street="Main",
553
- number="2",
554
- municipality="Monterrey",
555
- state=Address.State.NUEVO_LEON,
556
- postal_code="64000",
557
- )
558
- user = User.objects.create_user(username="addr", password="pwd", address=addr)
559
- self.assertEqual(user.address, addr)
560
-
561
-
562
- class PublicWifiUtilitiesTests(TestCase):
563
- def setUp(self):
564
- self.user = User.objects.create_user(username="wifi", password="pwd")
565
-
566
- def test_grant_public_access_records_allowlist(self):
567
- with tempfile.TemporaryDirectory() as tmp:
568
- base = Path(tmp)
569
- allow_file = base / "locks" / "public_wifi_allow.list"
570
- with override_settings(BASE_DIR=base):
571
- with patch("core.public_wifi._iptables_available", return_value=False):
572
- public_wifi.grant_public_access(self.user, "AA:BB:CC:DD:EE:FF")
573
- self.assertTrue(allow_file.exists())
574
- content = allow_file.read_text()
575
- self.assertIn("aa:bb:cc:dd:ee:ff", content)
576
- self.assertTrue(
577
- PublicWifiAccess.objects.filter(
578
- user=self.user, mac_address="aa:bb:cc:dd:ee:ff"
579
- ).exists()
580
- )
581
-
582
- def test_revoke_public_access_for_user_updates_allowlist(self):
583
- with tempfile.TemporaryDirectory() as tmp:
584
- base = Path(tmp)
585
- allow_file = base / "locks" / "public_wifi_allow.list"
586
- with override_settings(BASE_DIR=base):
587
- with patch("core.public_wifi._iptables_available", return_value=False):
588
- access = public_wifi.grant_public_access(
589
- self.user, "AA:BB:CC:DD:EE:FF"
590
- )
591
- public_wifi.revoke_public_access_for_user(self.user)
592
- access.refresh_from_db()
593
- self.assertIsNotNone(access.revoked_on)
594
- if allow_file.exists():
595
- self.assertNotIn("aa:bb:cc:dd:ee:ff", allow_file.read_text())
596
-
597
-
598
- class LiveSubscriptionTests(TestCase):
599
- def setUp(self):
600
- self.client = Client()
601
- self.user = User.objects.create_user(username="bob", password="pwd")
602
- self.account = EnergyAccount.objects.create(user=self.user, name="SUBSCRIBER")
603
- self.product = Product.objects.create(name="Gold", renewal_period=30)
604
- self.client.force_login(self.user)
605
-
606
- def test_create_and_list_live_subscription(self):
607
- response = self.client.post(
608
- reverse("add-live-subscription"),
609
- data={"account_id": self.account.id, "product_id": self.product.id},
610
- content_type="application/json",
611
- )
612
- self.assertEqual(response.status_code, 200)
613
- self.account.refresh_from_db()
614
- self.assertEqual(
615
- self.account.live_subscription_product,
616
- self.product,
617
- )
618
- self.assertIsNotNone(self.account.live_subscription_start_date)
619
- self.assertEqual(
620
- self.account.live_subscription_start_date,
621
- timezone.localdate(),
622
- )
623
- self.assertEqual(
624
- self.account.live_subscription_next_renewal,
625
- self.account.live_subscription_start_date
626
- + timedelta(days=self.product.renewal_period),
627
- )
628
-
629
- list_resp = self.client.get(
630
- reverse("live-subscription-list"), {"account_id": self.account.id}
631
- )
632
- self.assertEqual(list_resp.status_code, 200)
633
- data = list_resp.json()
634
- self.assertEqual(len(data["live_subscriptions"]), 1)
635
- self.assertEqual(data["live_subscriptions"][0]["product__name"], "Gold")
636
- self.assertEqual(data["live_subscriptions"][0]["id"], self.account.id)
637
- self.assertEqual(
638
- data["live_subscriptions"][0]["next_renewal"],
639
- str(self.account.live_subscription_next_renewal),
640
- )
641
-
642
- def test_product_list(self):
643
- response = self.client.get(reverse("product-list"))
644
- self.assertEqual(response.status_code, 200)
645
- data = response.json()
646
- self.assertEqual(len(data["products"]), 1)
647
- self.assertEqual(data["products"][0]["name"], "Gold")
648
-
649
-
650
- class OnboardingWizardTests(TestCase):
651
- def setUp(self):
652
- self.client = Client()
653
- User.objects.create_superuser("super", "super@example.com", "pwd")
654
- self.client.force_login(User.objects.get(username="super"))
655
-
656
- def test_onboarding_flow_creates_account(self):
657
- details_url = reverse("admin:core_energyaccount_onboard_details")
658
- response = self.client.get(details_url)
659
- self.assertEqual(response.status_code, 200)
660
- data = {
661
- "first_name": "John",
662
- "last_name": "Doe",
663
- "rfid": "ABCD1234",
664
- "vehicle_id": "VIN12345678901234",
665
- }
666
- resp = self.client.post(details_url, data)
667
- self.assertEqual(resp.status_code, 302)
668
- self.assertEqual(resp.url, reverse("admin:core_energyaccount_changelist"))
669
- user = User.objects.get(first_name="John", last_name="Doe")
670
- self.assertFalse(user.is_active)
671
- account = EnergyAccount.objects.get(user=user)
672
- self.assertTrue(account.rfids.filter(rfid="ABCD1234").exists())
673
- self.assertTrue(account.vehicles.filter(vin="VIN12345678901234").exists())
674
-
675
-
676
- class EVBrandFixtureTests(TestCase):
677
- def test_ev_brand_fixture_loads(self):
678
- call_command(
679
- "loaddata",
680
- *sorted(glob("core/fixtures/ev_brands__*.json")),
681
- *sorted(glob("core/fixtures/ev_models__*.json")),
682
- verbosity=0,
683
- )
684
- porsche = Brand.objects.get(name="Porsche")
685
- audi = Brand.objects.get(name="Audi")
686
- self.assertTrue(
687
- {"WP0", "WP1"} <= set(porsche.wmi_codes.values_list("code", flat=True))
688
- )
689
- self.assertTrue(
690
- set(audi.wmi_codes.values_list("code", flat=True)) >= {"WAU", "TRU"}
691
- )
692
- self.assertTrue(EVModel.objects.filter(brand=porsche, name="Taycan").exists())
693
- self.assertTrue(EVModel.objects.filter(brand=audi, name="e-tron GT").exists())
694
- self.assertTrue(EVModel.objects.filter(brand=porsche, name="Macan").exists())
695
- model3 = EVModel.objects.get(brand__name="Tesla", name="Model 3 RWD")
696
- self.assertEqual(model3.est_battery_kwh, Decimal("57.50"))
697
-
698
- def test_brand_from_vin(self):
699
- call_command(
700
- "loaddata",
701
- *sorted(glob("core/fixtures/ev_brands__*.json")),
702
- verbosity=0,
703
- )
704
- self.assertEqual(Brand.from_vin("WP0ZZZ12345678901").name, "Porsche")
705
- self.assertEqual(Brand.from_vin("WAUZZZ12345678901").name, "Audi")
706
- self.assertIsNone(Brand.from_vin("XYZ12345678901234"))
707
-
708
-
709
- class RFIDFixtureTests(TestCase):
710
- def test_fixture_assigns_gelectriic_rfid(self):
711
- call_command(
712
- "loaddata",
713
- "core/fixtures/users__arthexis.json",
714
- "core/fixtures/energy_accounts__gelectriic.json",
715
- "core/fixtures/rfids__ffffffff.json",
716
- verbosity=0,
717
- )
718
- account = EnergyAccount.objects.get(name="GELECTRIIC")
719
- tag = RFID.objects.get(rfid="FFFFFFFF")
720
- self.assertIn(account, tag.energy_accounts.all())
721
- self.assertEqual(tag.energy_accounts.count(), 1)
722
-
723
-
724
- class RFIDKeyVerificationFlagTests(TestCase):
725
- def test_flags_reset_on_key_change(self):
726
- tag = RFID.objects.create(
727
- rfid="ABC12345", key_a_verified=True, key_b_verified=True
728
- )
729
- tag.key_a = "A1A1A1A1A1A1"
730
- tag.save()
731
- self.assertFalse(tag.key_a_verified)
732
- tag.key_b = "B1B1B1B1B1B1"
733
- tag.save()
734
- self.assertFalse(tag.key_b_verified)
735
-
736
-
737
- class SecurityGroupTests(TestCase):
738
- def test_parent_and_user_assignment(self):
739
- parent = SecurityGroup.objects.create(name="Parents")
740
- child = SecurityGroup.objects.create(name="Children", parent=parent)
741
- user = User.objects.create_user(username="sg_user", password="secret")
742
- child.user_set.add(user)
743
- self.assertEqual(child.parent, parent)
744
- self.assertIn(user, child.user_set.all())
745
-
746
-
747
- class ReleaseProcessTests(TestCase):
748
- def setUp(self):
749
- self.package = Package.objects.create(name="pkg")
750
- self.release = PackageRelease.objects.create(
751
- package=self.package, version="1.0.0"
752
- )
753
-
754
- @mock.patch("core.views.release_utils._git_clean", return_value=False)
755
- def test_step_check_requires_clean_repo(self, git_clean):
756
- with self.assertRaises(Exception):
757
- _step_check_version(self.release, {}, Path("rel.log"))
758
-
759
- @mock.patch("core.views.release_utils._git_clean", return_value=True)
760
- @mock.patch("core.views.release_utils.network_available", return_value=False)
761
- def test_step_check_keeps_repo_clean(self, network_available, git_clean):
762
- version_path = Path("VERSION")
763
- original = version_path.read_text(encoding="utf-8")
764
- _step_check_version(self.release, {}, Path("rel.log"))
765
- proc = subprocess.run(
766
- ["git", "status", "--porcelain", str(version_path)],
767
- capture_output=True,
768
- text=True,
769
- )
770
- self.assertFalse(proc.stdout.strip())
771
- self.assertEqual(version_path.read_text(encoding="utf-8"), original)
772
-
773
- @mock.patch("core.views.requests.get")
774
- @mock.patch("core.views.release_utils.network_available", return_value=True)
775
- @mock.patch("core.views.release_utils._git_clean", return_value=True)
776
- def test_step_check_ignores_yanked_release(
777
- self, git_clean, network_available, requests_get
778
- ):
779
- response = mock.Mock()
780
- response.ok = True
781
- response.json.return_value = {
782
- "releases": {
783
- "0.1.12": [
784
- {"filename": "pkg.whl", "yanked": True},
785
- {"filename": "pkg.tar.gz", "yanked": True},
786
- ]
787
- }
788
- }
789
- requests_get.return_value = response
790
- self.release.version = "0.1.12"
791
- _step_check_version(self.release, {}, Path("rel.log"))
792
- requests_get.assert_called_once()
793
-
794
- @mock.patch("core.views.requests.get")
795
- @mock.patch("core.views.release_utils.network_available", return_value=True)
796
- @mock.patch("core.views.release_utils._git_clean", return_value=True)
797
- def test_step_check_blocks_available_release(
798
- self, git_clean, network_available, requests_get
799
- ):
800
- response = mock.Mock()
801
- response.ok = True
802
- response.json.return_value = {
803
- "releases": {
804
- "0.1.12": [
805
- {"filename": "pkg.whl", "yanked": False},
806
- {"filename": "pkg.tar.gz"},
807
- ]
808
- }
809
- }
810
- requests_get.return_value = response
811
- self.release.version = "0.1.12"
812
- with self.assertRaises(Exception) as exc:
813
- _step_check_version(self.release, {}, Path("rel.log"))
814
- self.assertIn("already on PyPI", str(exc.exception))
815
- requests_get.assert_called_once()
816
-
817
- @mock.patch("core.models.PackageRelease.dump_fixture")
818
- def test_save_does_not_dump_fixture(self, dump):
819
- self.release.pypi_url = "https://example.com"
820
- self.release.save()
821
- dump.assert_not_called()
822
-
823
- @mock.patch("core.views.subprocess.run")
824
- @mock.patch("core.views.PackageRelease.dump_fixture")
825
- @mock.patch("core.views.release_utils.promote", side_effect=Exception("boom"))
826
- def test_promote_cleans_repo_on_failure(self, promote, dump_fixture, run):
827
- with self.assertRaises(Exception):
828
- _step_promote_build(self.release, {}, Path("rel.log"))
829
- dump_fixture.assert_not_called()
830
- run.assert_any_call(["git", "reset", "--hard"], check=False)
831
- run.assert_any_call(["git", "clean", "-fd"], check=False)
832
-
833
- @mock.patch("core.views.subprocess.run")
834
- @mock.patch("core.views.PackageRelease.dump_fixture")
835
- @mock.patch("core.views.release_utils.promote")
836
- def test_promote_rebases_and_pushes_main(self, promote, dump_fixture, run):
837
- import subprocess as sp
838
-
839
- def fake_run(cmd, check=True, capture_output=False, text=False):
840
- if capture_output:
841
- return sp.CompletedProcess(cmd, 0, stdout="", stderr="")
842
- return sp.CompletedProcess(cmd, 0)
843
-
844
- run.side_effect = fake_run
845
- _step_promote_build(self.release, {}, Path("rel.log"))
846
- run.assert_any_call(["git", "fetch", "origin", "main"], check=True)
847
- run.assert_any_call(["git", "rebase", "origin/main"], check=True)
848
- run.assert_any_call(["git", "push"], check=True)
849
-
850
- @mock.patch("core.views.subprocess.run")
851
- @mock.patch("core.views.PackageRelease.dump_fixture")
852
- def test_promote_advances_version(self, dump_fixture, run):
853
- import subprocess as sp
854
-
855
- def fake_run(cmd, check=True, capture_output=False, text=False):
856
- if capture_output:
857
- return sp.CompletedProcess(cmd, 0, stdout="", stderr="")
858
- return sp.CompletedProcess(cmd, 0)
859
-
860
- run.side_effect = fake_run
861
-
862
- version_path = Path("VERSION")
863
- original = version_path.read_text(encoding="utf-8")
864
- version_path.write_text("0.0.1\n", encoding="utf-8")
865
-
866
- def fake_promote(*args, **kwargs):
867
- version_path.write_text(self.release.version + "\n", encoding="utf-8")
868
-
869
- with mock.patch("core.views.release_utils.promote", side_effect=fake_promote):
870
- _step_promote_build(self.release, {}, Path("rel.log"))
871
-
872
- self.assertEqual(
873
- version_path.read_text(encoding="utf-8"),
874
- self.release.version + "\n",
875
- )
876
- version_path.write_text(original, encoding="utf-8")
877
-
878
- @mock.patch("core.views.timezone.now")
879
- @mock.patch("core.views.PackageRelease.dump_fixture")
880
- @mock.patch("core.views.release_utils.publish")
881
- def test_publish_sets_pypi_url(self, publish, dump_fixture, now):
882
- now.return_value = datetime(2025, 3, 4, 5, 6, tzinfo=datetime_timezone.utc)
883
- _step_publish(self.release, {}, Path("rel.log"))
884
- self.release.refresh_from_db()
885
- self.assertEqual(
886
- self.release.pypi_url,
887
- f"https://pypi.org/project/{self.package.name}/{self.release.version}/",
888
- )
889
- self.assertEqual(
890
- self.release.release_on,
891
- datetime(2025, 3, 4, 5, 6, tzinfo=datetime_timezone.utc),
892
- )
893
- dump_fixture.assert_called_once()
894
-
895
- @mock.patch("core.views.PackageRelease.dump_fixture")
896
- @mock.patch("core.views.release_utils.publish", side_effect=Exception("boom"))
897
- def test_publish_failure_keeps_url_blank(self, publish, dump_fixture):
898
- with self.assertRaises(Exception):
899
- _step_publish(self.release, {}, Path("rel.log"))
900
- self.release.refresh_from_db()
901
- self.assertEqual(self.release.pypi_url, "")
902
- self.assertIsNone(self.release.release_on)
903
- dump_fixture.assert_not_called()
904
-
905
- def test_new_todo_does_not_reset_pending_flow(self):
906
- user = User.objects.create_superuser("admin", "admin@example.com", "pw")
907
- url = reverse("release-progress", args=[self.release.pk, "publish"])
908
- Todo.objects.create(request="Initial checklist item")
909
- steps = [("Confirm release TODO completion", core_views._step_check_todos)]
910
- with mock.patch("core.views.PUBLISH_STEPS", steps):
911
- self.client.force_login(user)
912
- response = self.client.get(url)
913
- self.assertTrue(response.context["has_pending_todos"])
914
- self.client.get(f"{url}?ack_todos=1")
915
- self.client.get(f"{url}?start=1")
916
- self.client.get(f"{url}?step=0")
917
- Todo.objects.create(request="Follow-up checklist item")
918
- response = self.client.get(url)
919
- self.assertEqual(
920
- Todo.objects.filter(is_deleted=False, done_on__isnull=True).count(),
921
- 1,
922
- )
923
- self.assertIsNone(response.context["todos"])
924
- self.assertFalse(response.context["has_pending_todos"])
925
- session = self.client.session
926
- ctx = session.get(f"release_publish_{self.release.pk}")
927
- self.assertTrue(ctx.get("todos_ack"))
928
-
929
- def test_release_progress_uses_lockfile(self):
930
- run = []
931
-
932
- def step1(release, ctx, log_path):
933
- run.append("step1")
934
-
935
- def step2(release, ctx, log_path):
936
- run.append("step2")
937
-
938
- steps = [("One", step1), ("Two", step2)]
939
- user = User.objects.create_superuser("admin", "admin@example.com", "pw")
940
- url = reverse("release-progress", args=[self.release.pk, "publish"])
941
- with mock.patch("core.views.PUBLISH_STEPS", steps):
942
- self.client.force_login(user)
943
- self.client.get(f"{url}?step=0")
944
- self.assertEqual(run, ["step1"])
945
- client2 = Client()
946
- client2.force_login(user)
947
- client2.get(f"{url}?step=1")
948
- self.assertEqual(run, ["step1", "step2"])
949
- lock_file = Path("locks") / f"release_publish_{self.release.pk}.json"
950
- self.assertFalse(lock_file.exists())
951
-
952
- def test_release_progress_restart(self):
953
- run = []
954
-
955
- def step_fail(release, ctx, log_path):
956
- run.append("step")
957
- raise Exception("boom")
958
-
959
- steps = [("Fail", step_fail)]
960
- user = User.objects.create_superuser("admin", "admin@example.com", "pw")
961
- url = reverse("release-progress", args=[self.release.pk, "publish"])
962
- count_file = Path("locks") / f"release_publish_{self.release.pk}.restarts"
963
- if count_file.exists():
964
- count_file.unlink()
965
- with mock.patch("core.views.PUBLISH_STEPS", steps):
966
- self.client.force_login(user)
967
- self.assertFalse(count_file.exists())
968
- self.client.get(f"{url}?step=0")
969
- self.client.get(f"{url}?step=0")
970
- self.assertEqual(run, ["step"])
971
- self.assertFalse(count_file.exists())
972
- self.client.get(f"{url}?restart=1")
973
- self.assertTrue(count_file.exists())
974
- self.assertEqual(count_file.read_text(), "1")
975
- self.client.get(f"{url}?step=0")
976
- self.assertEqual(run, ["step", "step"])
977
- self.client.get(f"{url}?restart=1")
978
- # Restart counter resets after running a step
979
- self.assertEqual(count_file.read_text(), "1")
980
-
981
-
982
- class ReleaseProgressSyncTests(TestCase):
983
- def setUp(self):
984
- self.client = Client()
985
- self.user = User.objects.create_superuser("admin", "admin@example.com", "pw")
986
- self.client.force_login(self.user)
987
- self.package = Package.objects.get(name="arthexis")
988
- self.version_path = Path("VERSION")
989
- self.original_version = self.version_path.read_text(encoding="utf-8")
990
- self.version_path.write_text("1.2.3", encoding="utf-8")
991
-
992
- def tearDown(self):
993
- self.version_path.write_text(self.original_version, encoding="utf-8")
994
-
995
- @mock.patch("core.views.PackageRelease.dump_fixture")
996
- @mock.patch("core.views.revision.get_revision", return_value="abc123")
997
- def test_unpublished_release_syncs_version_and_revision(
998
- self, get_revision, dump_fixture
999
- ):
1000
- release = PackageRelease.objects.create(
1001
- package=self.package,
1002
- version="1.0.0",
1003
- )
1004
- release.revision = "oldrev"
1005
- release.save(update_fields=["revision"])
1006
-
1007
- url = reverse("release-progress", args=[release.pk, "publish"])
1008
- response = self.client.get(url)
1009
-
1010
- self.assertEqual(response.status_code, 200)
1011
- release.refresh_from_db()
1012
- self.assertEqual(release.version, "1.2.4")
1013
- self.assertEqual(release.revision, "abc123")
1014
- dump_fixture.assert_called_once()
1015
-
1016
- def test_published_release_not_current_returns_404(self):
1017
- release = PackageRelease.objects.create(
1018
- package=self.package,
1019
- version="1.2.4",
1020
- pypi_url="https://example.com",
1021
- )
1022
-
1023
- url = reverse("release-progress", args=[release.pk, "publish"])
1024
- response = self.client.get(url)
1025
-
1026
- self.assertEqual(response.status_code, 404)
1027
-
1028
-
1029
- class ReleaseProgressFixtureVisibilityTests(TestCase):
1030
- def setUp(self):
1031
- self.client = Client()
1032
- self.user = User.objects.create_superuser(
1033
- "fixture-check", "fixture@example.com", "pw"
1034
- )
1035
- self.client.force_login(self.user)
1036
- current_version = Path("VERSION").read_text(encoding="utf-8").strip()
1037
- package = Package.objects.filter(is_active=True).first()
1038
- if package is None:
1039
- package = Package.objects.create(name="fixturepkg", is_active=True)
1040
- try:
1041
- self.release = PackageRelease.objects.get(
1042
- package=package, version=current_version
1043
- )
1044
- except PackageRelease.DoesNotExist:
1045
- self.release = PackageRelease.objects.create(
1046
- package=package, version=current_version
1047
- )
1048
- self.session_key = f"release_publish_{self.release.pk}"
1049
- self.log_name = core_views._release_log_name(
1050
- self.release.package.name, self.release.version
1051
- )
1052
- self.lock_path = Path("locks") / f"{self.session_key}.json"
1053
- self.restart_path = Path("locks") / f"{self.session_key}.restarts"
1054
- self.log_path = Path("logs") / self.log_name
1055
- for path in (self.lock_path, self.restart_path, self.log_path):
1056
- if path.exists():
1057
- path.unlink()
1058
- try:
1059
- self.fixture_step_index = next(
1060
- idx
1061
- for idx, (name, _) in enumerate(core_views.PUBLISH_STEPS)
1062
- if name == core_views.FIXTURE_REVIEW_STEP_NAME
1063
- )
1064
- except StopIteration: # pragma: no cover - defensive guard
1065
- self.fail("Fixture review step not configured in publish steps")
1066
- self.url = reverse("release-progress", args=[self.release.pk, "publish"])
1067
-
1068
- def tearDown(self):
1069
- session = self.client.session
1070
- if self.session_key in session:
1071
- session.pop(self.session_key)
1072
- session.save()
1073
- for path in (self.lock_path, self.restart_path, self.log_path):
1074
- if path.exists():
1075
- path.unlink()
1076
- super().tearDown()
1077
-
1078
- def _set_session(self, step: int, fixtures: list[dict]):
1079
- session = self.client.session
1080
- session[self.session_key] = {
1081
- "step": step,
1082
- "fixtures": fixtures,
1083
- "log": self.log_name,
1084
- "started": True,
1085
- }
1086
- session.save()
1087
-
1088
- def test_fixture_summary_visible_until_migration_step(self):
1089
- fixtures = [
1090
- {
1091
- "path": "core/fixtures/example.json",
1092
- "count": 2,
1093
- "models": ["core.Model"],
1094
- }
1095
- ]
1096
- self._set_session(self.fixture_step_index, fixtures)
1097
- response = self.client.get(self.url)
1098
- self.assertEqual(response.status_code, 200)
1099
- self.assertEqual(response.context["fixtures"], fixtures)
1100
- self.assertContains(response, "Fixture changes")
1101
-
1102
- def test_fixture_summary_hidden_after_migration_step(self):
1103
- fixtures = [
1104
- {
1105
- "path": "core/fixtures/example.json",
1106
- "count": 2,
1107
- "models": ["core.Model"],
1108
- }
1109
- ]
1110
- self._set_session(self.fixture_step_index + 1, fixtures)
1111
- response = self.client.get(self.url)
1112
- self.assertEqual(response.status_code, 200)
1113
- self.assertIsNone(response.context["fixtures"])
1114
- self.assertNotContains(response, "Fixture changes")
1115
-
1116
-
1117
- class PackageReleaseAdminActionTests(TestCase):
1118
- def setUp(self):
1119
- self.factory = RequestFactory()
1120
- self.site = AdminSite()
1121
- self.admin = PackageReleaseAdmin(PackageRelease, self.site)
1122
- self.admin.message_user = lambda *args, **kwargs: None
1123
- self.package = Package.objects.create(name="pkg")
1124
- self.package.is_active = True
1125
- self.package.save(update_fields=["is_active"])
1126
- self.release = PackageRelease.objects.create(
1127
- package=self.package,
1128
- version="1.0.0",
1129
- pypi_url="https://pypi.org/project/pkg/1.0.0/",
1130
- )
1131
- self.request = self.factory.get("/")
1132
-
1133
- @mock.patch("core.admin.PackageRelease.dump_fixture")
1134
- @mock.patch("core.admin.requests.get")
1135
- def test_validate_deletes_missing_release(self, mock_get, dump):
1136
- mock_get.return_value.status_code = 404
1137
- self.admin.validate_releases(self.request, PackageRelease.objects.all())
1138
- self.assertEqual(PackageRelease.objects.count(), 0)
1139
- dump.assert_called_once()
1140
-
1141
- @mock.patch("core.admin.PackageRelease.dump_fixture")
1142
- @mock.patch("core.admin.requests.get")
1143
- def test_validate_keeps_existing_release(self, mock_get, dump):
1144
- mock_get.return_value.status_code = 200
1145
- self.admin.validate_releases(self.request, PackageRelease.objects.all())
1146
- self.assertEqual(PackageRelease.objects.count(), 1)
1147
- dump.assert_not_called()
1148
-
1149
- @mock.patch("core.admin.PackageRelease.dump_fixture")
1150
- @mock.patch("core.admin.requests.get")
1151
- def test_refresh_from_pypi_creates_releases(self, mock_get, dump):
1152
- mock_get.return_value.raise_for_status.return_value = None
1153
- mock_get.return_value.json.return_value = {
1154
- "releases": {
1155
- "1.0.0": [
1156
- {"upload_time_iso_8601": "2024-01-01T12:30:00.000000Z"}
1157
- ],
1158
- "1.1.0": [
1159
- {"upload_time_iso_8601": "2024-02-02T15:45:00.000000Z"}
1160
- ],
1161
- }
1162
- }
1163
- self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1164
- new_release = PackageRelease.objects.get(version="1.1.0")
1165
- self.assertEqual(new_release.revision, "")
1166
- self.assertEqual(
1167
- new_release.release_on,
1168
- datetime(2024, 2, 2, 15, 45, tzinfo=datetime_timezone.utc),
1169
- )
1170
- dump.assert_called_once()
1171
-
1172
- @mock.patch("core.admin.PackageRelease.dump_fixture")
1173
- @mock.patch("core.admin.requests.get")
1174
- def test_refresh_from_pypi_updates_release_date(self, mock_get, dump):
1175
- self.release.release_on = None
1176
- self.release.save(update_fields=["release_on"])
1177
- mock_get.return_value.raise_for_status.return_value = None
1178
- mock_get.return_value.json.return_value = {
1179
- "releases": {
1180
- "1.0.0": [
1181
- {"upload_time_iso_8601": "2024-01-01T12:30:00.000000Z"}
1182
- ]
1183
- }
1184
- }
1185
- self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1186
- self.release.refresh_from_db()
1187
- self.assertEqual(
1188
- self.release.release_on,
1189
- datetime(2024, 1, 1, 12, 30, tzinfo=datetime_timezone.utc),
1190
- )
1191
- dump.assert_called_once()
1192
-
1193
- @mock.patch("core.admin.PackageRelease.dump_fixture")
1194
- @mock.patch("core.admin.requests.get")
1195
- def test_refresh_from_pypi_restores_deleted_release(self, mock_get, dump):
1196
- self.release.is_deleted = True
1197
- self.release.save(update_fields=["is_deleted"])
1198
- mock_get.return_value.raise_for_status.return_value = None
1199
- mock_get.return_value.json.return_value = {
1200
- "releases": {
1201
- "1.0.0": [
1202
- {"upload_time_iso_8601": "2024-01-01T12:30:00.000000Z"}
1203
- ]
1204
- }
1205
- }
1206
-
1207
- self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1208
-
1209
- self.assertTrue(
1210
- PackageRelease.objects.filter(version="1.0.0").exists()
1211
- )
1212
- dump.assert_called_once()
1213
-
1214
-
1215
- class PackageActiveTests(TestCase):
1216
- def test_only_one_active_package(self):
1217
- default = Package.objects.get(name="arthexis")
1218
- self.assertTrue(default.is_active)
1219
- other = Package.objects.create(name="pkg", is_active=True)
1220
- default.refresh_from_db()
1221
- other.refresh_from_db()
1222
- self.assertFalse(default.is_active)
1223
- self.assertTrue(other.is_active)
1224
-
1225
-
1226
- class PackageReleaseCurrentTests(TestCase):
1227
- def setUp(self):
1228
- self.package = Package.objects.get(name="arthexis")
1229
- self.version_path = Path("VERSION")
1230
- self.original = self.version_path.read_text()
1231
- self.version_path.write_text("1.0.0")
1232
- self.release = PackageRelease.objects.create(
1233
- package=self.package, version="1.0.0"
1234
- )
1235
-
1236
- def tearDown(self):
1237
- self.version_path.write_text(self.original)
1238
-
1239
- def test_is_current_true_when_version_matches_and_package_active(self):
1240
- self.assertTrue(self.release.is_current)
1241
-
1242
- def test_is_current_false_when_package_inactive(self):
1243
- self.package.is_active = False
1244
- self.package.save()
1245
- self.assertFalse(self.release.is_current)
1246
-
1247
- def test_is_current_false_when_version_differs(self):
1248
- self.release.version = "2.0.0"
1249
- self.release.save()
1250
- self.assertFalse(self.release.is_current)
1251
-
1252
-
1253
- class PackageReleaseChangelistTests(TestCase):
1254
- def setUp(self):
1255
- self.client = Client()
1256
- User.objects.create_superuser("admin", "admin@example.com", "pw")
1257
- self.client.force_login(User.objects.get(username="admin"))
1258
-
1259
- def test_prepare_next_release_button_present(self):
1260
- response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1261
- prepare_url = reverse(
1262
- "admin:core_packagerelease_actions", args=["prepare_next_release"]
1263
- )
1264
- self.assertContains(response, prepare_url, html=False)
1265
-
1266
- def test_refresh_from_pypi_button_present(self):
1267
- response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1268
- refresh_url = reverse(
1269
- "admin:core_packagerelease_actions", args=["refresh_from_pypi"]
1270
- )
1271
- self.assertContains(response, refresh_url, html=False)
1272
-
1273
- def test_prepare_next_release_action_creates_release(self):
1274
- package = Package.objects.get(name="arthexis")
1275
- PackageRelease.all_objects.filter(package=package).delete()
1276
- response = self.client.post(
1277
- reverse(
1278
- "admin:core_packagerelease_actions", args=["prepare_next_release"]
1279
- )
1280
- )
1281
- self.assertEqual(response.status_code, 302)
1282
- self.assertTrue(
1283
- PackageRelease.all_objects.filter(package=package).exists()
1284
- )
1285
-
1286
-
1287
- class PackageAdminPrepareNextReleaseTests(TestCase):
1288
- def setUp(self):
1289
- self.factory = RequestFactory()
1290
- self.site = AdminSite()
1291
- self.admin = PackageAdmin(Package, self.site)
1292
- self.admin.message_user = lambda *args, **kwargs: None
1293
- self.package = Package.objects.get(name="arthexis")
1294
-
1295
- def test_prepare_next_release_active_creates_release(self):
1296
- PackageRelease.all_objects.filter(package=self.package).delete()
1297
- request = self.factory.get("/admin/core/package/prepare-next-release/")
1298
- response = self.admin.prepare_next_release_active(request)
1299
- self.assertEqual(response.status_code, 302)
1300
- self.assertEqual(
1301
- PackageRelease.all_objects.filter(package=self.package).count(), 1
1302
- )
1303
-
1304
-
1305
- class PackageAdminChangeViewTests(TestCase):
1306
- def setUp(self):
1307
- self.client = Client()
1308
- User.objects.create_superuser("admin", "admin@example.com", "pw")
1309
- self.client.force_login(User.objects.get(username="admin"))
1310
- self.package = Package.objects.get(name="arthexis")
1311
-
1312
- def test_prepare_next_release_button_visible_on_change_view(self):
1313
- response = self.client.get(
1314
- reverse("admin:core_package_change", args=[self.package.pk])
1315
- )
1316
- self.assertContains(response, "Prepare next Release")
1317
-
1318
-
1319
- class TodoDoneTests(TestCase):
1320
- def setUp(self):
1321
- self.client = Client()
1322
- User.objects.create_superuser("admin", "admin@example.com", "pw")
1323
- self.client.force_login(User.objects.get(username="admin"))
1324
-
1325
- def test_mark_done_sets_timestamp(self):
1326
- todo = Todo.objects.create(request="Task", is_seed_data=True)
1327
- resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1328
- self.assertRedirects(resp, reverse("admin:index"))
1329
- todo.refresh_from_db()
1330
- self.assertIsNotNone(todo.done_on)
1331
- self.assertFalse(todo.is_deleted)
1332
-
1333
- def test_mark_done_missing_task_refreshes(self):
1334
- todo = Todo.objects.create(request="Task", is_seed_data=True)
1335
- todo.delete()
1336
- resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1337
- self.assertRedirects(resp, reverse("admin:index"))
1338
- messages = [m.message for m in get_messages(resp.wsgi_request)]
1339
- self.assertFalse(messages)
1340
-
1341
- def test_mark_done_condition_failure_shows_message(self):
1342
- todo = Todo.objects.create(
1343
- request="Task",
1344
- on_done_condition="1 = 0",
1345
- )
1346
- resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1347
- self.assertRedirects(resp, reverse("admin:index"))
1348
- messages = [m.message for m in get_messages(resp.wsgi_request)]
1349
- self.assertTrue(messages)
1350
- self.assertIn("1 = 0", messages[0])
1351
- todo.refresh_from_db()
1352
- self.assertIsNone(todo.done_on)
1353
-
1354
- def test_mark_done_condition_invalid_expression(self):
1355
- todo = Todo.objects.create(
1356
- request="Task",
1357
- on_done_condition="1; SELECT 1",
1358
- )
1359
- resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1360
- self.assertRedirects(resp, reverse("admin:index"))
1361
- messages = [m.message for m in get_messages(resp.wsgi_request)]
1362
- self.assertTrue(messages)
1363
- self.assertIn("Semicolons", messages[0])
1364
- todo.refresh_from_db()
1365
- self.assertIsNone(todo.done_on)
1366
-
1367
- def test_mark_done_condition_resolves_sigils(self):
1368
- todo = Todo.objects.create(
1369
- request="Task",
1370
- on_done_condition="[TEST]",
1371
- )
1372
- with mock.patch.object(Todo, "resolve_sigils", return_value="1 = 1") as resolver:
1373
- resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1374
- self.assertRedirects(resp, reverse("admin:index"))
1375
- resolver.assert_called_once_with("on_done_condition")
1376
- todo.refresh_from_db()
1377
- self.assertIsNotNone(todo.done_on)
1378
-
1379
- def test_mark_done_respects_next_parameter(self):
1380
- todo = Todo.objects.create(request="Task")
1381
- next_url = reverse("admin:index") + "?section=todos"
1382
- resp = self.client.post(
1383
- reverse("todo-done", args=[todo.pk]),
1384
- {"next": next_url},
1385
- )
1386
- self.assertRedirects(resp, next_url, target_status_code=200)
1387
- todo.refresh_from_db()
1388
- self.assertIsNotNone(todo.done_on)
1389
-
1390
- def test_mark_done_rejects_external_next(self):
1391
- todo = Todo.objects.create(request="Task")
1392
- resp = self.client.post(
1393
- reverse("todo-done", args=[todo.pk]),
1394
- {"next": "https://example.com/"},
1395
- )
1396
- self.assertRedirects(resp, reverse("admin:index"))
1397
- todo.refresh_from_db()
1398
- self.assertIsNotNone(todo.done_on)
1399
-
1400
-
1401
- class TodoFocusViewTests(TestCase):
1402
- def setUp(self):
1403
- self.client = Client()
1404
- User.objects.create_superuser("admin", "admin@example.com", "pw")
1405
- self.client.force_login(User.objects.get(username="admin"))
1406
-
1407
- def test_focus_view_renders_requested_page(self):
1408
- todo = Todo.objects.create(request="Task", url="/docs/")
1409
- next_url = reverse("admin:index")
1410
- resp = self.client.get(
1411
- f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
1412
- )
1413
- self.assertEqual(resp.status_code, 200)
1414
- self.assertContains(resp, todo.request)
1415
- self.assertEqual(resp["X-Frame-Options"], "SAMEORIGIN")
1416
- self.assertContains(resp, f'src="{todo.url}"')
1417
- self.assertContains(resp, "Done")
1418
- self.assertContains(resp, "Back")
1419
-
1420
- def test_focus_view_uses_admin_change_when_no_url(self):
1421
- todo = Todo.objects.create(request="Task")
1422
- resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1423
- change_url = reverse("admin:core_todo_change", args=[todo.pk])
1424
- self.assertContains(resp, f'src="{change_url}"')
1425
-
1426
- def test_focus_view_includes_open_target_button(self):
1427
- todo = Todo.objects.create(request="Task", url="/docs/")
1428
- resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1429
- self.assertContains(resp, 'class="todo-button todo-button-open"')
1430
- self.assertContains(resp, 'target="_blank"')
1431
- self.assertContains(resp, 'href="/docs/"')
1432
-
1433
- def test_focus_view_sanitizes_loopback_absolute_url(self):
1434
- todo = Todo.objects.create(
1435
- request="Task",
1436
- url="http://127.0.0.1:8000/docs/?section=chart",
1437
- )
1438
- resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1439
- self.assertContains(resp, 'src="/docs/?section=chart"')
1440
-
1441
- def test_focus_view_rejects_external_absolute_url(self):
1442
- todo = Todo.objects.create(
1443
- request="Task",
1444
- url="https://outside.invalid/external/",
1445
- )
1446
- resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1447
- change_url = reverse("admin:core_todo_change", args=[todo.pk])
1448
- self.assertContains(resp, f'src="{change_url}"')
1449
-
1450
- def test_focus_view_avoids_recursive_focus_url(self):
1451
- todo = Todo.objects.create(request="Task")
1452
- focus_url = reverse("todo-focus", args=[todo.pk])
1453
- Todo.objects.filter(pk=todo.pk).update(url=focus_url)
1454
- resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1455
- change_url = reverse("admin:core_todo_change", args=[todo.pk])
1456
- self.assertContains(resp, f'src="{change_url}"')
1457
-
1458
- def test_focus_view_avoids_recursive_focus_absolute_url(self):
1459
- todo = Todo.objects.create(request="Task")
1460
- focus_url = reverse("todo-focus", args=[todo.pk])
1461
- Todo.objects.filter(pk=todo.pk).update(url=f"http://testserver{focus_url}")
1462
- resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1463
- change_url = reverse("admin:core_todo_change", args=[todo.pk])
1464
- self.assertContains(resp, f'src="{change_url}"')
1465
-
1466
- def test_focus_view_parses_auth_directives(self):
1467
- todo = Todo.objects.create(
1468
- request="Task",
1469
- url="/docs/?section=chart&_todo_auth=logout&_todo_auth=user:demo&_todo_auth=perm:core.view_user&_todo_auth=extra",
1470
- )
1471
- resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1472
- self.assertContains(resp, 'src="/docs/?section=chart"')
1473
- self.assertContains(resp, 'href="/docs/?section=chart"')
1474
- self.assertContains(resp, "logged out")
1475
- self.assertContains(resp, "Sign in using: demo")
1476
- self.assertContains(resp, "Required permissions: core.view_user")
1477
- self.assertContains(resp, "Additional authentication notes: extra")
1478
-
1479
- def test_focus_view_redirects_if_todo_completed(self):
1480
- todo = Todo.objects.create(request="Task")
1481
- todo.done_on = timezone.now()
1482
- todo.save(update_fields=["done_on"])
1483
- next_url = reverse("admin:index")
1484
- resp = self.client.get(
1485
- f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
1486
- )
1487
- self.assertRedirects(resp, next_url, target_status_code=200)
1488
-
1489
-
1490
- class TodoUrlValidationTests(TestCase):
1491
- def test_relative_url_valid(self):
1492
- todo = Todo(request="Task", url="/path")
1493
- todo.full_clean() # should not raise
1494
-
1495
- def test_absolute_url_invalid(self):
1496
- todo = Todo(request="Task", url="https://example.com/path")
1497
- with self.assertRaises(ValidationError):
1498
- todo.full_clean()
1499
-
1500
-
1501
- class TodoUniqueTests(TestCase):
1502
- def test_request_unique_case_insensitive(self):
1503
- Todo.objects.create(request="Task")
1504
- with self.assertRaises(IntegrityError):
1505
- Todo.objects.create(request="task")
1506
-
1507
-
1508
- class TodoAdminPermissionTests(TestCase):
1509
- def setUp(self):
1510
- self.client = Client()
1511
- User.objects.create_superuser("admin", "admin@example.com", "pw")
1512
- self.client.force_login(User.objects.get(username="admin"))
1513
-
1514
- def test_add_view_disallowed(self):
1515
- resp = self.client.get(reverse("admin:core_todo_add"))
1516
- self.assertEqual(resp.status_code, 403)
1517
-
1518
- def test_change_form_loads(self):
1519
- todo = Todo.objects.create(request="Task")
1520
- resp = self.client.get(reverse("admin:core_todo_change", args=[todo.pk]))
1521
- self.assertEqual(resp.status_code, 200)
1
+ import os
2
+
3
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
+ import django
5
+
6
+ django.setup()
7
+
8
+ from django.test import Client, TestCase, RequestFactory, override_settings
9
+ from django.urls import reverse
10
+ from django.http import HttpRequest
11
+ import csv
12
+ import json
13
+ from decimal import Decimal
14
+ from unittest import mock
15
+ from unittest.mock import patch
16
+ from pathlib import Path
17
+ import subprocess
18
+ import types
19
+ from glob import glob
20
+ from datetime import datetime, timedelta, timezone as datetime_timezone
21
+ import tempfile
22
+ from urllib.parse import quote
23
+
24
+ from django.utils import timezone
25
+ from django.contrib.auth.models import Permission
26
+ from django.contrib.messages import get_messages
27
+ from tablib import Dataset
28
+ from .models import (
29
+ User,
30
+ UserPhoneNumber,
31
+ EnergyAccount,
32
+ ElectricVehicle,
33
+ EnergyCredit,
34
+ Product,
35
+ Brand,
36
+ EVModel,
37
+ RFID,
38
+ SecurityGroup,
39
+ Package,
40
+ PackageRelease,
41
+ ReleaseManager,
42
+ Todo,
43
+ PublicWifiAccess,
44
+ )
45
+ from django.contrib.admin.sites import AdminSite
46
+ from core.admin import (
47
+ PackageReleaseAdmin,
48
+ PackageAdmin,
49
+ RFIDResource,
50
+ UserAdmin,
51
+ USER_PROFILE_INLINES,
52
+ )
53
+ from ocpp.models import Transaction, Charger
54
+
55
+ from django.core.exceptions import ValidationError
56
+ from django.core.management import call_command
57
+ from django.db import IntegrityError
58
+ from .backends import LocalhostAdminBackend
59
+ from core.views import (
60
+ _step_check_version,
61
+ _step_pre_release_actions,
62
+ _step_promote_build,
63
+ _step_publish,
64
+ )
65
+ from core import views as core_views
66
+ from core import public_wifi
67
+
68
+
69
+ class DefaultAdminTests(TestCase):
70
+ def test_arthexis_is_default_user(self):
71
+ self.assertTrue(User.objects.filter(username="arthexis").exists())
72
+ self.assertFalse(User.all_objects.filter(username="admin").exists())
73
+
74
+ def test_admin_created_and_local_only(self):
75
+ backend = LocalhostAdminBackend()
76
+ req = HttpRequest()
77
+ req.META["REMOTE_ADDR"] = "127.0.0.1"
78
+ user = backend.authenticate(req, username="admin", password="admin")
79
+ self.assertIsNotNone(user)
80
+ self.assertEqual(user.pk, 2)
81
+
82
+ remote = HttpRequest()
83
+ remote.META["REMOTE_ADDR"] = "10.0.0.1"
84
+ self.assertIsNone(
85
+ backend.authenticate(remote, username="admin", password="admin")
86
+ )
87
+
88
+ def test_admin_respects_forwarded_for(self):
89
+ backend = LocalhostAdminBackend()
90
+
91
+ req = HttpRequest()
92
+ req.META["REMOTE_ADDR"] = "10.0.0.1"
93
+ req.META["HTTP_X_FORWARDED_FOR"] = "127.0.0.1"
94
+ self.assertIsNotNone(
95
+ backend.authenticate(req, username="admin", password="admin"),
96
+ "X-Forwarded-For should permit allowed IP",
97
+ )
98
+
99
+ blocked = HttpRequest()
100
+ blocked.META["REMOTE_ADDR"] = "10.0.0.1"
101
+ blocked.META["HTTP_X_FORWARDED_FOR"] = "8.8.8.8"
102
+ self.assertIsNone(
103
+ backend.authenticate(blocked, username="admin", password="admin")
104
+ )
105
+
106
+
107
+ class UserOperateAsTests(TestCase):
108
+ @classmethod
109
+ def setUpTestData(cls):
110
+ cls.permission = Permission.objects.get(codename="view_todo")
111
+
112
+ def test_staff_user_delegates_permissions(self):
113
+ delegate = User.objects.create_user(username="delegate", password="secret")
114
+ delegate.user_permissions.add(self.permission)
115
+ operator = User.objects.create_user(
116
+ username="operator", password="secret", is_staff=True
117
+ )
118
+ self.assertFalse(operator.has_perm("core.view_todo"))
119
+ operator.operate_as = delegate
120
+ operator.full_clean()
121
+ operator.save()
122
+ operator.refresh_from_db()
123
+ self.assertTrue(operator.has_perm("core.view_todo"))
124
+
125
+ def test_only_staff_may_operate_as(self):
126
+ delegate = User.objects.create_user(username="delegate", password="secret")
127
+ operator = User.objects.create_user(username="operator", password="secret")
128
+ operator.operate_as = delegate
129
+ with self.assertRaises(ValidationError):
130
+ operator.full_clean()
131
+
132
+ def test_non_superuser_cannot_operate_as_staff(self):
133
+ staff_delegate = User.objects.create_user(
134
+ username="delegate", password="secret", is_staff=True
135
+ )
136
+ operator = User.objects.create_user(
137
+ username="operator", password="secret", is_staff=True
138
+ )
139
+ operator.operate_as = staff_delegate
140
+ with self.assertRaises(ValidationError):
141
+ operator.full_clean()
142
+
143
+ def test_recursive_chain_and_cycle_detection(self):
144
+ base = User.objects.create_user(username="base", password="secret")
145
+ base.user_permissions.add(self.permission)
146
+ middle = User.objects.create_user(
147
+ username="middle", password="secret", is_staff=True
148
+ )
149
+ middle.operate_as = base
150
+ middle.full_clean()
151
+ middle.save()
152
+ top = User.objects.create_superuser(
153
+ username="top", email="top@example.com", password="secret"
154
+ )
155
+ top.operate_as = middle
156
+ top.full_clean()
157
+ top.save()
158
+ top.refresh_from_db()
159
+ self.assertTrue(top.has_perm("core.view_todo"))
160
+
161
+ first = User.objects.create_superuser(
162
+ username="first", email="first@example.com", password="secret"
163
+ )
164
+ second = User.objects.create_superuser(
165
+ username="second", email="second@example.com", password="secret"
166
+ )
167
+ first.operate_as = second
168
+ first.full_clean()
169
+ first.save()
170
+ second.operate_as = first
171
+ second.full_clean()
172
+ second.save()
173
+ self.assertFalse(first._check_operate_as_chain(lambda user: False))
174
+
175
+ def test_module_permissions_fall_back(self):
176
+ delegate = User.objects.create_user(username="helper", password="secret")
177
+ delegate.user_permissions.add(self.permission)
178
+ operator = User.objects.create_user(
179
+ username="mod", password="secret", is_staff=True
180
+ )
181
+ operator.operate_as = delegate
182
+ operator.full_clean()
183
+ operator.save()
184
+ self.assertTrue(operator.has_module_perms("core"))
185
+
186
+ def test_has_profile_via_delegate(self):
187
+ delegate = User.objects.create_user(
188
+ username="delegate", password="secret", is_staff=True
189
+ )
190
+ ReleaseManager.objects.create(user=delegate)
191
+ operator = User.objects.create_superuser(
192
+ username="operator",
193
+ email="operator@example.com",
194
+ password="secret",
195
+ )
196
+ operator.operate_as = delegate
197
+ operator.full_clean()
198
+ operator.save()
199
+ profile = operator.get_profile(ReleaseManager)
200
+ self.assertIsNotNone(profile)
201
+ self.assertEqual(profile.user, delegate)
202
+ self.assertTrue(operator.has_profile(ReleaseManager))
203
+
204
+ def test_has_profile_via_group_membership(self):
205
+ member = User.objects.create_user(username="member", password="secret")
206
+ group = SecurityGroup.objects.create(name="Managers")
207
+ group.user_set.add(member)
208
+ profile = ReleaseManager.objects.create(group=group)
209
+ self.assertEqual(member.get_profile(ReleaseManager), profile)
210
+ self.assertTrue(member.has_profile(ReleaseManager))
211
+
212
+ def test_release_manager_property_uses_delegate_profile(self):
213
+ delegate = User.objects.create_user(
214
+ username="delegate-property", password="secret", is_staff=True
215
+ )
216
+ profile = ReleaseManager.objects.create(user=delegate)
217
+ operator = User.objects.create_superuser(
218
+ username="operator-property",
219
+ email="operator-property@example.com",
220
+ password="secret",
221
+ )
222
+ operator.operate_as = delegate
223
+ operator.full_clean()
224
+ operator.save()
225
+ self.assertEqual(operator.release_manager, profile)
226
+
227
+
228
+ class UserPhoneNumberTests(TestCase):
229
+ def test_get_phone_numbers_by_priority(self):
230
+ user = User.objects.create_user(username="phone-user", password="secret")
231
+ later = UserPhoneNumber.objects.create(
232
+ user=user, number="+15555550101", priority=10
233
+ )
234
+ earlier = UserPhoneNumber.objects.create(
235
+ user=user, number="+15555550100", priority=1
236
+ )
237
+ immediate = UserPhoneNumber.objects.create(
238
+ user=user, number="+15555550099", priority=0
239
+ )
240
+
241
+ phones = user.get_phones_by_priority()
242
+ self.assertEqual(phones, [immediate, earlier, later])
243
+
244
+ def test_get_phone_numbers_by_priority_orders_by_id_when_equal(self):
245
+ user = User.objects.create_user(username="phone-order", password="secret")
246
+ first = UserPhoneNumber.objects.create(
247
+ user=user, number="+19995550000", priority=0
248
+ )
249
+ second = UserPhoneNumber.objects.create(
250
+ user=user, number="+19995550001", priority=0
251
+ )
252
+
253
+ phones = user.get_phones_by_priority()
254
+ self.assertEqual(phones, [first, second])
255
+
256
+ def test_get_phone_numbers_by_priority_alias(self):
257
+ user = User.objects.create_user(username="phone-alias", password="secret")
258
+ phone = UserPhoneNumber.objects.create(
259
+ user=user, number="+14445550000", priority=3
260
+ )
261
+
262
+ self.assertEqual(user.get_phone_numbers_by_priority(), [phone])
263
+
264
+
265
+ class ProfileValidationTests(TestCase):
266
+ def test_system_user_cannot_receive_profiles(self):
267
+ system_user = User.objects.get(username=User.SYSTEM_USERNAME)
268
+ profile = ReleaseManager(user=system_user)
269
+ with self.assertRaises(ValidationError) as exc:
270
+ profile.full_clean()
271
+ self.assertIn("user", exc.exception.error_dict)
272
+
273
+
274
+ class UserAdminInlineTests(TestCase):
275
+ def setUp(self):
276
+ self.site = AdminSite()
277
+ self.factory = RequestFactory()
278
+ self.admin = UserAdmin(User, self.site)
279
+ self.system_user = User.objects.get(username=User.SYSTEM_USERNAME)
280
+ self.superuser = User.objects.create_superuser(
281
+ username="inline-super",
282
+ email="inline-super@example.com",
283
+ password="secret",
284
+ )
285
+
286
+ def test_profile_inlines_hidden_for_system_user(self):
287
+ request = self.factory.get("/")
288
+ request.user = self.superuser
289
+ system_inlines = self.admin.get_inline_instances(request, self.system_user)
290
+ system_profiles = [
291
+ inline
292
+ for inline in system_inlines
293
+ if inline.__class__ in USER_PROFILE_INLINES
294
+ ]
295
+ self.assertFalse(system_profiles)
296
+
297
+ other_inlines = self.admin.get_inline_instances(request, self.superuser)
298
+ other_profiles = [
299
+ inline
300
+ for inline in other_inlines
301
+ if inline.__class__ in USER_PROFILE_INLINES
302
+ ]
303
+ self.assertEqual(len(other_profiles), len(USER_PROFILE_INLINES))
304
+
305
+
306
+ class RFIDLoginTests(TestCase):
307
+ def setUp(self):
308
+ self.client = Client()
309
+ self.user = User.objects.create_user(username="alice", password="secret")
310
+ self.account = EnergyAccount.objects.create(user=self.user, name="ALICE")
311
+ tag = RFID.objects.create(rfid="CARD123")
312
+ self.account.rfids.add(tag)
313
+
314
+ def test_rfid_login_success(self):
315
+ response = self.client.post(
316
+ reverse("rfid-login"),
317
+ data={"rfid": "CARD123"},
318
+ content_type="application/json",
319
+ )
320
+ self.assertEqual(response.status_code, 200)
321
+ self.assertEqual(response.json()["username"], "alice")
322
+
323
+ def test_rfid_login_invalid(self):
324
+ response = self.client.post(
325
+ reverse("rfid-login"),
326
+ data={"rfid": "UNKNOWN"},
327
+ content_type="application/json",
328
+ )
329
+ self.assertEqual(response.status_code, 401)
330
+
331
+ @patch("core.backends.subprocess.run")
332
+ def test_rfid_login_external_command_success(self, mock_run):
333
+ tag = self.account.rfids.first()
334
+ tag.external_command = "echo ok"
335
+ tag.save(update_fields=["external_command"])
336
+ mock_run.return_value = types.SimpleNamespace(returncode=0)
337
+
338
+ response = self.client.post(
339
+ reverse("rfid-login"),
340
+ data={"rfid": "CARD123"},
341
+ content_type="application/json",
342
+ )
343
+
344
+ self.assertEqual(response.status_code, 200)
345
+ run_args, run_kwargs = mock_run.call_args
346
+ self.assertEqual(run_args[0], "echo ok")
347
+ self.assertTrue(run_kwargs.get("shell"))
348
+ env = run_kwargs.get("env", {})
349
+ self.assertEqual(env.get("RFID_VALUE"), "CARD123")
350
+ self.assertEqual(env.get("RFID_LABEL_ID"), str(tag.pk))
351
+
352
+ @patch("core.backends.subprocess.run")
353
+ def test_rfid_login_external_command_failure(self, mock_run):
354
+ tag = self.account.rfids.first()
355
+ tag.external_command = "exit 1"
356
+ tag.save(update_fields=["external_command"])
357
+ mock_run.return_value = types.SimpleNamespace(returncode=1)
358
+
359
+ response = self.client.post(
360
+ reverse("rfid-login"),
361
+ data={"rfid": "CARD123"},
362
+ content_type="application/json",
363
+ )
364
+
365
+ self.assertEqual(response.status_code, 401)
366
+ mock_run.assert_called_once()
367
+
368
+
369
+ class RFIDBatchApiTests(TestCase):
370
+ def setUp(self):
371
+ self.client = Client()
372
+ self.user = User.objects.create_user(username="bob", password="secret")
373
+ self.account = EnergyAccount.objects.create(user=self.user, name="BOB")
374
+ self.client.force_login(self.user)
375
+
376
+ def test_export_rfids(self):
377
+ tag_black = RFID.objects.create(rfid="CARD999", custom_label="Main Tag")
378
+ tag_white = RFID.objects.create(rfid="CARD998", color=RFID.WHITE)
379
+ self.account.rfids.add(tag_black, tag_white)
380
+ response = self.client.get(reverse("rfid-batch"))
381
+ self.assertEqual(response.status_code, 200)
382
+ self.assertEqual(
383
+ response.json(),
384
+ {
385
+ "rfids": [
386
+ {
387
+ "rfid": "CARD999",
388
+ "custom_label": "Main Tag",
389
+ "energy_accounts": [self.account.id],
390
+ "allowed": True,
391
+ "color": "B",
392
+ "released": False,
393
+ }
394
+ ]
395
+ },
396
+ )
397
+
398
+ def test_export_rfids_color_filter(self):
399
+ RFID.objects.create(rfid="CARD111", color=RFID.WHITE)
400
+ response = self.client.get(reverse("rfid-batch"), {"color": "W"})
401
+ self.assertEqual(
402
+ response.json(),
403
+ {
404
+ "rfids": [
405
+ {
406
+ "rfid": "CARD111",
407
+ "custom_label": "",
408
+ "energy_accounts": [],
409
+ "allowed": True,
410
+ "color": "W",
411
+ "released": False,
412
+ }
413
+ ]
414
+ },
415
+ )
416
+
417
+ def test_export_rfids_released_filter(self):
418
+ RFID.objects.create(rfid="CARD112", released=True)
419
+ RFID.objects.create(rfid="CARD113", released=False)
420
+ response = self.client.get(reverse("rfid-batch"), {"released": "true"})
421
+ self.assertEqual(
422
+ response.json(),
423
+ {
424
+ "rfids": [
425
+ {
426
+ "rfid": "CARD112",
427
+ "custom_label": "",
428
+ "energy_accounts": [],
429
+ "allowed": True,
430
+ "color": "B",
431
+ "released": True,
432
+ }
433
+ ]
434
+ },
435
+ )
436
+
437
+ def test_import_rfids(self):
438
+ data = {
439
+ "rfids": [
440
+ {
441
+ "rfid": "A1B2C3D4",
442
+ "custom_label": "Imported Tag",
443
+ "energy_accounts": [self.account.id],
444
+ "allowed": True,
445
+ "color": "W",
446
+ "released": True,
447
+ }
448
+ ]
449
+ }
450
+ response = self.client.post(
451
+ reverse("rfid-batch"),
452
+ data=json.dumps(data),
453
+ content_type="application/json",
454
+ )
455
+ self.assertEqual(response.status_code, 200)
456
+ self.assertEqual(response.json()["imported"], 1)
457
+ self.assertTrue(
458
+ RFID.objects.filter(
459
+ rfid="A1B2C3D4",
460
+ custom_label="Imported Tag",
461
+ energy_accounts=self.account,
462
+ color=RFID.WHITE,
463
+ released=True,
464
+ ).exists()
465
+ )
466
+
467
+
468
+ class AllowedRFIDTests(TestCase):
469
+ def setUp(self):
470
+ self.user = User.objects.create_user(username="eve", password="secret")
471
+ self.account = EnergyAccount.objects.create(user=self.user, name="EVE")
472
+ self.rfid = RFID.objects.create(rfid="BAD123")
473
+ self.account.rfids.add(self.rfid)
474
+
475
+ def test_disallow_removes_and_blocks(self):
476
+ self.rfid.allowed = False
477
+ self.rfid.save()
478
+ self.account.refresh_from_db()
479
+ self.assertFalse(self.account.rfids.exists())
480
+
481
+ with self.assertRaises(IntegrityError):
482
+ RFID.objects.create(rfid="BAD123")
483
+
484
+
485
+ class RFIDValidationTests(TestCase):
486
+ def test_invalid_format_raises(self):
487
+ tag = RFID(rfid="xyz")
488
+ with self.assertRaises(ValidationError):
489
+ tag.full_clean()
490
+
491
+ def test_lowercase_saved_uppercase(self):
492
+ tag = RFID.objects.create(rfid="deadbeef")
493
+ self.assertEqual(tag.rfid, "DEADBEEF")
494
+
495
+ def test_long_rfid_allowed(self):
496
+ tag = RFID.objects.create(rfid="DEADBEEF10")
497
+ self.assertEqual(tag.rfid, "DEADBEEF10")
498
+
499
+ def test_find_user_by_rfid(self):
500
+ user = User.objects.create_user(username="finder", password="pwd")
501
+ acc = EnergyAccount.objects.create(user=user, name="FINDER")
502
+ tag = RFID.objects.create(rfid="ABCD1234")
503
+ acc.rfids.add(tag)
504
+ found = RFID.get_account_by_rfid("abcd1234")
505
+ self.assertEqual(found, acc)
506
+
507
+ def test_custom_label_length(self):
508
+ tag = RFID(rfid="FACE1234", custom_label="x" * 33)
509
+ with self.assertRaises(ValidationError):
510
+ tag.full_clean()
511
+
512
+
513
+ class RFIDLabelSequenceTests(TestCase):
514
+ def test_next_scan_label_starts_at_ten(self):
515
+ self.assertEqual(RFID.next_scan_label(), 10)
516
+
517
+ def test_next_scan_label_skips_non_multiples(self):
518
+ RFID.objects.create(label_id=21, rfid="SEQTEST21")
519
+
520
+ self.assertEqual(RFID.next_scan_label(), 30)
521
+
522
+ def test_next_copy_label_increments_by_one(self):
523
+ source = RFID.objects.create(label_id=40, rfid="SEQTEST40")
524
+
525
+ self.assertEqual(RFID.next_copy_label(source), 41)
526
+
527
+ def test_next_copy_label_skips_existing(self):
528
+ source = RFID.objects.create(label_id=50, rfid="SEQTEST50")
529
+ RFID.objects.create(label_id=51, rfid="SEQTEST51")
530
+
531
+ self.assertEqual(RFID.next_copy_label(source), 52)
532
+
533
+
534
+ class RFIDAssignmentTests(TestCase):
535
+ def setUp(self):
536
+ self.user1 = User.objects.create_user(username="user1", password="x")
537
+ self.user2 = User.objects.create_user(username="user2", password="x")
538
+ self.acc1 = EnergyAccount.objects.create(user=self.user1, name="USER1")
539
+ self.acc2 = EnergyAccount.objects.create(user=self.user2, name="USER2")
540
+ self.tag = RFID.objects.create(rfid="ABCDEF12")
541
+
542
+ def test_rfid_can_only_attach_to_one_account(self):
543
+ self.acc1.rfids.add(self.tag)
544
+ with self.assertRaises(ValidationError):
545
+ self.acc2.rfids.add(self.tag)
546
+
547
+
548
+ class EnergyAccountTests(TestCase):
549
+ def test_balance_calculation(self):
550
+ user = User.objects.create_user(username="balance", password="x")
551
+ acc = EnergyAccount.objects.create(user=user, name="BALANCE")
552
+ EnergyCredit.objects.create(account=acc, amount_kw=50)
553
+ charger = Charger.objects.create(charger_id="T1")
554
+ Transaction.objects.create(
555
+ charger=charger,
556
+ account=acc,
557
+ meter_start=0,
558
+ meter_stop=20,
559
+ start_time=timezone.now(),
560
+ stop_time=timezone.now(),
561
+ )
562
+ self.assertEqual(acc.total_kw_spent, 20)
563
+ self.assertEqual(acc.balance_kw, 30)
564
+
565
+ def test_authorization_requires_positive_balance(self):
566
+ user = User.objects.create_user(username="auth", password="x")
567
+ acc = EnergyAccount.objects.create(user=user, name="AUTH")
568
+ self.assertFalse(acc.can_authorize())
569
+
570
+ EnergyCredit.objects.create(account=acc, amount_kw=5)
571
+ self.assertTrue(acc.can_authorize())
572
+
573
+ def test_service_account_ignores_balance(self):
574
+ user = User.objects.create_user(username="service", password="x")
575
+ acc = EnergyAccount.objects.create(
576
+ user=user, service_account=True, name="SERVICE"
577
+ )
578
+ self.assertTrue(acc.can_authorize())
579
+
580
+ def test_account_without_user(self):
581
+ acc = EnergyAccount.objects.create(name="NOUSER")
582
+ tag = RFID.objects.create(rfid="NOUSER1")
583
+ acc.rfids.add(tag)
584
+ self.assertIsNone(acc.user)
585
+ self.assertTrue(acc.rfids.filter(rfid="NOUSER1").exists())
586
+
587
+
588
+ class ElectricVehicleTests(TestCase):
589
+ def test_account_can_have_multiple_vehicles(self):
590
+ user = User.objects.create_user(username="cars", password="x")
591
+ acc = EnergyAccount.objects.create(user=user, name="CARS")
592
+ tesla = Brand.objects.create(name="Tesla")
593
+ nissan = Brand.objects.create(name="Nissan")
594
+ model_s = EVModel.objects.create(brand=tesla, name="Model S")
595
+ leaf = EVModel.objects.create(brand=nissan, name="Leaf")
596
+ ElectricVehicle.objects.create(
597
+ account=acc, brand=tesla, model=model_s, vin="VIN12345678901234"
598
+ )
599
+ ElectricVehicle.objects.create(
600
+ account=acc, brand=nissan, model=leaf, vin="VIN23456789012345"
601
+ )
602
+ self.assertEqual(acc.vehicles.count(), 2)
603
+
604
+
605
+ class AddressTests(TestCase):
606
+ def test_invalid_municipality_state(self):
607
+ addr = Address(
608
+ street="Main",
609
+ number="1",
610
+ municipality="Monterrey",
611
+ state=Address.State.COAHUILA,
612
+ postal_code="00000",
613
+ )
614
+ with self.assertRaises(ValidationError):
615
+ addr.full_clean()
616
+
617
+ def test_user_link(self):
618
+ addr = Address.objects.create(
619
+ street="Main",
620
+ number="2",
621
+ municipality="Monterrey",
622
+ state=Address.State.NUEVO_LEON,
623
+ postal_code="64000",
624
+ )
625
+ user = User.objects.create_user(username="addr", password="pwd", address=addr)
626
+ self.assertEqual(user.address, addr)
627
+
628
+
629
+ class PublicWifiUtilitiesTests(TestCase):
630
+ def setUp(self):
631
+ self.user = User.objects.create_user(username="wifi", password="pwd")
632
+
633
+ def test_grant_public_access_records_allowlist(self):
634
+ with tempfile.TemporaryDirectory() as tmp:
635
+ base = Path(tmp)
636
+ allow_file = base / "locks" / "public_wifi_allow.list"
637
+ with override_settings(BASE_DIR=base):
638
+ with patch("core.public_wifi._iptables_available", return_value=False):
639
+ public_wifi.grant_public_access(self.user, "AA:BB:CC:DD:EE:FF")
640
+ self.assertTrue(allow_file.exists())
641
+ content = allow_file.read_text()
642
+ self.assertIn("aa:bb:cc:dd:ee:ff", content)
643
+ self.assertTrue(
644
+ PublicWifiAccess.objects.filter(
645
+ user=self.user, mac_address="aa:bb:cc:dd:ee:ff"
646
+ ).exists()
647
+ )
648
+
649
+ def test_revoke_public_access_for_user_updates_allowlist(self):
650
+ with tempfile.TemporaryDirectory() as tmp:
651
+ base = Path(tmp)
652
+ allow_file = base / "locks" / "public_wifi_allow.list"
653
+ with override_settings(BASE_DIR=base):
654
+ with patch("core.public_wifi._iptables_available", return_value=False):
655
+ access = public_wifi.grant_public_access(
656
+ self.user, "AA:BB:CC:DD:EE:FF"
657
+ )
658
+ public_wifi.revoke_public_access_for_user(self.user)
659
+ access.refresh_from_db()
660
+ self.assertIsNotNone(access.revoked_on)
661
+ if allow_file.exists():
662
+ self.assertNotIn("aa:bb:cc:dd:ee:ff", allow_file.read_text())
663
+
664
+ def test_allow_mac_configures_drop_rule(self):
665
+ with tempfile.TemporaryDirectory() as tmp:
666
+ base = Path(tmp)
667
+ executed_rules = []
668
+
669
+ def fake_run(args, **kwargs):
670
+ from types import SimpleNamespace
671
+
672
+ if args[0] != "iptables":
673
+ return subprocess.run(args, **kwargs)
674
+ command = args[1:]
675
+ if command[:2] == ["-C", "FORWARD"] and "--mac-source" in command:
676
+ return SimpleNamespace(returncode=1, stdout="")
677
+ if command[:2] == ["-C", "FORWARD"] and "-o" in command and "DROP" in command:
678
+ return SimpleNamespace(returncode=1, stdout="")
679
+ if command[0] in {"-A", "-I", "-D"}:
680
+ executed_rules.append(command)
681
+ return SimpleNamespace(returncode=0, stdout="")
682
+ return SimpleNamespace(returncode=0, stdout="")
683
+
684
+ with override_settings(BASE_DIR=base):
685
+ with patch("core.public_wifi._iptables_available", return_value=True):
686
+ with patch("core.public_wifi.subprocess.run", side_effect=fake_run):
687
+ public_wifi.grant_public_access(self.user, "AA:BB:CC:DD:EE:FF")
688
+
689
+ self.assertIn(
690
+ ["-A", "FORWARD", "-i", "wlan0", "-o", "wlan1", "-j", "DROP"],
691
+ executed_rules,
692
+ )
693
+ self.assertIn(
694
+ [
695
+ "-I",
696
+ "FORWARD",
697
+ "1",
698
+ "-i",
699
+ "wlan0",
700
+ "-m",
701
+ "mac",
702
+ "--mac-source",
703
+ "aa:bb:cc:dd:ee:ff",
704
+ "-j",
705
+ "ACCEPT",
706
+ ],
707
+ executed_rules,
708
+ )
709
+
710
+
711
+ class LiveSubscriptionTests(TestCase):
712
+ def setUp(self):
713
+ self.client = Client()
714
+ self.user = User.objects.create_user(username="bob", password="pwd")
715
+ self.account = EnergyAccount.objects.create(user=self.user, name="SUBSCRIBER")
716
+ self.product = Product.objects.create(name="Gold", renewal_period=30)
717
+ self.client.force_login(self.user)
718
+
719
+ def test_create_and_list_live_subscription(self):
720
+ response = self.client.post(
721
+ reverse("add-live-subscription"),
722
+ data={"account_id": self.account.id, "product_id": self.product.id},
723
+ content_type="application/json",
724
+ )
725
+ self.assertEqual(response.status_code, 200)
726
+ self.account.refresh_from_db()
727
+ self.assertEqual(
728
+ self.account.live_subscription_product,
729
+ self.product,
730
+ )
731
+ self.assertIsNotNone(self.account.live_subscription_start_date)
732
+ self.assertEqual(
733
+ self.account.live_subscription_start_date,
734
+ timezone.localdate(),
735
+ )
736
+ self.assertEqual(
737
+ self.account.live_subscription_next_renewal,
738
+ self.account.live_subscription_start_date
739
+ + timedelta(days=self.product.renewal_period),
740
+ )
741
+
742
+ list_resp = self.client.get(
743
+ reverse("live-subscription-list"), {"account_id": self.account.id}
744
+ )
745
+ self.assertEqual(list_resp.status_code, 200)
746
+ data = list_resp.json()
747
+ self.assertEqual(len(data["live_subscriptions"]), 1)
748
+ self.assertEqual(data["live_subscriptions"][0]["product__name"], "Gold")
749
+ self.assertEqual(data["live_subscriptions"][0]["id"], self.account.id)
750
+ self.assertEqual(
751
+ data["live_subscriptions"][0]["next_renewal"],
752
+ str(self.account.live_subscription_next_renewal),
753
+ )
754
+
755
+ def test_product_list(self):
756
+ response = self.client.get(reverse("product-list"))
757
+ self.assertEqual(response.status_code, 200)
758
+ data = response.json()
759
+ self.assertEqual(len(data["products"]), 1)
760
+ self.assertEqual(data["products"][0]["name"], "Gold")
761
+
762
+
763
+ class OnboardingWizardTests(TestCase):
764
+ def setUp(self):
765
+ self.client = Client()
766
+ User.objects.create_superuser("super", "super@example.com", "pwd")
767
+ self.client.force_login(User.objects.get(username="super"))
768
+
769
+ def test_onboarding_flow_creates_account(self):
770
+ details_url = reverse("admin:core_energyaccount_onboard_details")
771
+ response = self.client.get(details_url)
772
+ self.assertEqual(response.status_code, 200)
773
+ data = {
774
+ "first_name": "John",
775
+ "last_name": "Doe",
776
+ "rfid": "ABCD1234",
777
+ "vehicle_id": "VIN12345678901234",
778
+ }
779
+ resp = self.client.post(details_url, data)
780
+ self.assertEqual(resp.status_code, 302)
781
+ self.assertEqual(resp.url, reverse("admin:core_energyaccount_changelist"))
782
+ user = User.objects.get(first_name="John", last_name="Doe")
783
+ self.assertFalse(user.is_active)
784
+ account = EnergyAccount.objects.get(user=user)
785
+ self.assertTrue(account.rfids.filter(rfid="ABCD1234").exists())
786
+ self.assertTrue(account.vehicles.filter(vin="VIN12345678901234").exists())
787
+
788
+
789
+ class EVBrandFixtureTests(TestCase):
790
+ def test_ev_brand_fixture_loads(self):
791
+ call_command(
792
+ "loaddata",
793
+ *sorted(glob("core/fixtures/ev_brands__*.json")),
794
+ *sorted(glob("core/fixtures/ev_models__*.json")),
795
+ verbosity=0,
796
+ )
797
+ porsche = Brand.objects.get(name="Porsche")
798
+ audi = Brand.objects.get(name="Audi")
799
+ self.assertTrue(
800
+ {"WP0", "WP1"} <= set(porsche.wmi_codes.values_list("code", flat=True))
801
+ )
802
+ self.assertTrue(
803
+ set(audi.wmi_codes.values_list("code", flat=True)) >= {"WAU", "TRU"}
804
+ )
805
+ self.assertTrue(EVModel.objects.filter(brand=porsche, name="Taycan").exists())
806
+ self.assertTrue(EVModel.objects.filter(brand=audi, name="e-tron GT").exists())
807
+ self.assertTrue(EVModel.objects.filter(brand=porsche, name="Macan").exists())
808
+ model3 = EVModel.objects.get(brand__name="Tesla", name="Model 3 RWD")
809
+ self.assertEqual(model3.est_battery_kwh, Decimal("57.50"))
810
+
811
+ def test_brand_from_vin(self):
812
+ call_command(
813
+ "loaddata",
814
+ *sorted(glob("core/fixtures/ev_brands__*.json")),
815
+ verbosity=0,
816
+ )
817
+ self.assertEqual(Brand.from_vin("WP0ZZZ12345678901").name, "Porsche")
818
+ self.assertEqual(Brand.from_vin("WAUZZZ12345678901").name, "Audi")
819
+ self.assertIsNone(Brand.from_vin("XYZ12345678901234"))
820
+
821
+
822
+ class RFIDFixtureTests(TestCase):
823
+ def test_fixture_assigns_gelectriic_rfid(self):
824
+ call_command(
825
+ "loaddata",
826
+ "core/fixtures/users__arthexis.json",
827
+ "core/fixtures/energy_accounts__gelectriic.json",
828
+ "core/fixtures/rfids__ffffffff.json",
829
+ verbosity=0,
830
+ )
831
+ account = EnergyAccount.objects.get(name="GELECTRIIC")
832
+ tag = RFID.objects.get(rfid="FFFFFFFF")
833
+ self.assertIn(account, tag.energy_accounts.all())
834
+ self.assertEqual(tag.energy_accounts.count(), 1)
835
+
836
+
837
+ class RFIDImportExportCommandTests(TestCase):
838
+ def test_export_supports_account_names(self):
839
+ account = EnergyAccount.objects.create(name="PRIMARY")
840
+ tag = RFID.objects.create(rfid="CARD500")
841
+ tag.energy_accounts.add(account)
842
+
843
+ temp_file = tempfile.NamedTemporaryFile(delete=False)
844
+ temp_file.close()
845
+ rows = []
846
+ try:
847
+ call_command("export_rfids", temp_file.name, account_field="name")
848
+ with open(temp_file.name, newline="", encoding="utf-8") as fh:
849
+ reader = csv.DictReader(fh)
850
+ rows = list(reader)
851
+ finally:
852
+ os.unlink(temp_file.name)
853
+
854
+ self.assertEqual(len(rows), 1)
855
+ self.assertEqual(rows[0]["energy_account_names"], "PRIMARY")
856
+
857
+ def test_import_creates_missing_account_by_name(self):
858
+ temp_file = tempfile.NamedTemporaryFile("w", newline="", delete=False)
859
+ try:
860
+ writer = csv.writer(temp_file)
861
+ writer.writerow(
862
+ [
863
+ "rfid",
864
+ "custom_label",
865
+ "energy_account_names",
866
+ "allowed",
867
+ "color",
868
+ "released",
869
+ ]
870
+ )
871
+ writer.writerow(
872
+ [
873
+ "NAMETAG001",
874
+ "",
875
+ "Imported Account",
876
+ "true",
877
+ RFID.WHITE,
878
+ "true",
879
+ ]
880
+ )
881
+ temp_file.flush()
882
+ finally:
883
+ temp_file.close()
884
+
885
+ try:
886
+ call_command("import_rfids", temp_file.name, account_field="name")
887
+ finally:
888
+ os.unlink(temp_file.name)
889
+
890
+ account = EnergyAccount.objects.get(name="IMPORTED ACCOUNT")
891
+ tag = RFID.objects.get(rfid="NAMETAG001")
892
+ self.assertIsNone(account.user)
893
+ self.assertTrue(tag.energy_accounts.filter(pk=account.pk).exists())
894
+
895
+ def test_admin_export_supports_account_names(self):
896
+ account = EnergyAccount.objects.create(name="PRIMARY")
897
+ tag = RFID.objects.create(rfid="CARD501")
898
+ tag.energy_accounts.add(account)
899
+
900
+ resource = RFIDResource(account_field="name")
901
+ dataset = resource.export(queryset=RFID.objects.order_by("rfid"))
902
+
903
+ self.assertIn("energy_account_names", dataset.headers)
904
+ self.assertEqual(dataset.dict[0]["energy_account_names"], "PRIMARY")
905
+
906
+ def test_admin_import_creates_missing_account_by_name(self):
907
+ resource = RFIDResource(account_field="name")
908
+ headers = resource.get_export_headers()
909
+ dataset = Dataset()
910
+ dataset.headers = headers
911
+ row = {header: "" for header in headers}
912
+ row.update(
913
+ {
914
+ "label_id": "200",
915
+ "rfid": "NAMETAG002",
916
+ "custom_label": "",
917
+ "energy_account_names": "Imported Admin Account",
918
+ "allowed": "true",
919
+ "color": RFID.BLACK,
920
+ "kind": RFID.CLASSIC,
921
+ "released": "false",
922
+ }
923
+ )
924
+ dataset.append([row[h] for h in headers])
925
+
926
+ result = resource.import_data(dataset, dry_run=False)
927
+ self.assertFalse(result.has_errors())
928
+
929
+ account = EnergyAccount.objects.get(name="IMPORTED ADMIN ACCOUNT")
930
+ tag = RFID.objects.get(rfid="NAMETAG002")
931
+ self.assertTrue(tag.energy_accounts.filter(pk=account.pk).exists())
932
+
933
+
934
+ class RFIDKeyVerificationFlagTests(TestCase):
935
+ def test_flags_reset_on_key_change(self):
936
+ tag = RFID.objects.create(
937
+ rfid="ABC12345", key_a_verified=True, key_b_verified=True
938
+ )
939
+ tag.key_a = "A1A1A1A1A1A1"
940
+ tag.save()
941
+ self.assertFalse(tag.key_a_verified)
942
+ tag.key_b = "B1B1B1B1B1B1"
943
+ tag.save()
944
+ self.assertFalse(tag.key_b_verified)
945
+
946
+
947
+ class SecurityGroupTests(TestCase):
948
+ def test_parent_and_user_assignment(self):
949
+ parent = SecurityGroup.objects.create(name="Parents")
950
+ child = SecurityGroup.objects.create(name="Children", parent=parent)
951
+ user = User.objects.create_user(username="sg_user", password="secret")
952
+ child.user_set.add(user)
953
+ self.assertEqual(child.parent, parent)
954
+ self.assertIn(user, child.user_set.all())
955
+
956
+
957
+ class ReleaseProcessTests(TestCase):
958
+ def setUp(self):
959
+ self.package = Package.objects.create(name="pkg")
960
+ self.release = PackageRelease.objects.create(
961
+ package=self.package, version="1.0.0"
962
+ )
963
+
964
+ @mock.patch("core.views._collect_dirty_files")
965
+ @mock.patch("core.views._sync_with_origin_main")
966
+ @mock.patch("core.views.release_utils._git_clean", return_value=False)
967
+ def test_step_check_requires_clean_repo(
968
+ self, git_clean, sync_main, collect_dirty
969
+ ):
970
+ collect_dirty.return_value = [
971
+ {"path": "core/models.py", "status": "M", "status_label": "Modified"}
972
+ ]
973
+ ctx: dict = {}
974
+ with self.assertRaises(core_views.DirtyRepository):
975
+ _step_check_version(self.release, ctx, Path("rel.log"))
976
+ self.assertEqual(
977
+ ctx["dirty_files"],
978
+ [
979
+ {
980
+ "path": "core/models.py",
981
+ "status": "M",
982
+ "status_label": "Modified",
983
+ }
984
+ ],
985
+ )
986
+ sync_main.assert_called_once_with(Path("rel.log"))
987
+
988
+ @mock.patch("core.views._sync_with_origin_main")
989
+ @mock.patch("core.views.release_utils._git_clean", return_value=True)
990
+ @mock.patch("core.views.release_utils.network_available", return_value=False)
991
+ def test_step_check_keeps_repo_clean(
992
+ self, network_available, git_clean, sync_main
993
+ ):
994
+ version_path = Path("VERSION")
995
+ original = version_path.read_text(encoding="utf-8")
996
+ _step_check_version(self.release, {}, Path("rel.log"))
997
+ proc = subprocess.run(
998
+ ["git", "status", "--porcelain", str(version_path)],
999
+ capture_output=True,
1000
+ text=True,
1001
+ )
1002
+ self.assertFalse(proc.stdout.strip())
1003
+ self.assertEqual(version_path.read_text(encoding="utf-8"), original)
1004
+ sync_main.assert_called_once_with(Path("rel.log"))
1005
+
1006
+ @mock.patch("core.views.requests.get")
1007
+ @mock.patch("core.views._sync_with_origin_main")
1008
+ @mock.patch("core.views.release_utils.network_available", return_value=True)
1009
+ @mock.patch("core.views.release_utils._git_clean", return_value=True)
1010
+ def test_step_check_ignores_yanked_release(
1011
+ self, git_clean, network_available, sync_main, requests_get
1012
+ ):
1013
+ response = mock.Mock()
1014
+ response.ok = True
1015
+ response.json.return_value = {
1016
+ "releases": {
1017
+ "0.1.12": [
1018
+ {"filename": "pkg.whl", "yanked": True},
1019
+ {"filename": "pkg.tar.gz", "yanked": True},
1020
+ ]
1021
+ }
1022
+ }
1023
+ requests_get.return_value = response
1024
+ self.release.version = "0.1.12"
1025
+ _step_check_version(self.release, {}, Path("rel.log"))
1026
+ requests_get.assert_called_once()
1027
+ sync_main.assert_called_once_with(Path("rel.log"))
1028
+
1029
+ @mock.patch("core.views.requests.get")
1030
+ @mock.patch("core.views._sync_with_origin_main")
1031
+ @mock.patch("core.views.release_utils.network_available", return_value=True)
1032
+ @mock.patch("core.views.release_utils._git_clean", return_value=True)
1033
+ def test_step_check_blocks_available_release(
1034
+ self, git_clean, network_available, sync_main, requests_get
1035
+ ):
1036
+ response = mock.Mock()
1037
+ response.ok = True
1038
+ response.json.return_value = {
1039
+ "releases": {
1040
+ "0.1.12": [
1041
+ {"filename": "pkg.whl", "yanked": False},
1042
+ {"filename": "pkg.tar.gz"},
1043
+ ]
1044
+ }
1045
+ }
1046
+ requests_get.return_value = response
1047
+ self.release.version = "0.1.12"
1048
+ with self.assertRaises(Exception) as exc:
1049
+ _step_check_version(self.release, {}, Path("rel.log"))
1050
+ self.assertIn("already on PyPI", str(exc.exception))
1051
+ requests_get.assert_called_once()
1052
+ sync_main.assert_called_once_with(Path("rel.log"))
1053
+
1054
+ @mock.patch("core.views.release_utils.network_available", return_value=False)
1055
+ @mock.patch("core.views._collect_dirty_files")
1056
+ @mock.patch("core.views._sync_with_origin_main")
1057
+ @mock.patch("core.views.subprocess.run")
1058
+ @mock.patch("core.views.release_utils._git_clean", return_value=False)
1059
+ def test_step_check_commits_release_prep_changes(
1060
+ self,
1061
+ git_clean,
1062
+ subprocess_run,
1063
+ sync_main,
1064
+ collect_dirty,
1065
+ network_available,
1066
+ ):
1067
+ fixture_path = next(Path("core/fixtures").glob("releases__*.json"))
1068
+ collect_dirty.return_value = [
1069
+ {
1070
+ "path": str(fixture_path),
1071
+ "status": "M",
1072
+ "status_label": "Modified",
1073
+ },
1074
+ {"path": "CHANGELOG.rst", "status": "M", "status_label": "Modified"},
1075
+ ]
1076
+ subprocess_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
1077
+
1078
+ ctx: dict[str, object] = {}
1079
+ _step_check_version(self.release, ctx, Path("rel.log"))
1080
+
1081
+ add_call = mock.call(
1082
+ ["git", "add", str(fixture_path), "CHANGELOG.rst"],
1083
+ check=True,
1084
+ )
1085
+ commit_call = mock.call(
1086
+ [
1087
+ "git",
1088
+ "commit",
1089
+ "-m",
1090
+ "chore: sync release fixtures and changelog",
1091
+ ],
1092
+ check=True,
1093
+ )
1094
+ self.assertIn(add_call, subprocess_run.call_args_list)
1095
+ self.assertIn(commit_call, subprocess_run.call_args_list)
1096
+ self.assertNotIn("dirty_files", ctx)
1097
+
1098
+ @mock.patch("core.views.release_utils.network_available", return_value=False)
1099
+ @mock.patch("core.views._collect_dirty_files")
1100
+ @mock.patch("core.views._sync_with_origin_main")
1101
+ @mock.patch("core.views.subprocess.run")
1102
+ @mock.patch("core.views.release_utils._git_clean", return_value=False)
1103
+ def test_step_check_commits_changelog_only(
1104
+ self,
1105
+ git_clean,
1106
+ subprocess_run,
1107
+ sync_main,
1108
+ collect_dirty,
1109
+ network_available,
1110
+ ):
1111
+ collect_dirty.return_value = [
1112
+ {"path": "CHANGELOG.rst", "status": "M", "status_label": "Modified"}
1113
+ ]
1114
+ subprocess_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
1115
+
1116
+ ctx: dict[str, object] = {}
1117
+ _step_check_version(self.release, ctx, Path("rel.log"))
1118
+
1119
+ subprocess_run.assert_any_call(
1120
+ ["git", "add", "CHANGELOG.rst"], check=True
1121
+ )
1122
+ subprocess_run.assert_any_call(
1123
+ ["git", "commit", "-m", "docs: refresh changelog"], check=True
1124
+ )
1125
+ self.assertNotIn("dirty_files", ctx)
1126
+
1127
+ @mock.patch("core.models.PackageRelease.dump_fixture")
1128
+ def test_save_does_not_dump_fixture(self, dump):
1129
+ self.release.pypi_url = "https://example.com"
1130
+ self.release.save()
1131
+ dump.assert_not_called()
1132
+
1133
+ @mock.patch("core.views.subprocess.run")
1134
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1135
+ @mock.patch("core.views.release_utils.promote", side_effect=Exception("boom"))
1136
+ def test_promote_cleans_repo_on_failure(self, promote, dump_fixture, run):
1137
+ import subprocess as sp
1138
+
1139
+ def fake_run(cmd, check=True, capture_output=False, text=False):
1140
+ if capture_output:
1141
+ stdout = ""
1142
+ if cmd[:3] == ["git", "rev-parse", "origin/main"]:
1143
+ stdout = "abc123\n"
1144
+ elif cmd[:4] == ["git", "merge-base", "HEAD", "origin/main"]:
1145
+ stdout = "abc123\n"
1146
+ return sp.CompletedProcess(cmd, 0, stdout=stdout, stderr="")
1147
+ return sp.CompletedProcess(cmd, 0)
1148
+
1149
+ run.side_effect = fake_run
1150
+ with self.assertRaises(Exception):
1151
+ _step_promote_build(self.release, {}, Path("rel.log"))
1152
+ dump_fixture.assert_not_called()
1153
+ run.assert_any_call(["git", "reset", "--hard"], check=False)
1154
+ run.assert_any_call(["git", "clean", "-fd"], check=False)
1155
+
1156
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1157
+ @mock.patch("core.views._ensure_release_todo")
1158
+ @mock.patch("core.views._sync_with_origin_main")
1159
+ @mock.patch("core.views.subprocess.run")
1160
+ def test_pre_release_syncs_with_main(
1161
+ self, run, sync_main, ensure_todo, dump_fixture
1162
+ ):
1163
+ import subprocess as sp
1164
+
1165
+ def fake_run(cmd, check=True, capture_output=False, text=False):
1166
+ if capture_output:
1167
+ return sp.CompletedProcess(cmd, 0, stdout="", stderr="")
1168
+ if cmd[:2] == ["git", "diff"]:
1169
+ return sp.CompletedProcess(cmd, 1)
1170
+ return sp.CompletedProcess(cmd, 0)
1171
+
1172
+ run.side_effect = fake_run
1173
+ ensure_todo.return_value = (
1174
+ mock.Mock(request="Create release pkg 1.0.1", url="", request_details=""),
1175
+ Path("core/fixtures/todos__next_release.json"),
1176
+ )
1177
+
1178
+ version_path = Path("VERSION")
1179
+ original_version = version_path.read_text(encoding="utf-8")
1180
+
1181
+ try:
1182
+ _step_pre_release_actions(self.release, {}, Path("rel.log"))
1183
+ finally:
1184
+ version_path.write_text(original_version, encoding="utf-8")
1185
+
1186
+ sync_main.assert_called_once_with(Path("rel.log"))
1187
+ release_fixtures = sorted(
1188
+ str(path) for path in Path("core/fixtures").glob("releases__*.json")
1189
+ )
1190
+ if release_fixtures:
1191
+ run.assert_any_call(["git", "add", *release_fixtures], check=True)
1192
+ run.assert_any_call(["git", "add", "CHANGELOG.rst"], check=True)
1193
+ run.assert_any_call(["git", "add", "VERSION"], check=True)
1194
+ run.assert_any_call(["git", "diff", "--cached", "--quiet"], check=False)
1195
+ ensure_todo.assert_called_once_with(self.release, previous_version=mock.ANY)
1196
+
1197
+ @mock.patch("core.views.subprocess.run")
1198
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1199
+ @mock.patch("core.views.release_utils.promote")
1200
+ def test_promote_verifies_origin_and_pushes_main(self, promote, dump_fixture, run):
1201
+ import subprocess as sp
1202
+
1203
+ def fake_run(cmd, check=True, capture_output=False, text=False):
1204
+ if capture_output:
1205
+ stdout = ""
1206
+ if cmd[:3] == ["git", "rev-parse", "origin/main"]:
1207
+ stdout = "abc123\n"
1208
+ elif cmd[:4] == ["git", "merge-base", "HEAD", "origin/main"]:
1209
+ stdout = "abc123\n"
1210
+ return sp.CompletedProcess(cmd, 0, stdout=stdout, stderr="")
1211
+ return sp.CompletedProcess(cmd, 0)
1212
+
1213
+ run.side_effect = fake_run
1214
+ _step_promote_build(self.release, {}, Path("rel.log"))
1215
+ run.assert_any_call(["git", "fetch", "origin", "main"], check=True)
1216
+ run.assert_any_call(
1217
+ ["git", "rev-parse", "origin/main"],
1218
+ check=True,
1219
+ capture_output=True,
1220
+ text=True,
1221
+ )
1222
+ run.assert_any_call(
1223
+ ["git", "merge-base", "HEAD", "origin/main"],
1224
+ check=True,
1225
+ capture_output=True,
1226
+ text=True,
1227
+ )
1228
+ run.assert_any_call(["git", "push"], check=True)
1229
+
1230
+ @mock.patch("core.views.subprocess.run")
1231
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1232
+ @mock.patch("core.views.release_utils.promote")
1233
+ def test_promote_aborts_if_origin_advances(self, promote, dump_fixture, run):
1234
+ import subprocess as sp
1235
+
1236
+ def fake_run(cmd, check=True, capture_output=False, text=False):
1237
+ if capture_output:
1238
+ if cmd[:3] == ["git", "rev-parse", "origin/main"]:
1239
+ return sp.CompletedProcess(cmd, 0, stdout="new\n", stderr="")
1240
+ if cmd[:4] == ["git", "merge-base", "HEAD", "origin/main"]:
1241
+ return sp.CompletedProcess(cmd, 0, stdout="old\n", stderr="")
1242
+ return sp.CompletedProcess(cmd, 0, stdout="", stderr="")
1243
+ return sp.CompletedProcess(cmd, 0)
1244
+
1245
+ run.side_effect = fake_run
1246
+
1247
+ with self.assertRaises(Exception):
1248
+ _step_promote_build(self.release, {}, Path("rel.log"))
1249
+
1250
+ promote.assert_not_called()
1251
+ run.assert_any_call(["git", "reset", "--hard"], check=False)
1252
+ run.assert_any_call(["git", "clean", "-fd"], check=False)
1253
+
1254
+ @mock.patch("core.views.subprocess.run")
1255
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1256
+ def test_promote_advances_version(self, dump_fixture, run):
1257
+ import subprocess as sp
1258
+
1259
+ def fake_run(cmd, check=True, capture_output=False, text=False):
1260
+ if capture_output:
1261
+ stdout = ""
1262
+ if cmd[:3] == ["git", "rev-parse", "origin/main"]:
1263
+ stdout = "abc123\n"
1264
+ elif cmd[:4] == ["git", "merge-base", "HEAD", "origin/main"]:
1265
+ stdout = "abc123\n"
1266
+ return sp.CompletedProcess(cmd, 0, stdout=stdout, stderr="")
1267
+ return sp.CompletedProcess(cmd, 0)
1268
+
1269
+ run.side_effect = fake_run
1270
+
1271
+ version_path = Path("VERSION")
1272
+ original = version_path.read_text(encoding="utf-8")
1273
+ version_path.write_text("0.0.1\n", encoding="utf-8")
1274
+
1275
+ def fake_promote(*args, **kwargs):
1276
+ version_path.write_text(self.release.version + "\n", encoding="utf-8")
1277
+
1278
+ with mock.patch("core.views.release_utils.promote", side_effect=fake_promote):
1279
+ _step_promote_build(self.release, {}, Path("rel.log"))
1280
+
1281
+ self.assertEqual(
1282
+ version_path.read_text(encoding="utf-8"),
1283
+ self.release.version + "\n",
1284
+ )
1285
+ version_path.write_text(original, encoding="utf-8")
1286
+
1287
+ @mock.patch("core.views.timezone.now")
1288
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1289
+ @mock.patch("core.views.release_utils.publish")
1290
+ def test_publish_sets_pypi_url(self, publish, dump_fixture, now):
1291
+ now.return_value = datetime(2025, 3, 4, 5, 6, tzinfo=datetime_timezone.utc)
1292
+ publish.return_value = ["PyPI"]
1293
+ _step_publish(self.release, {}, Path("rel.log"))
1294
+ self.release.refresh_from_db()
1295
+ self.assertEqual(
1296
+ self.release.pypi_url,
1297
+ f"https://pypi.org/project/{self.package.name}/{self.release.version}/",
1298
+ )
1299
+ self.assertEqual(self.release.github_url, "")
1300
+ self.assertEqual(
1301
+ self.release.release_on,
1302
+ datetime(2025, 3, 4, 5, 6, tzinfo=datetime_timezone.utc),
1303
+ )
1304
+ dump_fixture.assert_called_once()
1305
+ publish.assert_called_once()
1306
+ kwargs = publish.call_args.kwargs
1307
+ self.assertIn("repositories", kwargs)
1308
+ repositories = kwargs["repositories"]
1309
+ self.assertEqual(len(repositories), 1)
1310
+ self.assertEqual(repositories[0].name, "PyPI")
1311
+
1312
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1313
+ @mock.patch("core.views.release_utils.publish", side_effect=Exception("boom"))
1314
+ def test_publish_failure_keeps_url_blank(self, publish, dump_fixture):
1315
+ with self.assertRaises(Exception):
1316
+ _step_publish(self.release, {}, Path("rel.log"))
1317
+ self.release.refresh_from_db()
1318
+ self.assertEqual(self.release.pypi_url, "")
1319
+ self.assertEqual(self.release.github_url, "")
1320
+ self.assertIsNone(self.release.release_on)
1321
+ dump_fixture.assert_not_called()
1322
+
1323
+ @mock.patch("core.views.timezone.now")
1324
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1325
+ @mock.patch("core.views.release_utils.publish")
1326
+ def test_publish_records_github_url_when_configured(
1327
+ self, publish, dump_fixture, now
1328
+ ):
1329
+ now.return_value = datetime(2025, 3, 4, 6, 7, tzinfo=datetime_timezone.utc)
1330
+ user = User.objects.create_superuser("release-owner", "owner@example.com", "pw")
1331
+ manager = ReleaseManager.objects.create(
1332
+ user=user,
1333
+ pypi_username="octocat",
1334
+ pypi_token="primary-token",
1335
+ github_token="gh-token",
1336
+ secondary_pypi_url="https://upload.github.com/pypi/",
1337
+ )
1338
+ self.release.release_manager = manager
1339
+ self.release.save(update_fields=["release_manager"])
1340
+ self.package.repository_url = "https://github.com/example/project"
1341
+ self.package.save(update_fields=["repository_url"])
1342
+ publish.return_value = ["PyPI", "GitHub Packages"]
1343
+
1344
+ _step_publish(self.release, {}, Path("rel.log"))
1345
+
1346
+ self.release.refresh_from_db()
1347
+ self.assertTrue(self.release.github_url)
1348
+ self.assertIn("github.com/example/project", self.release.github_url)
1349
+ args, kwargs = publish.call_args
1350
+ repositories = kwargs.get("repositories")
1351
+ self.assertEqual(len(repositories), 2)
1352
+ self.assertEqual(repositories[0].name, "PyPI")
1353
+ self.assertEqual(repositories[1].name, "GitHub Packages")
1354
+
1355
+ @mock.patch("core.views.subprocess.run")
1356
+ @mock.patch("core.views._sync_with_origin_main")
1357
+ def test_pre_release_actions_skipped_in_dry_run(self, sync_main, run):
1358
+ log_path = Path("rel.log")
1359
+ if log_path.exists():
1360
+ log_path.unlink()
1361
+
1362
+ try:
1363
+ _step_pre_release_actions(self.release, {"dry_run": True}, log_path)
1364
+ self.assertTrue(log_path.exists())
1365
+ contents = log_path.read_text(encoding="utf-8")
1366
+ self.assertIn("Dry run: skipping pre-release actions", contents)
1367
+ finally:
1368
+ if log_path.exists():
1369
+ log_path.unlink()
1370
+
1371
+ sync_main.assert_not_called()
1372
+ run.assert_not_called()
1373
+
1374
+ @mock.patch("core.views.release_utils.promote")
1375
+ def test_promote_build_skipped_in_dry_run(self, promote):
1376
+ log_path = Path("rel.log")
1377
+ if log_path.exists():
1378
+ log_path.unlink()
1379
+
1380
+ try:
1381
+ _step_promote_build(self.release, {"dry_run": True}, log_path)
1382
+ self.assertTrue(log_path.exists())
1383
+ contents = log_path.read_text(encoding="utf-8")
1384
+ self.assertIn("Dry run: skipping build promotion", contents)
1385
+ finally:
1386
+ if log_path.exists():
1387
+ log_path.unlink()
1388
+
1389
+ promote.assert_not_called()
1390
+
1391
+ @mock.patch("core.views.release_utils.publish")
1392
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1393
+ def test_publish_uses_test_repository_in_dry_run(self, dump_fixture, publish):
1394
+ log_path = Path("rel.log")
1395
+ if log_path.exists():
1396
+ log_path.unlink()
1397
+
1398
+ publish.return_value = ["Test PyPI"]
1399
+ env = {
1400
+ "PYPI_TEST_REPOSITORY_URL": "https://test.example/simple/",
1401
+ "PYPI_TEST_API_TOKEN": "token",
1402
+ }
1403
+
1404
+ with mock.patch.dict(os.environ, env, clear=False):
1405
+ _step_publish(self.release, {"dry_run": True}, log_path)
1406
+
1407
+ self.release.refresh_from_db()
1408
+ self.assertEqual(self.release.pypi_url, "")
1409
+ self.assertEqual(self.release.github_url, "")
1410
+ self.assertIsNone(self.release.release_on)
1411
+ dump_fixture.assert_not_called()
1412
+ publish.assert_called_once()
1413
+ repositories = publish.call_args.kwargs["repositories"]
1414
+ self.assertEqual(len(repositories), 1)
1415
+ target = repositories[0]
1416
+ self.assertEqual(target.name, "Test PyPI")
1417
+ self.assertEqual(target.repository_url, "https://test.example/simple/")
1418
+ self.assertFalse(target.verify_availability)
1419
+ self.assertTrue(log_path.exists())
1420
+ contents = log_path.read_text(encoding="utf-8")
1421
+ self.assertIn("Dry run: uploading distribution", contents)
1422
+ self.assertIn("Dry run: skipped release metadata updates", contents)
1423
+ log_path.unlink(missing_ok=True)
1424
+
1425
+ def test_release_progress_toggle_dry_run_before_start(self):
1426
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1427
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1428
+ self.client.force_login(user)
1429
+
1430
+ response = self.client.get(f"{url}?set_dry_run=1&dry_run=1", follow=True)
1431
+
1432
+ self.assertEqual(response.status_code, 200)
1433
+ context = response.context
1434
+ if isinstance(context, list):
1435
+ context = context[-1]
1436
+ self.assertTrue(context["dry_run"])
1437
+ self.assertTrue(context["dry_run_toggle_enabled"])
1438
+ session = self.client.session
1439
+ ctx = session.get(f"release_publish_{self.release.pk}")
1440
+ self.assertTrue(ctx.get("dry_run"))
1441
+
1442
+ def test_release_progress_toggle_blocked_while_running(self):
1443
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1444
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1445
+ self.client.force_login(user)
1446
+ session = self.client.session
1447
+ session[f"release_publish_{self.release.pk}"] = {
1448
+ "step": 0,
1449
+ "started": True,
1450
+ "paused": False,
1451
+ }
1452
+ session.save()
1453
+
1454
+ response = self.client.get(f"{url}?set_dry_run=1&dry_run=1")
1455
+ self.assertEqual(response.status_code, 302)
1456
+ session = self.client.session
1457
+ ctx = session.get(f"release_publish_{self.release.pk}")
1458
+ self.assertFalse(ctx.get("dry_run"))
1459
+
1460
+ follow = self.client.get(url)
1461
+ follow_context = follow.context
1462
+ if isinstance(follow_context, list):
1463
+ follow_context = follow_context[-1]
1464
+ self.assertFalse(follow_context["dry_run"])
1465
+ self.assertFalse(follow_context["dry_run_toggle_enabled"])
1466
+
1467
+ def test_start_request_sets_dry_run_flag(self):
1468
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1469
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1470
+ self.client.force_login(user)
1471
+
1472
+ self.client.get(f"{url}?start=1&dry_run=1")
1473
+
1474
+ session = self.client.session
1475
+ ctx = session.get(f"release_publish_{self.release.pk}")
1476
+ self.assertTrue(ctx.get("dry_run"))
1477
+
1478
+ def test_new_todo_does_not_reset_pending_flow(self):
1479
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1480
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1481
+ Todo.objects.create(request="Initial checklist item")
1482
+ steps = [("Confirm release TODO completion", core_views._step_check_todos)]
1483
+ with mock.patch("core.views.PUBLISH_STEPS", steps):
1484
+ self.client.force_login(user)
1485
+ response = self.client.get(url)
1486
+ self.assertTrue(response.context["has_pending_todos"])
1487
+ self.client.get(f"{url}?ack_todos=1")
1488
+ self.client.get(f"{url}?start=1")
1489
+ self.client.get(f"{url}?step=0")
1490
+ Todo.objects.create(request="Follow-up checklist item")
1491
+ response = self.client.get(url)
1492
+ self.assertEqual(
1493
+ Todo.objects.filter(is_deleted=False, done_on__isnull=True).count(),
1494
+ 1,
1495
+ )
1496
+ self.assertIsNone(response.context["todos"])
1497
+ self.assertFalse(response.context["has_pending_todos"])
1498
+ session = self.client.session
1499
+ ctx = session.get(f"release_publish_{self.release.pk}")
1500
+ self.assertTrue(ctx.get("todos_ack"))
1501
+
1502
+ def test_release_progress_uses_lockfile(self):
1503
+ run = []
1504
+
1505
+ def step1(release, ctx, log_path):
1506
+ run.append("step1")
1507
+
1508
+ def step2(release, ctx, log_path):
1509
+ run.append("step2")
1510
+
1511
+ steps = [("One", step1), ("Two", step2)]
1512
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1513
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1514
+ with mock.patch("core.views.PUBLISH_STEPS", steps):
1515
+ self.client.force_login(user)
1516
+ self.client.get(f"{url}?step=0")
1517
+ self.assertEqual(run, ["step1"])
1518
+ client2 = Client()
1519
+ client2.force_login(user)
1520
+ client2.get(f"{url}?step=1")
1521
+ self.assertEqual(run, ["step1", "step2"])
1522
+ lock_file = Path("locks") / f"release_publish_{self.release.pk}.json"
1523
+ self.assertFalse(lock_file.exists())
1524
+
1525
+ def test_release_progress_restart(self):
1526
+ run = []
1527
+
1528
+ def step_fail(release, ctx, log_path):
1529
+ run.append("step")
1530
+ raise Exception("boom")
1531
+
1532
+ steps = [("Fail", step_fail)]
1533
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1534
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1535
+ count_file = Path("locks") / f"release_publish_{self.release.pk}.restarts"
1536
+ if count_file.exists():
1537
+ count_file.unlink()
1538
+ with mock.patch("core.views.PUBLISH_STEPS", steps):
1539
+ self.client.force_login(user)
1540
+ self.assertFalse(count_file.exists())
1541
+ self.client.get(f"{url}?step=0")
1542
+ self.client.get(f"{url}?step=0")
1543
+ self.assertEqual(run, ["step"])
1544
+ self.assertFalse(count_file.exists())
1545
+ self.client.get(f"{url}?restart=1")
1546
+ self.assertTrue(count_file.exists())
1547
+ self.assertEqual(count_file.read_text(), "1")
1548
+ self.client.get(f"{url}?step=0")
1549
+ self.assertEqual(run, ["step", "step"])
1550
+ self.client.get(f"{url}?restart=1")
1551
+ # Restart counter resets after running a step
1552
+ self.assertEqual(count_file.read_text(), "1")
1553
+
1554
+
1555
+ class ReleaseProgressSyncTests(TestCase):
1556
+ def setUp(self):
1557
+ self.client = Client()
1558
+ self.user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1559
+ self.client.force_login(self.user)
1560
+ self.package = Package.objects.get(name="arthexis")
1561
+ self.version_path = Path("VERSION")
1562
+ self.original_version = self.version_path.read_text(encoding="utf-8")
1563
+ self.version_path.write_text("1.2.3", encoding="utf-8")
1564
+
1565
+ def tearDown(self):
1566
+ self.version_path.write_text(self.original_version, encoding="utf-8")
1567
+
1568
+ @mock.patch("core.views.PackageRelease.dump_fixture")
1569
+ @mock.patch("core.views.revision.get_revision", return_value="abc123")
1570
+ def test_unpublished_release_syncs_version_and_revision(
1571
+ self, get_revision, dump_fixture
1572
+ ):
1573
+ release = PackageRelease.objects.create(
1574
+ package=self.package,
1575
+ version="1.0.0",
1576
+ )
1577
+ release.revision = "oldrev"
1578
+ release.save(update_fields=["revision"])
1579
+
1580
+ url = reverse("release-progress", args=[release.pk, "publish"])
1581
+ response = self.client.get(url)
1582
+
1583
+ self.assertEqual(response.status_code, 200)
1584
+ release.refresh_from_db()
1585
+ self.assertEqual(release.version, "1.2.4")
1586
+ self.assertEqual(release.revision, "abc123")
1587
+ dump_fixture.assert_called_once()
1588
+
1589
+ def test_published_release_not_current_returns_404(self):
1590
+ release = PackageRelease.objects.create(
1591
+ package=self.package,
1592
+ version="1.2.4",
1593
+ pypi_url="https://example.com",
1594
+ )
1595
+
1596
+ url = reverse("release-progress", args=[release.pk, "publish"])
1597
+ response = self.client.get(url)
1598
+
1599
+ self.assertEqual(response.status_code, 404)
1600
+
1601
+
1602
+ class ReleaseProgressFixtureVisibilityTests(TestCase):
1603
+ def setUp(self):
1604
+ self.client = Client()
1605
+ self.user = User.objects.create_superuser(
1606
+ "fixture-check", "fixture@example.com", "pw"
1607
+ )
1608
+ self.client.force_login(self.user)
1609
+ current_version = Path("VERSION").read_text(encoding="utf-8").strip()
1610
+ package = Package.objects.filter(is_active=True).first()
1611
+ if package is None:
1612
+ package = Package.objects.create(name="fixturepkg", is_active=True)
1613
+ try:
1614
+ self.release = PackageRelease.objects.get(
1615
+ package=package, version=current_version
1616
+ )
1617
+ except PackageRelease.DoesNotExist:
1618
+ self.release = PackageRelease.objects.create(
1619
+ package=package, version=current_version
1620
+ )
1621
+ self.session_key = f"release_publish_{self.release.pk}"
1622
+ self.log_name = core_views._release_log_name(
1623
+ self.release.package.name, self.release.version
1624
+ )
1625
+ self.lock_path = Path("locks") / f"{self.session_key}.json"
1626
+ self.restart_path = Path("locks") / f"{self.session_key}.restarts"
1627
+ self.log_path = Path("logs") / self.log_name
1628
+ for path in (self.lock_path, self.restart_path, self.log_path):
1629
+ if path.exists():
1630
+ path.unlink()
1631
+ try:
1632
+ self.fixture_step_index = next(
1633
+ idx
1634
+ for idx, (name, _) in enumerate(core_views.PUBLISH_STEPS)
1635
+ if name == core_views.FIXTURE_REVIEW_STEP_NAME
1636
+ )
1637
+ except StopIteration: # pragma: no cover - defensive guard
1638
+ self.fail("Fixture review step not configured in publish steps")
1639
+ self.url = reverse("release-progress", args=[self.release.pk, "publish"])
1640
+
1641
+ def tearDown(self):
1642
+ session = self.client.session
1643
+ if self.session_key in session:
1644
+ session.pop(self.session_key)
1645
+ session.save()
1646
+ for path in (self.lock_path, self.restart_path, self.log_path):
1647
+ if path.exists():
1648
+ path.unlink()
1649
+ super().tearDown()
1650
+
1651
+ def _set_session(self, step: int, fixtures: list[dict]):
1652
+ session = self.client.session
1653
+ session[self.session_key] = {
1654
+ "step": step,
1655
+ "fixtures": fixtures,
1656
+ "log": self.log_name,
1657
+ "started": True,
1658
+ }
1659
+ session.save()
1660
+
1661
+ def test_fixture_summary_visible_until_migration_step(self):
1662
+ fixtures = [
1663
+ {
1664
+ "path": "core/fixtures/example.json",
1665
+ "count": 2,
1666
+ "models": ["core.Model"],
1667
+ }
1668
+ ]
1669
+ self._set_session(self.fixture_step_index, fixtures)
1670
+ response = self.client.get(self.url)
1671
+ self.assertEqual(response.status_code, 200)
1672
+ self.assertEqual(response.context["fixtures"], fixtures)
1673
+ self.assertContains(response, "Fixture changes")
1674
+
1675
+ def test_fixture_summary_hidden_after_migration_step(self):
1676
+ fixtures = [
1677
+ {
1678
+ "path": "core/fixtures/example.json",
1679
+ "count": 2,
1680
+ "models": ["core.Model"],
1681
+ }
1682
+ ]
1683
+ self._set_session(self.fixture_step_index + 1, fixtures)
1684
+ response = self.client.get(self.url)
1685
+ self.assertEqual(response.status_code, 200)
1686
+ self.assertIsNone(response.context["fixtures"])
1687
+ self.assertNotContains(response, "Fixture changes")
1688
+
1689
+
1690
+ class PackageReleaseAdminActionTests(TestCase):
1691
+ def setUp(self):
1692
+ self.factory = RequestFactory()
1693
+ self.site = AdminSite()
1694
+ self.admin = PackageReleaseAdmin(PackageRelease, self.site)
1695
+ self.messages = []
1696
+
1697
+ def _capture_message(request, message, level=messages.INFO):
1698
+ self.messages.append((message, level))
1699
+
1700
+ self.admin.message_user = _capture_message
1701
+ self.package = Package.objects.create(name="pkg")
1702
+ self.package.is_active = True
1703
+ self.package.save(update_fields=["is_active"])
1704
+ self.release = PackageRelease.objects.create(
1705
+ package=self.package,
1706
+ version="1.0.0",
1707
+ pypi_url="https://pypi.org/project/pkg/1.0.0/",
1708
+ )
1709
+ self.request = self.factory.get("/")
1710
+
1711
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
1712
+ @mock.patch("core.admin.requests.get")
1713
+ def test_validate_deletes_missing_release(self, mock_get, dump):
1714
+ mock_get.return_value.status_code = 404
1715
+ self.admin.validate_releases(self.request, PackageRelease.objects.all())
1716
+ self.assertEqual(PackageRelease.objects.count(), 0)
1717
+ dump.assert_called_once()
1718
+
1719
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
1720
+ @mock.patch("core.admin.requests.get")
1721
+ def test_validate_keeps_existing_release(self, mock_get, dump):
1722
+ mock_get.return_value.status_code = 200
1723
+ self.admin.validate_releases(self.request, PackageRelease.objects.all())
1724
+ self.assertEqual(PackageRelease.objects.count(), 1)
1725
+ dump.assert_not_called()
1726
+
1727
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
1728
+ @mock.patch("core.admin.requests.get")
1729
+ def test_refresh_from_pypi_creates_releases(self, mock_get, dump):
1730
+ mock_get.return_value.raise_for_status.return_value = None
1731
+ mock_get.return_value.json.return_value = {
1732
+ "releases": {
1733
+ "1.0.0": [
1734
+ {"upload_time_iso_8601": "2024-01-01T12:30:00.000000Z"}
1735
+ ],
1736
+ "1.1.0": [
1737
+ {"upload_time_iso_8601": "2024-02-02T15:45:00.000000Z"}
1738
+ ],
1739
+ }
1740
+ }
1741
+ self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1742
+ new_release = PackageRelease.objects.get(version="1.1.0")
1743
+ self.assertEqual(new_release.revision, "")
1744
+ self.assertEqual(
1745
+ new_release.release_on,
1746
+ datetime(2024, 2, 2, 15, 45, tzinfo=datetime_timezone.utc),
1747
+ )
1748
+ dump.assert_called_once()
1749
+
1750
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
1751
+ @mock.patch("core.admin.requests.get")
1752
+ def test_refresh_from_pypi_updates_release_date(self, mock_get, dump):
1753
+ self.release.release_on = None
1754
+ self.release.save(update_fields=["release_on"])
1755
+ mock_get.return_value.raise_for_status.return_value = None
1756
+ mock_get.return_value.json.return_value = {
1757
+ "releases": {
1758
+ "1.0.0": [
1759
+ {"upload_time_iso_8601": "2024-01-01T12:30:00.000000Z"}
1760
+ ]
1761
+ }
1762
+ }
1763
+ self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1764
+ self.release.refresh_from_db()
1765
+ self.assertEqual(
1766
+ self.release.release_on,
1767
+ datetime(2024, 1, 1, 12, 30, tzinfo=datetime_timezone.utc),
1768
+ )
1769
+ dump.assert_called_once()
1770
+
1771
+ @mock.patch("core.admin.PackageRelease.dump_fixture")
1772
+ @mock.patch("core.admin.requests.get")
1773
+ def test_refresh_from_pypi_restores_deleted_release(self, mock_get, dump):
1774
+ self.release.is_deleted = True
1775
+ self.release.save(update_fields=["is_deleted"])
1776
+ mock_get.return_value.raise_for_status.return_value = None
1777
+ mock_get.return_value.json.return_value = {
1778
+ "releases": {
1779
+ "1.0.0": [
1780
+ {"upload_time_iso_8601": "2024-01-01T12:30:00.000000Z"}
1781
+ ]
1782
+ }
1783
+ }
1784
+
1785
+ self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1786
+
1787
+ self.assertTrue(
1788
+ PackageRelease.objects.filter(version="1.0.0").exists()
1789
+ )
1790
+ dump.assert_called_once()
1791
+
1792
+ @mock.patch("core.admin.release_utils.check_pypi_readiness")
1793
+ def test_test_pypi_connection_action_reports_messages(self, check):
1794
+ check.return_value = types.SimpleNamespace(
1795
+ ok=True,
1796
+ messages=[
1797
+ ("success", "Twine available"),
1798
+ ("warning", "Offline mode enabled; skipping network connectivity checks"),
1799
+ ],
1800
+ )
1801
+
1802
+ self.admin.test_pypi_connection(
1803
+ self.request, PackageRelease.objects.filter(pk=self.release.pk)
1804
+ )
1805
+
1806
+ self.assertIn(
1807
+ (f"{self.release}: Twine available", messages.SUCCESS),
1808
+ self.messages,
1809
+ )
1810
+ self.assertIn(
1811
+ (
1812
+ f"{self.release}: Offline mode enabled; skipping network connectivity checks",
1813
+ messages.WARNING,
1814
+ ),
1815
+ self.messages,
1816
+ )
1817
+ self.assertIn(
1818
+ (f"{self.release}: PyPI connectivity check passed", messages.SUCCESS),
1819
+ self.messages,
1820
+ )
1821
+
1822
+ @mock.patch("core.admin.release_utils.check_pypi_readiness")
1823
+ def test_test_pypi_connection_handles_errors(self, check):
1824
+ check.return_value = types.SimpleNamespace(
1825
+ ok=False, messages=[("error", "Missing PyPI credentials")]
1826
+ )
1827
+
1828
+ self.admin.test_pypi_connection(
1829
+ self.request, PackageRelease.objects.filter(pk=self.release.pk)
1830
+ )
1831
+
1832
+ self.assertIn(
1833
+ (f"{self.release}: Missing PyPI credentials", messages.ERROR),
1834
+ self.messages,
1835
+ )
1836
+ self.assertNotIn(
1837
+ (f"{self.release}: PyPI connectivity check passed", messages.SUCCESS),
1838
+ self.messages,
1839
+ )
1840
+
1841
+ @mock.patch("core.admin.release_utils.check_pypi_readiness")
1842
+ def test_test_pypi_connection_action_button(self, check):
1843
+ check.return_value = types.SimpleNamespace(
1844
+ ok=True, messages=[("success", "Twine available")]
1845
+ )
1846
+
1847
+ self.admin.test_pypi_connection_action(self.request, self.release)
1848
+
1849
+ self.assertIn(
1850
+ (f"{self.release}: PyPI connectivity check passed", messages.SUCCESS),
1851
+ self.messages,
1852
+ )
1853
+
1854
+ def test_test_pypi_connection_requires_selection(self):
1855
+ self.admin.test_pypi_connection(self.request, PackageRelease.objects.none())
1856
+
1857
+ self.assertIn(
1858
+ ("Select at least one release to test", messages.ERROR), self.messages
1859
+ )
1860
+
1861
+
1862
+ class PackageActiveTests(TestCase):
1863
+ def test_only_one_active_package(self):
1864
+ default = Package.objects.get(name="arthexis")
1865
+ self.assertTrue(default.is_active)
1866
+ other = Package.objects.create(name="pkg", is_active=True)
1867
+ default.refresh_from_db()
1868
+ other.refresh_from_db()
1869
+ self.assertFalse(default.is_active)
1870
+ self.assertTrue(other.is_active)
1871
+
1872
+
1873
+ class PackageReleaseCurrentTests(TestCase):
1874
+ def setUp(self):
1875
+ self.package = Package.objects.get(name="arthexis")
1876
+ self.version_path = Path("VERSION")
1877
+ self.original = self.version_path.read_text()
1878
+ self.version_path.write_text("1.0.0")
1879
+ self.release = PackageRelease.objects.create(
1880
+ package=self.package, version="1.0.0"
1881
+ )
1882
+
1883
+ def tearDown(self):
1884
+ self.version_path.write_text(self.original)
1885
+
1886
+ def test_is_current_true_when_version_matches_and_package_active(self):
1887
+ self.assertTrue(self.release.is_current)
1888
+
1889
+ def test_is_current_false_when_package_inactive(self):
1890
+ self.package.is_active = False
1891
+ self.package.save()
1892
+ self.assertFalse(self.release.is_current)
1893
+
1894
+ def test_is_current_false_when_version_differs(self):
1895
+ self.release.version = "2.0.0"
1896
+ self.release.save()
1897
+ self.assertFalse(self.release.is_current)
1898
+
1899
+
1900
+ class PackageReleaseChangelistTests(TestCase):
1901
+ def setUp(self):
1902
+ self.client = Client()
1903
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1904
+ self.client.force_login(User.objects.get(username="admin"))
1905
+
1906
+ def test_prepare_next_release_button_present(self):
1907
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1908
+ prepare_url = reverse(
1909
+ "admin:core_packagerelease_actions", args=["prepare_next_release"]
1910
+ )
1911
+ self.assertContains(response, prepare_url, html=False)
1912
+
1913
+ def test_refresh_from_pypi_button_present(self):
1914
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1915
+ refresh_url = reverse(
1916
+ "admin:core_packagerelease_actions", args=["refresh_from_pypi"]
1917
+ )
1918
+ self.assertContains(response, refresh_url, html=False)
1919
+
1920
+ def test_prepare_next_release_action_creates_release(self):
1921
+ package = Package.objects.get(name="arthexis")
1922
+ PackageRelease.all_objects.filter(package=package).delete()
1923
+ response = self.client.post(
1924
+ reverse(
1925
+ "admin:core_packagerelease_actions", args=["prepare_next_release"]
1926
+ )
1927
+ )
1928
+ self.assertEqual(response.status_code, 302)
1929
+ self.assertTrue(
1930
+ PackageRelease.all_objects.filter(package=package).exists()
1931
+ )
1932
+
1933
+
1934
+ class PackageAdminPrepareNextReleaseTests(TestCase):
1935
+ def setUp(self):
1936
+ self.factory = RequestFactory()
1937
+ self.site = AdminSite()
1938
+ self.admin = PackageAdmin(Package, self.site)
1939
+ self.admin.message_user = lambda *args, **kwargs: None
1940
+ self.package = Package.objects.get(name="arthexis")
1941
+
1942
+ def test_prepare_next_release_active_creates_release(self):
1943
+ PackageRelease.all_objects.filter(package=self.package).delete()
1944
+ request = self.factory.get("/admin/core/package/prepare-next-release/")
1945
+ response = self.admin.prepare_next_release_active(request)
1946
+ self.assertEqual(response.status_code, 302)
1947
+ self.assertEqual(
1948
+ PackageRelease.all_objects.filter(package=self.package).count(), 1
1949
+ )
1950
+
1951
+
1952
+ class PackageAdminChangeViewTests(TestCase):
1953
+ def setUp(self):
1954
+ self.client = Client()
1955
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1956
+ self.client.force_login(User.objects.get(username="admin"))
1957
+ self.package = Package.objects.get(name="arthexis")
1958
+
1959
+ def test_prepare_next_release_button_visible_on_change_view(self):
1960
+ response = self.client.get(
1961
+ reverse("admin:core_package_change", args=[self.package.pk])
1962
+ )
1963
+ self.assertContains(response, "Prepare next Release")
1964
+
1965
+
1966
+ class TodoDoneTests(TestCase):
1967
+ def setUp(self):
1968
+ self.client = Client()
1969
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1970
+ self.client.force_login(User.objects.get(username="admin"))
1971
+
1972
+ def test_mark_done_sets_timestamp(self):
1973
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
1974
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1975
+ self.assertRedirects(resp, reverse("admin:index"))
1976
+ todo.refresh_from_db()
1977
+ self.assertIsNotNone(todo.done_on)
1978
+ self.assertFalse(todo.is_deleted)
1979
+
1980
+ def test_mark_done_missing_task_refreshes(self):
1981
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
1982
+ todo.delete()
1983
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1984
+ self.assertRedirects(resp, reverse("admin:index"))
1985
+ messages = [m.message for m in get_messages(resp.wsgi_request)]
1986
+ self.assertFalse(messages)
1987
+
1988
+ def test_mark_done_condition_failure_shows_message(self):
1989
+ todo = Todo.objects.create(
1990
+ request="Task",
1991
+ on_done_condition="1 = 0",
1992
+ )
1993
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1994
+ self.assertRedirects(resp, reverse("admin:index"))
1995
+ messages = [m.message for m in get_messages(resp.wsgi_request)]
1996
+ self.assertTrue(messages)
1997
+ self.assertIn("1 = 0", messages[0])
1998
+ todo.refresh_from_db()
1999
+ self.assertIsNone(todo.done_on)
2000
+
2001
+ def test_mark_done_condition_invalid_expression(self):
2002
+ todo = Todo.objects.create(
2003
+ request="Task",
2004
+ on_done_condition="1; SELECT 1",
2005
+ )
2006
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
2007
+ self.assertRedirects(resp, reverse("admin:index"))
2008
+ messages = [m.message for m in get_messages(resp.wsgi_request)]
2009
+ self.assertTrue(messages)
2010
+ self.assertIn("Semicolons", messages[0])
2011
+ todo.refresh_from_db()
2012
+ self.assertIsNone(todo.done_on)
2013
+
2014
+ def test_mark_done_condition_resolves_sigils(self):
2015
+ todo = Todo.objects.create(
2016
+ request="Task",
2017
+ on_done_condition="[TEST]",
2018
+ )
2019
+ with mock.patch.object(Todo, "resolve_sigils", return_value="1 = 1") as resolver:
2020
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
2021
+ self.assertRedirects(resp, reverse("admin:index"))
2022
+ resolver.assert_called_once_with("on_done_condition")
2023
+ todo.refresh_from_db()
2024
+ self.assertIsNotNone(todo.done_on)
2025
+
2026
+ def test_mark_done_respects_next_parameter(self):
2027
+ todo = Todo.objects.create(request="Task")
2028
+ next_url = reverse("admin:index") + "?section=todos"
2029
+ resp = self.client.post(
2030
+ reverse("todo-done", args=[todo.pk]),
2031
+ {"next": next_url},
2032
+ )
2033
+ self.assertRedirects(resp, next_url, target_status_code=200)
2034
+ todo.refresh_from_db()
2035
+ self.assertIsNotNone(todo.done_on)
2036
+
2037
+ def test_mark_done_rejects_external_next(self):
2038
+ todo = Todo.objects.create(request="Task")
2039
+ resp = self.client.post(
2040
+ reverse("todo-done", args=[todo.pk]),
2041
+ {"next": "https://example.com/"},
2042
+ )
2043
+ self.assertRedirects(resp, reverse("admin:index"))
2044
+ todo.refresh_from_db()
2045
+ self.assertIsNotNone(todo.done_on)
2046
+
2047
+
2048
+ class TodoFocusViewTests(TestCase):
2049
+ def setUp(self):
2050
+ self.client = Client()
2051
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
2052
+ self.client.force_login(User.objects.get(username="admin"))
2053
+
2054
+ def test_focus_view_renders_requested_page(self):
2055
+ todo = Todo.objects.create(request="Task", url="/docs/")
2056
+ next_url = reverse("admin:index")
2057
+ resp = self.client.get(
2058
+ f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
2059
+ )
2060
+ self.assertEqual(resp.status_code, 200)
2061
+ self.assertContains(resp, todo.request)
2062
+ self.assertEqual(resp["X-Frame-Options"], "SAMEORIGIN")
2063
+ self.assertContains(resp, f'src="{todo.url}"')
2064
+ self.assertContains(resp, "Done")
2065
+ self.assertContains(resp, "Back")
2066
+
2067
+ def test_focus_view_uses_admin_change_when_no_url(self):
2068
+ todo = Todo.objects.create(request="Task")
2069
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
2070
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
2071
+ self.assertContains(resp, f'src="{change_url}"')
2072
+
2073
+ def test_focus_view_includes_open_target_button(self):
2074
+ todo = Todo.objects.create(request="Task", url="/docs/")
2075
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
2076
+ self.assertContains(resp, 'class="todo-button todo-button-open"')
2077
+ self.assertContains(resp, 'target="_blank"')
2078
+ self.assertContains(resp, 'href="/docs/"')
2079
+
2080
+ def test_focus_view_sanitizes_loopback_absolute_url(self):
2081
+ todo = Todo.objects.create(
2082
+ request="Task",
2083
+ url="http://127.0.0.1:8000/docs/?section=chart",
2084
+ )
2085
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
2086
+ self.assertContains(resp, 'src="/docs/?section=chart"')
2087
+
2088
+ def test_focus_view_rejects_external_absolute_url(self):
2089
+ todo = Todo.objects.create(
2090
+ request="Task",
2091
+ url="https://outside.invalid/external/",
2092
+ )
2093
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
2094
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
2095
+ self.assertContains(resp, f'src="{change_url}"')
2096
+
2097
+ def test_focus_view_avoids_recursive_focus_url(self):
2098
+ todo = Todo.objects.create(request="Task")
2099
+ focus_url = reverse("todo-focus", args=[todo.pk])
2100
+ Todo.objects.filter(pk=todo.pk).update(url=focus_url)
2101
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
2102
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
2103
+ self.assertContains(resp, f'src="{change_url}"')
2104
+
2105
+ def test_focus_view_avoids_recursive_focus_absolute_url(self):
2106
+ todo = Todo.objects.create(request="Task")
2107
+ focus_url = reverse("todo-focus", args=[todo.pk])
2108
+ Todo.objects.filter(pk=todo.pk).update(url=f"http://testserver{focus_url}")
2109
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
2110
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
2111
+ self.assertContains(resp, f'src="{change_url}"')
2112
+
2113
+ def test_focus_view_parses_auth_directives(self):
2114
+ todo = Todo.objects.create(
2115
+ request="Task",
2116
+ url="/docs/?section=chart&_todo_auth=logout&_todo_auth=user:demo&_todo_auth=perm:core.view_user&_todo_auth=extra",
2117
+ )
2118
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
2119
+ self.assertContains(resp, 'src="/docs/?section=chart"')
2120
+ self.assertContains(resp, 'href="/docs/?section=chart"')
2121
+ self.assertContains(resp, "logged out")
2122
+ self.assertContains(resp, "Sign in using: demo")
2123
+ self.assertContains(resp, "Required permissions: core.view_user")
2124
+ self.assertContains(resp, "Additional authentication notes: extra")
2125
+
2126
+ def test_focus_view_redirects_if_todo_completed(self):
2127
+ todo = Todo.objects.create(request="Task")
2128
+ todo.done_on = timezone.now()
2129
+ todo.save(update_fields=["done_on"])
2130
+ next_url = reverse("admin:index")
2131
+ resp = self.client.get(
2132
+ f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
2133
+ )
2134
+ self.assertRedirects(resp, next_url, target_status_code=200)
2135
+
2136
+
2137
+ class TodoUrlValidationTests(TestCase):
2138
+ def test_relative_url_valid(self):
2139
+ todo = Todo(request="Task", url="/path")
2140
+ todo.full_clean() # should not raise
2141
+
2142
+ def test_absolute_url_invalid(self):
2143
+ todo = Todo(request="Task", url="https://example.com/path")
2144
+ with self.assertRaises(ValidationError):
2145
+ todo.full_clean()
2146
+
2147
+
2148
+ class TodoUniqueTests(TestCase):
2149
+ def test_request_unique_case_insensitive(self):
2150
+ Todo.objects.create(request="Task")
2151
+ with self.assertRaises(IntegrityError):
2152
+ Todo.objects.create(request="task")
2153
+
2154
+
2155
+ class TodoAdminPermissionTests(TestCase):
2156
+ def setUp(self):
2157
+ self.client = Client()
2158
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
2159
+ self.client.force_login(User.objects.get(username="admin"))
2160
+
2161
+ def test_add_view_disallowed(self):
2162
+ resp = self.client.get(reverse("admin:core_todo_add"))
2163
+ self.assertEqual(resp.status_code, 403)
2164
+
2165
+ def test_change_form_loads(self):
2166
+ todo = Todo.objects.create(request="Task")
2167
+ resp = self.client.get(reverse("admin:core_todo_change", args=[todo.pk]))
2168
+ self.assertEqual(resp.status_code, 200)