Files
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

309 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Реестр виджетов дашборда и сборка персональной раскладки.
Дашборд собирается из виджетов (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()