arthexis 0.1.26__py3-none-any.whl → 0.1.28__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.

core/tests.py CHANGED
@@ -5,6 +5,7 @@ import django
5
5
 
6
6
  django.setup()
7
7
 
8
+ from django import forms
8
9
  from django.test import Client, TestCase, RequestFactory, override_settings
9
10
  from django.urls import reverse
10
11
  from django.http import HttpRequest
@@ -51,6 +52,7 @@ from core.admin import (
51
52
  PackageReleaseAdmin,
52
53
  PackageAdmin,
53
54
  RFIDResource,
55
+ SecurityGroupAdmin,
54
56
  UserAdmin,
55
57
  USER_PROFILE_INLINES,
56
58
  )
@@ -309,6 +311,16 @@ class UserAdminInlineTests(TestCase):
309
311
  self.assertEqual(len(other_profiles), len(USER_PROFILE_INLINES))
310
312
 
311
313
 
314
+ class SecurityGroupAdminTests(TestCase):
315
+ def setUp(self):
316
+ self.site = AdminSite()
317
+ self.admin = SecurityGroupAdmin(SecurityGroup, self.site)
318
+
319
+ def test_search_fields_include_name_and_parent(self):
320
+ self.assertIn("name", self.admin.search_fields)
321
+ self.assertIn("parent__name", self.admin.search_fields)
322
+
323
+
312
324
  class RFIDLoginTests(TestCase):
313
325
  def setUp(self):
314
326
  self.client = Client()
@@ -324,7 +336,9 @@ class RFIDLoginTests(TestCase):
324
336
  content_type="application/json",
325
337
  )
326
338
  self.assertEqual(response.status_code, 200)
327
- self.assertEqual(response.json()["username"], "alice")
339
+ payload = response.json()
340
+ self.assertEqual(payload["username"], "alice")
341
+ self.assertEqual(payload["redirect"], "/")
328
342
 
329
343
  def test_rfid_login_invalid(self):
330
344
  response = self.client.post(
@@ -348,6 +362,7 @@ class RFIDLoginTests(TestCase):
348
362
  )
349
363
 
350
364
  self.assertEqual(response.status_code, 200)
365
+ self.assertEqual(response.json().get("redirect"), "/")
351
366
  run_args, run_kwargs = mock_run.call_args
352
367
  self.assertEqual(run_args[0], "echo ok")
353
368
  self.assertTrue(run_kwargs.get("shell"))
@@ -384,6 +399,7 @@ class RFIDLoginTests(TestCase):
384
399
  )
385
400
 
386
401
  self.assertEqual(response.status_code, 200)
402
+ self.assertEqual(response.json().get("redirect"), "/")
387
403
  mock_popen.assert_called_once()
388
404
  args, kwargs = mock_popen.call_args
389
405
  self.assertEqual(args[0], "echo welcome")
@@ -410,6 +426,24 @@ class RFIDLoginTests(TestCase):
410
426
  self.assertEqual(response.status_code, 401)
411
427
  mock_popen.assert_not_called()
412
428
 
429
+ def test_rfid_login_uses_next_redirect(self):
430
+ response = self.client.post(
431
+ reverse("rfid-login"),
432
+ data={"rfid": "CARD123", "next": "/dashboard/"},
433
+ content_type="application/json",
434
+ )
435
+ self.assertEqual(response.status_code, 200)
436
+ self.assertEqual(response.json().get("redirect"), "/dashboard/")
437
+
438
+ def test_rfid_login_ignores_unsafe_redirect(self):
439
+ response = self.client.post(
440
+ reverse("rfid-login"),
441
+ data={"rfid": "CARD123", "next": "https://example.com/"},
442
+ content_type="application/json",
443
+ )
444
+ self.assertEqual(response.status_code, 200)
445
+ self.assertEqual(response.json().get("redirect"), "/")
446
+
413
447
 
414
448
  class RFIDBatchApiTests(TestCase):
415
449
  def setUp(self):
@@ -442,6 +476,140 @@ class RFIDBatchApiTests(TestCase):
442
476
  },
443
477
  )
444
478
 
