Skip to content

Commit f08c35c

Browse files
committed
Fix missing contract tabs
1 parent 75dc83e commit f08c35c

6 files changed

Lines changed: 226 additions & 60 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .contract import ContractStatusChoices
2+
3+
__all__ = (
4+
'ContractStatusChoices',
5+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from django.utils.translation import gettext as _
2+
from utilities.choices import ChoiceSet
3+
4+
from netbox_lifecycle import constants
5+
6+
7+
class ContractStatusChoices(ChoiceSet):
8+
"""
9+
Support contract status choices.
10+
"""
11+
12+
ACTIVE = constants.CONTRACT_STATUS_ACTIVE
13+
EXPIRED = constants.CONTRACT_STATUS_EXPIRED
14+
FUTURE = constants.CONTRACT_STATUS_FUTURE
15+
UNSPECIFIED = constants.CONTRACT_STATUS_UNSPECIFIED
16+
17+
CHOICES = (
18+
(ACTIVE, _('Active')),
19+
(FUTURE, _('Future')),
20+
(UNSPECIFIED, _('Unspecified')),
21+
(EXPIRED, _('Expired')),
22+
)

netbox_lifecycle/filtersets/contract.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from utilities.filtersets import register_filterset
99
from virtualization.models import VirtualMachine
1010

11+
from netbox_lifecycle import constants
12+
from netbox_lifecycle.choices import ContractStatusChoices
1113
from netbox_lifecycle.models import (
1214
License,
1315
SupportContract,
@@ -187,9 +189,10 @@ class SupportContractAssignmentFilterSet(NetBoxModelFilterSet):
187189
to_field_name='status',
188190
label=_('Device Status'),
189191
)
190-
expired = django_filters.BooleanFilter(
191-
method='filter_expired',
192-
label=_('Expired'),
192+
status = django_filters.ChoiceFilter(
193+
choices=ContractStatusChoices.CHOICES,
194+
method='filter_status',
195+
label=_('Status'),
193196
)
194197

195198
class Meta:
@@ -205,6 +208,7 @@ class Meta:
205208
'virtual_machine_id',
206209
'license_id',
207210
'device_status',
211+
'status',
208212
)
209213

210214
def search(self, queryset, name, value):
@@ -224,9 +228,16 @@ def search(self, queryset, name, value):
224228
)
225229
return queryset.filter(qs_filter).distinct()
226230

227-
def filter_expired(self, queryset, name, value):
231+
def filter_status(self, queryset, name, value):
228232
today = timezone.now().date()
229233
expired = Q(end__lt=today) | Q(end__isnull=True, contract__end__lt=today)
230-
if value:
234+
future = Q(contract__start__gt=today)
235+
if value == constants.CONTRACT_STATUS_ACTIVE:
236+
return queryset.exclude(expired).exclude(future)
237+
elif value == constants.CONTRACT_STATUS_FUTURE:
238+
return queryset.filter(future)
239+
elif value == constants.CONTRACT_STATUS_EXPIRED:
231240
return queryset.filter(expired)
232-
return queryset.exclude(expired)
241+
elif value == constants.CONTRACT_STATUS_UNSPECIFIED:
242+
return queryset.filter(end__isnull=True, contract__end__isnull=True)
243+
return queryset

netbox_lifecycle/template_content.py

Lines changed: 89 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import traceback
2+
13
from django.conf import settings
24
from django.contrib.contenttypes.models import ContentType
35
from django.utils.translation import gettext_lazy as _
46
from netbox.plugins import PluginTemplateExtension
57
from netbox.ui import panels, actions
68

7-
from .models import hardware
8-
from .ui import HardwareLifecyclePanel, HardwareLifecycleDatesPanel
9+
from netbox_lifecycle import constants
10+
from netbox_lifecycle.models import hardware
11+
from netbox_lifecycle.ui import HardwareLifecyclePanel, HardwareLifecycleDatesPanel
12+
from netbox_lifecycle.ui.panels.tabbed import TabbedTablePanel
913

1014
PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get('netbox_lifecycle', {})
1115

