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>
This commit is contained in:
2026-09-22 10:47:14 +03:00
parent 0057f367a2
commit 3387418332
7 changed files with 121 additions and 11 deletions
+33 -6
View File
@@ -170,6 +170,14 @@ 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:
"""Только существующие ключи, без дублей, с сохранением порядка."""
@@ -221,9 +229,17 @@ def resolve_layout_keys(user) -> list:
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:
@@ -235,6 +251,7 @@ def build_dashboard_context(request) -> dict:
'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)
@@ -250,27 +267,37 @@ 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} for k in 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})
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) -> list:
"""Сохранить раскладку: включённые и доступные ключи в заданном порядке."""
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},
employee=user, defaults={'widgets': result, 'widget_widths': width_map},
)
return result
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-09-22 07:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0021_dashboarddefaults_dashboardlayout'),
]
operations = [
migrations.AddField(
model_name='dashboardlayout',
name='widget_widths',
field=models.JSONField(blank=True, default=dict, verbose_name='Ширины виджетов'),
),
]
+2
View File
@@ -16,6 +16,8 @@ class DashboardLayout(models.Model):
related_name='dashboard_layout', verbose_name=_('Сотрудник'),
)
widgets = models.JSONField(_('Виджеты (порядок)'), default=list, blank=True)
# {ключ виджета: 'half' | 'full'} — ширина виджета в сетке дашборда.
widget_widths = models.JSONField(_('Ширины виджетов'), default=dict, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
+52
View File
@@ -93,6 +93,58 @@ class DashboardLayoutLogicTests(TestCase):
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()
+2 -1
View File
@@ -79,7 +79,8 @@ def dashboard_customize_view(request):
enabled = request.POST.getlist('enabled')
if not order: # JS отключён — берём порядок чекбоксов
order = enabled
dash.save_layout(request.user, order, enabled)
widths = {k: request.POST.get(f'width_{k}', dash.DEFAULT_WIDTH) for k in enabled}
dash.save_layout(request.user, order, enabled, widths=widths)
return redirect('dashboard')
context = {'rows': dash.customize_rows(request.user)}
+5 -3
View File
@@ -9,11 +9,13 @@
</button>
</div>
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
<div style="display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start;">
{% for w in dashboard_widgets %}
{% include w.template %}
<div style="{% if w.width == 'half' %}flex: 1 1 calc(50% - 0.75rem); min-width: min(100%, 22rem);{% else %}flex: 1 1 100%; min-width: 0;{% endif %}">
{% include w.template %}
</div>
{% empty %}
<div style="padding: 2rem; text-align: center; color: var(--color-muted-foreground); font-size: 0.875rem;">
<div style="flex: 1 1 100%; padding: 2rem; text-align: center; color: var(--color-muted-foreground); font-size: 0.875rem;">
{% trans "Все виджеты скрыты." %}
<a href="/dashboard/customize/" hx-get="/dashboard/customize/" hx-target="#main-content" hx-push-url="/dashboard/customize/" hx-indicator="#loader" style="color: var(--color-primary);">{% trans "Настроить дашборд" %}</a>
</div>
+9 -1
View File
@@ -4,7 +4,7 @@
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem;">
<div>
<h1 style="font-size: 1.5rem; font-weight: 700; color: var(--color-foreground); margin-bottom: 0.25rem;">{% trans "Настройка дашборда" %}</h1>
<p style="font-size: 0.875rem; color: var(--color-muted-foreground);">{% trans "Включите нужные виджеты и расположите их в удобном порядке." %}</p>
<p style="font-size: 0.875rem; color: var(--color-muted-foreground);">{% trans "Включите нужные виджеты, задайте ширину и порядок. Два виджета «½» встают в один ряд, «Полная» — на всю ширину." %}</p>
</div>
<button class="btn btn-secondary" style="border: 1px solid var(--color-border); font-size: 0.8125rem; white-space: nowrap;"
hx-get="/dashboard/" hx-target="#main-content" hx-push-url="/dashboard/" hx-indicator="#loader">← {% trans "К дашборду" %}</button>
@@ -27,6 +27,14 @@
<span style="display: block; font-size: 0.8125rem; color: var(--color-muted-foreground); margin-top: 0.125rem;">{{ row.widget.description }}</span>
</span>
</label>
<div style="display: inline-flex; flex-shrink: 0; border: 1px solid var(--color-border); border-radius: 0.375rem; overflow: hidden; font-size: 0.75rem;">
<label style="padding: 0.25rem 0.5rem; cursor: pointer; color: var(--color-muted-foreground);">
<input type="radio" name="width_{{ row.widget.key }}" value="full" {% if row.width == 'full' %}checked{% endif %} style="margin-right: 0.2rem;">{% trans "Полная" %}
</label>
<label style="padding: 0.25rem 0.5rem; cursor: pointer; color: var(--color-muted-foreground); border-left: 1px solid var(--color-border);">
<input type="radio" name="width_{{ row.widget.key }}" value="half" {% if row.width == 'half' %}checked{% endif %} style="margin-right: 0.2rem;">½
</label>
</div>
<div style="display: flex; gap: 0.25rem; flex-shrink: 0;">
<button type="button" class="btn btn-ghost btn-icon" title="{% trans 'Выше' %}" onclick="dashMove(this, -1)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m18 15-6-6-6 6"/></svg>