479
+
480
+ class UserAdminPasswordChangeTests(TestCase):
481
+ def setUp(self):
482
+ self.client = Client()
483
+ self.admin = User.objects.create_superuser(
484
+ username="admin", email="admin@example.com", password="adminpass"
485
+ )
486
+ self.user = User.objects.create_user(username="target", password="oldpass")
487
+ self.client.force_login(self.admin)
488
+
489
+ def test_change_password_form_excludes_rfid_field(self):
490
+ url = reverse("admin:core_user_password_change", args=[self.user.pk])
491
+ response = self.client.get(url)
492
+ self.assertEqual(response.status_code, 200)
493
+ self.assertNotContains(response, 'name="rfid"')
494
+ self.assertNotContains(response, "Login RFID")
495
+
496
+ def test_change_password_does_not_alter_existing_rfid_assignment(self):
497
+ account = EnergyAccount.objects.create(user=self.user, name="TARGET")
498
+ tag = RFID.objects.create(rfid="CARD778")
499
+ account.rfids.add(tag)
500
+ url = reverse("admin:core_user_password_change", args=[self.user.pk])
501
+ response = self.client.post(
502
+ url,
503
+ {
504
+ "new_password1": "NewStrongPass123",
505
+ "new_password2": "NewStrongPass123",
506
+ },
507
+ )
508
+ self.assertRedirects(
509
+ response, reverse("admin:core_user_change", args=[self.user.pk])
510
+ )
511
+ account.refresh_from_db()
512
+ self.assertTrue(account.rfids.filter(pk=tag.pk).exists())
513
+ self.user.refresh_from_db()
514
+ self.assertTrue(self.user.check_password("NewStrongPass123"))
515
+
516
+
517
+ class UserAdminLoginRFIDTests(TestCase):
518
+ def setUp(self):
519
+ self.client = Client()
520
+ self.admin = User.objects.create_superuser(
521
+ username="admin", email="admin@example.com", password="adminpass"
522
+ )
523
+ self.user = User.objects.create_user(username="target", password="oldpass")
524
+ self.client.force_login(self.admin)
525
+
526
+ def _build_change_form_data(self, response, **overrides):
527
+ form = response.context["adminform"].form
528
+ data = {}
529
+ for name, field in form.fields.items():
530
+ if isinstance(field, forms.ModelMultipleChoiceField):
531
+ initial = form.initial.get(name, field.initial) or []
532
+ data[name] = [str(value.pk) for value in initial]
533
+ elif isinstance(field, forms.BooleanField):
534
+ initial = form.initial.get(name, field.initial)
535
+ if initial:
536
+ data[name] = "on"
537
+ elif name != "login_rfid":
538
+ value = form.initial.get(name, field.initial)
539
+ data[name] = "" if value is None else value
540
+ data["_save"] = "Save"
541
+ data.update({key: value for key, value in overrides.items() if value is not None})
542
+ return data
543
+
544
+ def test_change_form_includes_login_rfid_field(self):
545
+ tag = RFID.objects.create(rfid="CARD777")
546
+ EnergyAccount.objects.create(user=self.user, name="TARGET")
547
+ url = reverse("admin:core_user_change", args=[self.user.pk])
548
+ response = self.client.get(url)
549
+ self.assertEqual(response.status_code, 200)
550
+ self.assertContains(response, 'name="login_rfid"')
551
+ self.assertContains(response, str(tag.pk))
552
+
553
+ def test_change_form_assigns_login_rfid(self):
554
+ account = EnergyAccount.objects.create(user=self.user, name="TARGET")
555
+ tag = RFID.objects.create(rfid="CARD778")
556
+ url = reverse("admin:core_user_change", args=[self.user.pk])
557
+ response = self.client.get(url)
558
+ form_data = self._build_change_form_data(response, login_rfid=str(tag.pk))
559
+ post_response = self.client.post(url, form_data)
560
+ self.assertRedirects(
561
+ post_response, reverse("admin:core_user_change", args=[self.user.pk])
562
+ )
563
+ account.refresh_from_db()
564
+ self.assertTrue(account.rfids.filter(pk=tag.pk).exists())
565
+
566
+ def test_change_form_creates_energy_account_when_missing(self):
567
+ tag = RFID.objects.create(rfid="CARD779")
568
+ url = reverse("admin:core_user_change", args=[self.user.pk])
569
+ response = self.client.get(url)
570
+ form_data = self._build_change_form_data(response, login_rfid=str(tag.pk))
571
+ post_response = self.client.post(url, form_data)
572
+ self.assertRedirects(
573
+ post_response, reverse("admin:core_user_change", args=[self.user.pk])
574
+ )
575
+ account = EnergyAccount.objects.get(user=self.user)
576
+ self.assertTrue(account.rfids.filter(pk=tag.pk).exists())
577
+
578
+
579
+ class AdminSitePasswordChangeTests(TestCase):
580
+ def setUp(self):
581
+ self.client = Client()
582
+ self.admin = User.objects.create_superuser(
583
+ username="admin", email="admin@example.com", password="adminpass"
584
+ )
585
+ self.client.force_login(self.admin)
586
+
587
+ def test_admin_password_change_form_excludes_rfid_field(self):
588
+ response = self.client.get(reverse("admin:password_change"))
589
+ self.assertEqual(response.status_code, 200)
590
+ self.assertNotContains(response, 'name="rfid"')
591
+ self.assertNotContains(response, "Login RFID")
592
+
593
+ def test_admin_password_change_keeps_existing_rfid_assignment(self):
594
+ account = EnergyAccount.objects.create(user=self.admin, name="ADMIN")
595
+ tag = RFID.objects.create(rfid="CARD881")
596
+ account.rfids.add(tag)
597
+ response = self.client.post(
598
+ reverse("admin:password_change"),
599
+ {
600
+ "old_password": "adminpass",
601
+ "new_password1": "UltraSecurePass123",
602
+ "new_password2": "UltraSecurePass123",
603
+ "password1": "UltraSecurePass123",
604
+ "password2": "UltraSecurePass123",
605
+ },
606
+ )
607
+ self.assertRedirects(response, reverse("admin:password_change_done"))
608
+ self.admin.refresh_from_db()
609
+ self.assertTrue(self.admin.check_password("UltraSecurePass123"))
610
+ account.refresh_from_db()
611
+ self.assertTrue(account.rfids.filter(pk=tag.pk).exists())
612
+
445
613
  def test_export_rfids_color_filter(self):
