Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
227 changes: 227 additions & 0 deletions users/tests/test_expert_admin.py
Original file line number Diff line number Diff line change
@@ -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, '<div class="text"><span>Email</span></div>', 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'<td class="field-email">{user.email}</td>', 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)
Loading