Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,30 @@ Example:

## Configuration

None
The plugin can be configured via `PLUGINS_CONFIG` in your NetBox configuration file:

```python
PLUGINS_CONFIG = {
'netbox_lifecycle': {
'contract_card_position': 'right_page',
},
}
```

### Available Settings

| Setting | Default | Description |
|---------|---------|-------------|
| `contract_card_position` | `right_page` | Position of the Support Contracts card on device detail pages. Options: `left_page`, `right_page`, `full_width_page`. |

### Contract Card Position

When enabled, a Support Contracts card will be displayed on device detail pages showing all contract assignments grouped by status:

- **Active**: Contracts currently in effect
- **Future**: Contracts with a start date in the future
- **Unspecified**: Contracts without an end date
- **Expired**: Contracts that have ended (lazy-loaded for performance)

## Usage

Expand Down
4 changes: 3 additions & 1 deletion netbox_lifecycle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ class NetBoxLifeCycle(PluginConfig):
base_url = 'lifecycle'
min_version = '4.3.0'
required_settings = []
default_settings = {}
default_settings = {
'contract_card_position': 'right_page',
}
queues = []
graphql_schema = 'graphql.schema.schema'

Expand Down
16 changes: 15 additions & 1 deletion netbox_lifecycle/constants/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
from netbox_lifecycle.constants.contract import (
CONTRACT_STATUS_ACTIVE,
CONTRACT_STATUS_EXPIRED,
CONTRACT_STATUS_FUTURE,
CONTRACT_STATUS_UNSPECIFIED,
CONTRACT_STATUS_COLOR,
)
from netbox_lifecycle.constants.hardware import HARDWARE_LIFECYCLE_MODELS

__all__ = ('HARDWARE_LIFECYCLE_MODELS',)
__all__ = (
'CONTRACT_STATUS_ACTIVE',
'CONTRACT_STATUS_EXPIRED',
'CONTRACT_STATUS_FUTURE',
'CONTRACT_STATUS_UNSPECIFIED',
'CONTRACT_STATUS_COLOR',
'HARDWARE_LIFECYCLE_MODELS',
)
14 changes: 14 additions & 0 deletions netbox_lifecycle/constants/contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from django.utils.translation import gettext_lazy as _

CONTRACT_STATUS_ACTIVE = 'active'
CONTRACT_STATUS_FUTURE = 'future'
CONTRACT_STATUS_UNSPECIFIED = 'unspecified'
CONTRACT_STATUS_EXPIRED = 'expired'

# (label, badge_color)
CONTRACT_STATUS_COLOR = {
CONTRACT_STATUS_ACTIVE: (_('Active'), 'success'),
CONTRACT_STATUS_FUTURE: (_('Future'), 'info'),
CONTRACT_STATUS_UNSPECIFIED: (_('Unspecified'), 'secondary'),
CONTRACT_STATUS_EXPIRED: (_('Expired'), 'danger'),
}
25 changes: 25 additions & 0 deletions netbox_lifecycle/models/contract.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from datetime import date

from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.functions import Lower
Expand All @@ -7,6 +9,13 @@
from dcim.choices import DeviceStatusChoices
from netbox.models import PrimaryModel

from netbox_lifecycle.constants import (
CONTRACT_STATUS_ACTIVE,
CONTRACT_STATUS_EXPIRED,
CONTRACT_STATUS_FUTURE,
CONTRACT_STATUS_UNSPECIFIED,
)


__all__ = (
'Vendor',
Expand Down Expand Up @@ -183,6 +192,22 @@ def end_date(self):
return self.end
return self.contract.end

@property
def status(self):
today = date.today()

# Check if contract starts in the future
if self.contract.start and self.contract.start > today:
return CONTRACT_STATUS_FUTURE

# Use assignment's end_date property (falls back to contract.end)
end = self.end_date
if end is None:
return CONTRACT_STATUS_UNSPECIFIED
if end < today:
return CONTRACT_STATUS_EXPIRED
return CONTRACT_STATUS_ACTIVE

def get_device_status_color(self):
if self.device is None:
return
Expand Down
179 changes: 84 additions & 95 deletions netbox_lifecycle/template_content.py
Original file line number Diff line number Diff line change
@@ -1,123 +1,112 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse

from netbox.plugins import PluginTemplateExtension

from .models import hardware, contract
from .models import hardware

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

def log_info(message: str):
from logging import getLogger

logger = getLogger('netbox_lifecycle.template_content')
logger.info(f'{message}')
class DeviceLifecycleContent(PluginTemplateExtension):
models = ['dcim.device']

def get_contract_card_position(self):
return PLUGIN_SETTINGS.get('contract_card_position', '')

class DeviceHardwareInfoExtension(PluginTemplateExtension):
def right_page(self):
def _render_lifecycle_info(self):
object = self.context.get('object')
support_contract = contract.SupportContractAssignment.objects.filter(
device_id=object.id
content_type = ContentType.objects.get(app_label='dcim', model='devicetype')
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.device_type_id,
assigned_object_type_id=content_type.id,
).first()
log_info(f'Kind is: {self.kind}')
match self.kind:
case "device":
content_type = ContentType.objects.get(
app_label="dcim", model="devicetype"
)
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.device_type_id,
assigned_object_type_id=content_type.id,
).first()
case "module":
content_type = ContentType.objects.get(
app_label="dcim", model="moduletype"
)
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.module_type_id,
assigned_object_type_id=content_type.id,
).first()
case "devicetype" | "moduletype":
content_type = ContentType.objects.get(
app_label="dcim", model=self.kind
)
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.id,
assigned_object_type_id=content_type.id,
).first()
context = {
'support_contract': support_contract,
'lifecycle_info': lifecycle_info,
}
return self.render(
'netbox_lifecycle/inc/support_contract_info.html', extra_context=context
'netbox_lifecycle/inc/hardware_lifecycle_info.html',
extra_context={'lifecycle_info': lifecycle_info},
)


