Closes #6715: Add tenant assignment for cables

This commit is contained in:
jeremystretch 2021-10-19 12:33:17 -04:00
parent ba7361bdc7
commit 0afd3e6189
15 changed files with 79 additions and 44 deletions

View File

@ -3,11 +3,16 @@
!!! warning "PostgreSQL 10 Required" !!! warning "PostgreSQL 10 Required"
NetBox v3.1 requires PostgreSQL 10 or later. NetBox v3.1 requires PostgreSQL 10 or later.
### Breaking Changes
* The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination.
### Enhancements ### Enhancements
* [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces
* [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices * [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices
* [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support
* [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables
* [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations
### Other Changes ### Other Changes

View File

@ -758,14 +758,15 @@ class CableSerializer(PrimaryModelSerializer):
termination_a = serializers.SerializerMethodField(read_only=True) termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=CableStatusChoices, required=False) status = ChoiceField(choices=CableStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
'custom_fields', 'tags', 'custom_fields',
] ]
def _get_termination(self, obj, side): def _get_termination(self, obj, side):

View File

@ -1189,7 +1189,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
return queryset.filter(qs_filter).distinct() return queryset.filter(qs_filter).distinct()
class CableFilterSet(PrimaryModelFilterSet): class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -1230,14 +1230,6 @@ class CableFilterSet(PrimaryModelFilterSet):
method='filter_device', method='filter_device',
field_name='device__site__slug' field_name='device__site__slug'
) )
tenant_id = MultiValueNumberFilter(
method='filter_device',
field_name='device__tenant_id'
)
tenant = MultiValueNumberFilter(
method='filter_device',
field_name='device__tenant__slug'
)
tag = TagFilter() tag = TagFilter()
class Meta: class Meta:

View File

@ -468,6 +468,10 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
widget=StaticSelect(), widget=StaticSelect(),
initial='' initial=''
) )
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
)
label = forms.CharField( label = forms.CharField(
max_length=100, max_length=100,
required=False required=False
@ -488,7 +492,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
class Meta: class Meta:
nullable_fields = [ nullable_fields = [
'type', 'status', 'label', 'color', 'length', 'type', 'status', 'tenant', 'label', 'color', 'length',
] ]
def clean(self): def clean(self):

View File

@ -821,6 +821,12 @@ class CableCSVForm(CustomFieldModelCSVForm):
required=False, required=False,
help_text='Physical medium classification' help_text='Physical medium classification'
) )
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
)
length_unit = CSVChoiceField( length_unit = CSVChoiceField(
choices=CableLengthUnitChoices, choices=CableLengthUnitChoices,
required=False, required=False,
@ -831,7 +837,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
model = Cable model = Cable
fields = [ fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
'status', 'label', 'color', 'length', 'length_unit', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
] ]
help_texts = { help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'), 'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),

View File

@ -2,6 +2,7 @@ from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import * from dcim.models import *
from extras.forms import CustomFieldModelForm from extras.forms import CustomFieldModelForm
from extras.models import Tag from extras.models import Tag
from tenancy.forms import TenancyForm
from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
__all__ = ( __all__ = (
@ -17,7 +18,7 @@ __all__ = (
) )
class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): class ConnectCableToDeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
""" """
Base form for connecting a Cable to a Device component Base form for connecting a Cable to a Device component
""" """
@ -78,7 +79,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
model = Cable model = Cable
fields = [ fields = [
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags',
] ]
widgets = { widgets = {
'status': StaticSelect, 'status': StaticSelect,
@ -169,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
) )
class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm): class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
termination_b_provider = DynamicModelChoiceField( termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
label='Provider', label='Provider',
@ -219,7 +221,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
model = Cable model = Cable
fields = [ fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags',
] ]
def clean_termination_b_id(self): def clean_termination_b_id(self):
@ -227,7 +230,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
return getattr(self.cleaned_data['termination_b_id'], 'pk', None) return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
termination_b_region = DynamicModelChoiceField( termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
label='Region', label='Region',
@ -280,8 +283,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
'color', 'length', 'length_unit', 'tags', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
] ]
def clean_termination_b_id(self): def clean_termination_b_id(self):

View File

