Skip to content
Draft
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
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
.django-stub-gen/

# Flask stuff:
instance/
Expand Down Expand Up @@ -158,4 +159,10 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea
.idea/

# VS Code
.vscode/

# AI stuff
.claude/
1 change: 1 addition & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,52 @@ The plugin can be configured via `PLUGINS_CONFIG` in your NetBox configuration f
```python
PLUGINS_CONFIG = {
'netbox_lifecycle': {
# UI card positions
'lifecycle_card_position': 'right_page',
'contract_card_position': 'right_page',
'license_card_position': 'right_page',
},
}
```

### Available Settings

#### UI Card Positions

| Setting | Default | Description |
|---------|---------|-------------|
| `lifecycle_card_position` | `right_page` | Position of the Hardware Lifecycle Info card on Device, Module, DeviceType, and ModuleType detail pages. Options: `left_page`, `right_page`, `full_width_page`. |
| `lifecycle_card_position` | `right_page` | Position of the Hardware Lifecycle Info card on DeviceType and ModuleType detail pages. Options: `left_page`, `right_page`, `full_width_page`. |
| `contract_card_position` | `right_page` | Position of the Support Contracts card on Device and VirtualMachine detail pages. Options: `left_page`, `right_page`, `full_width_page`. |
| `license_card_position` | `right_page` | Position of the Licenses card on Device and VirtualMachine detail pages. Options: `left_page`, `right_page`, `full_width_page`. |

### EoX Integration

The plugin includes a vendor-agnostic EoX sync system. Each row in **EoX Settings** (`/lifecycle/eox/`) configures one driver/manufacturer pair — OAuth credentials, sync interval, and the manufacturer whose DeviceTypes/ModuleTypes are queried. The vendor API URL is owned by the driver, not by the row, so an operator cannot point the wrong driver at the wrong API. Multiple rows per driver are permitted (one per manufacturer); each `(driver, manufacturer)` pair must be unique. The OAuth client secret is stored Fernet-encrypted at rest using a key derived from Django's `SECRET_KEY`.

Drivers are pluggable. The currently shipped driver is **Cisco EoX**; add a vendor by implementing `BaseEoXDriver` under `netbox_lifecycle/utilities/eox/drivers/` (setting the class-level `api_url`) and registering it in `DRIVERS`.

**Two background jobs:**

- `EoXSyncJob` — automatically scheduled per row via `enqueue_once(interval=row.sync_interval)` whenever the row is saved with `enabled=True`. Re-enqueues itself at the end of each run so interval changes are honored.
- `EoXManualSyncJob` — one-shot, triggered by the **Run Now** action on the row's detail page.

**Lookup order per DeviceType / ModuleType (Cisco driver):**

1. Finds a serial number from a related Device or Module and queries `EOXBySerialNumber`.
2. Falls back to the `part_number` field and queries `EOXByProductID` if no serial is available or the serial lookup returns no data.

**Fields populated by the Cisco driver:**

| Cisco API field | HardwareLifecycle field |
|---|---|
| `EndOfSaleDate` | `end_of_sale` |
| `EndOfSWMaintenanceReleases` | `end_of_maintenance` |
| `EndOfSecurityVulSupportDate` | `end_of_security` |
| `LastDateOfSupport` | `end_of_support` |
| `EndOfServiceContractRenewal` | `last_contract_renewal` |
| `LinkToProductBulletinURL` | `documentation` |

Cisco EoX API access requires an active SNTC or PSS agreement and an OAuth client registered at [apiconsole.cisco.com](https://apiconsole.cisco.com/). Job history is visible on the row's detail page and in NetBox's built-in **System → Jobs** view.

### Hardware Lifecycle Info Card

Expand Down
2 changes: 2 additions & 0 deletions netbox_lifecycle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class NetBoxLifeCycle(PluginConfig):
def ready(self):

super().ready()
# Import EoX job classes so JobRunner registration takes effect at startup.
from netbox_lifecycle.jobs import EoXManualSyncJob, EoXSyncJob # noqa: F401

from dcim.models import DeviceType, ModuleType
from django.contrib.contenttypes.fields import GenericRelation
Expand Down
39 changes: 39 additions & 0 deletions netbox_lifecycle/api/_serializers/eox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from dcim.api.serializers_.manufacturers import ManufacturerSerializer
from netbox.api.serializers import NetBoxModelSerializer
from rest_framework import serializers

from netbox_lifecycle.models import EoXAPISettings

__all__ = ('EoXAPISettingsSerializer',)


class EoXAPISettingsSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='plugins-api:netbox_lifecycle-api:eoxapisettings-detail'
)
manufacturer = ManufacturerSerializer(nested=True)

