diff --git a/users/admin.py b/users/admin.py index 170e817b..aedd056e 100644 --- a/users/admin.py +++ b/users/admin.py @@ -429,7 +429,14 @@ class UserLinkAdmin(admin.ModelAdmin): @admin.register(Expert) class ExpertAdmin(admin.ModelAdmin): - list_display = ("id", "user") + list_display = ("id", "user", "email") + search_fields = ("user__first_name", "user__last_name", "user__email") + list_filter = ("user__partner_program_profiles__partner_program",) + list_select_related = ("user",) + + @admin.display(description="Email") + def email(self, obj): + return obj.user.email @admin.register(UserEducation) diff --git a/users/tests/test_expert_admin.py b/users/tests/test_expert_admin.py new file mode 100644 index 00000000..b944e0e7 --- /dev/null +++ b/users/tests/test_expert_admin.py @@ -0,0 +1,227 @@ +from django.contrib.admin.filters import RelatedFieldListFilter +from django.db import connection +from django.test import TestCase +from django.urls import reverse +from django.utils import translation + +from users.models import CustomUser + +from .helpers import ( + add_user_to_program, + build_partner_program, + build_superuser, + build_user, +) + + +class ExpertAdminTests(TestCase): + program_parameter = "user__partner_program_profiles__partner_program__id__exact" + + @classmethod + def setUpTestData(cls): + cls.admin_user = build_superuser() + cls.program = build_partner_program(name="FinFor25-26", tag="finfor") + cls.other_program = build_partner_program(name="Другая программа", tag="other") + cls.empty_program = build_partner_program(name="Без экспертов", tag="empty") + cls.ivan = build_user( + email="e@tokarev.work", + first_name="Иван", + last_name="Иванов", + user_type=CustomUser.EXPERT, + ) + cls.anna = build_user( + email="anna@example.com", + first_name="Анна", + last_name="Смирнова", + user_type=CustomUser.EXPERT, + ) + cls.other_ivanov = build_user( + email="petr@example.com", + first_name="Пётр", + last_name="Иванов", + user_type=CustomUser.EXPERT, + ) + cls.no_program = build_user( + email="no-program@example.com", + first_name="Ольга", + last_name="Петрова", + user_type=CustomUser.EXPERT, + ) + cls.member = build_user(email="member@example.com") + add_user_to_program(cls.ivan, cls.program) + add_user_to_program(cls.ivan, cls.other_program) + add_user_to_program(cls.anna, cls.program) + add_user_to_program(cls.other_ivanov, cls.other_program) + add_user_to_program(cls.member, cls.program) + + def setUp(self): + self.client.force_login(self.admin_user) + self.url = reverse("admin:users_expert_changelist") + + def changelist(self, params=None): + response = self.client.get(self.url, params or {}) + self.assertEqual(response.status_code, 200) + return response + + def assert_experts(self, response, users): + # Keep a list: set equality would hide duplicate rows from a reverse JOIN. + result = list(response.context["cl"].result_list) + self.assertCountEqual( + [expert.user_id for expert in result], [user.pk for user in users] + ) + self.assertEqual(response.context["cl"].result_count, len(users)) + + def test_changelist_has_standard_search_and_program_choices(self): + with translation.override("en"): + response = self.changelist() + self.assertContains(response, 'id="searchbar"') + self.assertContains(response, 'id="changelist-filter"') + self.assertContains(response, "By partner program") + changelist = response.context["cl"] + program_filter = changelist.filter_specs[0] + self.assertIsInstance(program_filter, RelatedFieldListFilter) + choices = list(program_filter.choices(changelist)) + self.assertEqual(choices[0]["display"], "All") + self.assertTrue(choices[0]["selected"]) + self.assertCountEqual( + [choice["display"] for choice in choices[1:]], + [str(self.program), str(self.other_program), str(self.empty_program)], + ) + + def test_search_first_name(self): + self.assert_experts(self.changelist({"q": "Анна"}), [self.anna]) + + def test_changelist_displays_email_column_after_user(self): + response = self.changelist() + columns = list(response.context["cl"].list_display) + self.assertIn("id", columns) + self.assertEqual(columns[columns.index("user") + 1], "email") + self.assertContains(response, 'class="column-email"') + self.assertContains( + response, '
Email
', html=True + ) + for user in (self.ivan, self.anna, self.other_ivanov, self.no_program): + with self.subTest(user=user.pk): + self.assertContains( + response, f'{user.email}', html=True + ) + + def test_search_last_name(self): + self.assert_experts( + self.changelist({"q": "Иванов"}), [self.ivan, self.other_ivanov] + ) + + def test_search_email(self): + self.assert_experts(self.changelist({"q": self.ivan.email}), [self.ivan]) + + def test_search_partial_email(self): + for term in ("tokarev", "e@", "TOKAREV.WORK"): + with self.subTest(term=term): + self.assert_experts(self.changelist({"q": term}), [self.ivan]) + + def test_search_full_name_matches_terms_across_fields(self): + # Standard admin search ANDs words across all fields, not exact full names. + self.assert_experts( + self.changelist({"q": "Иван Иванов"}), [self.ivan, self.other_ivanov] + ) + self.assert_experts(self.changelist({"q": "Анна Смирнова"}), [self.anna]) + self.assert_experts(self.changelist({"q": "Анна Иванов"}), []) + + def test_search_ascii_email_is_case_insensitive(self): + self.assert_experts(self.changelist({"q": "ANNA@EXAMPLE.COM"}), [self.anna]) + + def test_search_cyrillic_is_case_insensitive_on_postgresql(self): + # SQLite LIKE не обеспечивает полноценную регистронезависимость Unicode/кириллицы. + # Поведение production PostgreSQL проверяется в Backend PostgreSQL CI. + if connection.vendor != "postgresql": + self.skipTest( + "Регистронезависимость кириллицы проверяется только на PostgreSQL" + ) + self.assert_experts(self.changelist({"q": "АННА СМИРНОВА"}), [self.anna]) + + def test_unknown_search_is_empty(self): + self.assert_experts(self.changelist({"q": "Несуществующий"}), []) + + def test_without_filter_all_experts_appear_once_including_without_program(self): + self.assert_experts( + self.changelist(), [self.ivan, self.anna, self.other_ivanov, self.no_program] + ) + + def test_program_filter_excludes_other_program_and_unregistered_experts(self): + self.assert_experts( + self.changelist({self.program_parameter: self.program.pk}), + [self.ivan, self.anna], + ) + + def test_expert_registered_in_two_programs_appears_once_in_each(self): + for program, expected in ( + (self.program, [self.ivan, self.anna]), + (self.other_program, [self.ivan, self.other_ivanov]), + ): + with self.subTest(program=program.pk): + self.assert_experts( + self.changelist({self.program_parameter: program.pk}), expected + ) + + def test_program_without_experts_returns_empty_results(self): + self.assert_experts( + self.changelist({self.program_parameter: self.empty_program.pk}), [] + ) + + def test_search_and_program_filter_are_combined(self): + for term, expected in ( + ("Иванов", [self.ivan]), + (self.ivan.email, [self.ivan]), + ("tokarev", [self.ivan]), + (self.other_ivanov.email, []), + ): + with self.subTest(term=term): + self.assert_experts( + self.changelist({self.program_parameter: self.program.pk, "q": term}), + expected, + ) + + def test_all_clears_only_program_filter_and_keeps_search(self): + response = self.changelist( + {self.program_parameter: self.program.pk, "q": "Иванов"} + ) + changelist = response.context["cl"] + all_choice = next(iter(changelist.filter_specs[0].choices(changelist))) + response = self.client.get(self.url + all_choice["query_string"]) + self.assertEqual(response.status_code, 200) + self.assert_experts(response, [self.ivan, self.other_ivanov]) + + def test_nonexistent_program_is_empty(self): + self.assert_experts(self.changelist({self.program_parameter: 999999999}), []) + + def test_invalid_program_uses_standard_admin_redirect_without_server_error(self): + response = self.client.get(self.url, {self.program_parameter: "not-a-number"}) + self.assertRedirects(response, self.url + "?e=1") + + def test_user_and_email_columns_do_not_query_once_per_expert(self): + for params in ( + {}, + {self.program_parameter: self.program.pk}, + {self.program_parameter: self.program.pk, "q": "example.com"}, + ): + with self.subTest(params=params): + changelist = self.changelist(params).context["cl"] + # Clone to discard the render cache; rows and their users need one query. + with self.assertNumQueries(1): + rows = list(changelist.result_list.all()) + labels = [ + (str(expert.user), changelist.model_admin.email(expert)) + for expert in rows + ] + self.assertTrue(labels) + + def test_non_staff_cannot_access_changelist(self): + self.client.force_login(self.ivan) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("admin:login"), response.url) + + def test_staff_without_expert_permission_cannot_access_changelist(self): + staff = build_user(email="staff@example.com", is_staff=True) + self.client.force_login(staff) + self.assertEqual(self.client.get(self.url).status_code, 403)