446
614
  RFID.objects.create(rfid="CARD111", color=RFID.WHITE)
447
615
  response = self.client.get(reverse("rfid-batch"), {"color": "W"})
@@ -990,6 +1158,38 @@ class RFIDImportExportCommandTests(TestCase):
990
1158
  result = resource.import_data(dataset, dry_run=False)
991
1159
  self.assertFalse(result.has_errors())
992
1160
 
1161
+ def test_admin_import_restores_soft_deleted_label(self):
1162
+ tag = RFID.objects.create(rfid="DELETEME01")
1163
+ label = tag.label_id
1164
+ tag.delete()
1165
+ self.assertFalse(RFID.objects.filter(label_id=label).exists())
1166
+ self.assertTrue(
1167
+ RFID.all_objects.filter(label_id=label, is_deleted=True).exists()
1168
+ )
1169
+
1170
+ resource = RFIDResource()
1171
+ headers = resource.get_export_headers()
1172
+ dataset = Dataset()
1173
+ dataset.headers = headers
1174
+ row = {header: "" for header in headers}
1175
+ row.update(
1176
+ {
1177
+ "label_id": str(label),
1178
+ "rfid": "DELETEME02",
1179
+ "allowed": "true",
1180
+ "color": RFID.BLACK,
1181
+ "kind": RFID.CLASSIC,
1182
+ }
1183
+ )
1184
+ dataset.append([row[h] for h in headers])
1185
+
1186
+ result = resource.import_data(dataset, dry_run=False)
1187
+
1188
+ self.assertFalse(result.has_errors())
1189
+ restored = RFID.all_objects.get(label_id=label)
1190
+ self.assertEqual(restored.rfid, "DELETEME02")
1191
+ self.assertFalse(restored.is_deleted)
1192
+
993
1193
  account = EnergyAccount.objects.get(name="IMPORTED ADMIN ACCOUNT")
994
1194
  tag = RFID.objects.get(rfid="NAMETAG002")
995
1195
  self.assertTrue(tag.energy_accounts.filter(pk=account.pk).exists())
@@ -2534,7 +2734,7 @@ class TodoFocusViewTests(TestCase):
2534
2734
  def test_focus_view_sanitizes_loopback_absolute_url(self):
2535
2735
  todo = Todo.objects.create(
2536
2736
  request="Task",
2537
- url="http://127.0.0.1:8000/docs/?section=chart",
2737
+ url="http://127.0.0.1:8888/docs/?section=chart",
2538
2738
  )
2539
2739
  resp = self.client.get(reverse("todo-focus", args=[todo.pk]))
2540
2740
  self.assertContains(resp, 'src="/docs/?section=chart"')
core/user_data.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from functools import lru_cache
3
4
  from pathlib import Path
4
5
  from io import BytesIO
5
6
  from zipfile import ZipFile
