arthexis 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/tests.py CHANGED
@@ -2,42 +2,59 @@ import os
2
2
 
3
3
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
4
4
  import django
5
+
5
6
  django.setup()
6
7
 
7
- from django.test import Client, TestCase, RequestFactory
8
+ from django.test import Client, TestCase, RequestFactory, override_settings
8
9
  from django.urls import reverse
9
10
  from django.http import HttpRequest
10
11
  import json
12
+ from decimal import Decimal
11
13
  from unittest import mock
14
+ from unittest.mock import patch
12
15
  from pathlib import Path
13
16
  import subprocess
17
+ from glob import glob
18
+ from datetime import timedelta
19
+ import tempfile
20
+ from urllib.parse import quote
14
21
 
15
22
  from django.utils import timezone
23
+ from django.contrib.auth.models import Permission
24
+ from django.contrib.messages import get_messages
16
25
  from .models import (
17
26
  User,
27
+ UserPhoneNumber,
18
28
  EnergyAccount,
19
29
  ElectricVehicle,
20
30
  EnergyCredit,
21
- Address,
22
31
  Product,
23
- Subscription,
24
32
  Brand,
25
33
  EVModel,
26
34
  RFID,
27
- FediverseProfile,
28
35
  SecurityGroup,
29
36
  Package,
30
37
  PackageRelease,
38
+ ReleaseManager,
39
+ Todo,
40
+ PublicWifiAccess,
31
41
  )
32
42
  from django.contrib.admin.sites import AdminSite
33
- from core.admin import PackageReleaseAdmin, PackageAdmin
43
+ from core.admin import (
44
+ PackageReleaseAdmin,
45
+ PackageAdmin,
46
+ UserAdmin,
47
+ USER_PROFILE_INLINES,
48
+ )
34
49
  from ocpp.models import Transaction, Charger
35
50
 
36
51
  from django.core.exceptions import ValidationError
37
52
  from django.core.management import call_command
38
53
  from django.db import IntegrityError
39
54
  from .backends import LocalhostAdminBackend
40
- from core.views import _step_check_pypi, _step_promote_build, _step_publish
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
41
58
 
42
59
 
43
60
  class DefaultAdminTests(TestCase):
@@ -78,6 +95,205 @@ class DefaultAdminTests(TestCase):
78
95
  )
79
96
 
80
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
+
81
297
  class RFIDLoginTests(TestCase):
82
298
  def setUp(self):
83
299
  self.client = Client()
@@ -112,7 +328,7 @@ class RFIDBatchApiTests(TestCase):
112
328
  self.client.force_login(self.user)
113
329
 
114
330
  def test_export_rfids(self):
115
- tag_black = RFID.objects.create(rfid="CARD999")
331
+ tag_black = RFID.objects.create(rfid="CARD999", custom_label="Main Tag")
116
332
  tag_white = RFID.objects.create(rfid="CARD998", color=RFID.WHITE)
117
333
  self.account.rfids.add(tag_black, tag_white)
118
334
  response = self.client.get(reverse("rfid-batch"))
