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>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""Реестр виджетов дашборда и сборка персональной раскладки.
|
||
|
||
Дашборд собирается из виджетов (core.dashboard.DASHBOARD_WIDGETS). Порядок и
|
||
состав берутся из персональной раскладки сотрудника (DashboardLayout), а если
|
||
её нет — из набора по умолчанию для типа локации (DashboardDefaults).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Callable
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
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
|
||
required_section: str | None = None # ключ раздела портала для доступа к виджету
|
||
|
||
@property
|
||
def template(self) -> str:
|
||
return f'pages/dashboard_widgets/{self.template_name}.html'
|
||
|
||
|
||
# ── Построители контекста виджетов ───────────────────────────────────────────
|
||
|
||
def _ctx_metrics(request) -> dict:
|
||
from django.utils import timezone
|
||
from apps.employees.models import Employee
|
||
from apps.it_manager.models import Ticket
|
||
from apps.locations.models import Pharmacy
|
||
from core.models import Directory, DirectoryType
|
||
from core.middleware import ONLINE_WINDOW
|
||
|
||
from core.portal_access import user_can_access
|
||
from core.location_access import accessible_pharmacies, scoped_employees, scoped_queryset
|
||
|
||
# Каждая карточка проверяет свой раздел: reports не даёт доступ к IT
|
||
# или справочникам. Не запрашиваем закрытые данные даже для контекста.
|
||
context = {}
|
||
if user_can_access(request.user, 'hr_portal'):
|
||
active = scoped_employees(request.user, Employee.objects.filter(is_active=True))
|
||
context.update(
|
||
employees_online=active.filter(
|
||
last_seen__gte=timezone.now() - ONLINE_WINDOW).count(),
|
||
employees_active=active.count(),
|
||
)
|
||
if user_can_access(request.user, 'pharmacies'):
|
||
context['pharmacies_active'] = accessible_pharmacies(request.user).filter(is_active=True).count()
|
||
tickets = Ticket.objects.filter(status__code__in=['new', 'in_progress', 'waiting'])
|
||
all_tickets = user_can_access(request.user, 'it_manager')
|
||
if all_tickets:
|
||
tickets = scoped_queryset(request.user, tickets)
|
||
else:
|
||
tickets = tickets.filter(requester=request.user)
|
||
context.update(open_tickets=tickets.count(), tickets_are_personal=not all_tickets)
|
||
if user_can_access(request.user, 'databases'):
|
||
context.update(
|
||
directory_types_count=DirectoryType.objects.filter(is_active=True).count(),
|
||
directories_count=Directory.objects.count(),
|
||
)
|
||
return context
|
||
|
||
|
||
def _ctx_news(request) -> dict:
|
||
from apps.feed.models import Post
|
||
return {
|
||
'latest_posts': Post.objects.filter(
|
||
is_published=True).select_related('author')[:3],
|
||
}
|
||
|
||
|
||
def _ctx_my_tasks(request) -> dict:
|
||
from apps.tasks.models import Task
|
||
tasks = (
|
||
Task.objects.for_user(request.user)
|
||
.exclude(status__in=['done', 'cancelled'])
|
||
.order_by('due_date', '-created_at')[:6]
|
||
)
|
||
return {'my_tasks': tasks}
|
||
|
||
|
||
def _ctx_my_hr_requests(request) -> dict:
|
||
from apps.employees.models import EmployeeHrRequest
|
||
requests = (
|
||
EmployeeHrRequest.objects
|
||
.filter(employee=request.user)
|
||
.order_by('-created_at')[:5]
|
||
)
|
||
return {'my_hr_requests': requests}
|
||
|
||
|
||
def _ctx_my_tickets(request) -> dict:
|
||
from apps.it_manager.models import Ticket
|
||
tickets = (
|
||
Ticket.objects.filter(requester=request.user)
|
||
.exclude(status__code__in=['resolved', 'closed'])
|
||
.select_related('status', 'priority')
|
||
.order_by('-created_at')[:5]
|
||
)
|
||
return {'my_tickets': tickets}
|
||
|
||
|
||
_UPCOMING_DOT = {
|
||
'holiday': '#dc2626',
|
||
'leave': '#d97706',
|
||
'birthday': '#7c3aed',
|
||
'shift': '#0d9488',
|
||
'event': '#2563eb',
|
||
}
|
||
|
||
|
||
def _ctx_upcoming(request) -> dict:
|
||
from apps.visit_manager.calendar_service import upcoming_events
|
||
events = upcoming_events(request.user, days=14)[:8]
|
||
for event in events:
|
||
event['dot'] = _UPCOMING_DOT.get(event['kind'], '#64748b')
|
||
return {'upcoming_events': events}
|
||
|
||
|
||
# ── Реестр ───────────────────────────────────────────────────────────────────
|
||
|
||
DASHBOARD_WIDGETS: tuple[DashboardWidget, ...] = (
|
||
DashboardWidget(
|
||
'welcome', 'Приветствие',
|
||
'Приветственный баннер с именем, датой и кнопками быстрых действий.',
|
||
'welcome',
|
||
),
|
||
DashboardWidget(
|
||
'metrics', 'Метрики',
|
||
'Сводка: сотрудники онлайн, аптеки, открытые IT-заявки, справочники.',
|
||
'metrics', _ctx_metrics, required_section='reports',
|
||
),
|
||
DashboardWidget(
|
||
'my_tasks', 'Мои задачи',
|
||
'Мои активные задачи (где я автор, ответственный, соисполнитель).',
|
||
'my_tasks', _ctx_my_tasks, required_section='tasks',
|
||
),
|
||
DashboardWidget(
|
||
'upcoming', 'Ближайшие события',
|
||
'Праздники, отпуска, дни рождения, смены и встречи на 2 недели вперёд.',
|
||
'upcoming', _ctx_upcoming, required_section='calendar',
|
||
),
|
||
DashboardWidget(
|
||
'my_hr_requests', 'Мои кадровые заявки',
|
||
'Статусы моих заявок на отпуск, больничный, отгул и т.п.',
|
||
'my_hr_requests', _ctx_my_hr_requests, required_section='hr_portal',
|
||
),
|
||
DashboardWidget(
|
||
'my_tickets', 'Мои IT-заявки',
|
||
'Мои открытые заявки в IT-поддержку и их статус.',
|
||
'my_tickets', _ctx_my_tickets, required_section='it_support',
|
||
),
|
||
DashboardWidget(
|
||
'news', 'Последние новости',
|
||
'Свежие публикации и объявления компании.',
|
||
'news', _ctx_news, required_section='news',
|
||
),
|
||
DashboardWidget(
|
||
'quick_access', 'Быстрый доступ',
|
||
'Плитки со ссылками на почту, документы, задачи и другие разделы.',
|
||
'quick_access',
|
||
),
|
||
)
|
||
|
||
WIDGETS_BY_KEY = {w.key: w for w in DASHBOARD_WIDGETS}
|
||
|
||
# Полный набор в порядке реестра (fallback, если ничего не настроено).
|
||
DEFAULT_WIDGET_KEYS = [w.key for w in DASHBOARD_WIDGETS]
|
||
|
||
# Ширина виджета в сетке дашборда.
|
||
WIDGET_WIDTHS = ('full', 'half')
|
||
DEFAULT_WIDTH = 'full'
|
||
|
||
|
||
def normalize_width(value) -> str:
|
||
return value if value in WIDGET_WIDTHS else DEFAULT_WIDTH
|
||
|
||
|
||
def valid_keys(keys) -> list:
|
||
"""Только существующие ключи, без дублей, с сохранением порядка."""
|
||
seen = set()
|
||
result = []
|
||
if not isinstance(keys, (list, tuple)):
|
||
return []
|
||
for k in keys:
|
||
if isinstance(k, str) and k in WIDGETS_BY_KEY and k not in seen:
|
||
seen.add(k)
|
||
result.append(k)
|
||
return result
|
||
|
||
|
||
def widget_allowed(widget: DashboardWidget, user) -> bool:
|
||
"""Доступен ли виджет пользователю (по разделу портала)."""
|
||
if not user or not getattr(user, 'is_authenticated', False) or not user.is_active:
|
||
return False
|
||
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 ''))
|
||
return keys or list(DEFAULT_WIDGET_KEYS)
|
||
|
||
|
||
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:
|
||
return [k for k in valid_keys(layout.widgets) if k in allowed]
|
||
default = default_keys_for(getattr(user, 'user_location_type', None))
|
||
return [k for k in default if k in allowed]
|
||
|
||
|
||
def layout_widths(user) -> dict:
|
||
"""Сохранённые ширины виджетов пользователя ({ключ: 'half'|'full'})."""
|
||
from core.models import DashboardLayout
|
||
layout = DashboardLayout.objects.filter(employee=user).first()
|
||
return dict(layout.widget_widths or {}) if layout else {}
|
||
|
||
|
||
def build_dashboard_context(request) -> dict:
|
||
"""Плоский контекст всех включённых виджетов + список для отрисовки."""
|
||
keys = resolve_layout_keys(request.user)
|
||
widths = layout_widths(request.user)
|
||
merged: dict = {}
|
||
widgets = []
|
||
for key in keys:
|
||
widget = WIDGETS_BY_KEY[key]
|
||
if widget.context_builder is not None:
|
||
extra = widget.context_builder(request) or {}
|
||
merged.update(extra)
|
||
widgets.append({
|
||
'key': widget.key,
|
||
'label': widget.label,
|
||
'template': widget.template,
|
||
'width': normalize_width(widths.get(key, DEFAULT_WIDTH)),
|
||
})
|
||
merged['dashboard_widgets'] = widgets
|
||
merged['dashboard_has_custom_layout'] = _has_custom_layout(request.user)
|
||
return merged
|
||
|
||
|
||
def _has_custom_layout(user) -> bool:
|
||
from core.models import DashboardLayout
|
||
return DashboardLayout.objects.filter(employee=user).exists()
|
||
|
||
|
||
def customize_rows(user) -> list:
|
||
"""Строки для страницы настройки: включённые (в порядке) + выключенные.
|
||
|
||
Только доступные пользователю виджеты — нельзя включить то, к чему нет
|
||
доступа. У каждой строки есть ширина ('half'|'full').
|
||
"""
|
||
allowed = set(accessible_keys(user))
|
||
widths = layout_widths(user)
|
||
enabled = resolve_layout_keys(user)
|
||
enabled_set = set(enabled)
|
||
rows = [
|
||
{'widget': WIDGETS_BY_KEY[k], 'enabled': True,
|
||
'width': normalize_width(widths.get(k, DEFAULT_WIDTH))}
|
||
for k in enabled
|
||
]
|
||
for widget in DASHBOARD_WIDGETS:
|
||
if widget.key in allowed and widget.key not in enabled_set:
|
||
rows.append({
|
||
'widget': widget, 'enabled': False,
|
||
'width': normalize_width(widths.get(widget.key, DEFAULT_WIDTH)),
|
||
})
|
||
return rows
|
||
|
||
|
||
def save_layout(user, ordered_keys, enabled_keys, widths=None) -> list:
|
||
"""Сохранить раскладку: включённые и доступные ключи в заданном порядке
|
||
+ ширины (widths: {ключ: 'half'|'full'})."""
|
||
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 and k in allowed]
|
||
width_map = {k: normalize_width((widths or {}).get(k, DEFAULT_WIDTH)) for k in result}
|
||
DashboardLayout.objects.update_or_create(
|
||
employee=user, defaults={'widgets': result, 'widget_widths': width_map},
|
||
)
|
||
return result
|
||
|
||
|
||
def reset_layout(user) -> None:
|
||
"""Удалить персональную раскладку — вернуться к дефолту по локации."""
|
||
from core.models import DashboardLayout
|
||
DashboardLayout.objects.filter(employee=user).delete()
|