@@ -58,8 +62,7 @@ def right_page(self):
5862
if hasattr(self, '_render_lifecycle_info'):
5963
result += self._render_lifecycle_info('right_page')
6064
if hasattr(self, '_render_contract_card'):
61-
result += self._render_contract_card('right_page', expired=False)
62-
result += self._render_contract_card('right_page', expired=True)
65+
result += self._render_contract_card('right_page')
6366
if hasattr(self, '_render_license_card'):
6467
result += self._render_license_card('right_page')
6568
return result
@@ -69,8 +72,7 @@ def left_page(self):
6972
if hasattr(self, '_render_lifecycle_info'):
7073
result += self._render_lifecycle_info('left_page')
7174
if hasattr(self, '_render_contract_card'):
72-
result += self._render_contract_card('left_page', expired=False)
73-
result += self._render_contract_card('left_page', expired=True)
75+
result += self._render_contract_card('left_page')
7476
if hasattr(self, '_render_license_card'):
7577
result += self._render_license_card('left_page')
7678
return result
@@ -80,8 +82,7 @@ def full_width_page(self):
8082
if hasattr(self, '_render_lifecycle_info'):
8183
result += self._render_lifecycle_info('full_width_page')
8284
if hasattr(self, '_render_contract_card'):
83-
result += self._render_contract_card('full_width_page', expired=False)
84-
result += self._render_contract_card('full_width_page', expired=True)
85+
result += self._render_contract_card('full_width_page')
8586
if hasattr(self, '_render_license_card'):
8687
result += self._render_license_card('full_width_page')
8788
return result
@@ -122,56 +123,87 @@ class ContractMixin:
122123
def get_contract_card_position(self):
123124
return PLUGIN_SETTINGS.get('contract_card_position', 'right_page')
124125

125-
def _render_contract_card(self, location=None, expired=None):
126+
def _render_contract_card(self, location=None):
126127
if self.get_contract_card_position() != location:
127128
return ''
128129

129130
title = _('Contracts')
130-
filter = {}
131-
action = []
132-
if expired is True or expired is False:
133-
filter = {'expired': expired}
134-
title = _('Expired Contracts') if expired else _('Active Contracts')
135-
136-
if not expired:
137-
action = [
138-
actions.AddObject(
139-
'netbox_lifecycle.SupportContractAssignment',
140-
url_params={
141-
self.model_name: lambda ctx: ctx['object'].pk,
142-
},
143-
),
144-
]
131+
action = [
132+
actions.AddObject(
133+
'netbox_lifecycle.SupportContractAssignment',
134+
url_params={
135+
self.model_name: lambda ctx: ctx['object'].pk,
136+
},
137+
),
138+
]
145139

140+
include_columns = [
141+
'contract',
142+
'sku',
143+
]
144+
exclude_columns = [
145+
'device_name',
146+
'module_name',
147+
'virtual_machine_name',
148+
'license_name',
149+
'device_model',
150+
'device_serial',
151+
'module_serial',
152+
'device_status',
153+
'virtual_machine_status',
154+
'quantity',
155+
'renewal',
156+
'end',
157+
'description',
158+
'comments',
159+
'actions',
160+
]
161+
filter = {
162+
self.field_name: lambda ctx: ctx['object'].pk,
163+
}
146164
context = self.get_context(self.context)
147-
panel = panels.ObjectsTablePanel(
148-
title=title,
149-
model='netbox_lifecycle.supportcontractassignment',
150-
filters={self.field_name: lambda ctx: ctx['object'].pk, **filter},
151-
include_columns=[
152-
'contract',
153-
'sku',
154-
],
155-
exclude_columns=[
156-
'device_name',
157-
'module_name',
158-
'virtual_machine_name',
159-
'license_name',
160-
'device_model',
161-
'device_serial',
162-
'module_serial',
163-
'device_status',
164-
'virtual_machine_status',
165-
'quantity',
166-
'renewal',
167-
'end',
168-
'description',
169-
'comments',
170-
'actions',
171-
],
172-
actions=action,
173-
)
174-
return panel.render(context=context)
165+
try:
166+
panel = TabbedTablePanel(
167+
title=title,
168+
tabs={
169+
'active': panels.ObjectsTablePanel(
170+
title=_('Active'),
171+
model='netbox_lifecycle.supportcontractassignment',
172+
filters={**filter, 'status': constants.CONTRACT_STATUS_ACTIVE},
173+
include_columns=include_columns,
174+
exclude_columns=exclude_columns,
175+
),
176+
'expired': panels.ObjectsTablePanel(
177+
title=_('Expired'),
178+
model='netbox_lifecycle.supportcontractassignment',
179+
filters={**filter, 'status': constants.CONTRACT_STATUS_EXPIRED},
180+
include_columns=include_columns,
181+
exclude_columns=exclude_columns,
182+
),
183+
'future': panels.ObjectsTablePanel(
184+
title=_('Future'),
185+
model='netbox_lifecycle.supportcontractassignment',
186+
filters={**filter, 'status': constants.CONTRACT_STATUS_FUTURE},
187+
include_columns=include_columns,
188+
exclude_columns=exclude_columns,
189+
),
190+
'unspecified': panels.ObjectsTablePanel(
191+
title=_('Unspecified'),
192+
model='netbox_lifecycle.supportcontractassignment',
193+
filters={
194+
**filter,
195+
'status': constants.CONTRACT_STATUS_UNSPECIFIED,
196+
},
197+
include_columns=include_columns,
198+
exclude_columns=exclude_columns,
199+
),
200+
},
201+
actions=action,
202+
)
203+
return panel.render(context=context)
204+
except Exception as e:
205+
traceback.print_exception(type(e), e, e.__traceback__)
206+
# traceback.print_exception(e)
175207

176208

