Files
ACORE/core/tests_dashboard.py
lokses 3387418332 dashboard: группировка виджетов по ширине (½ / полная)
Каждому виджету можно задать ширину: «Полная» (на всю строку) или «½».
Виджеты выстраиваются в flex-сетку: два «½» встают в один ряд, «Полная»
занимает строку целиком. На узких экранах половинные переносятся в столбик.

- DashboardLayout.widget_widths (JSON {ключ: half|full}) + миграция 0022;
  поле widgets не тронуто — обратная совместимость сохранена
- core/dashboard: layout_widths, ширина в build_dashboard_context/
  customize_rows, save_layout(widths=...), normalize_width
- dashboard.html: flex-wrap сетка по ширине; customize: переключатель ½/полная
- tests: 6 (сохранение/чтение ширин, нормализация, дефолт, контекст, POST)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 10:47:14 +03:00

368 lines
17 KiB
Python

from datetime import date
from django.test import TestCase
from apps.employees.models import Employee
from core import dashboard as dash
from core.models import DashboardDefaults, DashboardLayout
from core.test_utils import login_2fa
def make_employee(**overrides):
n = Employee.objects.count() + 1
defaults = {
'username': f'dash.user{n}',
'email': f'dash.user{n}@company.local',
'first_name': 'Даш',
'last_name': 'Борд',
'hire_date': date(2026, 1, 1),
'user_location_type': 'office',
'mattermost_sync_enabled': False,
'ad_sync_enabled': False,
}
defaults.update(overrides)
emp = Employee.objects.create(**defaults)
from django.contrib.auth.models import Group
emp.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0])
return emp
def grant_reports(user):
"""Выдать доступ к разделу 'reports' (нужен виджету 'metrics')."""
from django.contrib.auth.models import Group
from core.tests_portal_access import portal_perm
group = Group.objects.create(name=f'reports-{user.pk}')
group.permissions.add(portal_perm('access_reports'))
user.groups.add(group)
return user
class DashboardLayoutLogicTests(TestCase):
def test_default_fallback_all_accessible(self):
user = make_employee()
self.assertEqual(dash.resolve_layout_keys(user), dash.accessible_keys(user))
def test_default_per_location_type(self):
d = DashboardDefaults.load()
d.office = ['news', 'my_tasks']
d.pharmacy = ['upcoming']
d.save()
office = make_employee(user_location_type='office')
pharmacy = make_employee(user_location_type='pharmacy')
self.assertEqual(dash.resolve_layout_keys(office), ['news', 'my_tasks'])
self.assertEqual(dash.resolve_layout_keys(pharmacy), ['upcoming'])
def test_personal_layout_overrides_default(self):
d = DashboardDefaults.load()
d.office = ['metrics']
d.save()
user = make_employee(user_location_type='office')
dash.save_layout(user, ['news', 'welcome', 'metrics'], ['news', 'welcome'])
# только включённые, в заданном порядке
self.assertEqual(dash.resolve_layout_keys(user), ['news', 'welcome'])
def test_valid_keys_drops_unknown_and_dups(self):
self.assertEqual(
dash.valid_keys(['news', 'bogus', 'news', 'metrics']),
['news', 'metrics'],
)
def test_save_layout_filters_disabled(self):
user = grant_reports(make_employee())
saved = dash.save_layout(user, ['welcome', 'news', 'metrics'], ['welcome', 'metrics'])
self.assertEqual(saved, ['welcome', 'metrics'])
self.assertEqual(DashboardLayout.objects.get(employee=user).widgets, ['welcome', 'metrics'])
def test_reset_layout_returns_to_default(self):
user = make_employee(user_location_type='office')
dash.save_layout(user, ['news'], ['news'])
self.assertTrue(DashboardLayout.objects.filter(employee=user).exists())
dash.reset_layout(user)
self.assertFalse(DashboardLayout.objects.filter(employee=user).exists())
self.assertEqual(dash.resolve_layout_keys(user), dash.accessible_keys(user))
def test_customize_rows_enabled_first(self):
user = grant_reports(make_employee())
dash.save_layout(user, ['news', 'metrics'], ['news', 'metrics'])
rows = dash.customize_rows(user)
keys = [r['widget'].key for r in rows]
# включённые первыми в своём порядке, затем остальные доступные
self.assertEqual(keys[:2], ['news', 'metrics'])
self.assertEqual(set(keys), set(dash.accessible_keys(user)))
self.assertTrue(rows[0]['enabled'])
self.assertFalse(rows[2]['enabled'])
class DashboardWidthTests(TestCase):
def _ctx(self, user):
from django.test import RequestFactory
req = RequestFactory().get('/dashboard/')
req.user = user
return dash.build_dashboard_context(req)
def test_save_and_read_widths(self):
user = make_employee()
dash.save_layout(user, ['welcome', 'news'], ['welcome', 'news'],
widths={'welcome': 'half', 'news': 'half'})
self.assertEqual(dash.layout_widths(user), {'welcome': 'half', 'news': 'half'})
self.assertEqual(DashboardLayout.objects.get(employee=user).widget_widths,
{'welcome': 'half', 'news': 'half'})
def test_invalid_width_normalized_to_full(self):
user = make_employee()
dash.save_layout(user, ['news'], ['news'], widths={'news': 'bogus'})
self.assertEqual(dash.layout_widths(user), {'news': 'full'})
def test_default_width_is_full(self):
user = make_employee()
widgets = self._ctx(user)['dashboard_widgets']
self.assertTrue(widgets)
self.assertTrue(all(w['width'] == 'full' for w in widgets))
def test_context_reflects_saved_width(self):
user = make_employee()
dash.save_layout(user, ['welcome', 'news'], ['welcome', 'news'],
widths={'welcome': 'half', 'news': 'full'})
by_key = {w['key']: w for w in self._ctx(user)['dashboard_widgets']}
self.assertEqual(by_key['welcome']['width'], 'half')
self.assertEqual(by_key['news']['width'], 'full')
def test_customize_rows_carry_width(self):
user = make_employee()
dash.save_layout(user, ['news'], ['news'], widths={'news': 'half'})
row = next(r for r in dash.customize_rows(user) if r['widget'].key == 'news')
self.assertEqual(row['width'], 'half')
def test_customize_post_saves_width(self):
user = make_employee()
login_2fa(self.client, user)
resp = self.client.post('/dashboard/customize/', {
'order': 'welcome,news', 'enabled': ['welcome', 'news'],
'width_welcome': 'half', 'width_news': 'full',
}, HTTP_HX_REQUEST='true')
self.assertEqual(resp.status_code, 302)
self.assertEqual(DashboardLayout.objects.get(employee=user).widget_widths,
{'welcome': 'half', 'news': 'full'})
class DashboardWidgetAccessTests(TestCase):
def test_metrics_hidden_without_reports(self):
user = make_employee()
self.assertNotIn('metrics', dash.accessible_keys(user))
self.assertNotIn('metrics', dash.resolve_layout_keys(user))
keys = [r['widget'].key for r in dash.customize_rows(user)]
self.assertNotIn('metrics', keys)
def test_metrics_visible_with_reports(self):
user = grant_reports(make_employee())
self.assertIn('metrics', dash.accessible_keys(user))
self.assertIn('metrics', dash.resolve_layout_keys(user))
def test_save_layout_drops_inaccessible(self):
user = make_employee() # без доступа к reports
saved = dash.save_layout(user, ['metrics', 'news'], ['metrics', 'news'])
self.assertEqual(saved, ['news']) # metrics отброшен
def test_saved_inaccessible_widget_not_rendered(self):
"""Виджет, оставшийся в раскладке, но недоступный — не отдаётся."""
user = grant_reports(make_employee())
dash.save_layout(user, ['metrics', 'news'], ['metrics', 'news'])
# снимаем доступ: убираем группу reports
user.groups.filter(name__startswith='reports-').delete()
user = Employee.objects.get(pk=user.pk) # сброс кэша прав
self.assertNotIn('metrics', dash.resolve_layout_keys(user))
self.assertIn('news', dash.resolve_layout_keys(user))
class DashboardWidgetRegistryTests(TestCase):
def test_new_widgets_registered(self):
for key in ('upcoming', 'my_hr_requests', 'my_tickets'):
self.assertIn(key, dash.WIDGETS_BY_KEY)
self.assertIn(key, dash.DEFAULT_WIDGET_KEYS)
def test_my_hr_requests_context(self):
from apps.employees.models import EmployeeHrRequest
user = make_employee()
EmployeeHrRequest.objects.create(
employee=user, request_type='vacation', status='pending',
)
# чужая заявка не попадает
other = make_employee()
EmployeeHrRequest.objects.create(
employee=other, request_type='sick_leave', status='pending',
)
from django.test import RequestFactory
req = RequestFactory().get('/dashboard/')
req.user = user
ctx = dash.WIDGETS_BY_KEY['my_hr_requests'].context_builder(req)
self.assertEqual(list(ctx['my_hr_requests']), list(user.hr_requests.all()))
class DashboardViewTests(TestCase):
def test_dashboard_renders_widgets(self):
user = make_employee()
login_2fa(self.client, user)
resp = self.client.get('/dashboard/', HTTP_HX_REQUEST='true')
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Настроить дашборд')
# новые виджеты по умолчанию отрисованы (пустые состояния)
self.assertContains(resp, 'Ближайшие события')
self.assertContains(resp, 'Мои кадровые заявки')
self.assertContains(resp, 'Мои IT-заявки')
def test_customize_get(self):
user = make_employee()
login_2fa(self.client, user)
resp = self.client.get('/dashboard/customize/', HTTP_HX_REQUEST='true')
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Настройка дашборда')
def test_customize_post_saves(self):
user = grant_reports(make_employee()) # reports нужен для виджета metrics
login_2fa(self.client, user)
resp = self.client.post(
'/dashboard/customize/',
{'order': 'news,metrics,welcome', 'enabled': ['news', 'metrics']},
HTTP_HX_REQUEST='true',
)
self.assertEqual(resp.status_code, 302)
self.assertEqual(DashboardLayout.objects.get(employee=user).widgets, ['news', 'metrics'])
def test_customize_post_reset(self):
user = make_employee()
dash.save_layout(user, ['news'], ['news'])
login_2fa(self.client, user)
resp = self.client.post(
'/dashboard/customize/', {'reset': '1'}, HTTP_HX_REQUEST='true',
)
self.assertEqual(resp.status_code, 302)
self.assertFalse(DashboardLayout.objects.filter(employee=user).exists())
class DashboardSecurityRegressionTests(TestCase):
def request_for(self, user):
from django.test import RequestFactory
request = RequestFactory().get('/dashboard/')
request.user = user
return request
def test_no_group_does_not_build_closed_widget_contexts(self):
from unittest.mock import patch
user = make_employee()
user.groups.clear()
DashboardLayout.objects.create(employee=user, widgets=list(dash.DEFAULT_WIDGET_KEYS))
with patch('apps.visit_manager.calendar_service.upcoming_events') as events:
context = dash.build_dashboard_context(self.request_for(user))
events.assert_not_called()
self.assertEqual([w['key'] for w in context['dashboard_widgets']],
['welcome', 'my_tickets', 'quick_access'])
for key in ('latest_posts', 'my_tasks', 'my_hr_requests', 'upcoming_events'):
self.assertNotIn(key, context)
def test_forged_post_cannot_enable_closed_widgets_or_change_other_layout(self):
user = make_employee()
user.groups.clear()
other = make_employee()
DashboardLayout.objects.create(employee=other, widgets=['news'])
login_2fa(self.client, user)
response = self.client.post('/dashboard/customize/', {
'employee': other.pk, 'order': ','.join(dash.DEFAULT_WIDGET_KEYS),
'enabled': dash.DEFAULT_WIDGET_KEYS,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(DashboardLayout.objects.get(employee=user).widgets,
['welcome', 'my_tickets', 'quick_access'])
self.assertEqual(DashboardLayout.objects.get(employee=other).widgets, ['news'])
def test_empty_layout_stays_empty_until_reset(self):
user = make_employee()
dash.save_layout(user, [], [])
context = dash.build_dashboard_context(self.request_for(user))
self.assertEqual(context['dashboard_widgets'], [])
self.assertTrue(context['dashboard_has_custom_layout'])
login_2fa(self.client, user)
self.assertContains(self.client.get('/dashboard/', HTTP_HX_REQUEST='true'),
'Все виджеты скрыты.')
def test_revocation_does_not_restore_default_widgets(self):
user = grant_reports(make_employee())
dash.save_layout(user, ['metrics'], ['metrics'])
user.groups.clear()
self.assertEqual(dash.resolve_layout_keys(Employee.objects.get(pk=user.pk)), [])
def test_welcome_buttons_and_widget_links_respect_sections(self):
user = make_employee()
user.groups.clear()
login_2fa(self.client, user)
for htmx in ('true', ''):
response = self.client.get('/dashboard/', HTTP_HX_REQUEST=htmx)
self.assertEqual(response.status_code, 200)
for path in ('/tasks/', '/chat/', '/calendar/', '/news/', '/hr-portal/'):
self.assertNotContains(response, f'hx-get="{path}"')
def test_anonymous_cannot_read_or_save_dashboard(self):
for path in ('/dashboard/', '/dashboard/customize/'):
self.assertEqual(self.client.get(path).status_code, 302)
self.assertEqual(self.client.post(path, {'enabled': ['news']}).status_code, 302)
self.assertFalse(DashboardLayout.objects.exists())
def test_post_requires_csrf(self):
from django.test import Client
client = Client(enforce_csrf_checks=True)
login_2fa(client, make_employee())
self.assertEqual(client.post('/dashboard/customize/', {'enabled': ['news']}).status_code, 403)
self.assertFalse(DashboardLayout.objects.exists())
def test_reports_does_not_grant_other_sections_metrics(self):
user = grant_reports(make_employee(is_staff=True))
context = dash._ctx_metrics(self.request_for(user))
for key in ('pharmacies_active', 'directory_types_count', 'directories_count'):
self.assertNotIn(key, context)
self.assertTrue(context['tickets_are_personal'])
login_2fa(self.client, user)
response = self.client.get('/dashboard/', HTTP_HX_REQUEST='true')
self.assertNotContains(response, 'Доступных аптек')
self.assertNotContains(response, 'Справочников')
self.assertContains(response, 'Моих открытых IT-заявок')
def test_ticket_counts_are_personal_unless_it_access_granted(self):
from apps.it_manager.models import Ticket
from core.models import Directory, DirectoryType
from core.tests_portal_access import portal_perm
user = grant_reports(make_employee())
other = make_employee()
values = {}
for field, code in [('ticket_type', 'other'), ('priority', 'medium'), ('status', 'new')]:
kind = {'ticket_type': 'ticket_type', 'priority': 'ticket_priority', 'status': 'ticket_status'}[field]
dtype, _ = DirectoryType.objects.get_or_create(code=kind, defaults={'name': kind})
values[field], _ = Directory.objects.get_or_create(
directory_type=dtype, code=code, defaults={'name': code})
for employee in (user, other):
Ticket.objects.create(requester=employee, title='Private ticket', description='test', **values)
self.assertEqual(dash._ctx_metrics(self.request_for(user))['open_tickets'], 1)
self.assertEqual(len(dash._ctx_my_tickets(self.request_for(user))['my_tickets']), 1)
user.user_permissions.add(portal_perm('access_it_manager'))
from core.test_utils import grant_all_locations
grant_all_locations(user)
user = Employee.objects.get(pk=user.pk)
context = dash._ctx_metrics(self.request_for(user))
self.assertEqual(context['open_tickets'], 2)
self.assertFalse(context['tickets_are_personal'])
def test_tasks_do_not_include_nonparticipant(self):
from apps.tasks.models import Task
user, other = make_employee(), make_employee()
own = Task.objects.create(creator=user, title='Own')
Task.objects.create(creator=other, title='Private')
self.assertEqual(list(dash._ctx_my_tasks(self.request_for(user))['my_tasks']), [own])
def test_preview_does_not_inherit_superuser_widgets(self):
user = make_employee(is_superuser=True)
user._access_preview_perms = set()
user._access_preview_groups = []
self.assertEqual(dash.accessible_keys(user), ['welcome', 'my_tickets', 'quick_access'])
def test_invalid_saved_json_keys_are_ignored(self):
self.assertEqual(dash.valid_keys([{}, ['news'], 'news', None]), ['news'])
self.assertEqual(dash.valid_keys('news'), [])