3387418332
Каждому виджету можно задать ширину: «Полная» (на всю строку) или «½».
Виджеты выстраиваются в 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>
368 lines
17 KiB
Python
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'), [])
|