diff --git a/README.md b/README.md
index d93255c..b44ebb6 100644
--- a/README.md
+++ b/README.md
@@ -38,18 +38,68 @@ 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',
+
+ # Cisco EoX API sync (optional — can also be configured via the Web UI)
+ 'cisco_eox_enabled': False,
+ 'cisco_eox_client_id': '',
+ 'cisco_eox_client_secret': '',
+ 'cisco_eox_sync_interval': 10080, # minutes (default: weekly)
+ 'cisco_eox_manufacturer_names': 'Cisco',
},
}
```
+> **Note:** Cisco EoX credentials configured here are read as plain text from your configuration file.
+> For production deployments it is recommended to configure them instead through the Web UI
+> (`/lifecycle/cisco-eox/settings/`), where the client secret is stored Fernet-encrypted at rest.
+
### 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`. |
+
+#### Cisco EoX API (`PLUGINS_CONFIG` fallback)
+
+These settings are only used when no database configuration record exists. The Web UI (`/lifecycle/cisco-eox/settings/`) takes precedence when configured.
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `cisco_eox_enabled` | `False` | Enable the Cisco EoX background sync job. |
+| `cisco_eox_client_id` | `''` | OAuth Client ID from [Cisco API Console](https://apiconsole.cisco.com/). Requires an active SNTC or PSS agreement. |
+| `cisco_eox_client_secret` | `''` | OAuth Client Secret. Stored as plain text here; use the Web UI for encrypted storage. |
+| `cisco_eox_sync_interval` | `10080` | Sync frequency in minutes. Common values: `60` (hourly), `1440` (daily), `10080` (weekly), `20160` (biweekly), `43200` (monthly). |
+| `cisco_eox_manufacturer_names` | `'Cisco'` | Comma-separated list of manufacturer names whose DeviceTypes and ModuleTypes are queried (e.g. `'Cisco,Cisco Systems'`). |
+
+### Cisco EoX Integration
+
+The plugin includes a background job (`CiscoEoXSyncJob`) that automatically populates `HardwareLifecycle` records with end-of-life dates from the [Cisco EoX API](https://developer.cisco.com/docs/support-apis/eox/).
+
+**Lookup order per DeviceType / ModuleType:**
+
+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:**
+
+| 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` |
+
+**Web UI configuration** is available at `/lifecycle/cisco-eox/settings/`. After entering credentials and enabling the sync, the job is automatically scheduled at the configured interval. An on-demand **Run Now** button is also provided. Job history is displayed on the settings page and in NetBox's built-in **System → Jobs** view.
### Hardware Lifecycle Info Card
diff --git a/netbox_lifecycle/__init__.py b/netbox_lifecycle/__init__.py
index d0360c1..4b72ce7 100644
--- a/netbox_lifecycle/__init__.py
+++ b/netbox_lifecycle/__init__.py
@@ -19,6 +19,12 @@ class NetBoxLifeCycle(PluginConfig):
'lifecycle_card_position': 'right_page',
'contract_card_position': 'right_page',
'license_card_position': 'right_page',
+ # Cisco EoX API — fallback to PLUGINS_CONFIG when no DB settings exist
+ 'cisco_eox_enabled': False,
+ 'cisco_eox_client_id': '',
+ 'cisco_eox_client_secret': '',
+ 'cisco_eox_sync_interval': 10080, # weekly, in minutes
+ 'cisco_eox_manufacturer_names': 'Cisco',
}
queues = []
graphql_schema = 'graphql.schema.schema'
@@ -27,6 +33,8 @@ def ready(self):
super().ready()
+ from netbox_lifecycle.jobs import CiscoEoXSyncJob # noqa: F401 — registers job
+
from dcim.models import DeviceType, ModuleType
from django.contrib.contenttypes.fields import GenericRelation
diff --git a/netbox_lifecycle/forms/cisco_eox.py b/netbox_lifecycle/forms/cisco_eox.py
new file mode 100644
index 0000000..65b5520
--- /dev/null
+++ b/netbox_lifecycle/forms/cisco_eox.py
@@ -0,0 +1,68 @@
+from django import forms
+
+from netbox_lifecycle.models.cisco_eox import SYNC_INTERVAL_CHOICES, CiscoEoXSettings
+
+__all__ = ('CiscoEoXSettingsForm',)
+
+
+class CiscoEoXSettingsForm(forms.ModelForm):
+ """
+ Form for editing the singleton CiscoEoXSettings record.
+
+ The client_secret field is rendered with a password widget so the value
+ is never echoed back to the browser. Leaving the field blank on edit
+ preserves the existing encrypted value in the database.
+ """
+
+ client_secret = forms.CharField(
+ label='OAuth Client Secret',
+ required=False,
+ widget=forms.PasswordInput(render_value=False),
+ help_text=(
+ 'Leave blank to keep the existing secret. '
+ 'Stored encrypted at rest.'
+ ),
+ )
+
+ sync_interval = forms.ChoiceField(
+ label='Sync Interval',
+ choices=SYNC_INTERVAL_CHOICES,
+ help_text='How often the background sync job should run.',
+ )
+
+ class Meta:
+ model = CiscoEoXSettings
+ fields = (
+ 'enabled',
+ 'client_id',
+ 'client_secret',
+ 'sync_interval',
+ 'manufacturer_names',
+ )
+ help_texts = {
+ 'client_id': (
+ 'Client ID from Cisco API Console '
+ '('
+ 'apiconsole.cisco.com).'
+ ),
+ 'manufacturer_names': (
+ 'Comma-separated list of manufacturer names to query '
+ '(e.g. Cisco,Cisco Systems).'
+ ),
+ }
+
+ def save(self, commit=True):
+ instance = super().save(commit=False)
+
+ # Only update the encrypted secret if a new value was provided
+ new_secret = self.cleaned_data.get('client_secret', '').strip()
+ if new_secret:
+ instance.client_secret = new_secret
+ # else: leave instance._client_secret unchanged (already loaded from DB)
+
+ # Coerce sync_interval to int (ChoiceField returns strings)
+ instance.sync_interval = int(self.cleaned_data['sync_interval'])
+
+ if commit:
+ instance.save()
+ return instance
diff --git a/netbox_lifecycle/jobs.py b/netbox_lifecycle/jobs.py
new file mode 100644
index 0000000..663c963
--- /dev/null
+++ b/netbox_lifecycle/jobs.py
@@ -0,0 +1,183 @@
+"""
+Background jobs for the netbox-lifecycle plugin.
+
+CiscoEoXSyncJob
+---------------
+Queries the Cisco EoX API for all DeviceTypes and ModuleTypes whose
+manufacturer name matches the configured list, then updates (or creates)
+their HardwareLifecycle records with the returned end-of-life dates.
+
+Lookup order (per item):
+ 1. Try a Device/Module serial number associated with this DeviceType/ModuleType
+ 2. Fall back to the DeviceType/ModuleType part_number field
+
+The job reschedules itself using ``enqueue_once()`` so the configured
+interval is honoured dynamically. Initial scheduling is triggered when
+the user saves the settings page (or calls ``enqueue_once()`` manually).
+"""
+
+import logging
+
+from netbox.jobs import JobRunner
+
+logger = logging.getLogger(__name__)
+
+__all__ = ('CiscoEoXSyncJob',)
+
+
+class CiscoEoXSyncJob(JobRunner):
+ class Meta:
+ name = 'Cisco EoX Sync'
+
+ def run(self, *args, **kwargs):
+ from django.contrib.contenttypes.models import ContentType
+
+ from dcim.models import Device, DeviceType, Module, ModuleType
+
+ from netbox_lifecycle.models import HardwareLifecycle
+ from netbox_lifecycle.utilities.cisco_eox_api import (
+ CiscoEoXAPIError,
+ CiscoEoXApiClient,
+ )
+ from netbox_lifecycle.utilities.settings_loader import get_cisco_eox_settings
+
+ # ------------------------------------------------------------------
+ # Load configuration
+ # ------------------------------------------------------------------
+ cfg = get_cisco_eox_settings()
+ if cfg is None:
+ self.logger.warning(
+ 'Cisco EoX sync is not configured or not enabled. Skipping.'
+ )
+ return
+
+ self.logger.info(
+ 'Starting Cisco EoX sync for manufacturers: %s',
+ cfg.manufacturer_names,
+ )
+
+ client = CiscoEoXApiClient(
+ client_id=cfg.client_id,
+ client_secret=cfg.client_secret,
+ )
+
+ updated = 0
+ skipped = 0
+ errored = 0
+
+ # ------------------------------------------------------------------
+ # Helper: resolve EoX data for one item
+ # ------------------------------------------------------------------
+ def _sync_item(item, ct, serial_qs, part_number_attr='part_number'):
+ nonlocal updated, skipped, errored
+
+ eox_data = None
+ label = f'{item._meta.verbose_name} "{item}"'
+
+ # 1. Try serial number from a related device/module
+ serial = (
+ serial_qs.filter(**{f'{item._meta.model_name}__pk': item.pk})
+ .exclude(serial='')
+ .values_list('serial', flat=True)
+ .first()
+ )
+ if serial:
+ try:
+ records = client.get_eox_by_serial([serial])
+ if records:
+ eox_data = client.parse_eox_record(records[0])
+ self.logger.debug(
+ '%s — EoX data found via serial %s (product: %s)',
+ label,
+ serial,
+ eox_data.get('_eol_product_id'),
+ )
+ except CiscoEoXAPIError as exc:
+ self.logger.debug(
+ '%s — serial lookup failed (%s), will try part_number.', label, exc
+ )
+
+ # 2. Fall back to part_number
+ if eox_data is None:
+ part_number = getattr(item, part_number_attr, '') or ''
+ if part_number:
+ try:
+ records = client.get_eox_by_product_id([part_number])
+ if records:
+ eox_data = client.parse_eox_record(records[0])
+ self.logger.debug(
+ '%s — EoX data found via part_number %s',
+ label,
+ part_number,
+ )
+ except CiscoEoXAPIError as exc:
+ self.logger.warning(
+ '%s — part_number lookup failed: %s', label, exc
+ )
+ errored += 1
+ return
+
+ if eox_data is None:
+ self.logger.debug('%s — no EoX data found; skipping.', label)
+ skipped += 1
+ return
+
+ # Strip internal-only keys before saving
+ lifecycle_fields = {
+ k: v for k, v in eox_data.items() if not k.startswith('_')
+ }
+ # Remove None values so existing dates are not blanked unintentionally
+ # when only partial data is returned
+ lifecycle_fields = {k: v for k, v in lifecycle_fields.items() if v is not None}
+
+ HardwareLifecycle.objects.update_or_create(
+ assigned_object_type=ct,
+ assigned_object_id=item.pk,
+ defaults=lifecycle_fields,
+ )
+ updated += 1
+
+ # ------------------------------------------------------------------
+ # Process DeviceTypes
+ # ------------------------------------------------------------------
+ dt_ct = ContentType.objects.get_for_model(DeviceType)
+ device_types = DeviceType.objects.filter(
+ manufacturer__name__in=cfg.manufacturer_names_list
+ ).select_related('manufacturer')
+
+ self.logger.info('Processing %d DeviceTypes …', device_types.count())
+ for dt in device_types:
+ _sync_item(dt, dt_ct, Device.objects, part_number_attr='part_number')
+
+ # ------------------------------------------------------------------
+ # Process ModuleTypes
+ # ------------------------------------------------------------------
+ mt_ct = ContentType.objects.get_for_model(ModuleType)
+ module_types = ModuleType.objects.filter(
+ manufacturer__name__in=cfg.manufacturer_names_list
+ ).select_related('manufacturer')
+
+ self.logger.info('Processing %d ModuleTypes …', module_types.count())
+ for mt in module_types:
+ _sync_item(mt, mt_ct, Module.objects, part_number_attr='part_number')
+
+ # ------------------------------------------------------------------
+ # Summary
+ # ------------------------------------------------------------------
+ self.logger.info(
+ 'Cisco EoX sync complete — updated: %d, skipped: %d, errored: %d',
+ updated,
+ skipped,
+ errored,
+ )
+
+ # ------------------------------------------------------------------
+ # Reschedule for next run
+ # ------------------------------------------------------------------
+ # Reload cfg in case it changed while this job was running
+ cfg = get_cisco_eox_settings()
+ if cfg and cfg.enabled:
+ CiscoEoXSyncJob.enqueue_once(interval=cfg.sync_interval)
+ self.logger.debug(
+ 'Rescheduled Cisco EoX sync in %d minutes.', cfg.sync_interval
+ )
diff --git a/netbox_lifecycle/migrations/0019_cisco_eox_settings.py b/netbox_lifecycle/migrations/0019_cisco_eox_settings.py
new file mode 100644
index 0000000..8e30e16
--- /dev/null
+++ b/netbox_lifecycle/migrations/0019_cisco_eox_settings.py
@@ -0,0 +1,53 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('netbox_lifecycle', '0018_netbox_v040500'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CiscoEoXSettings',
+ fields=[
+ (
+ 'id',
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name='ID',
+ ),
+ ),
+ ('enabled', models.BooleanField(default=False)),
+ ('client_id', models.CharField(blank=True, max_length=200)),
+ (
+ 'client_secret',
+ models.CharField(
+ blank=True, db_column='client_secret', max_length=500
+ ),
+ ),
+ (
+ 'sync_interval',
+ models.PositiveIntegerField(
+ choices=[
+ (60, 'Hourly'),
+ (1440, 'Daily'),
+ (10080, 'Weekly'),
+ (20160, 'Biweekly'),
+ (43200, 'Monthly'),
+ ],
+ default=10080,
+ ),
+ ),
+ (
+ 'manufacturer_names',
+ models.CharField(default='Cisco', max_length=500),
+ ),
+ ],
+ options={
+ 'verbose_name': 'Cisco EoX Settings',
+ },
+ ),
+ ]
diff --git a/netbox_lifecycle/models/__init__.py b/netbox_lifecycle/models/__init__.py
index edd31fe..93ab0aa 100644
--- a/netbox_lifecycle/models/__init__.py
+++ b/netbox_lifecycle/models/__init__.py
@@ -1,3 +1,4 @@
+from .cisco_eox import *
from .contract import *
from .hardware import *
from .license import *
diff --git a/netbox_lifecycle/models/cisco_eox.py b/netbox_lifecycle/models/cisco_eox.py
new file mode 100644
index 0000000..93a6ddb
--- /dev/null
+++ b/netbox_lifecycle/models/cisco_eox.py
@@ -0,0 +1,120 @@
+import base64
+import hashlib
+
+from django.conf import settings
+from django.db import models
+
+__all__ = ('CiscoEoXSettings',)
+
+SYNC_INTERVAL_CHOICES = [
+ (60, 'Hourly'),
+ (1440, 'Daily'),
+ (10080, 'Weekly'),
+ (20160, 'Biweekly'),
+ (43200, 'Monthly'),
+]
+
+
+def _get_fernet():
+ """Return a Fernet instance keyed from Django's SECRET_KEY."""
+ from cryptography.fernet import Fernet
+
+ key_material = settings.SECRET_KEY.encode()
+ digest = hashlib.sha256(key_material).digest()
+ fernet_key = base64.urlsafe_b64encode(digest)
+ return Fernet(fernet_key)
+
+
+class CiscoEoXSettings(models.Model):
+ """
+ Singleton model (pk=1) that stores Cisco EoX API credentials and sync
+ configuration. The OAuth client secret is stored Fernet-encrypted at rest
+ using a key derived from Django's SECRET_KEY.
+
+ A second source of truth is PLUGINS_CONFIG (see utilities/settings_loader.py);
+ the database record takes precedence when present.
+ """
+
+ enabled = models.BooleanField(
+ default=False,
+ help_text='Enable automatic Cisco EoX API synchronisation.',
+ )
+ client_id = models.CharField(
+ max_length=200,
+ blank=True,
+ verbose_name='OAuth Client ID',
+ help_text='Client ID obtained from Cisco API Console (apiconsole.cisco.com).',
+ )
+ _client_secret = models.CharField(
+ max_length=500,
+ blank=True,
+ db_column='client_secret',
+ verbose_name='OAuth Client Secret',
+ )
+ sync_interval = models.PositiveIntegerField(
+ choices=SYNC_INTERVAL_CHOICES,
+ default=10080,
+ verbose_name='Sync Interval',
+ help_text='How often the background sync job should run.',
+ )
+ manufacturer_names = models.CharField(
+ max_length=500,
+ default='Cisco',
+ verbose_name='Manufacturer Names',
+ help_text=(
+ 'Comma-separated list of manufacturer names whose device/module types '
+ 'will be queried against the Cisco EoX API.'
+ ),
+ )
+
+ class Meta:
+ verbose_name = 'Cisco EoX Settings'
+
+ def __str__(self):
+ return 'Cisco EoX Settings'
+
+ # ------------------------------------------------------------------
+ # Singleton helpers
+ # ------------------------------------------------------------------
+
+ @classmethod
+ def get_or_create_settings(cls):
+ obj, _ = cls.objects.get_or_create(pk=1)
+ return obj
+
+ # ------------------------------------------------------------------
+ # Encrypted client secret
+ # ------------------------------------------------------------------
+
+ @property
+ def client_secret(self):
+ if not self._client_secret:
+ return ''
+ try:
+ return _get_fernet().decrypt(self._client_secret.encode()).decode()
+ except Exception:
+ # If decryption fails (e.g. SECRET_KEY rotated), return empty string
+ return ''
+
+ @client_secret.setter
+ def client_secret(self, value):
+ if value:
+ self._client_secret = _get_fernet().encrypt(value.encode()).decode()
+ else:
+ self._client_secret = ''
+
+ # ------------------------------------------------------------------
+ # Convenience helpers
+ # ------------------------------------------------------------------
+
+ @property
+ def manufacturer_names_list(self):
+ """Return manufacturer names as a list of stripped strings."""
+ return [n.strip() for n in self.manufacturer_names.split(',') if n.strip()]
+
+ @property
+ def sync_interval_display(self):
+ for value, label in SYNC_INTERVAL_CHOICES:
+ if value == self.sync_interval:
+ return label
+ return f'{self.sync_interval} minutes'
diff --git a/netbox_lifecycle/navigation.py b/netbox_lifecycle/navigation.py
index d63565d..a9efbcb 100644
--- a/netbox_lifecycle/navigation.py
+++ b/netbox_lifecycle/navigation.py
@@ -38,12 +38,19 @@
)
+cisco_eox_settings = PluginMenuItem(
+ link='plugins:netbox_lifecycle:cisco_eox_settings',
+ link_text='Cisco EoX Settings',
+ permissions=['netbox_lifecycle.view_ciscoeoxsettings'],
+)
+
menu = PluginMenu(
label='Hardware Lifecycle',
groups=(
('Lifecycle', (lifecycle,)),
('Vendor Support', (vendors, skus, contracts, contract_assignments)),
('Licensing', (licenses, license_assignments)),
+ ('Cisco EoX', (cisco_eox_settings,)),
),
icon_class='mdi mdi-server',
)
diff --git a/netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings.html b/netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings.html
new file mode 100644
index 0000000..0aaa720
--- /dev/null
+++ b/netbox_lifecycle/templates/netbox_lifecycle/cisco_eox_settings.html
@@ -0,0 +1,141 @@
+{% extends 'base/layout.html' %}
+{% load helpers %}
+{% load perms %}
+
+{% block title %}Cisco EoX Settings{% endblock %}
+
+{% block content %}
+
| Enabled | ++ {% if settings_obj.enabled %} + Yes + {% else %} + No + {% endif %} + | +
|---|---|
| OAuth Client ID | +
+ {% if settings_obj.client_id %}
+ {{ settings_obj.client_id }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
| OAuth Client Secret | ++ {% if settings_obj._client_secret %} + •••••••• (stored encrypted) + {% else %} + — + {% endif %} + | +
| Sync Interval | +{{ settings_obj.sync_interval_display }} | +
| Manufacturer Names | +{{ settings_obj.manufacturer_names|placeholder }} | +
+ Immediately queue a Cisco EoX sync job. The job will run on the + next available RQ worker. +
+ +| Started | +Status | +Completed | +
|---|---|---|
| + {% if job.url %} + {{ job.created }} + {% else %} + {{ job.created }} + {% endif %} + | ++ {% if job.status == 'completed' %} + Completed + {% elif job.status == 'running' %} + Running + {% elif job.status == 'pending' %} + Pending + {% elif job.status == 'failed' %} + Failed + {% elif job.status == 'errored' %} + Errored + {% else %} + {{ job.status }} + {% endif %} + | +{{ job.completed|placeholder }} | +
No sync jobs have run yet.
+ {% endif %} ++ Access to the Cisco EoX API requires an active Cisco Smart Net Total Care (SNTC) + or Partner Support Services (PSS) agreement. +
+
+ The client secret is stored Fernet-encrypted in the database using a key
+ derived from Django's SECRET_KEY.
+