dashboard: проверка доступности виджетов и плиток быстрого доступа

Виджеты теперь могут требовать доступ к разделу портала (required_section).
Недоступный виджет не отдаётся и не предлагается в настройке — даже если
остался в сохранённой раскладке или в наборе по умолчанию; save_layout
отбрасывает недоступные ключи (защита от подмены). Виджет «Метрики»
(общекорпоративная сводка) требует доступа к разделу «Отчёты».

Плитки в «Быстром доступе» скрываются по can_section (Документы, Задачи,
Календарь, Сотрудники) — как email и справочники ранее; остаются только
разделы, доступные пользователю.

- core/dashboard.py: required_section, widget_allowed, accessible_keys;
  фильтрация в resolve/customize/save
- quick_access.html: плитки под can_section
- tests: 6 тестов доступности (скрытие/показ, отбрасывание, ре-фильтр)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:02:48 +03:00
parent df137e1074
commit 24331df462
3 changed files with 95 additions and 22 deletions
+36 -10
View File
@@ -15,8 +15,9 @@ class DashboardWidget:
key: str
label: str
description: str
template_name: str # pages/dashboard_widgets/<template_name>.html
context_builder: Callable | None = None # (request) -> dict | None
template_name: str # pages/dashboard_widgets/<template_name>.html
context_builder: Callable | None = None # (request) -> dict | None
required_section: str | None = None # ключ раздела портала для доступа к виджету
@property
def template(self) -> str:
@@ -113,7 +114,7 @@ DASHBOARD_WIDGETS: tuple[DashboardWidget, ...] = (
DashboardWidget(
'metrics', 'Метрики',
'Сводка: сотрудники онлайн, аптеки, открытые IT-заявки, справочники.',
'metrics', _ctx_metrics,
'metrics', _ctx_metrics, required_section='reports',
),
DashboardWidget(
'my_tasks', 'Мои задачи',
@@ -164,6 +165,19 @@ def valid_keys(keys) -> list:
return result
def widget_allowed(widget: DashboardWidget, user) -> bool:
"""Доступен ли виджет пользователю (по разделу портала)."""
if widget.required_section is None:
return True
from core.portal_access import user_can_access
return user_can_access(user, widget.required_section)
def accessible_keys(user) -> list:
"""Ключи виджетов, доступных пользователю (в порядке реестра)."""
return [w.key for w in DASHBOARD_WIDGETS if widget_allowed(w, user)]
def default_keys_for(location_type: str | None) -> list:
from core.models import DashboardDefaults
keys = valid_keys(DashboardDefaults.load().keys_for(location_type or ''))
@@ -171,15 +185,21 @@ def default_keys_for(location_type: str | None) -> list:
def resolve_layout_keys(user) -> list:
"""Ключи виджетов для сотрудника: своя раскладка или дефолт по локации."""
"""Ключи виджетов для сотрудника: своя раскладка или дефолт по локации.
Всегда отфильтровано по доступности — недоступный виджет не покажется,
даже если остался в сохранённой раскладке или в наборе по умолчанию.
"""
from core.models import DashboardLayout
allowed = set(accessible_keys(user))
layout = DashboardLayout.objects.filter(employee=user).first()
if layout is not None and layout.widgets:
keys = valid_keys(layout.widgets)
keys = [k for k in valid_keys(layout.widgets) if k in allowed]
if keys:
return keys
return default_keys_for(getattr(user, 'user_location_type', None))
default = default_keys_for(getattr(user, 'user_location_type', None))
return [k for k in default if k in allowed]
def build_dashboard_context(request) -> dict:
@@ -209,22 +229,28 @@ def _has_custom_layout(user) -> bool:
def customize_rows(user) -> list:
"""Строки для страницы настройки: включённые (в порядке) + выключенные."""
"""Строки для страницы настройки: включённые (в порядке) + выключенные.
Только доступные пользователю виджеты — нельзя включить то, к чему нет
доступа.
"""
allowed = set(accessible_keys(user))
enabled = resolve_layout_keys(user)
enabled_set = set(enabled)
rows = [{'widget': WIDGETS_BY_KEY[k], 'enabled': True} for k in enabled]
for widget in DASHBOARD_WIDGETS:
if widget.key not in enabled_set:
if widget.key in allowed and widget.key not in enabled_set:
rows.append({'widget': widget, 'enabled': False})
return rows
def save_layout(user, ordered_keys, enabled_keys) -> list:
"""Сохранить раскладку: включённые ключи в заданном порядке."""
"""Сохранить раскладку: включённые и доступные ключи в заданном порядке."""
from core.models import DashboardLayout
allowed = set(accessible_keys(user))
enabled_set = set(enabled_keys)
result = [k for k in valid_keys(ordered_keys) if k in enabled_set]
result = [k for k in valid_keys(ordered_keys) if k in enabled_set and k in allowed]
DashboardLayout.objects.update_or_create(
employee=user, defaults={'widgets': result},
)
+51 -12
View File
@@ -27,20 +27,30 @@ def make_employee(**overrides):
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_widgets(self):
def test_default_fallback_all_accessible(self):
user = make_employee()
self.assertEqual(dash.resolve_layout_keys(user), dash.DEFAULT_WIDGET_KEYS)
self.assertEqual(dash.resolve_layout_keys(user), dash.accessible_keys(user))
def test_default_per_location_type(self):
d = DashboardDefaults.load()
d.office = ['metrics', 'news']
d.pharmacy = ['my_tasks']
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), ['metrics', 'news'])
self.assertEqual(dash.resolve_layout_keys(pharmacy), ['my_tasks'])
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()
@@ -58,7 +68,7 @@ class DashboardLayoutLogicTests(TestCase):
)
def test_save_layout_filters_disabled(self):
user = make_employee()
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'])
@@ -69,20 +79,49 @@ class DashboardLayoutLogicTests(TestCase):
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.DEFAULT_WIDGET_KEYS)
self.assertEqual(dash.resolve_layout_keys(user), dash.accessible_keys(user))
def test_customize_rows_enabled_first(self):
user = make_employee()
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.DEFAULT_WIDGET_KEYS))
self.assertEqual(set(keys), set(dash.accessible_keys(user)))
self.assertTrue(rows[0]['enabled'])
self.assertFalse(rows[2]['enabled'])
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'):
@@ -127,7 +166,7 @@ class DashboardViewTests(TestCase):
self.assertContains(resp, 'Настройка дашборда')
def test_customize_post_saves(self):
user = make_employee()
user = grant_reports(make_employee()) # reports нужен для виджета metrics
login_2fa(self.client, user)
resp = self.client.post(
'/dashboard/customize/',
@@ -18,6 +18,7 @@
</a>
{% endif %}
{% if request.user|can_section:'documents' %}
<a href="/documents/" hx-get="/documents/" hx-target="#main-content" hx-push-url="/documents/" hx-indicator="#loader" class="li-hover" style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.875rem 0.25rem; border-radius: 0.5rem; text-decoration: none; cursor: pointer; transition: background 150ms;">
<div style="width: 2.5rem; height: 2.5rem; border-radius: 0.625rem; background: hsl(270 60% 55% / 0.12); color: hsl(270 60% 45%); display: flex; align-items: center; justify-content: center;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -27,7 +28,9 @@
</div>
<span style="font-size: 0.6875rem; color: var(--color-foreground); text-align: center; line-height: 1.3;">{% trans "Документы" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'tasks' %}
<a href="/tasks/" hx-get="/tasks/" hx-target="#main-content" hx-push-url="/tasks/" hx-indicator="#loader" class="li-hover" style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.875rem 0.25rem; border-radius: 0.5rem; text-decoration: none; cursor: pointer; transition: background 150ms;">
<div style="width: 2.5rem; height: 2.5rem; border-radius: 0.625rem; background: hsl(152 55% 45% / 0.12); color: hsl(152 55% 35%); display: flex; align-items: center; justify-content: center;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -37,7 +40,9 @@
</div>
<span style="font-size: 0.6875rem; color: var(--color-foreground); text-align: center; line-height: 1.3;">{% trans "Задачи" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'calendar' %}
<a href="/calendar/" hx-get="/calendar/" hx-target="#main-content" hx-push-url="/calendar/" hx-indicator="#loader" class="li-hover" style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.875rem 0.25rem; border-radius: 0.5rem; text-decoration: none; cursor: pointer; transition: background 150ms;">
<div style="width: 2.5rem; height: 2.5rem; border-radius: 0.625rem; background: hsl(38 92% 50% / 0.12); color: hsl(38 80% 35%); display: flex; align-items: center; justify-content: center;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -46,6 +51,7 @@
</div>
<span style="font-size: 0.6875rem; color: var(--color-foreground); text-align: center; line-height: 1.3;">{% trans "Календарь" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'databases' %}
<a href="/databases/" hx-get="/databases/" hx-target="#main-content" hx-push-url="/databases/" hx-indicator="#loader" class="li-hover" style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.875rem 0.25rem; border-radius: 0.5rem; text-decoration: none; cursor: pointer; transition: background 150ms;">
@@ -58,6 +64,7 @@
</a>
{% endif %}
{% if request.user|can_section:'hr_portal' %}
<a href="/hr-portal/" hx-get="/hr-portal/" hx-target="#main-content" hx-push-url="/hr-portal/" hx-indicator="#loader" class="li-hover" style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.875rem 0.25rem; border-radius: 0.5rem; text-decoration: none; cursor: pointer; transition: background 150ms;">
<div style="width: 2.5rem; height: 2.5rem; border-radius: 0.625rem; background: hsl(174 55% 42% / 0.12); color: hsl(174 55% 35%); display: flex; align-items: center; justify-content: center;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -68,6 +75,7 @@
</div>
<span style="font-size: 0.6875rem; color: var(--color-foreground); text-align: center; line-height: 1.3;">{% trans "Сотрудники" %}</span>
</a>
{% endif %}
<a href="/it-support/" hx-get="/it-support/" hx-target="#main-content" hx-push-url="/it-support/" hx-indicator="#loader" class="li-hover" style="display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.875rem 0.25rem; border-radius: 0.5rem; text-decoration: none; cursor: pointer; transition: background 150ms;">
<div style="width: 2.5rem; height: 2.5rem; border-radius: 0.625rem; background: hsl(152 55% 45% / 0.12); color: hsl(152 55% 35%); display: flex; align-items: center; justify-content: center;">