@ -691,13 +691,13 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
tag = TagFilterField(model) tag = TagFilterField(model)
class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = Cable model = Cable
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['site_id', 'rack_id', 'device_id'], ['site_id', 'rack_id', 'device_id'],
['type', 'status', 'color'], ['type', 'status', 'color'],
['tenant_id'], ['tenant_group_id', 'tenant_id'],
] ]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
@ -719,12 +719,6 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Site'), label=_('Site'),
fetch_trigger='open' fetch_trigger='open'
) )
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant'),
fetch_trigger='open'
)
rack_id = DynamicModelMultipleChoiceField( rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,

View File

@ -601,7 +601,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
self.fields['position'].widget.choices = [(position, f'U{position}')] self.fields['position'].widget.choices = [(position, f'U{position}')]
class CableForm(BootstrapMixin, CustomFieldModelForm): class CableForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False required=False
@ -610,7 +610,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
] ]
widgets = { widgets = {
'status': StaticSelect, 'status': StaticSelect,

View File

@ -15,4 +15,9 @@ class Migration(migrations.Migration):
name='tenant', name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='tenancy.tenant'),
), ),
migrations.AddField(
model_name='cable',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cables', to='tenancy.tenant'),
),
] ]

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0135_location_tenant'), ('dcim', '0135_tenancy_extensions'),
] ]
operations = [ operations = [

View File

@ -67,6 +67,13 @@ class Cable(PrimaryModel):
choices=CableStatusChoices, choices=CableStatusChoices,
default=CableStatusChoices.STATUS_CONNECTED default=CableStatusChoices.STATUS_CONNECTED
) )
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='cables',
blank=True,
null=True
)
label = models.CharField( label = models.CharField(
max_length=100, max_length=100,
blank=True blank=True

View File

@ -2,6 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from dcim.models import Cable from dcim.models import Cable
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
@ -45,6 +46,7 @@ class CableTable(BaseTable):
verbose_name='Termination B' verbose_name='Termination B'
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
tenant = TenantColumn()
length = TemplateColumn( length = TemplateColumn(
template_code=CABLE_LENGTH, template_code=CABLE_LENGTH,
order_by='_abs_length' order_by='_abs_length'
@ -58,7 +60,7 @@ class CableTable(BaseTable):
model = Cable model = Cable
fields = ( fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type', 'color', 'length', 'tags', 'status', 'type', 'tenant', 'color', 'length', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',

View File

@ -2819,6 +2819,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
tenants = ( tenants = (
Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'), Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
) )
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
@ -2834,9 +2835,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = ( devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1, tenant=tenants[0]), Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2, tenant=tenants[0]), Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1, tenant=tenants[1]), Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1),
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2), Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2),
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1), Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1),
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2), Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2),
@ -2863,12 +2864,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables # Cables
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save() Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self): def test_label(self):
@ -2921,9 +2922,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_tenant(self): def test_tenant(self):
tenant = Tenant.objects.all()[:2] tenant = Tenant.objects.all()[:2]
params = {'tenant_id': [tenant[0].pk, tenant[1].pk]} params = {'tenant_id': [tenant[0].pk, tenant[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'tenant': [tenant[0].slug, tenant[1].slug]} params = {'tenant': [tenant[0].slug, tenant[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_termination_types(self): def test_termination_types(self):
params = {'termination_a_type': 'dcim.consoleport'} params = {'termination_a_type': 'dcim.consoleport'}

View File

@ -23,6 +23,19 @@
<span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span> <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Tenant</th>
<td>
{% if object.tenant %}
{% if object.tenant.group %}
<a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
{% endif %}
<a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<th scope="row">Label</th> <th scope="row">Label</th>
<td>{{ object.label|placeholder }}</td> <td>{{ object.label|placeholder }}</td>

View File

@ -2,6 +2,8 @@
{% render_field form.status %} {% render_field form.status %}
{% render_field form.type %} {% render_field form.type %}
{% render_field form.tenant_group %}
{% render_field form.tenant %}
{% render_field form.label %} {% render_field form.label %}
{% render_field form.color %} {% render_field form.color %}
<div class="row mb-3"> <div class="row mb-3">
@ -17,7 +19,7 @@
{% render_field form.tags %} {% render_field form.tags %}
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="field-group"> <div class="field-group">
<div class="row mb-2"> <div class="row mb-3">
<h5 class="offset-sm-3">Custom Fields</h5> <h5 class="offset-sm-3">Custom Fields</h5>
</div> </div>
{% render_custom_fields form %} {% render_custom_fields form %}