1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

#3087: Add InvetoryItemRole

This commit is contained in:
jeremystretch
2021-12-27 10:18:39 -05:00
parent 77dd684916
commit 04fb5e544d
22 changed files with 469 additions and 10 deletions

View File

@@ -20,6 +20,7 @@ __all__ = [
'NestedInterfaceSerializer',
'NestedInterfaceTemplateSerializer',
'NestedInventoryItemSerializer',
'NestedInventoryItemRoleSerializer',
'NestedManufacturerSerializer',
'NestedModuleBaySerializer',
'NestedModuleBayTemplateSerializer',
@@ -384,6 +385,15 @@ class NestedInventoryItemSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'device', 'name', '_depth']
class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
inventoryitem_count = serializers.IntegerField(read_only=True)
class Meta:
model = models.InventoryItemRole
fields = ['id', 'url', 'display', 'name', 'slug', 'inventoryitem_count']
#
# Cables
#

View File

@@ -806,10 +806,6 @@ class DeviceBaySerializer(PrimaryModelSerializer):
]
#
# Inventory items
#
class InventoryItemSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer()
@@ -825,6 +821,22 @@ class InventoryItemSerializer(PrimaryModelSerializer):
]
#
# Device component roles
#
class InventoryItemRoleSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
inventoryitem_count = serializers.IntegerField(read_only=True)
class Meta:
model = InventoryItemRole
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
'last_updated', 'inventoryitem_count',
]
#
# Cables
#

View File

@@ -50,6 +50,9 @@ router.register('module-bays', views.ModuleBayViewSet)
router.register('device-bays', views.DeviceBayViewSet)
router.register('inventory-items', views.InventoryItemViewSet)
# Device component roles
router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
# Cables
router.register('cables', views.CableViewSet)

View File

@@ -623,6 +623,18 @@ class InventoryItemViewSet(ModelViewSet):
brief_prefetch_fields = ['device']
#
# Device component roles
#
class InventoryItemRoleViewSet(CustomFieldModelViewSet):
queryset = InventoryItemRole.objects.prefetch_related('tags').annotate(
inventoryitem_count=count_related(InventoryItem, 'role')
)
serializer_class = serializers.InventoryItemRoleSerializer
filterset_class = filtersets.InventoryItemRoleFilterSet
#
# Cables
#

View File

