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:
+36
-10
@@ -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
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user