@@ -123,6 +339,7 @@ class RFIDBatchApiTests(TestCase):
123
339
  "rfids": [
124
340
  {
125
341
  "rfid": "CARD999",
342
+ "custom_label": "Main Tag",
126
343
  "energy_accounts": [self.account.id],
127
344
  "allowed": True,
128
345
  "color": "B",
@@ -141,6 +358,7 @@ class RFIDBatchApiTests(TestCase):
141
358
  "rfids": [
142
359
  {
143
360
  "rfid": "CARD111",
361
+ "custom_label": "",
144
362
  "energy_accounts": [],
145
363
  "allowed": True,
146
364
  "color": "W",
@@ -160,6 +378,7 @@ class RFIDBatchApiTests(TestCase):
160
378
  "rfids": [
161
379
  {
162
380
  "rfid": "CARD112",
381
+ "custom_label": "",
163
382
  "energy_accounts": [],
164
383
  "allowed": True,
165
384
  "color": "B",
@@ -174,6 +393,7 @@ class RFIDBatchApiTests(TestCase):
174
393
  "rfids": [
175
394
  {
176
395
  "rfid": "A1B2C3D4",
396
+ "custom_label": "Imported Tag",
177
397
  "energy_accounts": [self.account.id],
178
398
  "allowed": True,
179
399
  "color": "W",
@@ -191,6 +411,7 @@ class RFIDBatchApiTests(TestCase):
191
411
  self.assertTrue(
192
412
  RFID.objects.filter(
193
413
  rfid="A1B2C3D4",
414
+ custom_label="Imported Tag",
194
415
  energy_accounts=self.account,
195
416
  color=RFID.WHITE,
196
417
  released=True,
@@ -237,6 +458,11 @@ class RFIDValidationTests(TestCase):
237
458
  found = RFID.get_account_by_rfid("abcd1234")
238
459
  self.assertEqual(found, acc)
239
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
+
240
466
 
241
467
  class RFIDAssignmentTests(TestCase):
242
468
  def setUp(self):
@@ -279,7 +505,9 @@ class EnergyAccountTests(TestCase):
279
505
 
280
506
  def test_service_account_ignores_balance(self):
281
507
  user = User.objects.create_user(username="service", password="x")
282
- acc = EnergyAccount.objects.create(user=user, service_account=True, name="SERVICE")
508
+ acc = EnergyAccount.objects.create(
509
+ user=user, service_account=True, name="SERVICE"
510
+ )
283
511
  self.assertTrue(acc.can_authorize())
284
512
 
285
513
  def test_account_without_user(self):
@@ -331,7 +559,43 @@ class AddressTests(TestCase):
331
559
  self.assertEqual(user.address, addr)
332
560
 
333
561
 
334
- class SubscriptionTests(TestCase):
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):
335
599
  def setUp(self):
336
600
  self.client = Client()
337
601
  self.user = User.objects.create_user(username="bob", password="pwd")
@@ -339,22 +603,41 @@ class SubscriptionTests(TestCase):
339
603
  self.product = Product.objects.create(name="Gold", renewal_period=30)
340
604
  self.client.force_login(self.user)
341
605
 
342
- def test_create_and_list_subscription(self):
606
+ def test_create_and_list_live_subscription(self):
343
607
  response = self.client.post(
344
- reverse("add-subscription"),
608
+ reverse("add-live-subscription"),
345
609
  data={"account_id": self.account.id, "product_id": self.product.id},
346
610
  content_type="application/json",
347
611
  )
348
612
  self.assertEqual(response.status_code, 200)
349
- self.assertEqual(Subscription.objects.count(), 1)
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
+ )
350
628
 
351
629
  list_resp = self.client.get(
352
- reverse("subscription-list"), {"account_id": self.account.id}
630
+ reverse("live-subscription-list"), {"account_id": self.account.id}
353
631
  )
354
632
  self.assertEqual(list_resp.status_code, 200)
355
633
  data = list_resp.json()
356
- self.assertEqual(len(data["subscriptions"]), 1)
357
- self.assertEqual(data["subscriptions"][0]["product__name"], "Gold")
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
+ )
358
641
 
359
642
  def test_product_list(self):
360
643
  response = self.client.get(reverse("product-list"))
@@ -394,8 +677,8 @@ class EVBrandFixtureTests(TestCase):
394
677
  def test_ev_brand_fixture_loads(self):
395
678
  call_command(
396
679
  "loaddata",
397
- "core/fixtures/ev_brands.json",
398
- "core/fixtures/ev_models.json",
680
+ *sorted(glob("core/fixtures/ev_brands__*.json")),
681
+ *sorted(glob("core/fixtures/ev_models__*.json")),
399
682
  verbosity=0,
400
683
  )
401
684
  porsche = Brand.objects.get(name="Porsche")
@@ -408,11 +691,14 @@ class EVBrandFixtureTests(TestCase):
408
691
  )
409
692
  self.assertTrue(EVModel.objects.filter(brand=porsche, name="Taycan").exists())
410
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"))
411
697
 
412
698
  def test_brand_from_vin(self):
413
699
  call_command(
414
700
  "loaddata",
415
- "core/fixtures/ev_brands.json",
701
+ *sorted(glob("core/fixtures/ev_brands__*.json")),
416
702
  verbosity=0,
417
703
  )
418
704
  self.assertEqual(Brand.from_vin("WP0ZZZ12345678901").name, "Porsche")
@@ -424,9 +710,9 @@ class RFIDFixtureTests(TestCase):
424
710
  def test_fixture_assigns_gelectriic_rfid(self):
425
711
  call_command(
426
712
  "loaddata",
427
- "core/fixtures/users.json",
428
- "core/fixtures/energy_accounts.json",
429
- "core/fixtures/rfids.json",
713
+ "core/fixtures/users__arthexis.json",
714
+ "core/fixtures/energy_accounts__gelectriic.json",
715
+ "core/fixtures/rfids__ffffffff.json",
430
716
  verbosity=0,
431
717
  )
432
718
  account = EnergyAccount.objects.get(name="GELECTRIIC")
@@ -458,37 +744,6 @@ class SecurityGroupTests(TestCase):
458
744
  self.assertIn(user, child.user_set.all())
459
745
 
460
746
 
461
- class FediverseProfileTests(TestCase):
462
- def setUp(self):
463
- self.user = User.objects.create_user(username="fed", password="secret")
464
-
465
- @mock.patch("requests.get")
466
- def test_connection_success_sets_verified(self, mock_get):
467
- mock_get.return_value.ok = True
468
- mock_get.return_value.raise_for_status.return_value = None
469
- profile = FediverseProfile.objects.create(
470
- user=self.user,
471
- service=FediverseProfile.MASTODON,
472
- host="example.com",
473
- handle="fed",
474
- access_token="tok",
475
- )
476
- self.assertTrue(profile.test_connection())
477
- self.assertIsNotNone(profile.verified_on)
478
-
479
- @mock.patch("requests.get", side_effect=Exception("boom"))
480
- def test_connection_failure_raises(self, mock_get):
481
- profile = FediverseProfile.objects.create(
482
- user=self.user,
483
- service=FediverseProfile.MASTODON,
484
- host="example.com",
485
- handle="fed",
486
- )
487
- with self.assertRaises(ValidationError):
488
- profile.test_connection()
489
- self.assertIsNone(profile.verified_on)
490
-
491
-
492
747
  class ReleaseProcessTests(TestCase):
493
748
  def setUp(self):
494
749
  self.package = Package.objects.create(name="pkg")
@@ -499,14 +754,14 @@ class ReleaseProcessTests(TestCase):
499
754
  @mock.patch("core.views.release_utils._git_clean", return_value=False)
500
755
  def test_step_check_requires_clean_repo(self, git_clean):
501
756
  with self.assertRaises(Exception):
502
- _step_check_pypi(self.release, {}, Path("rel.log"))
757
+ _step_check_version(self.release, {}, Path("rel.log"))
503
758
 
504
759
  @mock.patch("core.views.release_utils._git_clean", return_value=True)
505
760
  @mock.patch("core.views.release_utils.network_available", return_value=False)
506
761
  def test_step_check_keeps_repo_clean(self, network_available, git_clean):
507
762
  version_path = Path("VERSION")
508
763
  original = version_path.read_text(encoding="utf-8")
509
- _step_check_pypi(self.release, {}, Path("rel.log"))
764
+ _step_check_version(self.release, {}, Path("rel.log"))
510
765
  proc = subprocess.run(
511
766
  ["git", "status", "--porcelain", str(version_path)],
512
767
  capture_output=True,
@@ -524,9 +779,7 @@ class ReleaseProcessTests(TestCase):
524
779
  @mock.patch("core.views.subprocess.run")
525
780
  @mock.patch("core.views.PackageRelease.dump_fixture")
526
781
  @mock.patch("core.views.release_utils.promote", side_effect=Exception("boom"))
527
- def test_promote_cleans_repo_on_failure(
528
- self, promote, dump_fixture, run
529
- ):
782
+ def test_promote_cleans_repo_on_failure(self, promote, dump_fixture, run):
530
783
  with self.assertRaises(Exception):
531
784
  _step_promote_build(self.release, {}, Path("rel.log"))
532
785
  dump_fixture.assert_not_called()
@@ -651,6 +904,139 @@ class ReleaseProcessTests(TestCase):
651
904
  self.assertEqual(count_file.read_text(), "1")
652
905
 
653
906
 
907
+ class ReleaseProgressSyncTests(TestCase):
908
+ def setUp(self):
909
+ self.client = Client()
910
+ self.user = User.objects.create_superuser("admin", "admin@example.com", "pw")
911
+ self.client.force_login(self.user)
912
+ self.package = Package.objects.get(name="arthexis")
913
+ self.version_path = Path("VERSION")
914
+ self.original_version = self.version_path.read_text(encoding="utf-8")
915
+ self.version_path.write_text("1.2.3", encoding="utf-8")
916
+
917
+ def tearDown(self):
918
+ self.version_path.write_text(self.original_version, encoding="utf-8")
919
+
920
+ @mock.patch("core.views.PackageRelease.dump_fixture")
921
+ @mock.patch("core.views.revision.get_revision", return_value="abc123")
922
+ def test_unpublished_release_syncs_version_and_revision(
923
+ self, get_revision, dump_fixture
924
+ ):
925
+ release = PackageRelease.objects.create(
926
+ package=self.package,
927
+ version="1.0.0",
928
+ )
929
+ release.revision = "oldrev"
930
+ release.save(update_fields=["revision"])
931
+
932
+ url = reverse("release-progress", args=[release.pk, "publish"])
933
+ response = self.client.get(url)
934
+
935
+ self.assertEqual(response.status_code, 200)
936
+ release.refresh_from_db()
937
+ self.assertEqual(release.version, "1.2.4")
938
+ self.assertEqual(release.revision, "abc123")
939
+ dump_fixture.assert_called_once()
940
+
941
+ def test_published_release_not_current_returns_404(self):
942
+ release = PackageRelease.objects.create(
943
+ package=self.package,
944
+ version="1.2.4",
945
+ pypi_url="https://example.com",
946
+ )
947
+
948
+ url = reverse("release-progress", args=[release.pk, "publish"])
949
+ response = self.client.get(url)
950
+
951
+ self.assertEqual(response.status_code, 404)
952
+
953
+
954
+ class ReleaseProgressFixtureVisibilityTests(TestCase):
955
+ def setUp(self):
956
+ self.client = Client()
957
+ self.user = User.objects.create_superuser(
958
+ "fixture-check", "fixture@example.com", "pw"
959
+ )
960
+ self.client.force_login(self.user)
961
+ current_version = Path("VERSION").read_text(encoding="utf-8").strip()
962
+ package = Package.objects.filter(is_active=True).first()
963
+ if package is None:
964
+ package = Package.objects.create(name="fixturepkg", is_active=True)
965
+ try:
966
+ self.release = PackageRelease.objects.get(
967
+ package=package, version=current_version
968
+ )
969
+ except PackageRelease.DoesNotExist:
970
+ self.release = PackageRelease.objects.create(
971
+ package=package, version=current_version
972
+ )
973
+ self.session_key = f"release_publish_{self.release.pk}"
974
+ self.log_name = f"{self.release.package.name}-{self.release.version}.log"
975
+ self.lock_path = Path("locks") / f"{self.session_key}.json"
976
+ self.restart_path = Path("locks") / f"{self.session_key}.restarts"
977
+ self.log_path = Path("logs") / self.log_name
978
+ for path in (self.lock_path, self.restart_path, self.log_path):
979
+ if path.exists():
980
+ path.unlink()
981
+ try:
982
+ self.fixture_step_index = next(
983
+ idx
984
+ for idx, (name, _) in enumerate(core_views.PUBLISH_STEPS)
985
+ if name == core_views.FIXTURE_REVIEW_STEP_NAME
986
+ )
987
+ except StopIteration: # pragma: no cover - defensive guard
988
+ self.fail("Fixture review step not configured in publish steps")
989
+ self.url = reverse("release-progress", args=[self.release.pk, "publish"])
990
+
991
+ def tearDown(self):
992
+ session = self.client.session
993
+ if self.session_key in session:
994
+ session.pop(self.session_key)
995
+ session.save()
996
+ for path in (self.lock_path, self.restart_path, self.log_path):
997
+ if path.exists():
998
+ path.unlink()
999
+ super().tearDown()
1000
+
1001
+ def _set_session(self, step: int, fixtures: list[dict]):
1002
+ session = self.client.session
1003
+ session[self.session_key] = {
1004
+ "step": step,
1005
+ "fixtures": fixtures,
1006
+ "log": self.log_name,
1007
+ "started": True,
1008
+ }
1009
+ session.save()
1010
+
1011
+ def test_fixture_summary_visible_until_migration_step(self):
1012
+ fixtures = [
1013
+ {
1014
+ "path": "core/fixtures/example.json",
1015
+ "count": 2,
1016
+ "models": ["core.Model"],
1017
+ }
1018
+ ]
1019
+ self._set_session(self.fixture_step_index, fixtures)
1020
+ response = self.client.get(self.url)
1021
+ self.assertEqual(response.status_code, 200)
1022
+ self.assertEqual(response.context["fixtures"], fixtures)
1023
+ self.assertContains(response, "Fixture changes")
1024
+
1025
+ def test_fixture_summary_hidden_after_migration_step(self):
1026
+ fixtures = [
1027
+ {
1028
+ "path": "core/fixtures/example.json",
1029
+ "count": 2,
1030
+ "models": ["core.Model"],
1031
+ }
1032
+ ]
1033
+ self._set_session(self.fixture_step_index + 1, fixtures)
1034
+ response = self.client.get(self.url)
1035
+ self.assertEqual(response.status_code, 200)
1036
+ self.assertIsNone(response.context["fixtures"])
1037
+ self.assertNotContains(response, "Fixture changes")
1038
+
1039
+
654
1040
  class PackageReleaseAdminActionTests(TestCase):
655
1041
  def setUp(self):
656
1042
  self.factory = RequestFactory()
@@ -688,12 +1074,9 @@ class PackageReleaseAdminActionTests(TestCase):
688
1074
  mock_get.return_value.json.return_value = {
689
1075
  "releases": {"1.0.0": [], "1.1.0": []}
690
1076
  }
691
- self.admin.refresh_from_pypi(
692
- self.request, PackageRelease.objects.none()
693
- )
694
- self.assertTrue(
695
- PackageRelease.objects.filter(version="1.1.0").exists()
696
- )
1077
+ self.admin.refresh_from_pypi(self.request, PackageRelease.objects.none())
1078
+ new_release = PackageRelease.objects.get(version="1.1.0")
1079
+ self.assertEqual(new_release.revision, "")
697
1080
  dump.assert_called_once()
698
1081
 
699
1082
 
@@ -735,6 +1118,40 @@ class PackageReleaseCurrentTests(TestCase):
735
1118
  self.assertFalse(self.release.is_current)
736
1119
 
737
1120
 
1121
+ class PackageReleaseChangelistTests(TestCase):
1122
+ def setUp(self):
1123
+ self.client = Client()
1124
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1125
+ self.client.force_login(User.objects.get(username="admin"))
1126
+
1127
+ def test_prepare_next_release_button_present(self):
1128
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1129
+ prepare_url = reverse(
1130
+ "admin:core_packagerelease_actions", args=["prepare_next_release"]
1131
+ )
1132
+ self.assertContains(response, prepare_url, html=False)
1133
+
1134
+ def test_refresh_from_pypi_button_present(self):
1135
+ response = self.client.get(reverse("admin:core_packagerelease_changelist"))
1136
+ refresh_url = reverse(
1137
+ "admin:core_packagerelease_actions", args=["refresh_from_pypi"]
1138
+ )
1139
+ self.assertContains(response, refresh_url, html=False)
1140
+
1141
+ def test_prepare_next_release_action_creates_release(self):
1142
+ package = Package.objects.get(name="arthexis")
1143
+ PackageRelease.all_objects.filter(package=package).delete()
1144
+ response = self.client.post(
1145
+ reverse(
1146
+ "admin:core_packagerelease_actions", args=["prepare_next_release"]
1147
+ )
1148
+ )
1149
+ self.assertEqual(response.status_code, 302)
1150
+ self.assertTrue(
1151
+ PackageRelease.all_objects.filter(package=package).exists()
1152
+ )
1153
+
1154
+
738
1155
  class PackageAdminPrepareNextReleaseTests(TestCase):
739
1156
  def setUp(self):
740
1157
  self.factory = RequestFactory()
@@ -753,22 +1170,176 @@ class PackageAdminPrepareNextReleaseTests(TestCase):
753
1170
  )
754
1171
 
755
1172
 
756
- class PackageReleaseChangelistTests(TestCase):
1173
+ class PackageAdminChangeViewTests(TestCase):
757
1174
  def setUp(self):
758
1175
  self.client = Client()
759
1176
  User.objects.create_superuser("admin", "admin@example.com", "pw")
760
1177
  self.client.force_login(User.objects.get(username="admin"))
1178
+ self.package = Package.objects.get(name="arthexis")
761
1179
 
762
- def test_prepare_next_release_button_present(self):
763
- response = self.client.get(reverse("admin:core_packagerelease_changelist"))
764
- self.assertContains(
765
- response, reverse("admin:core_package_prepare_next_release"), html=False
1180
+ def test_prepare_next_release_button_visible_on_change_view(self):
1181
+ response = self.client.get(
1182
+ reverse("admin:core_package_change", args=[self.package.pk])
766
1183
  )
1184
+ self.assertContains(response, "Prepare next Release")
767
1185
 
768
- def test_refresh_from_pypi_button_present(self):
769
- response = self.client.get(reverse("admin:core_packagerelease_changelist"))
770
- refresh_url = reverse(
771
- "admin:core_packagerelease_actions", args=["refresh_from_pypi"]
1186
+
1187
+ class TodoDoneTests(TestCase):
1188
+ def setUp(self):
1189
+ self.client = Client()
1190
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1191
+ self.client.force_login(User.objects.get(username="admin"))
1192
+
1193
+ def test_mark_done_sets_timestamp(self):
1194
+ todo = Todo.objects.create(request="Task", is_seed_data=True)
1195
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1196
+ self.assertRedirects(resp, reverse("admin:index"))
1197
+ todo.refresh_from_db()
1198
+ self.assertIsNotNone(todo.done_on)
1199
+ self.assertFalse(todo.is_deleted)
1200
+
1201
+ def test_mark_done_condition_failure_shows_message(self):
1202
+ todo = Todo.objects.create(
1203
+ request="Task",
1204
+ on_done_condition="1 = 0",
772
1205
  )
773
- self.assertContains(response, refresh_url, html=False)
1206
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1207
+ self.assertRedirects(resp, reverse("admin:index"))
1208
+ messages = [m.message for m in get_messages(resp.wsgi_request)]
1209
+ self.assertTrue(messages)
1210
+ self.assertIn("1 = 0", messages[0])
1211
+ todo.refresh_from_db()
1212
+ self.assertIsNone(todo.done_on)
1213
+
1214
+ def test_mark_done_condition_invalid_expression(self):
1215
+ todo = Todo.objects.create(
1216
+ request="Task",
1217
+ on_done_condition="1; SELECT 1",
1218
+ )
1219
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1220
+ self.assertRedirects(resp, reverse("admin:index"))
1221
+ messages = [m.message for m in get_messages(resp.wsgi_request)]
1222
+ self.assertTrue(messages)
1223
+ self.assertIn("Semicolons", messages[0])
1224
+ todo.refresh_from_db()
1225
+ self.assertIsNone(todo.done_on)
1226
+
1227
+ def test_mark_done_condition_resolves_sigils(self):
1228
+ todo = Todo.objects.create(
1229
+ request="Task",
1230
+ on_done_condition="[TEST]",
1231
+ )
1232
+ with mock.patch.object(Todo, "resolve_sigils", return_value="1 = 1") as resolver:
1233
+ resp = self.client.post(reverse("todo-done", args=[todo.pk]))
1234
+ self.assertRedirects(resp, reverse("admin:index"))
1235
+ resolver.assert_called_once_with("on_done_condition")
1236
+ todo.refresh_from_db()
1237
+ self.assertIsNotNone(todo.done_on)
1238
+
1239
+ def test_mark_done_respects_next_parameter(self):
1240
+ todo = Todo.objects.create(request="Task")
1241
+ next_url = reverse("admin:index") + "?section=todos"
1242
+ resp = self.client.post(
1243
+ reverse("todo-done", args=[todo.pk]),
1244
+ {"next": next_url},
1245
+ )
1246
+ self.assertRedirects(resp, next_url, target_status_code=200)
1247
+ todo.refresh_from_db()
1248
+ self.assertIsNotNone(todo.done_on)
1249
+
1250
+ def test_mark_done_rejects_external_next(self):
1251
+ todo = Todo.objects.create(request="Task")
1252
+ resp = self.client.post(
1253
+ reverse("todo-done", args=[todo.pk]),
1254
+ {"next": "https://example.com/"},
1255
+ )
1256
+ self.assertRedirects(resp, reverse("admin:index"))
1257
+ todo.refresh_from_db()
1258
+ self.assertIsNotNone(todo.done_on)
1259
+
1260
+
1261
+ class TodoFocusViewTests(TestCase):
1262
+ def setUp(self):
1263
+ self.client = Client()
1264
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1265
+ self.client.force_login(User.objects.get(username="admin"))
1266
+
1267
+ def test_focus_view_renders_requested_page(self):
1268
+ todo = Todo.objects.create(request="Task", url="/docs/")
1269
+ next_url = reverse("admin:index")
1270
+ resp = self.client.get(
1271
+ f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
1272
+ )
1273
+ self.assertEqual(resp.status_code, 200)
1274
+ self.assertContains(resp, todo.request)
1275
+ self.assertEqual(resp["X-Frame-Options"], "SAMEORIGIN")
1276
+ self.assertContains(resp, f'src="{todo.url}"')
1277
+ self.assertContains(resp, "Done")
1278
+ self.assertContains(resp, "Back")
1279
+
1280
+ def test_focus_view_uses_admin_change_when_no_url(self):
1281
+ todo = Todo.objects.create(request="Task")
1282
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1283
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
1284
+ self.assertContains(resp, f'src="{change_url}"')
1285
+
1286
+ def test_focus_view_sanitizes_loopback_absolute_url(self):
1287
+ todo = Todo.objects.create(
1288
+ request="Task",
1289
+ url="http://127.0.0.1:8000/docs/?section=chart",
1290
+ )
1291
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1292
+ self.assertContains(resp, 'src="/docs/?section=chart"')
1293
+
1294
+ def test_focus_view_rejects_external_absolute_url(self):
1295
+ todo = Todo.objects.create(
1296
+ request="Task",
1297
+ url="https://outside.invalid/external/",
1298
+ )
1299
+ resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
1300
+ change_url = reverse("admin:core_todo_change", args=[todo.pk])
1301
+ self.assertContains(resp, f'src="{change_url}"')
1302
+
1303
+ def test_focus_view_redirects_if_todo_completed(self):
1304
+ todo = Todo.objects.create(request="Task")
1305
+ todo.done_on = timezone.now()
1306
+ todo.save(update_fields=["done_on"])
1307
+ next_url = reverse("admin:index")
1308
+ resp = self.client.get(
1309
+ f"{reverse('todo-focus', args=[todo.pk])}?next={quote(next_url)}"
1310
+ )
1311
+ self.assertRedirects(resp, next_url, target_status_code=200)
1312
+
1313
+
1314
+ class TodoUrlValidationTests(TestCase):
1315
+ def test_relative_url_valid(self):
1316
+ todo = Todo(request="Task", url="/path")
1317
+ todo.full_clean() # should not raise
1318
+
1319
+ def test_absolute_url_invalid(self):
1320
+ todo = Todo(request="Task", url="https://example.com/path")
1321
+ with self.assertRaises(ValidationError):
1322
+ todo.full_clean()
1323
+
1324
+
1325
+ class TodoUniqueTests(TestCase):
1326
+ def test_request_unique_case_insensitive(self):
1327
+ Todo.objects.create(request="Task")
1328
+ with self.assertRaises(IntegrityError):
1329
+ Todo.objects.create(request="task")
1330
+
1331
+
1332
+ class TodoAdminPermissionTests(TestCase):
1333
+ def setUp(self):
1334
+ self.client = Client()
1335
+ User.objects.create_superuser("admin", "admin@example.com", "pw")
1336
+ self.client.force_login(User.objects.get(username="admin"))
1337
+
1338
+ def test_add_view_disallowed(self):
1339
+ resp = self.client.get(reverse("admin:core_todo_add"))
1340
+ self.assertEqual(resp.status_code, 403)
774
1341
 
1342
+ def test_change_form_loads(self):
1343
+ todo = Todo.objects.create(request="Task")
1344
+ resp = self.client.get(reverse("admin:core_todo_change", args=[todo.pk]))
1345
+ self.assertEqual(resp.status_code, 200)