@@ -39,6 +39,7 @@ __all__ = (
'InterfaceFilterSet',
'InterfaceTemplateFilterSet',
'InventoryItemFilterSet',
'InventoryItemRoleFilterSet',
'LocationFilterSet',
'ManufacturerFilterSet',
'ModuleBayFilterSet',
@@ -1304,6 +1305,14 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
return queryset.filter(qs_filter)
class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta:
model = InventoryItemRole
fields = ['id', 'name', 'slug', 'color']
class VirtualChassisFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',

View File

@@ -30,6 +30,7 @@ __all__ = (
'InterfaceBulkEditForm',
'InterfaceTemplateBulkEditForm',
'InventoryItemBulkEditForm',
'InventoryItemRoleBulkEditForm',
'LocationBulkEditForm',
'ManufacturerBulkEditForm',
'ModuleBulkEditForm',
@@ -1186,3 +1187,24 @@ class InventoryItemBulkEditForm(
class Meta:
nullable_fields = ['label', 'manufacturer', 'part_id', 'description']
#
# Device component roles
#
class InventoryItemRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),
widget=forms.MultipleHiddenInput
)
color = ColorField(
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
class Meta:
nullable_fields = ['color', 'description']

View File

@@ -24,6 +24,7 @@ __all__ = (
'FrontPortCSVForm',
'InterfaceCSVForm',
'InventoryItemCSVForm',
'InventoryItemRoleCSVForm',
'LocationCSVForm',
'ManufacturerCSVForm',
'ModuleCSVForm',
@@ -805,6 +806,25 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
self.fields['parent'].queryset = InventoryItem.objects.none()
#
# Device component roles
#
class InventoryItemRoleCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
class Meta:
model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
#
# Cables
#
class CableCSVForm(CustomFieldModelCSVForm):
# Termination A
side_a_device = CSVModelChoiceField(
@@ -906,6 +926,10 @@ class CableCSVForm(CustomFieldModelCSVForm):
return length_unit if length_unit is not None else ''
#
# Virtual chassis
#
class VirtualChassisCSVForm(CustomFieldModelCSVForm):
master = CSVModelChoiceField(
queryset=Device.objects.all(),
@@ -919,6 +943,10 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm):
fields = ('name', 'domain', 'master')
#
# Power
#
class PowerPanelCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),

View File

@@ -27,6 +27,7 @@ __all__ = (
'InterfaceConnectionFilterForm',
'InterfaceFilterForm',
'InventoryItemFilterForm',
'InventoryItemRoleFilterForm',
'LocationFilterForm',
'ManufacturerFilterForm',
'ModuleFilterForm',
@@ -1120,6 +1121,15 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
#
# Device component roles
#
class InventoryItemRoleFilterForm(CustomFieldModelFilterForm):
model = InventoryItemRole
tag = TagFilterField(model)
#
# Connections
#

View File

@@ -37,6 +37,7 @@ __all__ = (
'InterfaceForm',
'InterfaceTemplateForm',
'InventoryItemForm',
'InventoryItemRoleForm',
'LocationForm',
'ManufacturerForm',
'ModuleForm',
@@ -1382,3 +1383,21 @@ class InventoryItemForm(CustomFieldModelForm):
'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'tags',
]
#
# Device component roles
#
class InventoryItemRoleForm(CustomFieldModelForm):
slug = SlugField()
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = InventoryItemRole
fields = [
'name', 'slug', 'color', 'description', 'tags',
]

View File

@@ -50,6 +50,9 @@ class DCIMQuery(graphene.ObjectType):
inventory_item = ObjectField(InventoryItemType)
inventory_item_list = ObjectListField(InventoryItemType)
inventory_item_role = ObjectField(InventoryItemRoleType)
inventory_item_role_list = ObjectListField(InventoryItemRoleType)
location = ObjectField(LocationType)
location_list = ObjectListField(LocationType)

View File

@@ -25,6 +25,7 @@ __all__ = (
'InterfaceType',
'InterfaceTemplateType',
'InventoryItemType',
'InventoryItemRoleType',
'LocationType',
'ManufacturerType',
'ModuleType',
@@ -242,6 +243,14 @@ class InventoryItemType(ComponentObjectType):
filterset_class = filtersets.InventoryItemFilterSet
class InventoryItemRoleType(OrganizationalObjectType):
class Meta:
model = models.InventoryItemRole
fields = '__all__'
filterset_class = filtersets.InventoryItemRoleFilterSet
class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType):
class Meta:

View File

@@ -0,0 +1,38 @@
import django.core.serializers.json
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('extras', '0067_configcontext_cluster_types'),
('dcim', '0145_modules'),
]
operations = [
migrations.CreateModel(
name='InventoryItemRole',
fields=[
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('color', utilities.fields.ColorField(default='9e9e9e', max_length=6)),
('description', models.CharField(blank=True, max_length=200)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='inventoryitem',
name='role',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.inventoryitemrole'),
),
]

View File

@@ -12,7 +12,8 @@ from dcim.constants import *
from dcim.fields import MACAddressField, WWNField
from dcim.svg import CableTraceSVG
from extras.utils import extras_features
from netbox.models import PrimaryModel
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
@@ -30,6 +31,7 @@ __all__ = (
'FrontPort',
'Interface',
'InventoryItem',
'InventoryItemRole',
'ModuleBay',
'PathEndpoint',
'PowerOutlet',
@@ -946,6 +948,38 @@ class DeviceBay(ComponentModel):
# Inventory items
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class InventoryItemRole(OrganizationalModel):
"""
Inventory items may optionally be assigned a functional role.
"""
name = models.CharField(
max_length=100,
unique=True
)
slug = models.SlugField(
max_length=100,
unique=True
)
color = ColorField(
default=ColorChoices.COLOR_GREY
)
description = models.CharField(
max_length=200,
blank=True,
)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:inventoryitemrole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class InventoryItem(MPTTModel, ComponentModel):
"""
@@ -973,6 +1007,13 @@ class InventoryItem(MPTTModel, ComponentModel):
blank=True,
help_text='Manufacturer-assigned part identifier'
)
role = models.ForeignKey(
to='dcim.InventoryItemRole',
on_delete=models.PROTECT,
related_name='inventory_items',
blank=True,
null=True
)
serial = models.CharField(
max_length=50,
verbose_name='Serial number',
@@ -993,7 +1034,7 @@ class InventoryItem(MPTTModel, ComponentModel):
objects = TreeManager()
clone_fields = ['device', 'parent', 'manufacturer', 'part_id']
clone_fields = ['device', 'parent', 'manufacturer', 'part_id', 'role']
class Meta:
ordering = ('device__id', 'parent__id', '_name')

View File

@@ -2,8 +2,8 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, ModuleBay,
Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
)
from tenancy.tables import TenantColumn
from utilities.tables import (
@@ -33,6 +33,7 @@ __all__ = (
'DeviceTable',
'FrontPortTable',
'InterfaceTable',
'InventoryItemRoleTable',
'InventoryItemTable',
'ModuleBayTable',
'PlatformTable',
@@ -68,11 +69,11 @@ def get_interface_state_attribute(record):
else:
return "disabled"
#
# Device roles
#
class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
@@ -791,6 +792,30 @@ class InventoryItemTable(DeviceComponentTable):
default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
class InventoryItemRoleTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
inventoryitem_count = LinkedCountColumn(
viewname='dcim:inventoryitem_list',
url_params={'role_id': 'pk'},
verbose_name='Items'
)
color = ColorColumn()
tags = TagColumn(
url_name='dcim:inventoryitemrole_list'
)
actions = ButtonsColumn(InventoryItemRole)
class Meta(BaseTable.Meta):
model = InventoryItemRole
fields = (
'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description', 'actions')
class DeviceInventoryItemTable(InventoryItemTable):
name = tables.TemplateColumn(
template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'

View File

@@ -1649,6 +1649,41 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
]
class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
model = InventoryItemRole
brief_fields = ['display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
create_data = [
{
'name': 'Inventory Item Role 4',
'slug': 'inventory-item-role-4',
'color': 'ffff00',
},
{
'name': 'Inventory Item Role 5',
'slug': 'inventory-item-role-5',
'color': 'ffff00',
},
{
'name': 'Inventory Item Role 6',
'slug': 'inventory-item-role-6',
'color': 'ffff00',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
roles = (
InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1', color='ff0000'),
InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2', color='00ff00'),
InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3', color='0000ff'),
)
InventoryItemRole.objects.bulk_create(roles)
class CableTest(APIViewTestCases.APIViewTestCase):
model = Cable
brief_fields = ['display', 'id', 'label', 'url']

View File

@@ -3091,6 +3091,33 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = InventoryItemRole.objects.all()
filterset = InventoryItemRoleFilterSet
@classmethod
def setUpTestData(cls):
roles = (
InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1', color='ff0000'),
InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2', color='00ff00'),
InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3', color='0000ff'),
)
InventoryItemRole.objects.bulk_create(roles)
def test_name(self):
params = {'name': ['Inventory Item Role 1', 'Inventory Item Role 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['inventory-item-role-1', 'inventory-item-role-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_color(self):
params = {'color': ['ff0000', '00ff00']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualChassis.objects.all()
filterset = VirtualChassisFilterSet

View File

@@ -1408,7 +1408,7 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Devie Role X',
'name': 'Device Role X',
'slug': 'device-role-x',
'color': 'c0c0c0',
'vm_role': False,
@@ -2375,6 +2375,41 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = InventoryItemRole
@classmethod
def setUpTestData(cls):
InventoryItemRole.objects.bulk_create([
InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'),
InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'),
InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Inventory Item Role X',
'slug': 'inventory-item-role-x',
'color': 'c0c0c0',
'description': 'New inventory item role',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,slug,color",
"Inventory Item Role 4,inventory-item-role-4,ff0000",
"Inventory Item Role 5,inventory-item-role-5,00ff00",
"Inventory Item Role 6,inventory-item-role-6,0000ff",
)
cls.bulk_edit_data = {
'color': '00ff00',
'description': 'New description',
}
# TODO: Change base class to PrimaryObjectViewTestCase
# Blocked by lack of common creation view for cables (termination A must be initialized)
class CableTestCase(

View File

@@ -425,6 +425,17 @@ urlpatterns = [
path('inventory-items/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'),
# Device roles
path('inventory-item-roles/', views.InventoryItemRoleListView.as_view(), name='inventoryitemrole_list'),
path('inventory-item-roles/add/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_add'),
path('inventory-item-roles/import/', views.InventoryItemRoleBulkImportView.as_view(), name='inventoryitemrole_import'),
path('inventory-item-roles/edit/', views.InventoryItemRoleBulkEditView.as_view(), name='inventoryitemrole_bulk_edit'),
path('inventory-item-roles/delete/', views.InventoryItemRoleBulkDeleteView.as_view(), name='inventoryitemrole_bulk_delete'),
path('inventory-item-roles/<int:pk>/', views.InventoryItemRoleView.as_view(), name='inventoryitemrole'),
path('inventory-item-roles/<int:pk>/edit/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_edit'),
path('inventory-item-roles/<int:pk>/delete/', views.InventoryItemRoleDeleteView.as_view(), name='inventoryitemrole_delete'),
path('inventory-item-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitemrole_changelog', kwargs={'model': InventoryItemRole}),
# Cables
path('cables/', views.CableListView.as_view(), name='cable_list'),
path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),

View File

@@ -2428,6 +2428,59 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
template_name = 'dcim/inventoryitem_bulk_delete.html'
#
# Inventory item roles
#
class InventoryItemRoleListView(generic.ObjectListView):
queryset = InventoryItemRole.objects.annotate(
inventoryitem_count=count_related(InventoryItem, 'role'),
)
filterset = filtersets.InventoryItemRoleFilterSet
filterset_form = forms.InventoryItemRoleFilterForm
table = tables.InventoryItemRoleTable
class InventoryItemRoleView(generic.ObjectView):
queryset = InventoryItemRole.objects.all()
def get_extra_context(self, request, instance):
return {
'inventoryitem_count': InventoryItem.objects.filter(role=instance).count(),
}
class InventoryItemRoleEditView(generic.ObjectEditView):
queryset = InventoryItemRole.objects.all()
model_form = forms.InventoryItemRoleForm
class InventoryItemRoleDeleteView(generic.ObjectDeleteView):
queryset = InventoryItemRole.objects.all()
class InventoryItemRoleBulkImportView(generic.BulkImportView):
queryset = InventoryItemRole.objects.all()
model_form = forms.InventoryItemRoleCSVForm
table = tables.InventoryItemRoleTable
class InventoryItemRoleBulkEditView(generic.BulkEditView):
queryset = InventoryItemRole.objects.annotate(
inventoryitem_count=count_related(InventoryItem, 'role'),
)
filterset = filtersets.InventoryItemRoleFilterSet
table = tables.InventoryItemRoleTable
form = forms.InventoryItemRoleBulkEditForm
class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate(
inventoryitem_count=count_related(InventoryItem, 'role'),
)
table = tables.InventoryItemRoleTable
#
# Bulk Device component creation
#

View File

@@ -166,6 +166,7 @@ DEVICES_MENU = Menu(
get_model_item('dcim', 'modulebay', 'Module Bays', actions=['import']),
get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']),
get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']),
get_model_item('dcim', 'inventoryitemrole', 'Inventory Item Roles'),
),
),
),

View File

@@ -0,0 +1,53 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'dcim:inventoryitemrole_list' %}">Inventory Item Roles</a></li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Inventory Item Role</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Color</th>
<td>
<span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
</td>
</tr>
<tr>
<th scope="row">Inventory Items</th>
<td>
<a href="{% url 'dcim:inventoryitem_list' %}?role_id={{ object.pk }}">{{ inventoryitem_count }}</a>
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}