@@ -65,9 +66,13 @@ def _fixture_path(user, instance) -> Path:
65
66
  return _data_dir(user) / filename
66
67
 
67
68
 
68
- def _seed_fixture_path(instance) -> Path | None:
69
- label = f"{instance._meta.app_label}.{instance._meta.model_name}"
69
+ _SEED_FIXTURE_IGNORED_FIELDS = {"is_seed_data", "is_deleted", "is_user_data"}
70
+
71
+
72
+ @lru_cache(maxsize=1)
73
+ def _seed_fixture_index():
70
74
  base = Path(settings.BASE_DIR)
75
+ index: dict[str, dict[str, object]] = {}
71
76
  for path in base.glob("**/fixtures/*.json"):
72
77
  try:
73
78
  data = json.loads(path.read_text(encoding="utf-8"))
@@ -76,28 +81,55 @@ def _seed_fixture_path(instance) -> Path | None:
76
81
  if not isinstance(data, list) or not data:
77
82
  continue
78
83
  obj = data[0]
79
- if obj.get("model") != label:
84
+ if not isinstance(obj, dict):
80
85
  continue
81
- pk = obj.get("pk")
82
- if pk is not None and pk == instance.pk:
83
- return path
84
- fields = obj.get("fields", {}) or {}
86
+ label = obj.get("model")
87
+ if not isinstance(label, str):
88
+ continue
89
+ fields = obj.get("fields") or {}
90
+ if not isinstance(fields, dict):
91
+ fields = {}
85
92
  comparable_fields = {
86
93
  key: value
87
94
  for key, value in fields.items()
88
- if key not in {"is_seed_data", "is_deleted", "is_user_data"}
95
+ if key not in _SEED_FIXTURE_IGNORED_FIELDS
89
96
  }
97
+ pk = obj.get("pk")
98
+ entries = index.setdefault(label, {"pk": {}, "fields": []})
99
+ pk_index = entries.setdefault("pk", {})
100
+ field_index = entries.setdefault("fields", [])
101
+ if pk is not None:
102
+ pk_index[pk] = path
90
103
  if comparable_fields:
91
- match = True
92
- for field_name, value in comparable_fields.items():
93
- if not hasattr(instance, field_name):
94
- match = False
95
- break
96
- if getattr(instance, field_name) != value:
97
- match = False
98
- break
99
- if match:
100
- return path
104
+ field_index.append((comparable_fields, path))
105
+ return index
106
+
107
+
108
+ def _seed_fixture_path(instance, *, index=None) -> Path | None:
109
+ label = f"{instance._meta.app_label}.{instance._meta.model_name}"
110
+ fixture_index = index or _seed_fixture_index()
111
+ entries = fixture_index.get(label)
112
+ if not entries:
113
+ return None
114
+ pk = getattr(instance, "pk", None)
115
+ pk_index = entries.get("pk", {})
116
+ if pk is not None:
117
+ path = pk_index.get(pk)
118
+ if path is not None:
119
+ return path
120
+ for comparable_fields, path in entries.get("fields", []):
121
+ match = True
122
+ if not isinstance(comparable_fields, dict):
123
+ continue
124
+ for field_name, value in comparable_fields.items():
125
+ if not hasattr(instance, field_name):
126
+ match = False
127
+ break
128
+ if getattr(instance, field_name) != value:
129
+ match = False
130
+ break
131
+ if match:
132
+ return path
101
133
  return None
102
134
 
103
135
 
@@ -581,6 +613,7 @@ def _iter_entity_admin_models():
581
613
 
582
614
  def _seed_data_view(request):
583
615
  sections = []
616
+ fixture_index = _seed_fixture_index()
584
617
  for model, model_admin in _iter_entity_admin_models():
585
618
  objs = model.objects.filter(is_seed_data=True)
586
619
  if not objs.exists():
@@ -591,7 +624,7 @@ def _seed_data_view(request):
591
624
  f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
592
625
  args=[obj.pk],
593
626
  )
594
- fixture = _seed_fixture_path(obj)
627
+ fixture = _seed_fixture_path(obj, index=fixture_index)
595
628
  items.append({"url": url, "label": str(obj), "fixture": fixture})
596
629
  sections.append({"opts": model._meta, "items": items})
597
630
  context = admin.site.each_context(request)
core/views.py CHANGED
@@ -11,7 +11,7 @@ import requests
11
11
  from django.conf import settings
12
12
  from django.contrib.admin.sites import site as admin_site
