netbox-blade-chassis 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ from netbox.plugins import PluginConfig
2
+
3
+ from .version import __version__
4
+
5
+
6
+ class NetboxBladeChassisConfig(PluginConfig):
7
+ name = 'netbox_blade_chassis'
8
+ verbose_name = 'NetBox Blade Chassis'
9
+ description = 'Visualize blade server bays inside chassis devices in rack elevations.'
10
+ version = __version__
11
+ base_url = 'blade-chassis'
12
+ min_version = '4.6.0'
13
+ max_version = '4.7.99'
14
+
15
+ default_settings = {
16
+ 'enable_inline_elevation': True,
17
+ }
18
+
19
+ def ready(self):
20
+ super().ready()
21
+ from . import views # noqa: F401 — registers plugin views via decorators
22
+
23
+
24
+ config = NetboxBladeChassisConfig
File without changes
@@ -0,0 +1,9 @@
1
+ from django.urls import path
2
+
3
+ from netbox_blade_chassis.api import views
4
+
5
+ app_name = 'netbox_blade_chassis-api'
6
+
7
+ urlpatterns = [
8
+ path('racks/<int:pk>/elevation/', views.RackElevationAPIView.as_view(), name='rack-elevation'),
9
+ ]
@@ -0,0 +1,60 @@
1
+ from django.http import HttpResponse
2
+ from django.shortcuts import get_object_or_404
3
+ from rest_framework.response import Response
4
+ from rest_framework.views import APIView
5
+
6
+ from dcim.api.serializers_.racks import RackElevationDetailFilterSerializer
7
+ from dcim.api.serializers_.rackunits import RackUnitSerializer
8
+ from dcim.models import Rack
9
+
10
+ from netbox_blade_chassis.svg.racks import BladeChassisRackElevationSVG
11
+
12
+
13
+ class RackElevationAPIView(APIView):
14
+ queryset = Rack.objects.all()
15
+
16
+ def get(self, request, pk):
17
+ rack = get_object_or_404(Rack.objects.restrict(request.user, 'view'), pk=pk)
18
+ serializer = RackElevationDetailFilterSerializer(data=request.GET)
19
+ if not serializer.is_valid():
20
+ return Response(serializer.errors, 400)
21
+
22
+ data = serializer.validated_data
23
+
24
+ if data['render'] == 'svg':
25
+ highlight_params = []
26
+ for param in request.GET.getlist('highlight'):
27
+ try:
28
+ highlight_params.append(param.split(':', 1))
29
+ except ValueError:
30
+ pass
31
+
32
+ drawing = BladeChassisRackElevationSVG(
33
+ rack,
34
+ user=request.user,
35
+ unit_width=data['unit_width'],
36
+ unit_height=data['unit_height'],
37
+ legend_width=data['legend_width'],
38
+ margin_width=data['margin_width'],
39
+ include_images=data['include_images'],
40
+ base_url=request.build_absolute_uri('/'),
41
+ highlight_params=highlight_params,
42
+ )
43
+ return HttpResponse(drawing.render(data['face']).tostring(), content_type='image/svg+xml')
44
+
45
+ elevation = rack.get_rack_units(
46
+ face=data['face'],
47
+ user=request.user,
48
+ exclude=data['exclude'],
49
+ expand_devices=data['expand_devices'],
50
+ )
51
+
52
+ if q := data['q']:
53
+ q = q.lower()
54
+ elevation = [
55
+ unit for unit in elevation
56
+ if q in str(unit['id']) or q in str(unit['name']).lower()
57
+ ]
58
+
59
+ rack_units = RackUnitSerializer(elevation, many=True, context={'request': request})
60
+ return Response(rack_units.data)
@@ -0,0 +1,111 @@
1
+ from django import forms
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+ from dcim.forms.model_forms import DeviceBayTemplateForm as BaseDeviceBayTemplateForm
5
+ from dcim.forms.object_create import ComponentCreateForm
6
+ from utilities.forms.fields import ExpandableNameField
7
+ from utilities.forms.rendering import FieldSet
8
+
9
+ from netbox_blade_chassis.models import DeviceBayTemplateLayout
10
+
11
+
12
+ def _parse_position(value):
13
+ if value in (None, ''):
14
+ return None
15
+ return int(value)
16
+
17
+
18
+ def _layout_requested(position_x, position_y):
19
+ return position_x is not None and position_y is not None
20
+
21
+
22
+ class DeviceBayTemplateLayoutBulkForm(forms.ModelForm):
23
+ position_x = forms.IntegerField(
24
+ required=False,
25
+ min_value=0,
26
+ widget=forms.NumberInput(attrs={'class': 'form-control form-control-sm', 'min': 0}),
27
+ )
28
+ position_y = forms.IntegerField(
29
+ required=False,
30
+ min_value=0,
31
+ widget=forms.NumberInput(attrs={'class': 'form-control form-control-sm', 'min': 0}),
32
+ )
33
+
34
+ class Meta:
35
+ model = DeviceBayTemplateLayout
36
+ fields = ('position_x', 'position_y')
37
+
38
+
39
+ class DeviceBayTemplateForm(BaseDeviceBayTemplateForm):
40
+ class Meta(BaseDeviceBayTemplateForm.Meta):
41
+ fields = BaseDeviceBayTemplateForm.Meta.fields
42
+
43
+ position_x = forms.IntegerField(
44
+ label=_('Position X'),
45
+ min_value=0,
46
+ required=False,
47
+ help_text=_(
48
+ 'Horizontal slot index within the row (0-based). Both Position X and Y are required for elevation display.'
49
+ ),
50
+ )
51
+ position_y = forms.IntegerField(
52
+ label=_('Position Y'),
53
+ min_value=0,
54
+ required=False,
55
+ help_text=_(
56
+ 'Row index within the blade grid (0-based). Both Position X and Y are required for elevation display.'
57
+ ),
58
+ )
59
+
60
+ fieldsets = (
61
+ FieldSet('device_type', 'name', 'label', 'enabled', 'description', name=_('Template')),
62
+ FieldSet('position_x', 'position_y', name=_('Blade Layout')),
63
+ )
64
+
65
+ def __init__(self, *args, **kwargs):
66
+ super().__init__(*args, **kwargs)
67
+ if self.instance.pk:
68
+ layout = DeviceBayTemplateLayout.objects.filter(device_bay_template=self.instance).first()
69
+ if layout:
70
+ self.fields['position_x'].initial = layout.position_x
71
+ self.fields['position_y'].initial = layout.position_y
72
+
73
+ def save(self, commit=True):
74
+ instance = super().save(commit)
75
+ position_x = _parse_position(self.cleaned_data.get('position_x'))
76
+ position_y = _parse_position(self.cleaned_data.get('position_y'))
77
+
78
+ if not _layout_requested(position_x, position_y):
79
+ DeviceBayTemplateLayout.objects.filter(device_bay_template=instance).delete()
80
+ return instance
81
+
82
+ DeviceBayTemplateLayout.objects.update_or_create(
83
+ device_bay_template=instance,
84
+ defaults={
85
+ 'position_x': position_x,
86
+ 'position_y': position_y,
87
+ },
88
+ )
89
+ return instance
90
+
91
+
92
+ class DeviceBayTemplateCreateForm(ComponentCreateForm, DeviceBayTemplateForm):
93
+ position_x = ExpandableNameField(
94
+ label=_('Position X'),
95
+ required=False,
96
+ help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)'),
97
+ )
98
+ position_y = ExpandableNameField(
99
+ label=_('Position Y'),
100
+ required=False,
101
+ help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)'),
102
+ )
103
+ replication_fields = ('name', 'label', 'position_x', 'position_y')
104
+
105
+ fieldsets = (
106
+ FieldSet('device_type', 'name', 'label', 'enabled', 'description', name=_('Template')),
107
+ FieldSet('position_x', 'position_y', name=_('Blade Layout')),
108
+ )
109
+
110
+ class Meta(DeviceBayTemplateForm.Meta):
111
+ exclude = ('name', 'label', 'position_x', 'position_y')
@@ -0,0 +1,79 @@
1
+ from collections import defaultdict
2
+ from typing import NamedTuple
3
+
4
+ from django.core.exceptions import ValidationError
5
+ from django.utils.translation import gettext_lazy as _
6
+
7
+ from dcim.models import Device, DeviceType
8
+
9
+ from netbox_blade_chassis.models import DeviceBayTemplateLayout
10
+
11
+
12
+ class LayoutEntry(NamedTuple):
13
+ position_x: int
14
+ position_y: int
15
+
16
+
17
+ def get_layout_map(device_type: DeviceType) -> dict[str, LayoutEntry]:
18
+ layouts = DeviceBayTemplateLayout.objects.filter(
19
+ device_bay_template__device_type=device_type,
20
+ ).select_related('device_bay_template')
21
+
22
+ return {
23
+ layout.device_bay_template.name: LayoutEntry(
24
+ layout.position_x,
25
+ layout.position_y,
26
+ )
27
+ for layout in layouts
28
+ }
29
+
30
+
31
+ def device_has_blade_layout(device_type: DeviceType) -> bool:
32
+ return DeviceBayTemplateLayout.objects.filter(
33
+ device_bay_template__device_type=device_type,
34
+ ).exists()
35
+
36
+
37
+ def group_layout_by_row(
38
+ layout_map: dict[str, LayoutEntry],
39
+ ) -> dict[int, list[tuple[int, str]]]:
40
+ rows: dict[int, list[tuple[int, str]]] = defaultdict(list)
41
+ for name, entry in layout_map.items():
42
+ rows[entry.position_y].append((entry.position_x, name))
43
+ for row in rows.values():
44
+ row.sort(key=lambda item: item[0])
45
+ return dict(rows)
46
+
47
+
48
+ def get_symmetric_column_count(layout_map: dict[int, list[tuple[int, str]]]) -> int:
49
+ if not layout_map:
50
+ return 0
51
+ return max(max(position_x for position_x, _ in cells) + 1 for cells in layout_map.values())
52
+
53
+
54
+ def mirror_column_index(position_x: int, column_count: int) -> int:
55
+ return column_count - 1 - position_x
56
+
57
+
58
+ def validate_grid_symmetry(layout_map: dict[str, LayoutEntry]) -> None:
59
+ rows: dict[int, set[int]] = defaultdict(set)
60
+ for entry in layout_map.values():
61
+ rows[entry.position_y].add(entry.position_x)
62
+
63
+ if not rows:
64
+ return
65
+
66
+ row_column_counts = [max(columns) + 1 for columns in rows.values()]
67
+ if len(set(row_column_counts)) > 1:
68
+ raise ValidationError(_('All blade rows must have the same number of columns.'))
69
+
70
+
71
+ def get_chassis_title(device: Device) -> str:
72
+ return device.name or device.device_type.model
73
+
74
+
75
+ def get_blade_label(device: Device) -> str:
76
+ name = device.name or str(device.device_type)
77
+ if '#' in name:
78
+ return name.split('#', 1)[0]
79
+ return name
@@ -0,0 +1,28 @@
1
+ from django.db import migrations, models
2
+ import django.db.models.deletion
3
+
4
+
5
+ class Migration(migrations.Migration):
6
+
7
+ initial = True
8
+
9
+ dependencies = [
10
+ ('dcim', '0237_module_remove_local_context_data'),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name='DeviceBayTemplateLayout',
16
+ fields=[
17
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
+ ('position_x', models.PositiveIntegerField(default=0, help_text='Horizontal slot index within the row (0-based, left to right).', verbose_name='position X')),
19
+ ('position_y', models.PositiveIntegerField(default=0, help_text='Row index within the chassis (0-based, top to bottom).', verbose_name='position Y')),
20
+ ('device_bay_template', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='blade_layout', to='dcim.devicebaytemplate')),
21
+ ],
22
+ options={
23
+ 'verbose_name': 'device bay template layout',
24
+ 'verbose_name_plural': 'device bay template layouts',
25
+ 'ordering': ('position_y', 'position_x'),
26
+ },
27
+ ),
28
+ ]
File without changes
@@ -0,0 +1,53 @@
1
+ from django.core.exceptions import ValidationError
2
+ from django.db import models
3
+ from django.utils.translation import gettext_lazy as _
4
+
5
+
6
+ class DeviceBayTemplateLayout(models.Model):
7
+ device_bay_template = models.OneToOneField(
8
+ to='dcim.DeviceBayTemplate',
9
+ on_delete=models.CASCADE,
10
+ related_name='blade_layout',
11
+ )
12
+ position_x = models.PositiveIntegerField(
13
+ verbose_name=_('position X'),
14
+ default=0,
15
+ help_text=_('Horizontal slot index within the row (0-based, left to right).'),
16
+ )
17
+ position_y = models.PositiveIntegerField(
18
+ verbose_name=_('position Y'),
19
+ default=0,
20
+ help_text=_('Row index within the chassis (0-based, top to bottom).'),
21
+ )
22
+
23
+ class Meta:
24
+ ordering = ('position_y', 'position_x')
25
+ verbose_name = _('device bay template layout')
26
+ verbose_name_plural = _('device bay template layouts')
27
+
28
+ def __str__(self):
29
+ return f'{self.device_bay_template}: ({self.position_x}, {self.position_y})'
30
+
31
+ @property
32
+ def device_type(self):
33
+ return self.device_bay_template.device_type
34
+
35
+ def clean(self):
36
+ super().clean()
37
+
38
+ if not hasattr(self, 'device_bay_template'):
39
+ return
40
+
41
+ device_type = self.device_bay_template.device_type
42
+ duplicates = DeviceBayTemplateLayout.objects.filter(
43
+ device_bay_template__device_type=device_type,
44
+ position_x=self.position_x,
45
+ position_y=self.position_y,
46
+ ).exclude(pk=self.pk)
47
+
48
+ if duplicates.exists():
49
+ raise ValidationError({
50
+ 'position_x': _(
51
+ 'Another bay template on this device type already uses position ({x}, {y}).'
52
+ ).format(x=self.position_x, y=self.position_y),
53
+ })
File without changes
@@ -0,0 +1,269 @@
1
+ from django.utils.translation import gettext as _
2
+ from svgwrite.container import Hyperlink
3
+ from svgwrite.masking import ClipPath
4
+ from svgwrite.shapes import Line, Rect
5
+ from svgwrite.text import Text, TSpan
6
+
7
+ from dcim.models import Device
8
+ from dcim.svg.racks import RackElevationSVG, truncate_text
9
+ from utilities.html import foreground_color
10
+
11
+ from netbox_blade_chassis.layout import (
12
+ device_has_blade_layout,
13
+ get_blade_label,
14
+ get_chassis_title,
15
+ get_layout_map,
16
+ get_symmetric_column_count,
17
+ group_layout_by_row,
18
+ mirror_column_index,
19
+ )
20
+
21
+
22
+ class BladeChassisRackElevationSVG(RackElevationSVG):
23
+ HEADER_HEIGHT = 18
24
+ HEADER_FONT_SIZE = 10
25
+ CELL_FONT_SIZE = 7
26
+ LINE_HEIGHT_RATIO = 1.25
27
+
28
+ BLADE_STYLES = """
29
+ svg .blade-chassis { stroke: none; }
30
+ svg .blade-grid-bg { fill: var(--nbx-rack-slot-bg); stroke: none; }
31
+ svg .blade-cell-empty { fill: color-mix(in srgb, var(--nbx-rack-slot-bg) 65%, var(--nbx-rack-bg)); stroke: none; }
32
+ svg .blade-cell-filled { stroke: none; opacity: 0.92; }
33
+ svg .blade-grid-line { stroke: var(--nbx-rack-slot-border); stroke-width: 1; }
34
+ svg .blade-header-label { font-size: 10px; font-weight: 600; }
35
+ svg .blade-cell-label { font-size: 7px; }
36
+ svg .blade-cell-empty-label { font-size: 8px; fill: var(--nbx-rack-unit-color); opacity: 0.45; }
37
+ """
38
+
39
+ def __init__(self, *args, user=None, **kwargs):
40
+ super().__init__(*args, user=user, **kwargs)
41
+ self._user = user
42
+
43
+ def _setup_drawing(self):
44
+ drawing = super()._setup_drawing()
45
+ drawing.defs.add(drawing.style(self.BLADE_STYLES))
46
+ return drawing
47
+
48
+ def _device_is_viewable(self, device):
49
+ if device.pk in self.permitted_device_ids:
50
+ return True
51
+ if self._user is None:
52
+ return True
53
+ return Device.objects.restrict(self._user, 'view').filter(pk=device.pk).exists()
54
+
55
+ def draw_device_front(self, device, coords, size):
56
+ if device_has_blade_layout(device.device_type):
57
+ self._draw_blade_chassis(device, coords, size)
58
+ else:
59
+ super().draw_device_front(device, coords, size)
60
+
61
+ def draw_device_rear(self, device, coords, size):
62
+ if device_has_blade_layout(device.device_type):
63
+ self._draw_blade_chassis(device, coords, size, mirrored=True)
64
+ else:
65
+ super().draw_device_rear(device, coords, size)
66
+
67
+ def _draw_blade_chassis(self, device, coords, size, mirrored=False):
68
+ x, y = coords
69
+ width, height = size
70
+ layout_map = get_layout_map(device.device_type)
71
+ if not layout_map:
72
+ super().draw_device_front(device, coords, size)
73
+ return
74
+
75
+ bays_by_name = {bay.name: bay for bay in device.devicebays.all()}
76
+ rows = group_layout_by_row(layout_map)
77
+ row_indices = sorted(rows.keys())
78
+ row_count = len(row_indices)
79
+ column_count = get_symmetric_column_count(rows)
80
+
81
+ header_height = min(self.HEADER_HEIGHT, max(height * 0.18, 12))
82
+ inner_y = y + header_height
83
+ inner_height = max(height - header_height, 1)
84
+ row_height = inner_height / row_count if row_count else inner_height
85
+ column_width = width / column_count if column_count else width
86
+
87
+ color = device.role.color if device.role else None
88
+ header_bg = f'#{color}' if color else 'var(--nbx-rack-slot-bg)'
89
+ text_color = f'#{foreground_color(color)}' if color else 'var(--nbx-rack-unit-color)'
90
+ is_shaded = self.highlight_devices and device not in self.highlight_devices
91
+ css_extra = ' shaded' if is_shaded else ''
92
+
93
+ clip_id = f'blade-clip-{device.pk}'
94
+ clip_path = ClipPath(id=clip_id)
95
+ clip_path.add(Rect((x, inner_y), (width, inner_height)))
96
+ self.drawing.defs.add(clip_path)
97
+
98
+ chassis_link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_parent')
99
+ chassis_link.add(Rect(coords, size, style=f'fill: {header_bg}', class_=f'blade-chassis{css_extra}'))
100
+ chassis_link.add(
101
+ Text(
102
+ truncate_text(get_chassis_title(device), width, font_size=self.HEADER_FONT_SIZE),
103
+ insert=(x + width / 2, y + header_height / 2),
104
+ fill=text_color,
105
+ class_=f'blade-header-label{css_extra}',
106
+ )
107
+ )
108
+ self.drawing.add(chassis_link)
109
+
110
+ self.drawing.add(Rect((x, inner_y), (width, inner_height), class_=f'blade-grid-bg{css_extra}'))
111
+
112
+ for row_number, row_index in enumerate(row_indices):
113
+ cell_y = inner_y + row_number * row_height
114
+ cells_by_x = dict(rows[row_index])
115
+
116
+ for position_x in range(column_count):
117
+ bay_name = cells_by_x.get(position_x)
118
+ bay = bays_by_name.get(bay_name) if bay_name else None
119
+ display_x = mirror_column_index(position_x, column_count) if mirrored else position_x
120
+ cell_x = x + display_x * column_width
121
+ self._draw_blade_cell(
122
+ bay=bay,
123
+ coords=(cell_x, cell_y),
124
+ size=(column_width, row_height),
125
+ css_extra=css_extra,
126
+ clip_id=clip_id,
127
+ )
128
+
129
+ self._draw_blade_grid_lines(
130
+ x=x,
131
+ y=inner_y,
132
+ width=width,
133
+ height=inner_height,
134
+ row_count=row_count,
135
+ column_count=column_count,
136
+ css_extra=css_extra,
137
+ )
138
+ self.drawing.add(Line(
139
+ start=(x, inner_y),
140
+ end=(x + width, inner_y),
141
+ class_=f'blade-grid-line{css_extra}',
142
+ ))
143
+
144
+ def _draw_blade_grid_lines(self, x, y, width, height, row_count, column_count, css_extra=''):
145
+ row_height = height / row_count if row_count else height
146
+ column_width = width / column_count if column_count else width
147
+
148
+ for column_index in range(1, column_count):
149
+ line_x = x + column_index * column_width
150
+ self.drawing.add(Line(
151
+ start=(line_x, y),
152
+ end=(line_x, y + height),
153
+ class_=f'blade-grid-line{css_extra}',
154
+ ))
155
+
156
+ for row_index in range(1, row_count):
157
+ line_y = y + row_index * row_height
158
+ self.drawing.add(Line(
159
+ start=(x, line_y),
160
+ end=(x + width, line_y),
161
+ class_=f'blade-grid-line{css_extra}',
162
+ ))
163
+
164
+ def _draw_blade_cell(self, bay, coords, size, css_extra='', clip_id=None):
165
+ cell_x, cell_y = coords
166
+ cell_width, cell_height = size
167
+ clip_attr = {'clip-path': f'url(#{clip_id})'} if clip_id else {}
168
+ text_padding = 2
169
+ text_width = max(cell_width - text_padding * 2, 1)
170
+ text_height = max(cell_height - text_padding * 2, 1)
171
+
172
+ installed = bay.installed_device if bay else None
173
+ if installed and self._device_is_viewable(installed):
174
+ label = get_blade_label(installed)
175
+ description = _('Blade: {name}').format(name=label)
176
+ blade_color = installed.role.color if installed.role else None
177
+ fill_style = f'fill: #{blade_color}' if blade_color else None
178
+ label_color = f'#{foreground_color(blade_color)}' if blade_color else 'var(--nbx-rack-unit-color)'
179
+
180
+ link = Hyperlink(href=f'{self.base_url}{installed.get_absolute_url()}', target='_parent')
181
+ link.set_desc(description)
182
+ link.add(Rect(coords, size, style=fill_style, class_=f'blade-cell-filled{css_extra}', **clip_attr))
183
+ link.add(self._build_cell_text(
184
+ label,
185
+ center_x=cell_x + cell_width / 2,
186
+ center_y=cell_y + cell_height / 2,
187
+ width=text_width,
188
+ height=text_height,
189
+ fill=label_color,
190
+ css_class=f'blade-cell-label{css_extra}',
191
+ clip_attr=clip_attr,
192
+ ))
193
+ self.drawing.add(link)
194
+ else:
195
+ self.drawing.add(Rect(coords, size, class_=f'blade-cell-empty{css_extra}', **clip_attr))
196
+ if cell_height >= 10 and cell_width >= 10:
197
+ self.drawing.add(Text(
198
+ '—',
199
+ insert=(cell_x + cell_width / 2, cell_y + cell_height / 2),
200
+ class_=f'blade-cell-empty-label{css_extra}',
201
+ **clip_attr,
202
+ ))
203
+
204
+ def _build_cell_text(self, text, center_x, center_y, width, height, fill, css_class, clip_attr=None):
205
+ lines = self._wrap_text(text, width, height, self.CELL_FONT_SIZE)
206
+ line_height = self.CELL_FONT_SIZE * self.LINE_HEIGHT_RATIO
207
+ block_height = line_height * len(lines)
208
+ start_y = center_y - block_height / 2 + line_height / 2
209
+
210
+ text_elem = Text('', fill=fill, class_=css_class, **(clip_attr or {}))
211
+ text_elem['text-anchor'] = 'middle'
212
+
213
+ for index, line in enumerate(lines):
214
+ if index == 0:
215
+ text_elem.add(TSpan(line, insert=(center_x, start_y)))
216
+ else:
217
+ text_elem.add(TSpan(line, x=[center_x], dy=[f'{self.LINE_HEIGHT_RATIO}em']))
218
+
219
+ return text_elem
220
+
221
+ def _wrap_text(self, text, max_width, max_height, font_size):
222
+ char_width = font_size * 0.55
223
+ max_chars = max(1, int(max_width / char_width))
224
+ max_lines = max(1, int(max_height / (font_size * self.LINE_HEIGHT_RATIO)))
225
+
226
+ chunks = []
227
+ for part in text.split('.'):
228
+ if chunks:
229
+ chunks.append('.')
230
+ if part:
231
+ chunks.append(part)
232
+
233
+ lines = []
234
+ current = ''
235
+ for chunk in chunks:
236
+ candidate = current + chunk
237
+ if len(candidate) <= max_chars:
238
+ current = candidate
239
+ continue
240
+
241
+ if current:
242
+ lines.append(current)
243
+ current = ''
244
+
245
+ while chunk and len(lines) < max_lines:
246
+ if len(chunk) <= max_chars:
247
+ current = chunk
248
+ chunk = ''
249
+ break
250
+ lines.append(chunk[:max_chars])
251
+ chunk = chunk[max_chars:]
252
+
253
+ if current and len(lines) < max_lines:
254
+ lines.append(current)
255
+
256
+ if not lines:
257
+ lines = [truncate_text(text, max_width, font_size=font_size)]
258
+ elif len(lines) > max_lines:
259
+ lines = lines[:max_lines]
260
+
261
+ joined_length = sum(len(line) for line in lines)
262
+ if joined_length < len(text) and lines:
263
+ last = lines[-1]
264
+ if len(last) > 3:
265
+ lines[-1] = last[: max(1, max_chars - 1)] + '…'
266
+ else:
267
+ lines[-1] = '…'
268
+
269
+ return lines
@@ -0,0 +1,14 @@
1
+ from netbox.plugins import PluginTemplateExtension
2
+ from netbox.plugins.utils import get_plugin_config
3
+
4
+
5
+ class ElevationPatchExtension(PluginTemplateExtension):
6
+ def head(self):
7
+ if not get_plugin_config('netbox_blade_chassis', 'enable_inline_elevation', True):
8
+ return ''
9
+ return self.render('netbox_blade_chassis/inc/elevation_patch.html')
10
+
11
+
12
+ template_extensions = [
13
+ ElevationPatchExtension,
14
+ ]