177209
class LicenseMixin:
@@ -198,7 +230,8 @@ def _render_license_card(self, location=None, exclude=None, include=None):
198230
model='netbox_lifecycle.licenseassignment',
199231
filters={self.field_name: lambda ctx: ctx['object'].pk},
200232
include_columns=[
201-
'vendor', 'license',
233+
'vendor',
234+
'license',
202235
'quantity',
203236
],
204237
exclude_columns=[
@@ -235,7 +268,9 @@ class ModuleTypeLifecycleContent(LifecycleMixin, BaseMixin, PluginTemplateExtens
235268
models = ['dcim.moduletype']
236269

237270

238-
class VirtualMachineContractContent(ContractMixin, LicenseMixin, BaseMixin, PluginTemplateExtension):
271+
class VirtualMachineContractContent(
272+
ContractMixin, LicenseMixin, BaseMixin, PluginTemplateExtension
273+
):
239274
"""Template extension for VirtualMachine detail pages showing contracts and licenses."""
240275

241276
models = ['virtualization.virtualmachine']
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{% extends "ui/panels/_base.html" %}
2+
{% load i18n %}
3+
4+
{% block panel_content %}
5+
<ul class="nav nav-tabs" role="tablist">
6+
{% for tab in tabs %}
7+
<li class="nav-item" role="presentation">
8+
{% if tab.active %}
9+
<a href="#tab-{{ tab.name }}" class="nav-link active" data-bs-toggle="tab" role="tab"
10+
hx-get="{% url tab.viewname %}?embedded=True{% if tab.url_params %}&{{ tab.url_params.urlencode }}{% endif %}"
11+
hx-trigger="load" hx-target="#tab-{{ tab.name }}" hx-select=".htmx-container"
12+
hx-swap="innerHTML">
13+
{% trans tab.title %}
14+
</a>
15+
{% else %}
16+
<a href="#tab-{{ tab.name }}" class="nav-link" data-bs-toggle="tab" role="tab"
17+
hx-get="{% url tab.viewname %}?embedded=True{% if tab.url_params %}&{{ tab.url_params.urlencode }}{% endif %}"
18+
hx-trigger="click once" hx-target="#tab-{{ tab.name }}" hx-select=".htmx-container"
19+
hx-swap="innerHTML">
20+
{% trans tab.title %}
21+
</a>
22+
{% endif %}
23+
</li>
24+
{% endfor %}
25+
</ul>
26+
<div class="tab-content">
27+
{% for tab in tabs %}
28+
{% if tab.active %}
29+
<div class="tab-pane fade show active" id="tab-{{ tab.name }}" role="tabpanel">
30+
<div class="htmx-container table-responsive"></div>
31+
</div>
32+
{% else %}
33+
<div class="tab-pane" id="tab-{{ tab.name }}" role="tabpanel">
34+
<div class="htmx-container table-responsive"></div>
35+
</div>
36+
{% endif %}
37+
{% endfor %}
38+
</div>
39+
{% endblock panel_content %}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from netbox.ui.panels import Panel, ObjectsTablePanel
2+
from utilities.querydict import dict_to_querydict
3+
from utilities.string import title
4+
from utilities.views import get_viewname
5+
6+
7+
class TabbedTablePanel(Panel):
8+
"""
9+
A panel which displays panels within tabs.
10+
"""
11+
12+
template_name = 'netbox_lifecycle/ui/panels/tabbed_table.html'
13+
14+
def __init__(self, tabs: dict, **kwargs):
15+
super().__init__(**kwargs)
16+
for tab in tabs.values():
17+
if tab is ObjectsTablePanel:
18+
raise TypeError(
19+
f"TabbedTablePanel only accepts ObjectsTablePanel instances, got {type(tab)}"
20+
)
21+
self.tabs = tabs
22+
23+
def get_context(self, context):
24+
tabs = []
25+
first = True
26+
for name, panel in self.tabs.items():
27+
# If no title is specified, derive one from the model name
28+
url_params = {
29+
k: v(context) if callable(v) else v for k, v in panel.filters.items()
30+
}
31+
32+
if 'return_url' not in url_params and 'object' in context:
33+
url_params['return_url'] = context['object'].get_absolute_url()
34+
if panel.include_columns:
35+
url_params['include_columns'] = ','.join(panel.include_columns)
36+
if panel.exclude_columns:
37+
url_params['exclude_columns'] = ','.join(panel.exclude_columns)
38+
39+
tab = {
40+
'name': name,
41+
'model': panel.model,
42+
'viewname': get_viewname(panel.model, 'list'),
43+
'title': panel.title or title(panel.model._meta.verbose_name_plural),
44+
'active': True if first else False,
45+
'url_params': dict_to_querydict(url_params),
46+
}
47+
first = False
48+
tabs.append(tab)
49+
50+
return {
51+
**super().get_context(context),
52+
'title': self.title,
53+
'tabs': tabs,
54+
}

0 commit comments

Comments
 (0)