class Meta:
model = EoXAPISettings
fields = (
'url',
'id',
'display',
'driver',
'manufacturer',
'enabled',
'client_id',
'sync_interval',
'last_synced',
'description',
'comments',
'tags',
'custom_fields',
)
brief_fields = (
'url',
'id',
'display',
'driver',
'manufacturer',
)
2 changes: 2 additions & 0 deletions netbox_lifecycle/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from netbox_lifecycle.api._serializers.contract import *
from netbox_lifecycle.api._serializers.eox import *
from netbox_lifecycle.api._serializers.hardware import *
from netbox_lifecycle.api._serializers.license import *
from netbox_lifecycle.api._serializers.vendor import *

__all__ = (
'EoXAPISettingsSerializer',
'HardwareLifecycleSerializer',
'LicenseAssignmentSerializer',
'LicenseSerializer',
Expand Down
1 change: 1 addition & 0 deletions netbox_lifecycle/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .views import *

router = NetBoxRouter()
router.register('eoxapisettings', EoXAPISettingsViewSet)
router.register('hardwarelifecycle', HardwareLifecycleViewSet)
router.register('license', LicenseViewSet)
router.register('licenseassignment', LicenseAssignmentViewSet)
Expand Down
1 change: 1 addition & 0 deletions netbox_lifecycle/api/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .contract import *
from .eox import *
from .hardware import *
from .license import *
13 changes: 13 additions & 0 deletions netbox_lifecycle/api/views/eox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from netbox.api.viewsets import NetBoxModelViewSet

from netbox_lifecycle.api.serializers import EoXAPISettingsSerializer
from netbox_lifecycle.filtersets import EoXAPISettingsFilterSet
from netbox_lifecycle.models import EoXAPISettings

__all__ = ('EoXAPISettingsViewSet',)


class EoXAPISettingsViewSet(NetBoxModelViewSet):
queryset = EoXAPISettings.objects.all()
serializer_class = EoXAPISettingsSerializer
filterset_class = EoXAPISettingsFilterSet
3 changes: 2 additions & 1 deletion netbox_lifecycle/choices/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .contract import ContractStatusChoices
from .eox import DriverChoices

__all__ = ('ContractStatusChoices',)
__all__ = ('ContractStatusChoices', 'DriverChoices')
11 changes: 11 additions & 0 deletions netbox_lifecycle/choices/eox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet

from netbox_lifecycle import constants


class DriverChoices(ChoiceSet):
CISCO = constants.DRIVER_CISCO
CHOICES = [
(CISCO, _('Cisco EoX')),
]
2 changes: 2 additions & 0 deletions netbox_lifecycle/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
CONTRACT_STATUS_UNSPECIFIED,
)
from netbox_lifecycle.constants.hardware import HARDWARE_LIFECYCLE_MODELS
from netbox_lifecycle.constants.eox import DRIVER_CISCO

__all__ = (
'CONTRACT_STATUS_ACTIVE',
Expand All @@ -14,4 +15,5 @@
'CONTRACT_STATUS_FUTURE',
'CONTRACT_STATUS_UNSPECIFIED',
'HARDWARE_LIFECYCLE_MODELS',
'DRIVER_CISCO',
)
1 change: 1 addition & 0 deletions netbox_lifecycle/constants/eox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DRIVER_CISCO = 'cisco'
1 change: 1 addition & 0 deletions netbox_lifecycle/filtersets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .contract import *
from .eox import *
from .hardware import *
from .license import *
52 changes: 52 additions & 0 deletions netbox_lifecycle/filtersets/eox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import django_filters
from core.choices import JobIntervalChoices
from dcim.models import Manufacturer
from django.db.models import Q
from django.utils.translation import gettext as _
from netbox.filtersets import PrimaryModelFilterSet
from utilities.filtersets import register_filterset

from netbox_lifecycle.choices.eox import DriverChoices
from netbox_lifecycle.models import EoXAPISettings

__all__ = ('EoXAPISettingsFilterSet',)


@register_filterset
class EoXAPISettingsFilterSet(PrimaryModelFilterSet):
driver = django_filters.MultipleChoiceFilter(
choices=DriverChoices,
label=_('Driver'),
)
sync_interval = django_filters.MultipleChoiceFilter(
choices=JobIntervalChoices,
label=_('Sync interval'),
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer',
queryset=Manufacturer.objects.all(),
label=_('Manufacturer'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__name',
queryset=Manufacturer.objects.all(),
to_field_name='name',
label=_('Manufacturer (name)'),
)

class Meta:
model = EoXAPISettings
fields = (
'id',
'q',
'driver',
'enabled',
'manufacturer_id',
'sync_interval',
)

def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(client_id__icontains=value) | Q(description__icontains=value)
return queryset.filter(qs_filter).distinct()
1 change: 1 addition & 0 deletions netbox_lifecycle/forms/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .bulk_edit import *
from .bulk_import import *
from .eox import *
from .filtersets import *
from .model_forms import *
Loading