class TypeInfoExtension(PluginTemplateExtension):
def right_page(self):
def _render_contract_card(self):
object = self.context.get('object')
match self.kind:
case "device":
content_type = ContentType.objects.get(
app_label="dcim", model="devicetype"
)
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.device_type_id,
assigned_object_type_id=content_type.id,
).first()
case "module":
content_type = ContentType.objects.get(
app_label="dcim", model="moduletype"
)
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.module_type_id,
assigned_object_type_id=content_type.id,
).first()
case "devicetype" | "moduletype":
content_type = ContentType.objects.get(
app_label="dcim", model=self.kind
)
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.id,
assigned_object_type_id=content_type.id,
).first()

context = {'lifecycle_info': lifecycle_info}
return self.render(
'netbox_lifecycle/inc/hardware_lifecycle_info.html', extra_context=context
'netbox_lifecycle/inc/contract_card_placeholder.html',
extra_context={
'htmx_url': reverse(
'plugins:netbox_lifecycle:device_contracts_htmx',
kwargs={'pk': object.pk},
),
},
)

def right_page(self):
result = self._render_lifecycle_info()
if self.get_contract_card_position() == 'right_page':
result += self._render_contract_card()
return result

def left_page(self):
if self.get_contract_card_position() == 'left_page':
return self._render_contract_card()
return ''

class DeviceHardwareLifecycleInfo(DeviceHardwareInfoExtension):
models = [
'dcim.device',
]
kind = 'device'
def full_width_page(self):
if self.get_contract_card_position() == 'full_width_page':
return self._render_contract_card()
return ''


class ModuleLifecycleContent(PluginTemplateExtension):
models = ['dcim.module']

def right_page(self):
object = self.context.get('object')
content_type = ContentType.objects.get(app_label='dcim', model='moduletype')
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.module_type_id,
assigned_object_type_id=content_type.id,
).first()
return self.render(
'netbox_lifecycle/inc/hardware_lifecycle_info.html',
extra_context={'lifecycle_info': lifecycle_info},
)


class ModuleHardwareLifecycleInfo(TypeInfoExtension):
models = [
'dcim.module',
]
kind = 'module'
class DeviceTypeLifecycleContent(PluginTemplateExtension):
models = ['dcim.devicetype']

def right_page(self):
object = self.context.get('object')
content_type = ContentType.objects.get(app_label='dcim', model='devicetype')
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.id,
assigned_object_type_id=content_type.id,
).first()
return self.render(
'netbox_lifecycle/inc/hardware_lifecycle_info.html',
extra_context={'lifecycle_info': lifecycle_info},
)

class DeviceTypeHardwareLifecycleInfo(TypeInfoExtension):
models = [
'dcim.devicetype',
]
kind = 'devicetype'

class ModuleTypeLifecycleContent(PluginTemplateExtension):
models = ['dcim.moduletype']

class ModuleTypeHardwareLifecycleInfo(TypeInfoExtension):
models = [
'dcim.moduletype',
]
kind = 'moduletype'
def right_page(self):
object = self.context.get('object')
content_type = ContentType.objects.get(app_label='dcim', model='moduletype')
lifecycle_info = hardware.HardwareLifecycle.objects.filter(
assigned_object_id=object.id,
assigned_object_type_id=content_type.id,
).first()
return self.render(
'netbox_lifecycle/inc/hardware_lifecycle_info.html',
extra_context={'lifecycle_info': lifecycle_info},
)


template_extensions = (
DeviceHardwareLifecycleInfo,
ModuleHardwareLifecycleInfo,
DeviceTypeHardwareLifecycleInfo,
ModuleTypeHardwareLifecycleInfo,
DeviceLifecycleContent,
ModuleLifecycleContent,
DeviceTypeLifecycleContent,
ModuleTypeLifecycleContent,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% load i18n %}
{% load filters %}

<table class="table table-sm table-hover mb-0 mt-1">
<thead class="table-light">
<tr>
<th>{% trans "Contract" %}</th>
<th>{% trans "SKU" %}</th>
<th>{% trans "Vendor" %}</th>
<th class="text-end">{% trans "Ended" %}</th>
</tr>
</thead>
<tbody>
{% for assignment in assignments %}
<tr>
<td><a href="{{ assignment.contract.get_absolute_url }}">{{ assignment.contract.contract_id }}</a></td>
<td>{% if assignment.sku %}<a href="{{ assignment.sku.get_absolute_url }}">{{ assignment.sku.sku }}</a>{% else %}-{% endif %}</td>
<td class="text-muted">{{ assignment.contract.vendor|default:"-" }}</td>
<td class="text-end">{{ assignment.end_date|date:"Y-m-d"|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
Loading