13
13
  from django.contrib.admin.views.decorators import staff_member_required
14
- from django.contrib.auth import authenticate, login
14
+ from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
15
15
  from django.contrib import messages
16
16
  from django.contrib.sites.models import Site
17
17
  from django.http import Http404, JsonResponse, HttpResponse
@@ -99,7 +99,7 @@ def odoo_quote_report(request):
99
99
 
100
100
  if not profile or not profile.is_verified:
101
101
  context["error"] = _(
102
- "Configure and verify your Odoo employee credentials before generating the report."
102
+ "Configure and verify your CRM employee credentials before generating the report."
103
103
  )
104
104
  return TemplateResponse(
105
105
  request, "admin/core/odoo_quote_report.html", context
@@ -470,9 +470,60 @@ def _resolve_release_log_dir(preferred: Path) -> tuple[Path, str | None]:
470
470
  return fallback, warning
471
471
 
472
472
 
473
+ def _commit_todo_fixtures_if_needed(log_path: Path) -> None:
474
+ """Stage and commit modified TODO fixtures before syncing with origin/main."""
475
+
476
+ try:
477
+ status = subprocess.run(
478
+ ["git", "status", "--porcelain"],
479
+ check=True,
480
+ capture_output=True,
481
+ text=True,
482
+ )
483
+ except subprocess.CalledProcessError:
484
+ return
485
+
486
+ todo_paths: set[Path] = set()
487
+ for line in (status.stdout or "").splitlines():
488
+ if not line:
489
+ continue
490
+ path_fragment = line[3:].strip()
491
+ if "->" in path_fragment:
492
+ path_fragment = path_fragment.split("->", 1)[1].strip()
493
+ path = Path(path_fragment)
494
+ if path.suffix == ".json" and path.parent == Path("core/fixtures"):
495
+ if path.name.startswith("todo__"):
496
+ todo_paths.add(path)
497
+
498
+ if not todo_paths:
499
+ return
500
+
501
+ sorted_paths = sorted(todo_paths)
502
+ subprocess.run(
503
+ ["git", "add", *[str(path) for path in sorted_paths]],
504
+ check=True,
505
+ )
506
+ formatted = ", ".join(_format_path(path) for path in sorted_paths)
507
+ _append_log(log_path, f"Staged TODO fixtures {formatted}")
508
+
509
+ diff = subprocess.run(
510
+ ["git", "diff", "--cached", "--name-only", "--", *[str(path) for path in sorted_paths]],
511
+ check=True,
512
+ capture_output=True,
513
+ text=True,
514
+ )
515
+
516
+ if (diff.stdout or "").strip():
517
+ message = "chore: update TODO fixtures"
518
+ subprocess.run(["git", "commit", "-m", message], check=True)
519
+ _append_log(log_path, f"Committed TODO fixtures ({message})")
520
+
521
+
473
522
  def _sync_with_origin_main(log_path: Path) -> None:
474
523
  """Ensure the current branch is rebased onto ``origin/main``."""
475
524
 
525
+ _commit_todo_fixtures_if_needed(log_path)
526
+
476
527
  if not _has_remote("origin"):
477
528
  _append_log(log_path, "No git remote configured; skipping sync with origin/main")
478
529
  return
@@ -1549,12 +1600,28 @@ def rfid_login(request):
1549
1600
  if not rfid:
1550
1601
  return JsonResponse({"detail": "rfid required"}, status=400)
1551
1602
 
1603
+ redirect_to = data.get(REDIRECT_FIELD_NAME) or data.get("next")
1604
+ if redirect_to and not url_has_allowed_host_and_scheme(
1605
+ redirect_to,
1606
+ allowed_hosts={request.get_host()},
1607
+ require_https=request.is_secure(),
1608
+ ):
1609
+ redirect_to = ""
1610
+
1552
1611
  user = authenticate(request, rfid=rfid)
1553
1612
  if user is None:
1554
1613
  return JsonResponse({"detail": "invalid RFID"}, status=401)
1555
1614
 
1556
1615
  login(request, user)
1557
- return JsonResponse({"id": user.id, "username": user.username})
1616
+ if redirect_to:
1617
+ target = redirect_to
1618
+ elif user.is_staff:
1619
+ target = reverse("admin:index")
1620
+ else:
1621
+ target = "/"
1622
+ return JsonResponse(
1623
+ {"id": user.id, "username": user.username, "redirect": target}
1624
+ )
1558
1625
 
1559
1626
 
1560
1627
  @api_login_required