├── netbox_lifecycle ├── api │ ├── __init__.py │ ├── _serializers │ │ ├── __init__.py │ │ ├── vendor.py │ │ ├── hardware.py │ │ ├── license.py │ │ └── contract.py │ ├── views │ │ ├── __init__.py │ │ ├── hardware.py │ │ ├── license.py │ │ └── contract.py │ ├── serializers.py │ └── urls.py ├── graphql │ ├── __init__.py │ ├── schema.py │ ├── types.py │ └── filters.py ├── tests │ ├── __init__.py │ ├── test_constants.py │ ├── test_templatetags.py │ └── test_forms.py ├── utilities │ ├── __init__.py │ ├── gfk_mixins.py │ └── testing.py ├── migrations │ ├── __init__.py │ ├── 0010_licenseassignment_quantity.py │ ├── 0017_optional_lifecycle_dates.py │ ├── 0014_rename_last_contract_date_and_more.py │ ├── 0009_alter_licenseassignment_device.py │ ├── 0013_fix_hardware_lifecycle_model.py │ ├── 0015_supportcontractassignment_module.py │ ├── 0006_alter_supportcontractassignment_assigned_object_type.py │ ├── 0008_alter_supportcontractassignment_contract_and_more.py │ ├── 0005_remove_supportcontract_manufacturer_supportsku_and_more.py │ ├── 0012_primarymodels.py │ ├── 0003_remove_supportcontract_devices_and_more.py │ ├── 0016_add_virtual_machine_support.py │ ├── 0002_license_licenseassignment.py │ ├── 0011_alter_supportcontractassignment.py │ ├── 0004_supportcontractassignment_and_more.py │ ├── 0001_initial.py │ └── 0007_alter_hardwarelifecycle_options_and_more.py ├── templatetags │ ├── __init__.py │ └── filters.py ├── templates │ └── netbox_lifecycle │ │ ├── generic │ │ └── base.html │ │ ├── inc │ │ ├── contract_card_placeholder.html │ │ ├── support_contract_info.html │ │ └── hardware_lifecycle_info.html │ │ ├── htmx │ │ ├── contract_list.html │ │ ├── device_contracts.html │ │ └── virtualmachine_contracts.html │ │ ├── vendor.html │ │ ├── license.html │ │ ├── supportsku.html │ │ ├── licenseassignment.html │ │ ├── supportcontractassignment.html │ │ ├── license │ │ └── assignments.html │ │ ├── supportcontract │ │ └── assignments.html │ │ ├── supportcontract.html │ │ └── hardwarelifecycle.html ├── models │ ├── __init__.py │ ├── hardware.py │ └── license.py ├── tables │ ├── __init__.py │ ├── hardware.py │ ├── license.py │ └── contract.py ├── filtersets │ ├── __init__.py │ ├── hardware.py │ ├── license.py │ └── contract.py ├── forms │ ├── __init__.py │ ├── bulk_edit.py │ └── filtersets.py ├── views │ ├── __init__.py │ ├── hardware.py │ ├── license.py │ └── htmx.py ├── constants │ ├── hardware.py │ ├── contract.py │ └── __init__.py ├── navigation.py ├── __init__.py ├── search.py └── template_content.py ├── MANIFEST.in ├── .pre-commit-config.yaml ├── .idea ├── inspectionProfiles │ ├── profiles_settings.xml │ └── Project_Default.xml └── modules.xml ├── .github ├── workflows │ ├── pr_approval.yml │ ├── release.yml │ ├── build-test.yml │ ├── pypi.yml │ └── ci.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yaml │ └── bug_report.yaml ├── FUNDING.yml └── release-drafter.yml ├── ruff.toml ├── pyproject.toml ├── contrib └── configuration_lifecycle.py ├── README.md └── .gitignore /netbox_lifecycle/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_lifecycle/graphql/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_lifecycle/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_lifecycle/utilities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_lifecycle/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/_serializers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/generic/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} -------------------------------------------------------------------------------- /netbox_lifecycle/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .hardware import * 2 | from .contract import * 3 | from .license import * 4 | -------------------------------------------------------------------------------- /netbox_lifecycle/tables/__init__.py: -------------------------------------------------------------------------------- 1 | from .contract import * 2 | from .hardware import * 3 | from .license import * 4 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .contract import * 2 | from .hardware import * 3 | from .license import * 4 | -------------------------------------------------------------------------------- /netbox_lifecycle/filtersets/__init__.py: -------------------------------------------------------------------------------- 1 | from .contract import * 2 | from .hardware import * 3 | from .license import * 4 | -------------------------------------------------------------------------------- /netbox_lifecycle/forms/__init__.py: -------------------------------------------------------------------------------- 1 | from .bulk_edit import * 2 | from .filtersets import * 3 | from .model_forms import * 4 | -------------------------------------------------------------------------------- /netbox_lifecycle/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .contract import * 2 | from .hardware import * 3 | from .htmx import * 4 | from .license import * 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include netbox_lifecycle/templates * 4 | recursive-include netbox_lifecycle/static * -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.6.9 4 | hooks: 5 | - id: ruff 6 | name: "Ruff linter" 7 | args: [ netbox_lifecycle/ ] 8 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /netbox_lifecycle/constants/hardware.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | 3 | __all__ = ('HARDWARE_LIFECYCLE_MODELS',) 4 | 5 | HARDWARE_LIFECYCLE_MODELS = Q( 6 | app_label='dcim', 7 | model__in=( 8 | 'moduletype', 9 | 'devicetype', 10 | ), 11 | ) 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/pr_approval.yml: -------------------------------------------------------------------------------- 1 | name: Auto approve 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | auto-approve: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | pull-requests: write 14 | if: github.actor == 'dansheps' 15 | steps: 16 | - uses: hmarr/auto-approve-action@v4 -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | exclude = [] 2 | line-length = 120 3 | target-version = "py310" 4 | output-format = "github" 5 | 6 | [lint] 7 | extend-select = ["E1", "E2", "E3", "E501", "W"] 8 | ignore = ["F403", "F405"] 9 | preview = true 10 | 11 | [lint.per-file-ignores] 12 | "template_code.py" = ["E501"] 13 | "netbox_lifecycle/graphql/filters.py" = ["F821"] 14 | 15 | [format] 16 | quote-style = "single" 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # Reference: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: 💬 Community Slack 5 | url: https://netdev.chat/ 6 | about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems" -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/inc/contract_card_placeholder.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
6 |
7 |
{% trans "Support Contracts" %}
8 |
9 |
10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/serializers.py: -------------------------------------------------------------------------------- 1 | from netbox_lifecycle.api._serializers.contract import * 2 | from netbox_lifecycle.api._serializers.hardware import * 3 | from netbox_lifecycle.api._serializers.license import * 4 | from netbox_lifecycle.api._serializers.vendor import * 5 | 6 | __all__ = ( 7 | 'VendorSerializer', 8 | 'SupportSKUSerializer', 9 | 'SupportContractSerializer', 10 | 'SupportContractAssignmentSerializer', 11 | 'HardwareLifecycleSerializer', 12 | 'LicenseSerializer', 13 | 'LicenseAssignmentSerializer', 14 | ) 15 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0010_licenseassignment_quantity.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-16 14:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_lifecycle', '0009_alter_licenseassignment_device'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='licenseassignment', 15 | name='quantity', 16 | field=models.IntegerField(blank=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/views/hardware.py: -------------------------------------------------------------------------------- 1 | from netbox.api.viewsets import NetBoxModelViewSet 2 | from netbox_lifecycle.api.serializers import HardwareLifecycleSerializer 3 | from netbox_lifecycle.filtersets import HardwareLifecycleFilterSet 4 | from netbox_lifecycle.models import HardwareLifecycle 5 | 6 | 7 | __all__ = ('HardwareLifecycleViewSet',) 8 | 9 | 10 | class HardwareLifecycleViewSet(NetBoxModelViewSet): 11 | queryset = HardwareLifecycle.objects.all() 12 | serializer_class = HardwareLifecycleSerializer 13 | filterset_class = HardwareLifecycleFilterSet 14 | -------------------------------------------------------------------------------- /netbox_lifecycle/constants/contract.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | CONTRACT_STATUS_ACTIVE = 'active' 4 | CONTRACT_STATUS_FUTURE = 'future' 5 | CONTRACT_STATUS_UNSPECIFIED = 'unspecified' 6 | CONTRACT_STATUS_EXPIRED = 'expired' 7 | 8 | # (label, badge_color) 9 | CONTRACT_STATUS_COLOR = { 10 | CONTRACT_STATUS_ACTIVE: (_('Active'), 'success'), 11 | CONTRACT_STATUS_FUTURE: (_('Future'), 'info'), 12 | CONTRACT_STATUS_UNSPECIFIED: (_('Unspecified'), 'secondary'), 13 | CONTRACT_STATUS_EXPIRED: (_('Expired'), 'danger'), 14 | } 15 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/urls.py: -------------------------------------------------------------------------------- 1 | from netbox.api.routers import NetBoxRouter 2 | from .views import * 3 | 4 | router = NetBoxRouter() 5 | router.register('hardwarelifecycle', HardwareLifecycleViewSet) 6 | router.register('license', LicenseViewSet) 7 | router.register('licenseassignment', LicenseAssignmentViewSet) 8 | router.register('sku', SupportSKUViewSet) 9 | router.register('supportcontract', SupportContractViewSet) 10 | router.register('supportcontractassignment', SupportContractAssignmentViewSet) 11 | router.register('vendor', VendorViewSet) 12 | urlpatterns = router.urls 13 | -------------------------------------------------------------------------------- /netbox_lifecycle/constants/__init__.py: -------------------------------------------------------------------------------- 1 | from netbox_lifecycle.constants.contract import ( 2 | CONTRACT_STATUS_ACTIVE, 3 | CONTRACT_STATUS_EXPIRED, 4 | CONTRACT_STATUS_FUTURE, 5 | CONTRACT_STATUS_UNSPECIFIED, 6 | CONTRACT_STATUS_COLOR, 7 | ) 8 | from netbox_lifecycle.constants.hardware import HARDWARE_LIFECYCLE_MODELS 9 | 10 | __all__ = ( 11 | 'CONTRACT_STATUS_ACTIVE', 12 | 'CONTRACT_STATUS_EXPIRED', 13 | 'CONTRACT_STATUS_FUTURE', 14 | 'CONTRACT_STATUS_UNSPECIFIED', 15 | 'CONTRACT_STATUS_COLOR', 16 | 'HARDWARE_LIFECYCLE_MODELS', 17 | ) 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | on: 5 | push: 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | update_release_draft: 11 | permissions: 12 | # write permission is required to create a github release 13 | contents: write 14 | # write permission is required for autolabeler 15 | # otherwise, read permission is required at least 16 | pull-requests: write 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: release-drafter/release-drafter@v5 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [dansheps] 4 | patreon: dansheps 5 | #open_collective: dansheps 6 | #ko_fi: # Replace with a single Ko-fi username 7 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | #liberapay: # Replace with a single Liberapay username 10 | #issuehunt: # Replace with a single IssueHunt username 11 | #otechie: # Replace with a single Otechie username 12 | custom: https://paypal.me/dansheps84?country.x=CA&locale.x=en_US 13 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0017_optional_lifecycle_dates.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('netbox_lifecycle', '0016_add_virtual_machine_support'), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name='hardwarelifecycle', 13 | name='end_of_sale', 14 | field=models.DateField(blank=True, null=True), 15 | ), 16 | migrations.AlterField( 17 | model_name='hardwarelifecycle', 18 | name='end_of_support', 19 | field=models.DateField(blank=True, null=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0014_rename_last_contract_date_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.9 on 2024-10-13 03:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('netbox_lifecycle', '0013_fix_hardware_lifecycle_model'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='hardwarelifecycle', 15 | old_name='last_contract_date', 16 | new_name='last_contract_attach', 17 | ), 18 | migrations.AddField( 19 | model_name='hardwarelifecycle', 20 | name='last_contract_renewal', 21 | field=models.DateField(blank=True, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/views/license.py: -------------------------------------------------------------------------------- 1 | from netbox.api.viewsets import NetBoxModelViewSet 2 | from netbox_lifecycle.api.serializers import ( 3 | LicenseSerializer, 4 | LicenseAssignmentSerializer, 5 | ) 6 | from netbox_lifecycle.filtersets import LicenseAssignmentFilterSet, LicenseFilterSet 7 | from netbox_lifecycle.models import License, LicenseAssignment 8 | 9 | 10 | __all__ = ('LicenseViewSet', 'LicenseAssignmentViewSet') 11 | 12 | 13 | class LicenseViewSet(NetBoxModelViewSet): 14 | queryset = License.objects.all() 15 | serializer_class = LicenseSerializer 16 | filterset_class = LicenseFilterSet 17 | 18 | 19 | class LicenseAssignmentViewSet(NetBoxModelViewSet): 20 | queryset = LicenseAssignment.objects.all() 21 | serializer_class = LicenseAssignmentSerializer 22 | filterset_class = LicenseAssignmentFilterSet 23 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'type: feature' 7 | - 'type: enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'type: bug' 11 | - title: '🧰 Maintenance' 12 | labels: 13 | - 'type: housekeeping' 14 | - 'type: documentation' 15 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 16 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 17 | version-resolver: 18 | minor: 19 | labels: 20 | - 'type: feature' 21 | patch: 22 | labels: 23 | - 'type: enhancement' 24 | - 'type: bug' 25 | - 'type: housekeeping' 26 | - 'type: documentation' 27 | default: patch 28 | template: | 29 | ## Changes 30 | 31 | $CHANGES -------------------------------------------------------------------------------- /netbox_lifecycle/api/_serializers/vendor.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from netbox.api.serializers import NetBoxModelSerializer 4 | from netbox_lifecycle.models import Vendor 5 | 6 | __all__ = ('VendorSerializer',) 7 | 8 | 9 | class VendorSerializer(NetBoxModelSerializer): 10 | url = serializers.HyperlinkedIdentityField( 11 | view_name='plugins-api:netbox_lifecycle-api:vendor-detail' 12 | ) 13 | 14 | class Meta: 15 | model = Vendor 16 | fields = ( 17 | 'url', 18 | 'id', 19 | 'display', 20 | 'name', 21 | 'description', 22 | 'comments', 23 | 'tags', 24 | 'custom_fields', 25 | ) 26 | brief_fields = ( 27 | 'url', 28 | 'id', 29 | 'display', 30 | 'name', 31 | ) 32 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build Test 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | name: Build Distribution 7 | runs-on: ubuntu-latest 8 | environment: 9 | name: build 10 | steps: 11 | - name: Checkout repo 12 | uses: actions/checkout@v4 13 | - name: Set up Python 3.12 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.12 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install --upgrade setuptools wheel 21 | python -m pip install build --user 22 | - name: Build a binary wheel and a source tarball 23 | run: python -m build 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: python-package-distributions 28 | path: dist/ -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0009_alter_licenseassignment_device.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-12 20:40 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('dcim', '0171_cabletermination_change_logging'), 11 | ('netbox_lifecycle', '0008_alter_supportcontractassignment_contract_and_more'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='licenseassignment', 17 | name='device', 18 | field=models.ForeignKey( 19 | blank=True, 20 | null=True, 21 | on_delete=django.db.models.deletion.SET_NULL, 22 | related_name='licenses', 23 | to='dcim.device', 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /netbox_lifecycle/utilities/gfk_mixins.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | class DateFieldMixin: 5 | def model_to_dict(self, instance, fields, api=False): 6 | model_dict = super().model_to_dict(instance, fields, api) 7 | for key, value in list(model_dict.items()): 8 | if api: 9 | if type(value) is datetime.date: 10 | model_dict[key] = str(value) 11 | return model_dict 12 | 13 | 14 | class HardwareLifecycleViewMixin: 15 | def model_to_dict(self, instance, fields, api=False): 16 | model_dict = super().model_to_dict(instance, fields, api) 17 | for key, value in list(model_dict.items()): 18 | if type(value) is datetime.date: 19 | model_dict[key] = str(value) 20 | elif key in ['device_type', 'module_type'] and isinstance(value, object): 21 | model_dict[key] = value.first().pk 22 | return model_dict 23 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/htmx/contract_list.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load filters %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% for assignment in assignments %} 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% endfor %} 22 | 23 |
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Ended" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.end_date|date:"Y-m-d"|default:"-" }}
24 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0013_fix_hardware_lifecycle_model.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-09-19 03:35 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("contenttypes", "0002_remove_content_type_name"), 11 | ("netbox_lifecycle", "0012_primarymodels"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="hardwarelifecycle", 17 | name="assigned_object_type", 18 | field=models.ForeignKey( 19 | blank=True, 20 | limit_choices_to=models.Q( 21 | ("app_label", "dcim"), ("model__in", ("moduletype", "devicetype")) 22 | ), 23 | null=True, 24 | on_delete=django.db.models.deletion.PROTECT, 25 | related_name="+", 26 | to="contenttypes.contenttype", 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0015_supportcontractassignment_module.py: -------------------------------------------------------------------------------- 1 | # Generated manually for feature/85-120-module-assignment 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('dcim', '0185_gfk_indexes'), 11 | ('netbox_lifecycle', '0014_rename_last_contract_date_and_more'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='supportcontractassignment', 17 | name='module', 18 | field=models.ForeignKey( 19 | blank=True, 20 | null=True, 21 | on_delete=django.db.models.deletion.SET_NULL, 22 | related_name='contracts', 23 | to='dcim.module', 24 | ), 25 | ), 26 | migrations.AlterModelOptions( 27 | name='supportcontractassignment', 28 | options={'ordering': ['contract', 'device', 'module', 'license']}, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0006_alter_supportcontractassignment_assigned_object_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-11 17:10 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('contenttypes', '0002_remove_content_type_name'), 11 | ( 12 | 'netbox_lifecycle', 13 | '0005_remove_supportcontract_manufacturer_supportsku_and_more', 14 | ), 15 | ] 16 | 17 | operations = [ 18 | migrations.AlterField( 19 | model_name='supportcontractassignment', 20 | name='assigned_object_type', 21 | field=models.ForeignKey( 22 | blank=True, 23 | limit_choices_to=('dcim.Device', 'netbox_lifecycle.LicenseAssignment'), 24 | null=True, 25 | on_delete=django.db.models.deletion.PROTECT, 26 | related_name='+', 27 | to='contenttypes.contenttype', 28 | ), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "netbox-lifecycle" 10 | authors = [ 11 | {name = "Daniel Sheppard", email = "dans@dansheps.com"} 12 | ] 13 | maintainers = [ 14 | {name = "Daniel Sheppard", email = "dans@dansheps.com"}, 15 | ] 16 | description = "NetBox Support Contract and EOL/EOS management" 17 | readme = "README.md" 18 | requires-python = ">=3.10" 19 | keywords = ["netbox-plugin", ] 20 | version = "1.1.6" 21 | license = {file = "LICENSE"} 22 | classifiers = [ 23 | "Programming Language :: Python :: 3", 24 | ] 25 | dependencies = [ 26 | 'django-polymorphic', 27 | ] 28 | 29 | [project.urls] 30 | Documentation = "https://github.com/dansheps/netbox-lifecycle/blob/main/README.md" 31 | Source = "https://github.com/dansheps/netbox-lifecycle" 32 | Tracker = "https://github.com/dansheps/netbox-lifecycle/issues" 33 | 34 | [tool.setuptools.packages.find] 35 | exclude=["netbox_lifecycle.tests"] 36 | 37 | [tool.black] 38 | skip-string-normalization = 1 -------------------------------------------------------------------------------- /netbox_lifecycle/templatetags/filters.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | from dateutil.relativedelta import relativedelta 3 | from django import template 4 | from django.utils.safestring import mark_safe 5 | 6 | register = template.Library() 7 | 8 | 9 | def is_expired(value): 10 | return value < datetime.now().date() 11 | 12 | 13 | def expires_within_six_months(value): 14 | return value < (date.today() + relativedelta(months=+6)) 15 | 16 | 17 | @register.filter(is_safe=True) 18 | def date_badge_class(value): 19 | if not value: 20 | return 21 | 22 | if is_expired(value): 23 | return mark_safe('class="badge text-bg-danger"') 24 | elif expires_within_six_months(value): 25 | return mark_safe('class="badge text-bg-warning"') 26 | else: 27 | return mark_safe('class="badge text-bg-success"') 28 | 29 | 30 | @register.filter(is_safe=True) 31 | def contract_status_badge(status): 32 | from netbox_lifecycle.constants import CONTRACT_STATUS_COLOR 33 | 34 | if not status or status not in CONTRACT_STATUS_COLOR: 35 | return '' 36 | label, color = CONTRACT_STATUS_COLOR[status] 37 | return mark_safe(f'{label}') 38 | -------------------------------------------------------------------------------- /contrib/configuration_lifecycle.py: -------------------------------------------------------------------------------- 1 | ################################################################### 2 | # This file serves as a base configuration for testing purposes # 3 | # only. It is not intended for production use. # 4 | ################################################################### 5 | 6 | ALLOWED_HOSTS = ['*'] 7 | 8 | DATABASE = { 9 | 'NAME': 'netbox', 10 | 'USER': 'netbox', 11 | 'PASSWORD': 'netbox', 12 | 'HOST': 'localhost', 13 | 'PORT': '', 14 | 'CONN_MAX_AGE': 300, 15 | } 16 | 17 | PLUGINS = [ 18 | 'netbox_lifecycle', 19 | ] 20 | 21 | REDIS = { 22 | 'tasks': { 23 | 'HOST': 'localhost', 24 | 'PORT': 6379, 25 | 'USERNAME': '', 26 | 'PASSWORD': '', 27 | 'DATABASE': 0, 28 | 'SSL': False, 29 | }, 30 | 'caching': { 31 | 'HOST': 'localhost', 32 | 'PORT': 6379, 33 | 'USERNAME': '', 34 | 'PASSWORD': '', 35 | 'DATABASE': 1, 36 | 'SSL': False, 37 | } 38 | } 39 | 40 | SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 41 | 42 | DEFAULT_PERMISSIONS = {} 43 | 44 | LOGGING = { 45 | 'version': 1, 46 | 'disable_existing_loggers': True 47 | } 48 | -------------------------------------------------------------------------------- /netbox_lifecycle/tests/test_constants.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from netbox_lifecycle.constants import ( 4 | CONTRACT_STATUS_ACTIVE, 5 | CONTRACT_STATUS_EXPIRED, 6 | CONTRACT_STATUS_FUTURE, 7 | CONTRACT_STATUS_UNSPECIFIED, 8 | CONTRACT_STATUS_COLOR, 9 | ) 10 | 11 | 12 | class ContractStatusConstantsTest(TestCase): 13 | def test_status_constants_defined(self): 14 | self.assertEqual(CONTRACT_STATUS_ACTIVE, 'active') 15 | self.assertEqual(CONTRACT_STATUS_FUTURE, 'future') 16 | self.assertEqual(CONTRACT_STATUS_UNSPECIFIED, 'unspecified') 17 | self.assertEqual(CONTRACT_STATUS_EXPIRED, 'expired') 18 | 19 | def test_status_color_mapping_complete(self): 20 | self.assertIn(CONTRACT_STATUS_ACTIVE, CONTRACT_STATUS_COLOR) 21 | self.assertIn(CONTRACT_STATUS_FUTURE, CONTRACT_STATUS_COLOR) 22 | self.assertIn(CONTRACT_STATUS_UNSPECIFIED, CONTRACT_STATUS_COLOR) 23 | self.assertIn(CONTRACT_STATUS_EXPIRED, CONTRACT_STATUS_COLOR) 24 | 25 | def test_status_color_format(self): 26 | for status, (label, color) in CONTRACT_STATUS_COLOR.items(): 27 | self.assertIsInstance(str(label), str) 28 | self.assertIn(color, ['success', 'info', 'secondary', 'danger']) 29 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0008_alter_supportcontractassignment_contract_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-12 14:27 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('netbox_lifecycle', '0007_alter_hardwarelifecycle_options_and_more'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='supportcontractassignment', 16 | name='contract', 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | related_name='assignments', 22 | to='netbox_lifecycle.supportcontract', 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name='supportcontractassignment', 27 | name='sku', 28 | field=models.ForeignKey( 29 | blank=True, 30 | null=True, 31 | on_delete=django.db.models.deletion.SET_NULL, 32 | related_name='assignments', 33 | to='netbox_lifecycle.supportsku', 34 | ), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/vendor.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load buttons %} 3 | {% load custom_links %} 4 | {% load helpers %} 5 | {% load perms %} 6 | {% load plugins %} 7 | {% load tabs %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |
Vendor
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
Name{{ object.name }}
Description{{ object.description }}
25 |
26 |
27 | {% plugin_left_page object %} 28 | {% include 'inc/panels/tags.html' %} 29 |
30 |
31 | {% include 'inc/panels/related_objects.html' %} 32 | {% include 'inc/panels/custom_fields.html' %} 33 | {% include 'inc/panels/comments.html' %} 34 | {% plugin_right_page object %} 35 |
36 |
37 |
38 |
39 | {% plugin_full_width_page object %} 40 |
41 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /netbox_lifecycle/utilities/testing.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Manufacturer 2 | from netbox_lifecycle.models import Vendor, SupportSKU, SupportContract 3 | 4 | __all__ = ( 5 | 'create_test_vendor', 6 | 'create_test_supportsku', 7 | 'create_test_supportcontract', 8 | ) 9 | 10 | 11 | def create_test_vendor(name=None): 12 | if name is None: 13 | name = 'Vendor' 14 | return Vendor.objects.create(name=name) 15 | 16 | 17 | def create_test_supportsku(sku=None, manufacturer=None): 18 | if manufacturer is None: 19 | if Manufacturer.objects.all().count() == 0: 20 | manufacturer = Manufacturer.objects.create( 21 | name='Manufacturer', slug='manufacturer' 22 | ) 23 | else: 24 | manufacturer = Manufacturer.objects.first() 25 | 26 | return SupportSKU.objects.create(manufacturer=manufacturer, sku=sku) 27 | 28 | 29 | def create_test_supportcontract( 30 | contract_id=None, vendor=None, start=None, renewal=None, end=None 31 | ): 32 | if vendor is None: 33 | if Vendor.objects.all().count() == 0: 34 | vendor = create_test_vendor() 35 | else: 36 | vendor = Vendor.objects.first() 37 | 38 | return SupportContract.objects.create( 39 | vendor=vendor, contract_id=contract_id, start=start, renewal=renewal, end=end 40 | ) 41 | -------------------------------------------------------------------------------- /netbox_lifecycle/tables/hardware.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | import django_tables2 as tables 3 | 4 | from netbox.tables import NetBoxTable 5 | from netbox_lifecycle.models import HardwareLifecycle 6 | 7 | 8 | __all__ = ('HardwareLifecycleTable',) 9 | 10 | 11 | class HardwareLifecycleTable(NetBoxTable): 12 | name = tables.Column( 13 | linkify=True, 14 | accessor='name', 15 | orderable=False, 16 | ) 17 | assigned_object = tables.Column( 18 | linkify=True, 19 | verbose_name=_('Hardware'), 20 | orderable=False, 21 | ) 22 | assigned_object_count = tables.Column( 23 | verbose_name=_('Assigned Object Count'), 24 | orderable=False, 25 | ) 26 | 27 | class Meta(NetBoxTable.Meta): 28 | model = HardwareLifecycle 29 | fields = ( 30 | 'pk', 31 | 'name', 32 | 'assigned_object', 33 | 'end_of_sale', 34 | 'end_of_maintenance', 35 | 'end_of_security', 36 | 'end_of_support', 37 | 'last_contract_attach', 38 | 'last_contract_renewal', 39 | 'description', 40 | 'comments', 41 | ) 42 | default_columns = ( 43 | 'pk', 44 | 'name', 45 | 'assigned_object', 46 | 'end_of_sale', 47 | 'end_of_maintenance', 48 | ) 49 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/license.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load buttons %} 3 | {% load custom_links %} 4 | {% load helpers %} 5 | {% load perms %} 6 | {% load plugins %} 7 | {% load tabs %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |
License
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
Manufacturer{{ object.manufacturer|linkify }}
Name{{ object.name }}
Description{{ object.description }}
29 |
30 |
31 | {% plugin_left_page object %} 32 | {% include 'inc/panels/tags.html' %} 33 |
34 |
35 | {% include 'inc/panels/related_objects.html' %} 36 | {% include 'inc/panels/custom_fields.html' %} 37 | {% include 'inc/panels/comments.html' %} 38 | {% plugin_right_page object %} 39 |
40 |
41 |
42 |
43 | {% plugin_full_width_page object %} 44 |
45 |
46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/supportsku.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load buttons %} 3 | {% load custom_links %} 4 | {% load helpers %} 5 | {% load perms %} 6 | {% load plugins %} 7 | {% load tabs %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |
SKU
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
Manufacturer{{ object.manufacturer|linkify }}
SKU{{ object.sku }}
Description{{ object.description }}
29 |
30 |
31 | {% plugin_left_page object %} 32 | {% include 'inc/panels/tags.html' %} 33 |
34 |
35 | {% include 'inc/panels/related_objects.html' %} 36 | {% include 'inc/panels/custom_fields.html' %} 37 | {% include 'inc/panels/comments.html' %} 38 | {% plugin_right_page object %} 39 |
40 |
41 |
42 |
43 | {% plugin_full_width_page object %} 44 |
45 |
46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /netbox_lifecycle/tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from netbox_lifecycle.constants import ( 4 | CONTRACT_STATUS_ACTIVE, 5 | CONTRACT_STATUS_EXPIRED, 6 | CONTRACT_STATUS_FUTURE, 7 | CONTRACT_STATUS_UNSPECIFIED, 8 | ) 9 | from netbox_lifecycle.templatetags.filters import contract_status_badge 10 | 11 | 12 | class ContractStatusBadgeFilterTest(TestCase): 13 | def test_active_status_badge(self): 14 | result = contract_status_badge(CONTRACT_STATUS_ACTIVE) 15 | self.assertIn('text-bg-success', result) 16 | self.assertIn('Active', result) 17 | 18 | def test_future_status_badge(self): 19 | result = contract_status_badge(CONTRACT_STATUS_FUTURE) 20 | self.assertIn('text-bg-info', result) 21 | self.assertIn('Future', result) 22 | 23 | def test_unspecified_status_badge(self): 24 | result = contract_status_badge(CONTRACT_STATUS_UNSPECIFIED) 25 | self.assertIn('text-bg-secondary', result) 26 | self.assertIn('Unspecified', result) 27 | 28 | def test_expired_status_badge(self): 29 | result = contract_status_badge(CONTRACT_STATUS_EXPIRED) 30 | self.assertIn('text-bg-danger', result) 31 | self.assertIn('Expired', result) 32 | 33 | def test_invalid_status_returns_empty(self): 34 | result = contract_status_badge('invalid') 35 | self.assertEqual(result, '') 36 | 37 | def test_none_status_returns_empty(self): 38 | result = contract_status_badge(None) 39 | self.assertEqual(result, '') 40 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/licenseassignment.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load buttons %} 3 | {% load custom_links %} 4 | {% load helpers %} 5 | {% load perms %} 6 | {% load plugins %} 7 | {% load tabs %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |
Contract
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
License{{ object.license|linkify }}
Vendor{{ object.vendor|linkify }}
Device{{ object.device|linkify }}
Quantity{{ object.quantity }}
33 |
34 |
35 | {% plugin_left_page object %} 36 | {% include 'inc/panels/tags.html' %} 37 |
38 |
39 | {% include 'inc/panels/related_objects.html' %} 40 | {% include 'inc/panels/custom_fields.html' %} 41 | {% include 'inc/panels/comments.html' %} 42 | {% plugin_right_page object %} 43 |
44 |
45 |
46 |
47 | {% plugin_full_width_page object %} 48 |
49 |
50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/views/contract.py: -------------------------------------------------------------------------------- 1 | from netbox.api.viewsets import NetBoxModelViewSet 2 | from netbox_lifecycle.api.serializers import ( 3 | VendorSerializer, 4 | SupportContractSerializer, 5 | SupportContractAssignmentSerializer, 6 | SupportSKUSerializer, 7 | ) 8 | from netbox_lifecycle.filtersets import ( 9 | SupportSKUFilterSet, 10 | VendorFilterSet, 11 | SupportContractFilterSet, 12 | SupportContractAssignmentFilterSet, 13 | ) 14 | from netbox_lifecycle.models import ( 15 | Vendor, 16 | SupportContract, 17 | SupportContractAssignment, 18 | SupportSKU, 19 | ) 20 | 21 | 22 | __all__ = ( 23 | 'VendorViewSet', 24 | 'SupportSKUViewSet', 25 | 'SupportContractViewSet', 26 | 'SupportContractAssignmentViewSet', 27 | ) 28 | 29 | 30 | class VendorViewSet(NetBoxModelViewSet): 31 | queryset = Vendor.objects.all() 32 | serializer_class = VendorSerializer 33 | filterset_class = VendorFilterSet 34 | 35 | 36 | class SupportSKUViewSet(NetBoxModelViewSet): 37 | queryset = SupportSKU.objects.all() 38 | serializer_class = SupportSKUSerializer 39 | filterset_class = SupportSKUFilterSet 40 | 41 | 42 | class SupportContractViewSet(NetBoxModelViewSet): 43 | queryset = SupportContract.objects.all() 44 | serializer_class = SupportContractSerializer 45 | filterset_class = SupportContractFilterSet 46 | 47 | 48 | class SupportContractAssignmentViewSet(NetBoxModelViewSet): 49 | queryset = SupportContractAssignment.objects.all() 50 | serializer_class = SupportContractAssignmentSerializer 51 | filterset_class = SupportContractAssignmentFilterSet 52 | -------------------------------------------------------------------------------- /netbox_lifecycle/tables/license.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | import django_tables2 as tables 3 | 4 | from netbox.tables import NetBoxTable 5 | from netbox_lifecycle.models import License, LicenseAssignment 6 | 7 | 8 | __all__ = ( 9 | 'LicenseTable', 10 | 'LicenseAssignmentTable', 11 | ) 12 | 13 | 14 | class LicenseTable(NetBoxTable): 15 | name = tables.Column( 16 | verbose_name=_('Name'), 17 | linkify=True, 18 | ) 19 | manufacturer = tables.Column(verbose_name=_('Manufacturer'), linkify=True) 20 | 21 | class Meta(NetBoxTable.Meta): 22 | model = License 23 | fields = ( 24 | 'pk', 25 | 'name', 26 | 'description', 27 | 'comments', 28 | ) 29 | default_columns = ( 30 | 'pk', 31 | 'name', 32 | ) 33 | 34 | 35 | class LicenseAssignmentTable(NetBoxTable): 36 | license = tables.Column(verbose_name=_('License'), linkify=True) 37 | vendor = tables.Column(verbose_name=_('Vendor'), linkify=True) 38 | device = tables.Column(verbose_name=_('Device'), linkify=True) 39 | virtual_machine = tables.Column(verbose_name=_('Virtual Machine'), linkify=True) 40 | 41 | class Meta(NetBoxTable.Meta): 42 | model = LicenseAssignment 43 | fields = ( 44 | 'pk', 45 | 'license', 46 | 'vendor', 47 | 'device', 48 | 'virtual_machine', 49 | 'quantity', 50 | 'description', 51 | 'comments', 52 | ) 53 | default_columns = ('pk', 'license', 'vendor', 'device', 'virtual_machine') 54 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/supportcontractassignment.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load buttons %} 3 | {% load custom_links %} 4 | {% load helpers %} 5 | {% load perms %} 6 | {% load plugins %} 7 | {% load tabs %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |
Contract
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
Contract{{ object.contract|linkify }}
SKU{{ object.sku|linkify }}
Device{{ object.device|linkify }}
License{{ object.license|linkify }}
End Date{{ object.end }}
37 |
38 |
39 | {% plugin_left_page object %} 40 | {% include 'inc/panels/tags.html' %} 41 |
42 |
43 | {% include 'inc/panels/related_objects.html' %} 44 | {% include 'inc/panels/custom_fields.html' %} 45 | {% include 'inc/panels/comments.html' %} 46 | {% plugin_right_page object %} 47 |
48 |
49 |
50 |
51 | {% plugin_full_width_page object %} 52 |
53 |
54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /netbox_lifecycle/navigation.py: -------------------------------------------------------------------------------- 1 | from netbox.plugins import PluginMenuItem, PluginMenu 2 | 3 | lifecycle = PluginMenuItem( 4 | link='plugins:netbox_lifecycle:hardwarelifecycle_list', 5 | link_text='Hardware Lifecycle', 6 | permissions=['netbox_lifecycle.view_hardwarelifecycle'], 7 | ) 8 | 9 | vendors = PluginMenuItem( 10 | link='plugins:netbox_lifecycle:vendor_list', 11 | link_text='Vendors', 12 | permissions=['netbox_lifecycle.view_vendor'], 13 | ) 14 | skus = PluginMenuItem( 15 | link='plugins:netbox_lifecycle:supportsku_list', 16 | link_text='Support SKUs', 17 | permissions=['netbox_lifecycle.view_supportsku'], 18 | ) 19 | contracts = PluginMenuItem( 20 | link='plugins:netbox_lifecycle:supportcontract_list', 21 | link_text='Support Contracts', 22 | permissions=['netbox_lifecycle.view_supportcontract'], 23 | ) 24 | contract_assignments = PluginMenuItem( 25 | link='plugins:netbox_lifecycle:supportcontractassignment_list', 26 | link_text='Support Assignments', 27 | permissions=['netbox_lifecycle.view_supportcontractassignment'], 28 | ) 29 | licenses = PluginMenuItem( 30 | link='plugins:netbox_lifecycle:license_list', 31 | link_text='Licenses', 32 | permissions=['netbox_lifecycle.view_license'], 33 | ) 34 | license_assignments = PluginMenuItem( 35 | link='plugins:netbox_lifecycle:licenseassignment_list', 36 | link_text='License Assignments', 37 | permissions=['netbox_lifecycle.view_licenseassignment'], 38 | ) 39 | 40 | 41 | menu = PluginMenu( 42 | label='Hardware Lifecycle', 43 | groups=( 44 | ('Lifecycle', (lifecycle,)), 45 | ('Vendor Support', (vendors, skus, contracts, contract_assignments)), 46 | ('Licensing', (licenses, license_assignments)), 47 | ), 48 | icon_class='mdi mdi-server', 49 | ) 50 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 29 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/inc/support_contract_info.html: -------------------------------------------------------------------------------- 1 | 2 | {% load filters %} 3 | {% load helpers %} 4 | {# renders panel on object (device) with support contract info assigned to it #} 5 | 6 |
7 |
Support Contract 8 | {% if support_contract %} 9 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {% if support_contract.end == None %} 33 | 34 | {% else %} 35 | 36 | {% endif %} 37 | 38 |
Vendor{{ support_contract.contract.vendor|linkify|placeholder }}
Contract Number{{ support_contract.contract|linkify:"contract_id"|placeholder }}
Support SKU{{ support_contract.sku|linkify|placeholder }}
Start Date{{ support_contract.contract.start }}
End Date{{ support_contract.contract.end }}{{ support_contract.end }}
39 | {% else %} 40 | 41 |
No Support Contract Assigned
42 | {% endif %} 43 |
44 | 45 | {% include "netbox_lifecycle/inc/hardware_lifecycle_info.html" %} 46 | -------------------------------------------------------------------------------- /netbox_lifecycle/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import metadata 2 | 3 | from netbox.plugins import PluginConfig 4 | 5 | metadata = metadata('netbox_lifecycle') 6 | 7 | 8 | class NetBoxLifeCycle(PluginConfig): 9 | name = metadata.get('Name').replace('-', '_') 10 | verbose_name = metadata.get('Name').replace('-', ' ').title() 11 | description = metadata.get('Summary') 12 | version = metadata.get('Version') 13 | author = metadata.get('Author') 14 | author_email = metadata.get('Author-email') 15 | base_url = 'lifecycle' 16 | min_version = '4.3.0' 17 | required_settings = [] 18 | default_settings = { 19 | 'lifecycle_card_position': 'right_page', 20 | 'contract_card_position': 'right_page', 21 | } 22 | queues = [] 23 | graphql_schema = 'graphql.schema.schema' 24 | 25 | def ready(self): 26 | 27 | super().ready() 28 | 29 | from django.contrib.contenttypes.fields import GenericRelation 30 | from dcim.models import DeviceType, ModuleType 31 | from netbox_lifecycle.models import HardwareLifecycle 32 | 33 | # Add Generic Relations to appropriate models 34 | GenericRelation( 35 | to=HardwareLifecycle, 36 | content_type_field='assigned_object_type', 37 | object_id_field='assigned_object_id', 38 | related_name='device_type', 39 | related_query_name='device_type', 40 | ).contribute_to_class(DeviceType, 'hardware_lifecycle') 41 | GenericRelation( 42 | to=HardwareLifecycle, 43 | content_type_field='assigned_object_type', 44 | object_id_field='assigned_object_id', 45 | related_name='module_type', 46 | related_query_name='module_type', 47 | ).contribute_to_class(ModuleType, 'hardware_lifecycle') 48 | 49 | 50 | config = NetBoxLifeCycle 51 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI Build 2 | on: 3 | release: 4 | types: released 5 | 6 | jobs: 7 | 8 | build: 9 | name: Build Distribution for PyPI 10 | runs-on: ubuntu-latest 11 | environment: release 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v4 15 | - name: Set up Python 3.12 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.12 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install --upgrade setuptools wheel 23 | python -m pip install build --user 24 | - name: Build a binary wheel and a source tarball 25 | run: python -m build 26 | - name: Store the distribution packages 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: python-package-distributions 30 | path: dist/ 31 | publish-to-testpypi: 32 | name: Publish Python 🐍 distribution 📦 to PyPI 33 | needs: 34 | - build 35 | runs-on: ubuntu-latest 36 | environment: 37 | name: testpypi 38 | url: https://test.pypi.org/p/netbox-lifecycle 39 | permissions: 40 | id-token: write 41 | steps: 42 | - name: Publish package to TestPyPI 43 | uses: pypa/gh-action-pypi-publish@release/v1 44 | with: 45 | repository_url: https://test.pypi.org/legacy/ 46 | skip_existing: true 47 | publish-to-pypi: 48 | name: Publish Python 🐍 distribution 📦 to PyPI 49 | needs: 50 | - build 51 | runs-on: ubuntu-latest 52 | environment: 53 | name: pypi 54 | url: https://pypi.org/p/netbox-lifecycle 55 | permissions: 56 | id-token: write 57 | steps: 58 | - name: Download all the dists 59 | uses: actions/download-artifact@v4 60 | with: 61 | name: python-package-distributions 62 | path: dist/ 63 | - name: Publish package 64 | uses: pypa/gh-action-pypi-publish@release/v1 65 | with: 66 | skip_existing: true -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/inc/hardware_lifecycle_info.html: -------------------------------------------------------------------------------- 1 | 2 | {% load filters %} 3 | {% load helpers %} 4 | {# renders panel on object with lifecycle info assigned to it #} 5 | 6 |
7 |
Lifecycle Dates 8 | {% if lifecycle_info %} 9 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
End of Sale{{ lifecycle_info.end_of_sale|placeholder }}
End of Maintenance Updates{{ lifecycle_info.end_of_maintenance|placeholder }}
End of Security Updates{{ lifecycle_info.end_of_security|placeholder }}
Last Support Contract Attach{{ lifecycle_info.last_contract_attach|placeholder }}
Last Support Contract Renewal{{ lifecycle_info.last_contract_renewal|placeholder }}
End of Support{{ lifecycle_info.end_of_support|placeholder }}
39 | {% else %} 40 | 41 |
No Lifecycle Dates Defined
42 | {% endif %} 43 |
44 | -------------------------------------------------------------------------------- /netbox_lifecycle/search.py: -------------------------------------------------------------------------------- 1 | from netbox.search import SearchIndex, register_search 2 | from netbox_lifecycle.models import * 3 | 4 | 5 | @register_search 6 | class VendorIndex(SearchIndex): 7 | model = Vendor 8 | fields = ( 9 | ('name', 100), 10 | ('description', 4000), 11 | ('comments', 5000), 12 | ) 13 | display_attrs = ('description',) 14 | 15 | 16 | @register_search 17 | class SupportSKUIndex(SearchIndex): 18 | model = SupportSKU 19 | fields = ( 20 | ('sku', 100), 21 | ('description', 4000), 22 | ('comments', 5000), 23 | ) 24 | display_attrs = ('manufacturer', 'description') 25 | 26 | 27 | @register_search 28 | class SupportContractIndex(SearchIndex): 29 | model = SupportContract 30 | fields = ( 31 | ('contract_id', 100), 32 | ('description', 4000), 33 | ('comments', 5000), 34 | ) 35 | display_attrs = ('vendor', 'start', 'renewal', 'end', 'description') 36 | 37 | 38 | @register_search 39 | class SupportContractAssignmentIndex(SearchIndex): 40 | model = SupportContractAssignment 41 | fields = ( 42 | ('contract', 100), 43 | ('sku', 300), 44 | ('device', 400), 45 | ('license', 500), 46 | ('description', 4000), 47 | ('comments', 5000), 48 | ) 49 | display_attrs = ('vendor', 'start', 'renewal', 'end', 'description') 50 | 51 | 52 | @register_search 53 | class LicenseIndex(SearchIndex): 54 | model = License 55 | fields = ( 56 | ('name', 100), 57 | ('description', 4000), 58 | ('comments', 5000), 59 | ) 60 | display_attrs = ('manufacturer', 'description') 61 | 62 | 63 | @register_search 64 | class LicenseAssignmentIndex(SearchIndex): 65 | model = LicenseAssignment 66 | fields = ( 67 | ('license', 100), 68 | ('vendor', 200), 69 | ('device', 300), 70 | ('description', 4000), 71 | ('comments', 5000), 72 | ) 73 | display_attrs = ('manufacturer', 'description') 74 | -------------------------------------------------------------------------------- /netbox_lifecycle/graphql/schema.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import strawberry 4 | import strawberry_django 5 | 6 | from .types import * 7 | 8 | 9 | @strawberry.type(name="Query") 10 | class VendorQuery: 11 | vendor: VendorType = strawberry_django.field() 12 | vendor_list: List[VendorType] = strawberry_django.field() 13 | 14 | 15 | @strawberry.type(name="Query") 16 | class SupportSKUQuery: 17 | support_sku: SupportSKUType = strawberry_django.field() 18 | support_sku_list: List[SupportSKUType] = strawberry_django.field() 19 | 20 | 21 | @strawberry.type(name="Query") 22 | class SupportContractQuery: 23 | support_contract: SupportContractType = strawberry_django.field() 24 | support_contract_list: List[SupportContractType] = strawberry_django.field() 25 | 26 | 27 | @strawberry.type(name="Query") 28 | class SupportContractAssignmentQuery: 29 | support_contract_assignment: SupportContractAssignmentType = ( 30 | strawberry_django.field() 31 | ) 32 | support_contract_assignment_list: List[SupportContractAssignmentType] = ( 33 | strawberry_django.field() 34 | ) 35 | 36 | 37 | @strawberry.type(name="Query") 38 | class LicenseQuery: 39 | license: LicenseType = strawberry_django.field() 40 | license_list: List[LicenseType] = strawberry_django.field() 41 | 42 | 43 | @strawberry.type(name="Query") 44 | class LicenseAssignmentQuery: 45 | license_assignment: LicenseAssignmentType = strawberry_django.field() 46 | license_assignment_list: List[LicenseAssignmentType] = strawberry_django.field() 47 | 48 | 49 | @strawberry.type(name="Query") 50 | class HardwareLifecycleQuery: 51 | hardware_lifecycle: HardwareLifecycleType = strawberry_django.field() 52 | hardware_lifecycle_list: List[HardwareLifecycleType] = strawberry_django.field() 53 | 54 | 55 | schema = [ 56 | VendorQuery, 57 | SupportSKUQuery, 58 | SupportContractQuery, 59 | SupportContractAssignmentQuery, 60 | LicenseQuery, 61 | LicenseAssignmentQuery, 62 | HardwareLifecycleQuery, 63 | ] 64 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/_serializers/hardware.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from rest_framework import serializers 3 | 4 | from netbox.api.fields import ContentTypeField 5 | from netbox.api.serializers import NetBoxModelSerializer 6 | from netbox_lifecycle.models import HardwareLifecycle 7 | 8 | 9 | __all__ = ('HardwareLifecycleSerializer',) 10 | 11 | 12 | class HardwareLifecycleSerializer(NetBoxModelSerializer): 13 | url = serializers.HyperlinkedIdentityField( 14 | view_name='plugins-api:netbox_lifecycle-api:hardwarelifecycle-detail' 15 | ) 16 | assigned_object_type = ContentTypeField(queryset=ContentType.objects.all()) 17 | 18 | end_of_sale = serializers.DateField(required=False, allow_null=True) 19 | end_of_maintenance = serializers.DateField(required=False, allow_null=True) 20 | end_of_security = serializers.DateField(required=False, allow_null=True) 21 | last_contract_attach = serializers.DateField(required=False, allow_null=True) 22 | last_contract_renewal = serializers.DateField(required=False, allow_null=True) 23 | end_of_support = serializers.DateField(required=False, allow_null=True) 24 | 25 | class Meta: 26 | model = HardwareLifecycle 27 | fields = ( 28 | 'url', 29 | 'id', 30 | 'display', 31 | 'assigned_object_type', 32 | 'assigned_object_id', 33 | 'end_of_sale', 34 | 'end_of_maintenance', 35 | 'end_of_security', 36 | 'last_contract_attach', 37 | 'last_contract_renewal', 38 | 'end_of_support', 39 | 'notice', 40 | 'documentation', 41 | 'description', 42 | 'comments', 43 | 'tags', 44 | 'custom_fields', 45 | ) 46 | brief_fields = ( 47 | 'url', 48 | 'id', 49 | 'display', 50 | 'assigned_object_type', 51 | 'assigned_object_id', 52 | 'end_of_sale', 53 | ) 54 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/license/assignments.html: -------------------------------------------------------------------------------- 1 | {% extends 'netbox_lifecycle/generic/base.html' %} 2 | {% load render_table from django_tables2 %} 3 | {% load helpers %} 4 | {% load static %} 5 | 6 | {% block content %} 7 | {% include 'inc/table_controls_htmx.html' with table_modal="LicenseAssignment_config" %} 8 | 9 |
10 | {% csrf_token %} 11 | 12 |
13 |
14 | {% include 'htmx/table.html' %} 15 |
16 |
17 | 18 |
19 |
20 | {% if 'bulk_edit' in actions %} 21 |
22 | 25 |
26 | {% endif %} 27 |
28 | {% if 'bulk_delete' in actions %} 29 | 32 | {% endif %} 33 |
34 |
35 | {% if 'add' in actions %} 36 |
37 | 38 | Add 39 | 40 |
41 | {% endif %} 42 |
43 |
44 | {% endblock %} 45 | 46 | {% block modals %} 47 | {{ block.super }} 48 | {% table_config_form table %} 49 | {% endblock modals %} -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/supportcontract/assignments.html: -------------------------------------------------------------------------------- 1 | {% extends 'netbox_lifecycle/generic/base.html' %} 2 | {% load render_table from django_tables2 %} 3 | {% load helpers %} 4 | {% load static %} 5 | 6 | {% block content %} 7 | {% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %} 8 | 9 |
10 | {% csrf_token %} 11 | 12 |
13 |
14 | {% include 'htmx/table.html' %} 15 |
16 |
17 | 18 |
19 |
20 | {% if 'bulk_edit' in actions %} 21 |
22 | 25 |
26 | {% endif %} 27 |
28 | {% if 'bulk_delete' in actions %} 29 | 32 | {% endif %} 33 |
34 |
35 | {% if 'add' in actions %} 36 |
37 | 38 | Add 39 | 40 |
41 | {% endif %} 42 |
43 |
44 | {% endblock %} 45 | 46 | {% block modals %} 47 | {{ block.super }} 48 | {% table_config_form table table_name="ObjectTable" %} 49 | {% endblock modals %} -------------------------------------------------------------------------------- /netbox_lifecycle/views/hardware.py: -------------------------------------------------------------------------------- 1 | from netbox.views.generic import ( 2 | ObjectListView, 3 | ObjectEditView, 4 | ObjectDeleteView, 5 | ObjectView, 6 | BulkEditView, 7 | BulkDeleteView, 8 | ) 9 | from netbox_lifecycle.filtersets import HardwareLifecycleFilterSet 10 | from netbox_lifecycle.forms import ( 11 | HardwareLifecycleFilterForm, 12 | HardwareLifecycleBulkEditForm, 13 | ) 14 | from netbox_lifecycle.forms.model_forms import HardwareLifecycleForm 15 | from netbox_lifecycle.models import HardwareLifecycle 16 | from netbox_lifecycle.tables import HardwareLifecycleTable 17 | from utilities.views import register_model_view 18 | 19 | 20 | __all__ = ( 21 | 'HardwareLifecycleListView', 22 | 'HardwareLifecycleView', 23 | 'HardwareLifecycleEditView', 24 | 'HardwareLifecycleBulkEditView', 25 | 'HardwareLifecycleDeleteView', 26 | 'HardwareLifecycleBulkDeleteView', 27 | ) 28 | 29 | 30 | @register_model_view(HardwareLifecycle, name='list') 31 | class HardwareLifecycleListView(ObjectListView): 32 | queryset = HardwareLifecycle.objects.all() 33 | table = HardwareLifecycleTable 34 | filterset = HardwareLifecycleFilterSet 35 | filterset_form = HardwareLifecycleFilterForm 36 | 37 | 38 | @register_model_view(HardwareLifecycle) 39 | class HardwareLifecycleView(ObjectView): 40 | queryset = HardwareLifecycle.objects.all() 41 | 42 | def get_extra_context(self, request, instance): 43 | 44 | return {} 45 | 46 | 47 | @register_model_view(HardwareLifecycle, 'edit') 48 | class HardwareLifecycleEditView(ObjectEditView): 49 | queryset = HardwareLifecycle.objects.all() 50 | form = HardwareLifecycleForm 51 | 52 | 53 | @register_model_view(HardwareLifecycle, 'bulk_edit') 54 | class HardwareLifecycleBulkEditView(BulkEditView): 55 | queryset = HardwareLifecycle.objects.all() 56 | filterset = HardwareLifecycleFilterSet 57 | table = HardwareLifecycleTable 58 | form = HardwareLifecycleBulkEditForm 59 | 60 | 61 | @register_model_view(HardwareLifecycle, 'delete') 62 | class HardwareLifecycleDeleteView(ObjectDeleteView): 63 | queryset = HardwareLifecycle.objects.all() 64 | 65 | 66 | @register_model_view(HardwareLifecycle, 'bulk_delete') 67 | class HardwareLifecycleBulkDeleteView(BulkDeleteView): 68 | queryset = HardwareLifecycle.objects.all() 69 | filterset = HardwareLifecycleFilterSet 70 | table = HardwareLifecycleTable 71 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/_serializers/license.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from dcim.api.serializers_.devices import DeviceSerializer 4 | from dcim.api.serializers_.manufacturers import ManufacturerSerializer 5 | from netbox.api.serializers import NetBoxModelSerializer 6 | from virtualization.api.serializers_.virtualmachines import VirtualMachineSerializer 7 | from netbox_lifecycle.api._serializers.vendor import VendorSerializer 8 | from netbox_lifecycle.models import License, LicenseAssignment 9 | 10 | __all__ = ( 11 | 'LicenseSerializer', 12 | 'LicenseAssignmentSerializer', 13 | ) 14 | 15 | 16 | class LicenseSerializer(NetBoxModelSerializer): 17 | url = serializers.HyperlinkedIdentityField( 18 | view_name='plugins-api:netbox_lifecycle-api:license-detail' 19 | ) 20 | manufacturer = ManufacturerSerializer(nested=True) 21 | 22 | class Meta: 23 | model = License 24 | fields = ( 25 | 'url', 26 | 'id', 27 | 'display', 28 | 'name', 29 | 'manufacturer', 30 | 'description', 31 | 'comments', 32 | 'tags', 33 | 'custom_fields', 34 | ) 35 | brief_fields = ( 36 | 'url', 37 | 'id', 38 | 'display', 39 | 'name', 40 | ) 41 | 42 | 43 | class LicenseAssignmentSerializer(NetBoxModelSerializer): 44 | url = serializers.HyperlinkedIdentityField( 45 | view_name='plugins-api:netbox_lifecycle-api:licenseassignment-detail' 46 | ) 47 | license = LicenseSerializer(nested=True) 48 | vendor = VendorSerializer(nested=True) 49 | device = DeviceSerializer(nested=True, required=False, allow_null=True) 50 | virtual_machine = VirtualMachineSerializer( 51 | nested=True, required=False, allow_null=True 52 | ) 53 | 54 | class Meta: 55 | model = LicenseAssignment 56 | fields = ( 57 | 'url', 58 | 'id', 59 | 'display', 60 | 'vendor', 61 | 'license', 62 | 'device', 63 | 'virtual_machine', 64 | 'quantity', 65 | 'description', 66 | 'comments', 67 | 'tags', 68 | 'custom_fields', 69 | ) 70 | brief_fields = ( 71 | 'url', 72 | 'id', 73 | 'display', 74 | 'vendor', 75 | 'license', 76 | 'device', 77 | 'virtual_machine', 78 | ) 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetBox Lifecycle Plugin 2 | 3 | The Netbox Lifecycle plugin is a Hardware EOS/EOL, License and Support Contract tracking plugin for NetBox. 4 | 5 | ## Features 6 | 7 | * Tracking EOL/EOS data for DeviceTypes and ModuleTypes 8 | * Tracking Licenses (assignable to Devices and Virtual Machines) 9 | * Tracking Support Contracts (assignable to Devices, Modules, and Virtual Machines) 10 | 11 | # Requirements 12 | 13 | * Netbox 4.1+ 14 | * Python 3.10+ 15 | 16 | ## Compatibility Matrix 17 | 18 | | | Netbox 3.2.x | NetBox 4.1.x | 19 | |--------|----------------|----------------| 20 | | 1.0.0+ | Compatible | Not Compatible | 21 | | 1.1.3+ | Not Compatible | Compatible | 22 | 23 | ## Installation 24 | 25 | To install, simply include this plugin in the plugins configuration section of netbox. 26 | 27 | Example: 28 | ```python 29 | PLUGINS = [ 30 | 'netbox_lifecycle' 31 | ], 32 | ``` 33 | 34 | ## Configuration 35 | 36 | The plugin can be configured via `PLUGINS_CONFIG` in your NetBox configuration file: 37 | 38 | ```python 39 | PLUGINS_CONFIG = { 40 | 'netbox_lifecycle': { 41 | 'lifecycle_card_position': 'right_page', 42 | 'contract_card_position': 'right_page', 43 | }, 44 | } 45 | ``` 46 | 47 | ### Available Settings 48 | 49 | | Setting | Default | Description | 50 | |---------|---------|-------------| 51 | | `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`. | 52 | | `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`. | 53 | 54 | ### Hardware Lifecycle Info Card 55 | 56 | Displays EOL/EOS information for the hardware type on Device, Module, DeviceType, and ModuleType detail pages. 57 | 58 | ### Support Contracts Card 59 | 60 | Displays all contract assignments on Device and VirtualMachine detail pages, grouped by status: 61 | 62 | - **Active**: Contracts currently in effect 63 | - **Future**: Contracts with a start date in the future 64 | - **Unspecified**: Contracts without an end date 65 | - **Expired**: Contracts that have ended (lazy-loaded for performance) 66 | 67 | ## Usage 68 | 69 | TBD 70 | 71 | ## Additional Notes 72 | 73 | TBD 74 | 75 | ## Contribute 76 | 77 | Contributions are always welcome! Please open an issue first before contributing as the scope is going to be kept 78 | intentionally narrow 79 | 80 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0005_remove_supportcontract_manufacturer_supportsku_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-11 16:26 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import taggit.managers 6 | import utilities.json 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('extras', '0092_delete_jobresult'), 13 | ('dcim', '0171_cabletermination_change_logging'), 14 | ('netbox_lifecycle', '0004_supportcontractassignment_and_more'), 15 | ] 16 | 17 | operations = [ 18 | migrations.RemoveField( 19 | model_name='supportcontract', 20 | name='manufacturer', 21 | ), 22 | migrations.CreateModel( 23 | name='SupportSKU', 24 | fields=[ 25 | ( 26 | 'id', 27 | models.BigAutoField( 28 | auto_created=True, primary_key=True, serialize=False 29 | ), 30 | ), 31 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 32 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 33 | ( 34 | 'custom_field_data', 35 | models.JSONField( 36 | blank=True, 37 | default=dict, 38 | encoder=utilities.json.CustomFieldJSONEncoder, 39 | ), 40 | ), 41 | ('sku', models.CharField(max_length=100)), 42 | ( 43 | 'manufacturer', 44 | models.ForeignKey( 45 | on_delete=django.db.models.deletion.CASCADE, 46 | to='dcim.manufacturer', 47 | ), 48 | ), 49 | ( 50 | 'tags', 51 | taggit.managers.TaggableManager( 52 | through='extras.TaggedItem', to='extras.Tag' 53 | ), 54 | ), 55 | ], 56 | options={ 57 | 'ordering': ['manufacturer', 'sku'], 58 | }, 59 | ), 60 | migrations.AddField( 61 | model_name='supportcontractassignment', 62 | name='sku', 63 | field=models.ForeignKey( 64 | blank=True, 65 | null=True, 66 | on_delete=django.db.models.deletion.PROTECT, 67 | to='netbox_lifecycle.supportsku', 68 | ), 69 | ), 70 | ] 71 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/supportcontract.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load buttons %} 3 | {% load custom_links %} 4 | {% load helpers %} 5 | {% load perms %} 6 | {% load plugins %} 7 | {% load tabs %} 8 | 9 | {% if perms.netbox_lifecycle.add_supportcontractassignment %} 10 | {% block extra_controls %} 11 | 13 | Add Assignment 14 | 15 | {% endblock %} 16 | {% endif %} 17 | 18 | {% block content %} 19 |
20 |
21 |
22 |
Contract
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
Manufacturer{{ object.manufacturer|linkify|placeholder }}
Vendor{{ object.vendor|linkify|placeholder }}
Contract ID{{ object.contract_id }}
Description{{ object.description }}
42 |
43 |
44 |
45 |
Dates
46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
Start{{ object.start }}
Last renewal{{ object.renewal }}
End{{ object.end }}
61 |
62 |
63 | {% plugin_left_page object %} 64 | {% include 'inc/panels/tags.html' %} 65 |
66 |
67 | {% include 'inc/panels/related_objects.html' %} 68 | {% include 'inc/panels/custom_fields.html' %} 69 | {% include 'inc/panels/comments.html' %} 70 | {% plugin_right_page object %} 71 |
72 |
73 |
74 |
75 | {% plugin_full_width_page object %} 76 |
77 |
78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /netbox_lifecycle/models/hardware.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericForeignKey 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from django.urls import reverse 5 | 6 | from dcim.models import DeviceType, ModuleType, Device, Module 7 | from netbox.models import PrimaryModel 8 | 9 | from netbox_lifecycle.constants import HARDWARE_LIFECYCLE_MODELS 10 | 11 | 12 | __all__ = ('HardwareLifecycle',) 13 | 14 | 15 | class HardwareLifecycle(PrimaryModel): 16 | assigned_object_type = models.ForeignKey( 17 | to=ContentType, 18 | limit_choices_to=HARDWARE_LIFECYCLE_MODELS, 19 | on_delete=models.PROTECT, 20 | related_name='+', 21 | blank=True, 22 | null=True, 23 | ) 24 | assigned_object_id = models.PositiveBigIntegerField(blank=True, null=True) 25 | assigned_object = GenericForeignKey( 26 | ct_field='assigned_object_type', fk_field='assigned_object_id' 27 | ) 28 | 29 | end_of_sale = models.DateField(blank=True, null=True) 30 | end_of_maintenance = models.DateField(blank=True, null=True) 31 | end_of_security = models.DateField(blank=True, null=True) 32 | last_contract_attach = models.DateField(blank=True, null=True) 33 | last_contract_renewal = models.DateField(blank=True, null=True) 34 | end_of_support = models.DateField(blank=True, null=True) 35 | 36 | notice = models.CharField(max_length=500, blank=True, null=True) 37 | documentation = models.CharField(max_length=500, blank=True, null=True) 38 | 39 | class Meta: 40 | ordering = ['assigned_object_type'] 41 | constraints = ( 42 | models.UniqueConstraint( 43 | 'assigned_object_type', 44 | 'assigned_object_id', 45 | name='%(app_label)s_%(class)s_unique_object', 46 | violation_error_message="Objects must be unique.", 47 | ), 48 | ) 49 | 50 | @property 51 | def name(self): 52 | return self 53 | 54 | def __str__(self): 55 | if not self.assigned_object: 56 | return f'{self.pk}' 57 | elif isinstance(self.assigned_object, ModuleType): 58 | return f'Module Type: {self.assigned_object.model}' 59 | return f'Device Type: {self.assigned_object.model}' 60 | 61 | @property 62 | def assigned_object_count(self): 63 | if isinstance(self.assigned_object, DeviceType): 64 | return Device.objects.filter(device_type=self.assigned_object).count() 65 | return Module.objects.filter(module_type=self.assigned_object).count() 66 | 67 | def get_absolute_url(self): 68 | return reverse('plugins:netbox_lifecycle:hardwarelifecycle', args=[self.pk]) 69 | -------------------------------------------------------------------------------- /netbox_lifecycle/filtersets/hardware.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.utils.translation import gettext as _ 4 | from django.db.models import Q 5 | 6 | from dcim.models import ModuleType, DeviceType 7 | from netbox.filtersets import NetBoxModelFilterSet 8 | from netbox_lifecycle.models import HardwareLifecycle 9 | 10 | 11 | __all__ = ('HardwareLifecycleFilterSet',) 12 | 13 | 14 | class HardwareLifecycleFilterSet(NetBoxModelFilterSet): 15 | assigned_object_type_id = django_filters.ModelMultipleChoiceFilter( 16 | queryset=ContentType.objects.all() 17 | ) 18 | device_type = django_filters.ModelMultipleChoiceFilter( 19 | field_name='device_type__model', 20 | queryset=DeviceType.objects.all(), 21 | to_field_name='model', 22 | label=_('Device Type (Model)'), 23 | method='filter_types', 24 | ) 25 | device_type_id = django_filters.ModelMultipleChoiceFilter( 26 | field_name='device_type', 27 | queryset=DeviceType.objects.all(), 28 | label=_('Device Type'), 29 | method='filter_types', 30 | ) 31 | module_type = django_filters.ModelMultipleChoiceFilter( 32 | field_name='module_type__model', 33 | queryset=ModuleType.objects.all(), 34 | to_field_name='model', 35 | label=_('Module Type (Model)'), 36 | method='filter_types', 37 | ) 38 | module_type_id = django_filters.ModelMultipleChoiceFilter( 39 | field_name='module_type', 40 | queryset=ModuleType.objects.all(), 41 | label=_('Module Type'), 42 | method='filter_types', 43 | ) 44 | 45 | class Meta: 46 | model = HardwareLifecycle 47 | fields = ( 48 | 'id', 49 | 'assigned_object_type_id', 50 | 'assigned_object_id', 51 | 'end_of_sale', 52 | 'end_of_maintenance', 53 | 'end_of_security', 54 | 'end_of_support', 55 | ) 56 | 57 | def search(self, queryset, name, value): 58 | if not value.strip(): 59 | return queryset 60 | qs_filter = Q( 61 | Q(device_type__model__icontains=value) 62 | | Q(module_type__model__icontains=value) 63 | ) 64 | return queryset.filter(qs_filter).distinct() 65 | 66 | def filter_types(self, queryset, name, value): 67 | if '__' in name: 68 | name, leftover = name.split('__', 1) # noqa F841 69 | 70 | if type(value) is list: 71 | name = f'{name}__in' 72 | 73 | if not value: 74 | return queryset 75 | try: 76 | return queryset.filter(**{f'{name}': value}) 77 | except ValueError: 78 | return queryset.none() 79 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature Request 3 | description: Propose a new Plugin feature or enhancement 4 | labels: ["type: feature"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: > 9 | **NOTE:** This form is only for submitting well-formed proposals to extend or modify 10 | the plugin in some way. If you're trying to solve a problem but can't figure out how, 11 | or if you still need time to work on the details of a proposed new feature, please 12 | start a [discussion](https://github.com/netbox-community/netbox/discussions) instead. 13 | - type: input 14 | attributes: 15 | label: Plugin version 16 | description: What version of the plugin are you currently running? 17 | placeholder: v1.0.0 18 | validations: 19 | required: true 20 | - type: input 21 | attributes: 22 | label: NetBox version 23 | description: What version of NetBox are you currently running? 24 | placeholder: v3.5.0 25 | validations: 26 | required: true 27 | - type: dropdown 28 | attributes: 29 | label: Feature type 30 | options: 31 | - Data model extension 32 | - New functionality 33 | - Change to existing functionality 34 | validations: 35 | required: true 36 | - type: textarea 37 | attributes: 38 | label: Proposed functionality 39 | description: > 40 | Describe in detail the new feature or behavior you are proposing. Include any specific changes 41 | to work flows, data models, and/or the user interface. The more detail you provide here, the 42 | greater chance your proposal has of being discussed. Feature requests which don't include an 43 | actionable implementation plan will be rejected. 44 | validations: 45 | required: true 46 | - type: textarea 47 | attributes: 48 | label: Use case 49 | description: > 50 | Explain how adding this functionality would benefit NetBox users. What need does it address? 51 | validations: 52 | required: true 53 | - type: textarea 54 | attributes: 55 | label: Database changes 56 | description: > 57 | Note any changes to the database schema necessary to support the new feature. For example, 58 | does the proposal require adding a new model or field? (Not all new features require database 59 | changes.) 60 | - type: textarea 61 | attributes: 62 | label: External dependencies 63 | description: > 64 | List any new dependencies on external libraries or services that this new feature would 65 | introduce. For example, does the proposal require the installation of a new Python package? 66 | (Not all new features introduce new dependencies.) 67 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0012_primarymodels.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-09-17 17:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("netbox_lifecycle", "0011_alter_supportcontractassignment"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="hardwarelifecycle", 15 | name="comments", 16 | field=models.TextField(blank=True), 17 | ), 18 | migrations.AddField( 19 | model_name="hardwarelifecycle", 20 | name="description", 21 | field=models.CharField(blank=True, max_length=200), 22 | ), 23 | migrations.AddField( 24 | model_name="license", 25 | name="comments", 26 | field=models.TextField(blank=True), 27 | ), 28 | migrations.AddField( 29 | model_name="license", 30 | name="description", 31 | field=models.CharField(blank=True, max_length=200), 32 | ), 33 | migrations.AddField( 34 | model_name="licenseassignment", 35 | name="comments", 36 | field=models.TextField(blank=True), 37 | ), 38 | migrations.AddField( 39 | model_name="licenseassignment", 40 | name="description", 41 | field=models.CharField(blank=True, max_length=200), 42 | ), 43 | migrations.AddField( 44 | model_name="supportcontract", 45 | name="comments", 46 | field=models.TextField(blank=True), 47 | ), 48 | migrations.AddField( 49 | model_name="supportcontract", 50 | name="description", 51 | field=models.CharField(blank=True, max_length=200), 52 | ), 53 | migrations.AddField( 54 | model_name="supportcontractassignment", 55 | name="comments", 56 | field=models.TextField(blank=True), 57 | ), 58 | migrations.AddField( 59 | model_name="supportcontractassignment", 60 | name="description", 61 | field=models.CharField(blank=True, max_length=200), 62 | ), 63 | migrations.AddField( 64 | model_name="supportsku", 65 | name="comments", 66 | field=models.TextField(blank=True), 67 | ), 68 | migrations.AddField( 69 | model_name="supportsku", 70 | name="description", 71 | field=models.CharField(blank=True, max_length=200), 72 | ), 73 | migrations.AddField( 74 | model_name="vendor", 75 | name="comments", 76 | field=models.TextField(blank=True), 77 | ), 78 | migrations.AddField( 79 | model_name="vendor", 80 | name="description", 81 | field=models.CharField(blank=True, max_length=200), 82 | ), 83 | ] 84 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | description: Report a reproducible bug in the current release of the plugin 4 | labels: ["type: bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: > 9 | **NOTE:** This form is only for reporting _reproducible bugs_ in a current NetBox installation 10 | with the current version of this plugin. If you're having trouble with installation or just looking for 11 | assistance with using NetBox, please visit our 12 | [discussion forum](https://github.com/netbox-community/netbox/discussions) instead. 13 | - type: input 14 | attributes: 15 | label: Plugin version 16 | description: > 17 | What version of the plugin are you running? 18 | placeholder: v1.0.0 19 | validations: 20 | required: true 21 | - type: input 22 | attributes: 23 | label: NetBox version 24 | description: > 25 | What version of NetBox are you currently running? (If you don't have access to the most 26 | recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) 27 | before opening a bug report to see if your issue has already been addressed.) 28 | placeholder: v3.2.0 29 | validations: 30 | required: true 31 | - type: dropdown 32 | attributes: 33 | label: Python version 34 | description: What version of Python are you currently running? 35 | options: 36 | - 3.8 37 | - 3.9 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: Steps to Reproduce 43 | description: > 44 | Describe in detail the exact steps that someone else can take to 45 | reproduce this bug using the current stable release of NetBox and the plugin. 46 | Begin with the creation of any necessary database objects and call out every 47 | operation being performed explicitly. If reporting a bug in the REST API, be 48 | sure to reconstruct the raw HTTP request(s) being made: Don't rely on a client 49 | library such as pynetbox. Additionally, **do not rely on the demo instance** 50 | for reproducing suspected bugs, as its data is prone to modification or 51 | deletion at any time. 52 | placeholder: | 53 | 1. Click on "create widget" 54 | 2. Set foo to 12 and bar to G 55 | 3. Click the "create" button 56 | validations: 57 | required: true 58 | - type: textarea 59 | attributes: 60 | label: Expected Behavior 61 | description: What did you expect to happen? 62 | placeholder: A new widget should have been created with the specified attributes 63 | validations: 64 | required: true 65 | - type: textarea 66 | attributes: 67 | label: Observed Behavior 68 | description: What happened instead? 69 | placeholder: A TypeError exception was raised 70 | validations: 71 | required: true 72 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/hardwarelifecycle.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load buttons %} 3 | {% load custom_links %} 4 | {% load helpers %} 5 | {% load filters %} 6 | {% load perms %} 7 | {% load plugins %} 8 | {% load tabs %} 9 | 10 | {% block content %} 11 |
12 |
13 |
14 |
{{ object.assigned_object_type.name|capfirst}}
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
Manufacturer{{ object.assigned_object.manufacturer|linkify }}
Object{{ object.assigned_object|linkify }}
Description{{ object.description }}
30 |
31 |
32 |
33 |
Dates
34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
End of Sale{{ object.end_of_sale|placeholder }}
End of Maintenance Updates{{ object.end_of_maintenance|placeholder }}
End of Security Updates{{ object.end_of_security|placeholder }}
Last Support Contract Attach{{ object.last_contract_attach|placeholder }}
Last Support Contract Renewal{{ object.last_contract_renewal|placeholder }}
End of Support{{ object.end_of_support|placeholder }}
61 |
62 |
63 | {% plugin_left_page object %} 64 | {% include 'inc/panels/tags.html' %} 65 |
66 |
67 | {% include 'inc/panels/related_objects.html' %} 68 | {% include 'inc/panels/custom_fields.html' %} 69 | {% include 'inc/panels/comments.html' %} 70 | {% plugin_right_page object %} 71 |
72 |
73 |
74 |
75 | {% plugin_full_width_page object %} 76 |
77 |
78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - 'develop' 6 | - 'main' 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | - reopened 12 | branches: 13 | - 'main' 14 | concurrency: 15 | group: ci-${{ github.event_name }}-${{ github.ref }}-${{ github.actor }} 16 | cancel-in-progress: true 17 | permissions: 18 | contents: read 19 | jobs: 20 | build: 21 | name: Check Build 22 | runs-on: ubuntu-latest 23 | env: 24 | NETBOX_CONFIGURATION: netbox.configuration_lifecycle 25 | strategy: 26 | matrix: 27 | python-version: ['3.10', '3.11', '3.12'] 28 | services: 29 | redis: 30 | image: redis 31 | ports: 32 | - 6379:6379 33 | postgres: 34 | image: postgres 35 | env: 36 | POSTGRES_USER: netbox 37 | POSTGRES_PASSWORD: netbox 38 | options: >- 39 | --health-cmd pg_isready 40 | --health-interval 10s 41 | --health-timeout 5s 42 | --health-retries 5 43 | ports: 44 | - 5432:5432 45 | 46 | steps: 47 | - name: Echo Github Variables 48 | run: | 49 | echo "${{ github.event_name }}" 50 | echo "${{ github.action }}" 51 | echo "${{ github.action_path }}" 52 | echo "${{ github.action_ref }}" 53 | 54 | - name: Check out NetBox 55 | uses: actions/checkout@v4 56 | with: 57 | repository: 'netbox-community/netbox' 58 | ref: 'main' 59 | path: 'netbox' 60 | 61 | 62 | - name: Check out repo 63 | uses: actions/checkout@v4 64 | with: 65 | path: 'netbox-lifecycle' 66 | 67 | - name: Set up Python ${{ matrix.python-version }} 68 | uses: actions/setup-python@v5 69 | with: 70 | python-version: ${{ matrix.python-version }} 71 | 72 | - name: Install dependencies & set up configuration 73 | run: | 74 | python -m pip install --upgrade pip 75 | pip install -r netbox/requirements.txt 76 | pip install ruff black coverage tblib 77 | pip install -e netbox-lifecycle 78 | cp netbox-lifecycle/contrib/configuration_lifecycle.py netbox/netbox/netbox/configuration_lifecycle.py 79 | 80 | - name: Collect static files 81 | run: python netbox/netbox/manage.py collectstatic --no-input 82 | 83 | - name: Check for missing migrations 84 | run: python netbox/netbox/manage.py makemigrations --check 85 | 86 | - name: Check PEP8 compliance 87 | run: | 88 | ruff check netbox-lifecycle/netbox_lifecycle/ 89 | 90 | - name: Check Black 91 | uses: psf/black@stable 92 | with: 93 | options: "--check --skip-string-normalization" 94 | src: "netbox-lifecycle/netbox_lifecycle" 95 | 96 | - name: Run tests 97 | run: coverage run --source="netbox-lifecycle/netbox_lifecycle/" netbox/netbox/manage.py test netbox-lifecycle/netbox_lifecycle/ --parallel 98 | 99 | - name: Show coverage report 100 | run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*' 101 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0003_remove_supportcontract_devices_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-11 01:35 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import taggit.managers 6 | import utilities.json 7 | 8 | 9 | def migrate_to_assignments(apps, schema_editor): 10 | SupportContractDeviceAssignment = apps.get_model( 11 | 'netbox_lifecycle', 'SupportContractDeviceAssignment' 12 | ) 13 | SupportContract = apps.get_model('netbox_lifecycle', 'SupportContract') 14 | 15 | for contract in SupportContract.objects.all(): 16 | for device in contract.devices.all(): 17 | SupportContractDeviceAssignment.objects.create( 18 | contract=contract, device=device 19 | ) 20 | 21 | 22 | def migrate_from_assignments(apps, schema_editor): 23 | SupportContractDeviceAssignment = apps.get_model( 24 | 'netbox_lifecycle', 'SupportContractDeviceAssignment' 25 | ) 26 | 27 | for contract in SupportContractDeviceAssignment.objects.all(): 28 | contract.contract.devices.add(contract.device) 29 | 30 | 31 | class Migration(migrations.Migration): 32 | 33 | dependencies = [ 34 | ('dcim', '0171_cabletermination_change_logging'), 35 | ('extras', '0092_delete_jobresult'), 36 | ('netbox_lifecycle', '0002_license_licenseassignment'), 37 | ] 38 | 39 | operations = [ 40 | migrations.CreateModel( 41 | name='SupportContractDeviceAssignment', 42 | fields=[ 43 | ( 44 | 'id', 45 | models.BigAutoField( 46 | auto_created=True, primary_key=True, serialize=False 47 | ), 48 | ), 49 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 50 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 51 | ( 52 | 'custom_field_data', 53 | models.JSONField( 54 | blank=True, 55 | default=dict, 56 | encoder=utilities.json.CustomFieldJSONEncoder, 57 | ), 58 | ), 59 | ( 60 | 'contract', 61 | models.ForeignKey( 62 | on_delete=django.db.models.deletion.CASCADE, 63 | to='netbox_lifecycle.supportcontract', 64 | ), 65 | ), 66 | ( 67 | 'device', 68 | models.ForeignKey( 69 | on_delete=django.db.models.deletion.CASCADE, to='dcim.device' 70 | ), 71 | ), 72 | ( 73 | 'tags', 74 | taggit.managers.TaggableManager( 75 | through='extras.TaggedItem', to='extras.Tag' 76 | ), 77 | ), 78 | ], 79 | options={ 80 | 'ordering': ['contract', 'device'], 81 | }, 82 | ), 83 | migrations.RunPython(migrate_to_assignments, migrate_from_assignments), 84 | migrations.RemoveField( 85 | model_name='supportcontract', 86 | name='devices', 87 | ), 88 | ] 89 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0016_add_virtual_machine_support.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import django.db.models.deletion 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('virtualization', '0023_squashed_0036'), 9 | ('netbox_lifecycle', '0015_supportcontractassignment_module'), 10 | ] 11 | 12 | operations = [ 13 | # Add virtual_machine to LicenseAssignment 14 | migrations.AddField( 15 | model_name='licenseassignment', 16 | name='virtual_machine', 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | related_name='licenses', 22 | to='virtualization.virtualmachine', 23 | ), 24 | ), 25 | # Add virtual_machine to SupportContractAssignment 26 | migrations.AddField( 27 | model_name='supportcontractassignment', 28 | name='virtual_machine', 29 | field=models.ForeignKey( 30 | blank=True, 31 | null=True, 32 | on_delete=django.db.models.deletion.SET_NULL, 33 | related_name='contracts', 34 | to='virtualization.virtualmachine', 35 | ), 36 | ), 37 | # Update ordering for LicenseAssignment 38 | migrations.AlterModelOptions( 39 | name='licenseassignment', 40 | options={'ordering': ['license', 'device', 'virtual_machine']}, 41 | ), 42 | # Update ordering for SupportContractAssignment 43 | migrations.AlterModelOptions( 44 | name='supportcontractassignment', 45 | options={ 46 | 'ordering': [ 47 | 'contract', 48 | 'device', 49 | 'virtual_machine', 50 | 'module', 51 | 'license', 52 | ] 53 | }, 54 | ), 55 | # Remove old constraint from LicenseAssignment if it exists 56 | migrations.RemoveConstraint( 57 | model_name='licenseassignment', 58 | name='netbox_lifecycle_licenseassignment_unique_license_vendor_device', 59 | ), 60 | # Add check constraint for mutual exclusivity in LicenseAssignment 61 | migrations.AddConstraint( 62 | model_name='licenseassignment', 63 | constraint=models.CheckConstraint( 64 | check=models.Q(device__isnull=True, virtual_machine__isnull=False) 65 | | models.Q(device__isnull=False, virtual_machine__isnull=True) 66 | | models.Q(device__isnull=True, virtual_machine__isnull=True), 67 | name='netbox_lifecycle_licenseassignment_device_vm_exclusive', 68 | violation_error_message='Device and virtual machine are mutually exclusive.', 69 | ), 70 | ), 71 | # Add check constraint for mutual exclusivity in SupportContractAssignment 72 | migrations.AddConstraint( 73 | model_name='supportcontractassignment', 74 | constraint=models.CheckConstraint( 75 | check=models.Q(device__isnull=True, virtual_machine__isnull=False) 76 | | models.Q(device__isnull=False, virtual_machine__isnull=True) 77 | | models.Q(device__isnull=True, virtual_machine__isnull=True), 78 | name='netbox_lifecycle_supportcontractassignment_device_vm_exclusive', 79 | violation_error_message='Device and virtual machine are mutually exclusive.', 80 | ), 81 | ), 82 | ] 83 | -------------------------------------------------------------------------------- /netbox_lifecycle/filtersets/license.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from django.db.models import Q 3 | from django.utils.translation import gettext as _ 4 | 5 | from dcim.models import Manufacturer, Device 6 | from netbox.filtersets import NetBoxModelFilterSet 7 | from virtualization.models import VirtualMachine 8 | from netbox_lifecycle.models import Vendor, License, LicenseAssignment 9 | 10 | __all__ = ( 11 | 'LicenseFilterSet', 12 | 'LicenseAssignmentFilterSet', 13 | ) 14 | 15 | 16 | class LicenseFilterSet(NetBoxModelFilterSet): 17 | manufacturer_id = django_filters.ModelMultipleChoiceFilter( 18 | field_name='manufacturer', 19 | queryset=Manufacturer.objects.all(), 20 | label=_('Manufacturer'), 21 | ) 22 | manufacturer = django_filters.ModelMultipleChoiceFilter( 23 | field_name='manufacturer__slug', 24 | queryset=Manufacturer.objects.all(), 25 | to_field_name='slug', 26 | label=_('Manufacturer (Slug)'), 27 | ) 28 | 29 | class Meta: 30 | model = License 31 | fields = ( 32 | 'id', 33 | 'q', 34 | 'name', 35 | ) 36 | 37 | def search(self, queryset, name, value): 38 | if not value.strip(): 39 | return queryset 40 | qs_filter = Q(manufacturer__name__icontains=value) | Q(name__icontains=value) 41 | return queryset.filter(qs_filter).distinct() 42 | 43 | 44 | class LicenseAssignmentFilterSet(NetBoxModelFilterSet): 45 | license_id = django_filters.ModelMultipleChoiceFilter( 46 | field_name='license', 47 | queryset=License.objects.all(), 48 | label=_('License'), 49 | ) 50 | license = django_filters.ModelMultipleChoiceFilter( 51 | field_name='license__name', 52 | queryset=License.objects.all(), 53 | to_field_name='name', 54 | label=_('License'), 55 | ) 56 | vendor_id = django_filters.ModelMultipleChoiceFilter( 57 | field_name='vendor', 58 | queryset=Vendor.objects.all(), 59 | label=_('Vendor'), 60 | ) 61 | vendor = django_filters.ModelMultipleChoiceFilter( 62 | field_name='vendor__name', 63 | queryset=Vendor.objects.all(), 64 | to_field_name='name', 65 | label=_('Vendor'), 66 | ) 67 | device_id = django_filters.ModelMultipleChoiceFilter( 68 | field_name='device', 69 | queryset=Device.objects.all(), 70 | label=_('Device'), 71 | ) 72 | device = django_filters.ModelMultipleChoiceFilter( 73 | field_name='device__name', 74 | queryset=Device.objects.all(), 75 | to_field_name='name', 76 | label=_('Device'), 77 | ) 78 | virtual_machine_id = django_filters.ModelMultipleChoiceFilter( 79 | field_name='virtual_machine', 80 | queryset=VirtualMachine.objects.all(), 81 | label=_('Virtual Machine'), 82 | ) 83 | virtual_machine = django_filters.ModelMultipleChoiceFilter( 84 | field_name='virtual_machine__name', 85 | queryset=VirtualMachine.objects.all(), 86 | to_field_name='name', 87 | label=_('Virtual Machine'), 88 | ) 89 | 90 | class Meta: 91 | model = LicenseAssignment 92 | fields = ( 93 | 'id', 94 | 'q', 95 | ) 96 | 97 | def search(self, queryset, name, value): 98 | if not value.strip(): 99 | return queryset 100 | qs_filter = ( 101 | Q(license__manufacturer__name__icontains=value) 102 | | Q(license__name__icontains=value) 103 | | Q(vendor__name__icontains=value) 104 | | Q(device__name__icontains=value) 105 | | Q(virtual_machine__name__icontains=value) 106 | ) 107 | return queryset.filter(qs_filter).distinct() 108 | -------------------------------------------------------------------------------- /netbox_lifecycle/graphql/types.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, Union 2 | 3 | import strawberry 4 | import strawberry_django 5 | 6 | from dcim.graphql.types import ( 7 | ManufacturerType, 8 | DeviceType, 9 | DeviceTypeType, 10 | ModuleTypeType, 11 | ModuleType, 12 | ) 13 | from virtualization.graphql.types import VirtualMachineType 14 | from netbox.graphql.types import NetBoxObjectType 15 | from .filters import * 16 | 17 | from netbox_lifecycle import models 18 | 19 | __all__ = ( 20 | 'VendorType', 21 | 'SupportSKUType', 22 | 'SupportContractType', 23 | 'SupportContractAssignmentType', 24 | 'LicenseType', 25 | 'LicenseAssignmentType', 26 | 'HardwareLifecycleType', 27 | ) 28 | 29 | 30 | @strawberry_django.type(models.Vendor, fields='__all__', filters=VendorFilter) 31 | class VendorType(NetBoxObjectType): 32 | name: str 33 | 34 | 35 | @strawberry_django.type(models.SupportSKU, fields='__all__', filters=SupportSKUFilter) 36 | class SupportSKUType(NetBoxObjectType): 37 | 38 | sku: str 39 | manufacturer: ManufacturerType 40 | 41 | 42 | @strawberry_django.type( 43 | models.SupportContract, fields='__all__', filters=SupportContractFilter 44 | ) 45 | class SupportContractType(NetBoxObjectType): 46 | 47 | vendor: VendorType 48 | contract_id: str 49 | start: str | None 50 | renewal: str | None 51 | end: str | None 52 | 53 | 54 | @strawberry_django.type(models.License, fields='__all__', filters=LicenseFilter) 55 | class LicenseType(NetBoxObjectType): 56 | 57 | manufacturer: ManufacturerType 58 | name: str 59 | 60 | 61 | @strawberry_django.type( 62 | models.SupportContractAssignment, 63 | fields='__all__', 64 | filters=SupportContractAssignmentFilter, 65 | ) 66 | class SupportContractAssignmentType(NetBoxObjectType): 67 | contract: SupportContractType 68 | sku: SupportSKUType | None 69 | device: DeviceType | None 70 | module: ModuleType | None 71 | virtual_machine: VirtualMachineType | None 72 | license: LicenseType | None 73 | end: str | None 74 | 75 | 76 | @strawberry_django.type( 77 | models.LicenseAssignment, fields='__all__', filters=LicenseAssignmentFilter 78 | ) 79 | class LicenseAssignmentType(NetBoxObjectType): 80 | license: LicenseType 81 | vendor: VendorType 82 | device: DeviceType | None 83 | virtual_machine: VirtualMachineType | None 84 | quantity: int | None 85 | 86 | 87 | @strawberry_django.type( 88 | models.HardwareLifecycle, fields='__all__', filters=HardwareLifecycleFilter 89 | ) 90 | class HardwareLifecycleType(NetBoxObjectType): 91 | assigned_object_type: ( 92 | Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None 93 | ) 94 | assigned_object_id: int 95 | assigned_object: ( 96 | Annotated[ 97 | Union[ 98 | Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')], 99 | Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')], 100 | ], 101 | strawberry.union("HardwareLifecycleObjectTypes"), 102 | ] 103 | | None 104 | ) 105 | end_of_sale: str 106 | end_of_maintenance: str | None 107 | end_of_security: str | None 108 | last_contract_attach: str | None 109 | last_contract_renewal: str | None 110 | end_of_support: str 111 | notice: str | None 112 | documentation: str | None 113 | 114 | 115 | class HardwareLifecycleObjectTypes: 116 | class Meta: 117 | types = ( 118 | DeviceTypeType, 119 | ModuleTypeType, 120 | ) 121 | 122 | @classmethod 123 | def resolve_type(cls, instance, info): 124 | if type(instance) is DeviceType: 125 | return DeviceTypeType 126 | if type(instance) is ModuleType: 127 | return ModuleTypeType 128 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | *.egg-info/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | .idea 162 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0002_license_licenseassignment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-10 21:53 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import taggit.managers 6 | import utilities.json 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('dcim', '0171_cabletermination_change_logging'), 13 | ('extras', '0092_delete_jobresult'), 14 | ('netbox_lifecycle', '0001_initial'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='License', 20 | fields=[ 21 | ( 22 | 'id', 23 | models.BigAutoField( 24 | auto_created=True, primary_key=True, serialize=False 25 | ), 26 | ), 27 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 28 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 29 | ( 30 | 'custom_field_data', 31 | models.JSONField( 32 | blank=True, 33 | default=dict, 34 | encoder=utilities.json.CustomFieldJSONEncoder, 35 | ), 36 | ), 37 | ('name', models.CharField(max_length=100)), 38 | ( 39 | 'manufacturer', 40 | models.ForeignKey( 41 | on_delete=django.db.models.deletion.CASCADE, 42 | to='dcim.manufacturer', 43 | ), 44 | ), 45 | ( 46 | 'tags', 47 | taggit.managers.TaggableManager( 48 | through='extras.TaggedItem', to='extras.Tag' 49 | ), 50 | ), 51 | ], 52 | options={ 53 | 'ordering': ['manufacturer', 'name'], 54 | }, 55 | ), 56 | migrations.CreateModel( 57 | name='LicenseAssignment', 58 | fields=[ 59 | ( 60 | 'id', 61 | models.BigAutoField( 62 | auto_created=True, primary_key=True, serialize=False 63 | ), 64 | ), 65 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 66 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 67 | ( 68 | 'custom_field_data', 69 | models.JSONField( 70 | blank=True, 71 | default=dict, 72 | encoder=utilities.json.CustomFieldJSONEncoder, 73 | ), 74 | ), 75 | ( 76 | 'device', 77 | models.ForeignKey( 78 | on_delete=django.db.models.deletion.CASCADE, to='dcim.device' 79 | ), 80 | ), 81 | ( 82 | 'license', 83 | models.ForeignKey( 84 | on_delete=django.db.models.deletion.CASCADE, 85 | to='netbox_lifecycle.license', 86 | ), 87 | ), 88 | ( 89 | 'tags', 90 | taggit.managers.TaggableManager( 91 | through='extras.TaggedItem', to='extras.Tag' 92 | ), 93 | ), 94 | ( 95 | 'vendor', 96 | models.ForeignKey( 97 | on_delete=django.db.models.deletion.CASCADE, 98 | to='netbox_lifecycle.vendor', 99 | ), 100 | ), 101 | ], 102 | options={ 103 | 'ordering': ['license', 'device'], 104 | }, 105 | ), 106 | ] 107 | -------------------------------------------------------------------------------- /netbox_lifecycle/api/_serializers/contract.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from dcim.api.serializers_.devices import DeviceSerializer, ModuleSerializer 4 | from dcim.api.serializers_.manufacturers import ManufacturerSerializer 5 | from netbox.api.serializers import NetBoxModelSerializer 6 | from virtualization.api.serializers_.virtualmachines import VirtualMachineSerializer 7 | from netbox_lifecycle.api._serializers.license import LicenseAssignmentSerializer 8 | from netbox_lifecycle.api._serializers.vendor import VendorSerializer 9 | from netbox_lifecycle.models import ( 10 | SupportContract, 11 | SupportContractAssignment, 12 | SupportSKU, 13 | ) 14 | 15 | __all__ = ( 16 | 'SupportSKUSerializer', 17 | 'SupportContractSerializer', 18 | 'SupportContractAssignmentSerializer', 19 | ) 20 | 21 | 22 | class SupportSKUSerializer(NetBoxModelSerializer): 23 | url = serializers.HyperlinkedIdentityField( 24 | view_name='plugins-api:netbox_lifecycle-api:supportsku-detail' 25 | ) 26 | manufacturer = ManufacturerSerializer(nested=True) 27 | 28 | class Meta: 29 | model = SupportSKU 30 | fields = ( 31 | 'url', 32 | 'id', 33 | 'display', 34 | 'manufacturer', 35 | 'sku', 36 | 'description', 37 | 'comments', 38 | 'tags', 39 | 'custom_fields', 40 | ) 41 | brief_fields = ( 42 | 'url', 43 | 'id', 44 | 'display', 45 | 'manufacturer', 46 | 'sku', 47 | ) 48 | 49 | 50 | class SupportContractSerializer(NetBoxModelSerializer): 51 | url = serializers.HyperlinkedIdentityField( 52 | view_name='plugins-api:netbox_lifecycle-api:supportcontract-detail' 53 | ) 54 | vendor = VendorSerializer(nested=True) 55 | start = serializers.DateField(required=False) 56 | renewal = serializers.DateField(required=False) 57 | end = serializers.DateField(required=False) 58 | 59 | class Meta: 60 | model = SupportContract 61 | fields = ( 62 | 'url', 63 | 'id', 64 | 'display', 65 | 'vendor', 66 | 'contract_id', 67 | 'start', 68 | 'renewal', 69 | 'end', 70 | 'description', 71 | 'comments', 72 | 'tags', 73 | 'custom_fields', 74 | ) 75 | brief_fields = ( 76 | 'url', 77 | 'id', 78 | 'display', 79 | 'vendor', 80 | 'contract_id', 81 | ) 82 | 83 | 84 | class SupportContractAssignmentSerializer(NetBoxModelSerializer): 85 | url = serializers.HyperlinkedIdentityField( 86 | view_name='plugins-api:netbox_lifecycle-api:supportcontractassignment-detail' 87 | ) 88 | contract = SupportContractSerializer(nested=True) 89 | sku = SupportSKUSerializer(nested=True, required=False, allow_null=True) 90 | device = DeviceSerializer(nested=True, required=False, allow_null=True) 91 | module = ModuleSerializer(nested=True, required=False, allow_null=True) 92 | virtual_machine = VirtualMachineSerializer( 93 | nested=True, required=False, allow_null=True 94 | ) 95 | license = LicenseAssignmentSerializer(nested=True, required=False, allow_null=True) 96 | 97 | class Meta: 98 | model = SupportContractAssignment 99 | fields = ( 100 | 'url', 101 | 'id', 102 | 'display', 103 | 'contract', 104 | 'sku', 105 | 'device', 106 | 'module', 107 | 'virtual_machine', 108 | 'license', 109 | 'end', 110 | 'description', 111 | 'comments', 112 | 'tags', 113 | 'custom_fields', 114 | ) 115 | 116 | brief_fields = ( 117 | 'url', 118 | 'id', 119 | 'display', 120 | 'contract', 121 | 'sku', 122 | 'device', 123 | 'module', 124 | 'virtual_machine', 125 | 'license', 126 | ) 127 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0011_alter_supportcontractassignment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.4 on 2024-03-13 04:24 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | def migrate_assigned_object_forward(apps, schema_editor): 8 | SupportContractAssignment = apps.get_model( 9 | 'netbox_lifecycle', 'SupportContractAssignment' 10 | ) 11 | LicenseAssignment = apps.get_model('netbox_lifecycle', 'LicenseAssignment') 12 | Device = apps.get_model('dcim', 'Device') 13 | ContentType = apps.get_model('contenttypes', 'ContentType') 14 | 15 | for assignment in SupportContractAssignment.objects.all(): 16 | if assignment.assigned_object_type == ContentType.objects.get( 17 | app_label='dcim', model='device' 18 | ): 19 | device = Device.objects.get(pk=assignment.assigned_object_id) 20 | assignment.device = device 21 | assignment.save() 22 | else: 23 | license_assignment = LicenseAssignment.objects.get( 24 | pk=assignment.assigned_object_id 25 | ) 26 | assignment.device = license_assignment.device 27 | assignment.license = license_assignment 28 | assignment.save() 29 | 30 | 31 | def migrate_assigned_object_reverse(apps, schema_editor): 32 | SupportContractAssignment = apps.get_model( 33 | 'netbox_lifecycle', 'SupportContractAssignment' 34 | ) 35 | ContentType = apps.get_model('contenttypes', 'ContentType') 36 | 37 | device_type = ContentType.objects.get(app_label='dcim', model='device') 38 | license_type = ContentType.objects.get( 39 | app_label='netbox_lifecycle', model='licenseassignment' 40 | ) 41 | 42 | for assignment in SupportContractAssignment.objects.all(): 43 | if assignment.license is None: 44 | assignment.assigned_object_type = device_type 45 | assignment.assigned_object_id = assignment.device.pk 46 | assignment.save() 47 | else: 48 | assignment.assigned_object_id = license_type 49 | assignment.assigned_object_id = assignment.license.pk 50 | assignment.save() 51 | 52 | 53 | class Migration(migrations.Migration): 54 | 55 | dependencies = [ 56 | ('dcim', '0185_gfk_indexes'), 57 | ('netbox_lifecycle', '0010_licenseassignment_quantity'), 58 | ] 59 | 60 | operations = [ 61 | migrations.AddField( 62 | model_name='supportcontractassignment', 63 | name='device', 64 | field=models.ForeignKey( 65 | blank=True, 66 | null=True, 67 | on_delete=django.db.models.deletion.SET_NULL, 68 | related_name='contracts', 69 | to='dcim.device', 70 | ), 71 | ), 72 | migrations.AddField( 73 | model_name='supportcontractassignment', 74 | name='license', 75 | field=models.ForeignKey( 76 | blank=True, 77 | null=True, 78 | on_delete=django.db.models.deletion.SET_NULL, 79 | related_name='contracts', 80 | to='netbox_lifecycle.licenseassignment', 81 | ), 82 | ), 83 | migrations.RunPython( 84 | migrate_assigned_object_forward, migrate_assigned_object_reverse 85 | ), 86 | migrations.AlterModelOptions( 87 | name='supportcontractassignment', 88 | options={'ordering': ['contract', 'device', 'license']}, 89 | ), 90 | migrations.RemoveConstraint( 91 | model_name='supportcontractassignment', 92 | name='netbox_lifecycle_supportcontractassignment_unique_assignments', 93 | ), 94 | migrations.RemoveConstraint( 95 | model_name='supportcontractassignment', 96 | name='netbox_lifecycle_supportcontractassignment_unique_assignment_null_sku', 97 | ), 98 | migrations.RemoveField( 99 | model_name='supportcontractassignment', 100 | name='assigned_object_id', 101 | ), 102 | migrations.RemoveField( 103 | model_name='supportcontractassignment', 104 | name='assigned_object_type', 105 | ), 106 | ] 107 | -------------------------------------------------------------------------------- /netbox_lifecycle/forms/bulk_edit.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext as _ 3 | 4 | from netbox.forms import NetBoxModelBulkEditForm 5 | from utilities.forms.fields import DynamicModelChoiceField, CommentField 6 | 7 | from netbox_lifecycle.models import ( 8 | SupportContract, 9 | SupportSKU, 10 | SupportContractAssignment, 11 | LicenseAssignment, 12 | License, 13 | HardwareLifecycle, 14 | Vendor, 15 | ) 16 | from utilities.forms.rendering import FieldSet 17 | from utilities.forms.widgets import DatePicker 18 | 19 | 20 | class VendorBulkEditForm(NetBoxModelBulkEditForm): 21 | description = forms.CharField( 22 | label=_('Description'), max_length=200, required=False 23 | ) 24 | comments = CommentField() 25 | 26 | model = Vendor 27 | fieldsets = ( 28 | FieldSet( 29 | 'description', 30 | ), 31 | ) 32 | nullable_fields = ('description',) 33 | 34 | 35 | class SupportSKUBulkEditForm(NetBoxModelBulkEditForm): 36 | description = forms.CharField( 37 | label=_('Description'), max_length=200, required=False 38 | ) 39 | comments = CommentField() 40 | 41 | model = SupportSKU 42 | fieldsets = ( 43 | FieldSet( 44 | 'description', 45 | ), 46 | ) 47 | nullable_fields = ('description',) 48 | 49 | 50 | class SupportContractBulkEditForm(NetBoxModelBulkEditForm): 51 | description = forms.CharField( 52 | label=_('Description'), max_length=200, required=False 53 | ) 54 | comments = CommentField() 55 | 56 | model = SupportContract 57 | fieldsets = ( 58 | FieldSet( 59 | 'description', 60 | ), 61 | ) 62 | nullable_fields = ('description',) 63 | 64 | 65 | class SupportContractAssignmentBulkEditForm(NetBoxModelBulkEditForm): 66 | contract = DynamicModelChoiceField( 67 | queryset=SupportContract.objects.all(), 68 | label=_('Contract'), 69 | required=False, 70 | selector=True, 71 | ) 72 | sku = DynamicModelChoiceField( 73 | queryset=SupportSKU.objects.all(), label=_('SKU'), required=False, selector=True 74 | ) 75 | description = forms.CharField( 76 | label=_('Description'), max_length=200, required=False 77 | ) 78 | end = forms.DateField( 79 | label=_('End date'), 80 | required=False, 81 | widget=DatePicker(), 82 | ) 83 | comments = CommentField() 84 | 85 | model = SupportContractAssignment 86 | fieldsets = ( 87 | FieldSet( 88 | 'contract', 89 | 'sku', 90 | 'description', 91 | 'end', 92 | ), 93 | ) 94 | nullable_fields = () 95 | 96 | 97 | class LicenseBulkEditForm(NetBoxModelBulkEditForm): 98 | description = forms.CharField( 99 | label=_('Description'), max_length=200, required=False 100 | ) 101 | comments = CommentField() 102 | 103 | model = License 104 | fieldsets = ( 105 | FieldSet( 106 | 'description', 107 | ), 108 | ) 109 | nullable_fields = ('description',) 110 | 111 | 112 | class LicenseAssignmentBulkEditForm(NetBoxModelBulkEditForm): 113 | vendor = DynamicModelChoiceField( 114 | queryset=SupportSKU.objects.all(), label=_('SKU'), required=False, selector=True 115 | ) 116 | license = DynamicModelChoiceField( 117 | queryset=SupportContract.objects.all(), 118 | label=_('Contract'), 119 | required=False, 120 | selector=True, 121 | ) 122 | description = forms.CharField( 123 | label=_('Description'), max_length=200, required=False 124 | ) 125 | comments = CommentField() 126 | 127 | model = LicenseAssignment 128 | fieldsets = ( 129 | FieldSet( 130 | 'vendor', 131 | 'license', 132 | 'quantity', 133 | 'description', 134 | ), 135 | ) 136 | nullable_fields = ('quantity',) 137 | 138 | 139 | class HardwareLifecycleBulkEditForm(NetBoxModelBulkEditForm): 140 | description = forms.CharField( 141 | label=_('Description'), max_length=200, required=False 142 | ) 143 | comments = CommentField() 144 | 145 | model = HardwareLifecycle 146 | fieldsets = ( 147 | FieldSet( 148 | 'description', 149 | ), 150 | ) 151 | nullable_fields = ('description',) 152 | -------------------------------------------------------------------------------- /netbox_lifecycle/views/license.py: -------------------------------------------------------------------------------- 1 | from netbox.views.generic import ( 2 | ObjectListView, 3 | ObjectEditView, 4 | ObjectDeleteView, 5 | ObjectView, 6 | ObjectChildrenView, 7 | BulkEditView, 8 | BulkDeleteView, 9 | ) 10 | from netbox_lifecycle.filtersets import LicenseFilterSet, LicenseAssignmentFilterSet 11 | from netbox_lifecycle.forms import ( 12 | LicenseFilterForm, 13 | LicenseForm, 14 | LicenseAssignmentForm, 15 | LicenseAssignmentBulkEditForm, 16 | LicenseAssignmentFilterForm, 17 | LicenseBulkEditForm, 18 | ) 19 | from netbox_lifecycle.models import License, LicenseAssignment 20 | from netbox_lifecycle.tables import LicenseTable, LicenseAssignmentTable 21 | from utilities.views import ViewTab, register_model_view 22 | 23 | 24 | __all__ = ( 25 | 'LicenseListView', 26 | 'LicenseView', 27 | 'LicenseEditView', 28 | 'LicenseBulkEditView', 29 | 'LicenseDeleteView', 30 | 'LicenseBulkDeleteView', 31 | 'LicenseAssignmentsView', 32 | 'LicenseAssignmentListView', 33 | 'LicenseAssignmentView', 34 | 'LicenseAssignmentEditView', 35 | 'LicenseAssignmentDeleteView', 36 | 'LicenseAssignmentBulkEditView', 37 | 'LicenseAssignmentBulkDeleteView', 38 | ) 39 | 40 | 41 | @register_model_view(License, name='list') 42 | class LicenseListView(ObjectListView): 43 | queryset = License.objects.all() 44 | table = LicenseTable 45 | filterset = LicenseFilterSet 46 | filterset_form = LicenseFilterForm 47 | 48 | 49 | @register_model_view(License) 50 | class LicenseView(ObjectView): 51 | queryset = License.objects.all() 52 | 53 | 54 | @register_model_view(License, 'edit') 55 | class LicenseEditView(ObjectEditView): 56 | queryset = License.objects.all() 57 | form = LicenseForm 58 | 59 | 60 | @register_model_view(License, 'bulk_edit') 61 | class LicenseBulkEditView(BulkEditView): 62 | queryset = License.objects.all() 63 | filterset = LicenseFilterSet 64 | table = LicenseTable 65 | form = LicenseBulkEditForm 66 | 67 | 68 | @register_model_view(License, 'delete') 69 | class LicenseDeleteView(ObjectDeleteView): 70 | queryset = License.objects.all() 71 | 72 | 73 | @register_model_view(License, 'bulk_delete') 74 | class LicenseBulkDeleteView(BulkDeleteView): 75 | queryset = License.objects.all() 76 | filterset = LicenseFilterSet 77 | table = LicenseTable 78 | 79 | 80 | @register_model_view(License, 'assignments') 81 | class LicenseAssignmentsView(ObjectChildrenView): 82 | template_name = 'netbox_lifecycle/license/assignments.html' 83 | queryset = License.objects.all() 84 | child_model = LicenseAssignment 85 | table = LicenseAssignmentTable 86 | filterset = LicenseAssignmentFilterSet 87 | viewname = None 88 | actions = {'add': {'add'}, 'edit': {'change'}, 'delete': {'delete'}} 89 | tab = ViewTab( 90 | label='License Assignments', 91 | badge=lambda obj: LicenseAssignment.objects.filter(license=obj).count(), 92 | ) 93 | 94 | def get_children(self, request, parent): 95 | return self.child_model.objects.filter(license=parent) 96 | 97 | 98 | @register_model_view(LicenseAssignment, name='list') 99 | class LicenseAssignmentListView(ObjectListView): 100 | queryset = LicenseAssignment.objects.all() 101 | table = LicenseAssignmentTable 102 | filterset = LicenseAssignmentFilterSet 103 | filterset_form = LicenseAssignmentFilterForm 104 | 105 | 106 | @register_model_view(LicenseAssignment) 107 | class LicenseAssignmentView(ObjectView): 108 | queryset = LicenseAssignment.objects.all() 109 | 110 | 111 | @register_model_view(LicenseAssignment, 'edit') 112 | class LicenseAssignmentEditView(ObjectEditView): 113 | queryset = LicenseAssignment.objects.all() 114 | form = LicenseAssignmentForm 115 | 116 | 117 | @register_model_view(LicenseAssignment, 'delete') 118 | class LicenseAssignmentDeleteView(ObjectDeleteView): 119 | queryset = LicenseAssignment.objects.all() 120 | 121 | 122 | @register_model_view(LicenseAssignment, 'bulk_edit') 123 | class LicenseAssignmentBulkEditView(BulkEditView): 124 | queryset = LicenseAssignment.objects.all() 125 | filterset = LicenseAssignmentFilterSet 126 | table = LicenseAssignmentTable 127 | form = LicenseAssignmentBulkEditForm 128 | 129 | 130 | @register_model_view(LicenseAssignment, 'bulk_delete') 131 | class LicenseAssignmentBulkDeleteView(BulkDeleteView): 132 | queryset = LicenseAssignment.objects.all() 133 | filterset = LicenseAssignmentFilterSet 134 | table = LicenseAssignmentTable 135 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0004_supportcontractassignment_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-11 13:12 2 | from django.db import migrations, models 3 | import django.db.models.deletion 4 | import taggit.managers 5 | import utilities.json 6 | 7 | 8 | def migrate_to_assignments(apps, schema_editor): 9 | from django.contrib.contenttypes.models import ContentType 10 | 11 | SupportContractDeviceAssignment = apps.get_model( 12 | 'netbox_lifecycle', 'SupportContractDeviceAssignment' 13 | ) 14 | SupportContractAssignment = apps.get_model( 15 | 'netbox_lifecycle', 'SupportContractAssignment' 16 | ) 17 | Device = apps.get_model('dcim', 'Device') 18 | assigned_object_type = ContentType.objects.get_for_model(Device) 19 | 20 | for contract in SupportContractDeviceAssignment.objects.all(): 21 | SupportContractAssignment.objects.create( 22 | id=contract.id, 23 | created=contract.created, 24 | last_updated=contract.last_updated, 25 | custom_field_data=contract.custom_field_data, 26 | assigned_object_type_id=assigned_object_type.pk, 27 | assigned_object_id=contract.device.pk, 28 | contract=contract.contract, 29 | ) 30 | 31 | 32 | def migrate_from_assignments(apps, schema_editor): 33 | SupportContractDeviceAssignment = apps.get_model( 34 | 'netbox_lifecycle', 'SupportContractDeviceAssignment' 35 | ) 36 | SupportContractAssignment = apps.get_model( 37 | 'netbox_lifecycle', 'SupportContractAssignment' 38 | ) 39 | Device = apps.get_model('dcim', 'Device') 40 | for contract in SupportContractAssignment.objects.all(): 41 | if isinstance(contract.assigned_object, Device): 42 | SupportContractDeviceAssignment.objects.create( 43 | id=contract.id, 44 | created=contract.created, 45 | last_updated=contract.last_updated, 46 | custom_field_data=contract.custom_field_data, 47 | device_id=contract.assigned_object_id, 48 | contract=contract.contract, 49 | ) 50 | 51 | 52 | class Migration(migrations.Migration): 53 | 54 | dependencies = [ 55 | ('contenttypes', '0002_remove_content_type_name'), 56 | ('extras', '0092_delete_jobresult'), 57 | ('netbox_lifecycle', '0003_remove_supportcontract_devices_and_more'), 58 | ] 59 | 60 | operations = [ 61 | migrations.CreateModel( 62 | name='SupportContractAssignment', 63 | fields=[ 64 | ( 65 | 'id', 66 | models.BigAutoField( 67 | auto_created=True, primary_key=True, serialize=False 68 | ), 69 | ), 70 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 71 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 72 | ( 73 | 'custom_field_data', 74 | models.JSONField( 75 | blank=True, 76 | default=dict, 77 | encoder=utilities.json.CustomFieldJSONEncoder, 78 | ), 79 | ), 80 | ( 81 | 'assigned_object_id', 82 | models.PositiveBigIntegerField(blank=True, null=True), 83 | ), 84 | ( 85 | 'assigned_object_type', 86 | models.ForeignKey( 87 | blank=True, 88 | limit_choices_to=('dcim.Device', 'netbox_lifecycle.License'), 89 | null=True, 90 | on_delete=django.db.models.deletion.PROTECT, 91 | related_name='+', 92 | to='contenttypes.contenttype', 93 | ), 94 | ), 95 | ( 96 | 'contract', 97 | models.ForeignKey( 98 | on_delete=django.db.models.deletion.CASCADE, 99 | to='netbox_lifecycle.supportcontract', 100 | ), 101 | ), 102 | ( 103 | 'tags', 104 | taggit.managers.TaggableManager( 105 | through='extras.TaggedItem', to='extras.Tag' 106 | ), 107 | ), 108 | ], 109 | options={ 110 | 'ordering': ['contract', 'assigned_object_type', 'assigned_object_id'], 111 | }, 112 | ), 113 | migrations.RunPython(migrate_to_assignments, migrate_from_assignments), 114 | migrations.DeleteModel( 115 | name='SupportContractDeviceAssignment', 116 | ), 117 | ] 118 | -------------------------------------------------------------------------------- /netbox_lifecycle/models/license.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.db import models 3 | from django.db.models.functions import Lower 4 | from django.urls import reverse 5 | from django.utils.translation import gettext as _ 6 | 7 | from netbox.models import PrimaryModel 8 | 9 | 10 | __all__ = ('License', 'LicenseAssignment') 11 | 12 | 13 | class License(PrimaryModel): 14 | manufacturer = models.ForeignKey( 15 | to='dcim.Manufacturer', 16 | on_delete=models.CASCADE, 17 | related_name='licenses', 18 | ) 19 | name = models.CharField(max_length=100) 20 | 21 | clone_fields = ('manufacturer',) 22 | prerequisite_models = ('dcim.Manufacturer',) 23 | 24 | class Meta: 25 | ordering = ['manufacturer', 'name'] 26 | constraints = ( 27 | models.UniqueConstraint( 28 | 'manufacturer', 29 | Lower('name'), 30 | name='%(app_label)s_%(class)s_unique_manufacturer_name', 31 | violation_error_message="SKU name must be unique per manufacturer.", 32 | ), 33 | ) 34 | 35 | def __str__(self): 36 | return f'{self.name}' 37 | 38 | def get_absolute_url(self): 39 | return reverse('plugins:netbox_lifecycle:license', args=[self.pk]) 40 | 41 | 42 | class LicenseAssignment(PrimaryModel): 43 | license = models.ForeignKey( 44 | to='netbox_lifecycle.License', 45 | on_delete=models.CASCADE, 46 | related_name='assignments', 47 | ) 48 | vendor = models.ForeignKey( 49 | to='netbox_lifecycle.Vendor', 50 | on_delete=models.CASCADE, 51 | related_name='licenses', 52 | ) 53 | device = models.ForeignKey( 54 | to='dcim.Device', 55 | on_delete=models.SET_NULL, 56 | null=True, 57 | blank=True, 58 | related_name='licenses', 59 | ) 60 | virtual_machine = models.ForeignKey( 61 | to='virtualization.VirtualMachine', 62 | on_delete=models.SET_NULL, 63 | null=True, 64 | blank=True, 65 | related_name='licenses', 66 | ) 67 | quantity = models.IntegerField( 68 | null=True, 69 | blank=True, 70 | ) 71 | 72 | clone_fields = ( 73 | 'vendor', 74 | 'license', 75 | ) 76 | prerequisite_models = ( 77 | 'netbox_lifecycle.License', 78 | 'netbox_lifecycle.Vendor', 79 | 'dcim.Device', 80 | 'virtualization.VirtualMachine', 81 | ) 82 | 83 | class Meta: 84 | ordering = ['license', 'device', 'virtual_machine'] 85 | constraints = ( 86 | models.CheckConstraint( 87 | check=( 88 | models.Q(device__isnull=True, virtual_machine__isnull=False) 89 | | models.Q(device__isnull=False, virtual_machine__isnull=True) 90 | | models.Q(device__isnull=True, virtual_machine__isnull=True) 91 | ), 92 | name='%(app_label)s_%(class)s_device_vm_exclusive', 93 | violation_error_message=_( 94 | 'Device and virtual machine are mutually exclusive.' 95 | ), 96 | ), 97 | ) 98 | 99 | def __str__(self): 100 | if self.device: 101 | return f'{self.device.name}: {self.license.name}' 102 | if self.virtual_machine: 103 | return f'{self.virtual_machine.name}: {self.license.name}' 104 | return f'{self.license.name}' 105 | 106 | def get_absolute_url(self): 107 | return reverse('plugins:netbox_lifecycle:licenseassignment', args=[self.pk]) 108 | 109 | def clean(self): 110 | super().clean() 111 | 112 | # Mutual exclusivity validation 113 | if self.device and self.virtual_machine: 114 | raise ValidationError( 115 | _('Device and virtual machine are mutually exclusive. Select only one.') 116 | ) 117 | 118 | @property 119 | def assigned_object(self): 120 | """Return the device or virtual machine assigned to this license.""" 121 | return self.device or self.virtual_machine 122 | 123 | @property 124 | def name(self): 125 | if self.device: 126 | return self.device.name 127 | if self.virtual_machine: 128 | return self.virtual_machine.name 129 | return None 130 | 131 | @property 132 | def serial(self): 133 | if self.device: 134 | return self.device.serial 135 | return None # VMs don't have serial numbers 136 | 137 | @property 138 | def device_type(self): 139 | if self.device: 140 | return self.device.device_type 141 | return None # VMs don't have device_type 142 | 143 | @property 144 | def status(self): 145 | if self.device: 146 | return self.device.status 147 | if self.virtual_machine: 148 | return self.virtual_machine.status 149 | return None 150 | -------------------------------------------------------------------------------- /netbox_lifecycle/views/htmx.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.shortcuts import get_object_or_404, render 3 | from django.views import View 4 | 5 | from dcim.models import Device 6 | from virtualization.models import VirtualMachine 7 | 8 | from netbox_lifecycle.constants import ( 9 | CONTRACT_STATUS_ACTIVE, 10 | CONTRACT_STATUS_EXPIRED, 11 | CONTRACT_STATUS_FUTURE, 12 | CONTRACT_STATUS_UNSPECIFIED, 13 | ) 14 | from netbox_lifecycle.models import SupportContractAssignment 15 | 16 | 17 | __all__ = ( 18 | 'DeviceContractsHTMXView', 19 | 'DeviceContractsExpiredHTMXView', 20 | 'VirtualMachineContractsHTMXView', 21 | 'VirtualMachineContractsExpiredHTMXView', 22 | ) 23 | 24 | 25 | class DeviceContractsHTMXView(LoginRequiredMixin, View): 26 | """HTMX endpoint for device contract card content.""" 27 | 28 | def get(self, request, pk): 29 | device = get_object_or_404(Device, pk=pk) 30 | assignments = SupportContractAssignment.objects.filter( 31 | device=device 32 | ).select_related( 33 | 'contract', 'contract__vendor', 'sku', 'sku__manufacturer', 'license' 34 | ) 35 | 36 | grouped = { 37 | CONTRACT_STATUS_ACTIVE: [], 38 | CONTRACT_STATUS_FUTURE: [], 39 | CONTRACT_STATUS_UNSPECIFIED: [], 40 | CONTRACT_STATUS_EXPIRED: [], 41 | } 42 | for assignment in assignments: 43 | grouped[assignment.status].append(assignment) 44 | 45 | return render( 46 | request, 47 | 'netbox_lifecycle/htmx/device_contracts.html', 48 | { 49 | 'device': device, 50 | 'active': grouped[CONTRACT_STATUS_ACTIVE], 51 | 'future': grouped[CONTRACT_STATUS_FUTURE], 52 | 'unspecified': grouped[CONTRACT_STATUS_UNSPECIFIED], 53 | 'expired_count': len(grouped[CONTRACT_STATUS_EXPIRED]), 54 | }, 55 | ) 56 | 57 | 58 | class DeviceContractsExpiredHTMXView(LoginRequiredMixin, View): 59 | """HTMX endpoint for expired contracts only.""" 60 | 61 | def get(self, request, pk): 62 | device = get_object_or_404(Device, pk=pk) 63 | expired = [ 64 | a 65 | for a in SupportContractAssignment.objects.filter( 66 | device=device 67 | ).select_related( 68 | 'contract', 'contract__vendor', 'sku', 'sku__manufacturer', 'license' 69 | ) 70 | if a.status == CONTRACT_STATUS_EXPIRED 71 | ] 72 | 73 | return render( 74 | request, 75 | 'netbox_lifecycle/htmx/contract_list.html', 76 | { 77 | 'assignments': expired, 78 | }, 79 | ) 80 | 81 | 82 | class VirtualMachineContractsHTMXView(LoginRequiredMixin, View): 83 | """HTMX endpoint for virtual machine contract card content.""" 84 | 85 | def get(self, request, pk): 86 | virtual_machine = get_object_or_404(VirtualMachine, pk=pk) 87 | assignments = SupportContractAssignment.objects.filter( 88 | virtual_machine=virtual_machine 89 | ).select_related( 90 | 'contract', 'contract__vendor', 'sku', 'sku__manufacturer', 'license' 91 | ) 92 | 93 | grouped = { 94 | CONTRACT_STATUS_ACTIVE: [], 95 | CONTRACT_STATUS_FUTURE: [], 96 | CONTRACT_STATUS_UNSPECIFIED: [], 97 | CONTRACT_STATUS_EXPIRED: [], 98 | } 99 | for assignment in assignments: 100 | grouped[assignment.status].append(assignment) 101 | 102 | return render( 103 | request, 104 | 'netbox_lifecycle/htmx/virtualmachine_contracts.html', 105 | { 106 | 'virtual_machine': virtual_machine, 107 | 'active': grouped[CONTRACT_STATUS_ACTIVE], 108 | 'future': grouped[CONTRACT_STATUS_FUTURE], 109 | 'unspecified': grouped[CONTRACT_STATUS_UNSPECIFIED], 110 | 'expired_count': len(grouped[CONTRACT_STATUS_EXPIRED]), 111 | }, 112 | ) 113 | 114 | 115 | class VirtualMachineContractsExpiredHTMXView(LoginRequiredMixin, View): 116 | """HTMX endpoint for expired contracts only (virtual machine).""" 117 | 118 | def get(self, request, pk): 119 | virtual_machine = get_object_or_404(VirtualMachine, pk=pk) 120 | expired = [ 121 | a 122 | for a in SupportContractAssignment.objects.filter( 123 | virtual_machine=virtual_machine 124 | ).select_related( 125 | 'contract', 'contract__vendor', 'sku', 'sku__manufacturer', 'license' 126 | ) 127 | if a.status == CONTRACT_STATUS_EXPIRED 128 | ] 129 | 130 | return render( 131 | request, 132 | 'netbox_lifecycle/htmx/contract_list.html', 133 | { 134 | 'assignments': expired, 135 | }, 136 | ) 137 | -------------------------------------------------------------------------------- /netbox_lifecycle/graphql/filters.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | import strawberry 4 | import strawberry_django 5 | 6 | from core.graphql.filter_mixins import BaseObjectTypeFilterMixin 7 | from netbox_lifecycle import models 8 | 9 | 10 | __all__ = ( 11 | 'VendorFilter', 12 | 'SupportSKUFilter', 13 | 'SupportContractFilter', 14 | 'SupportContractAssignmentFilter', 15 | 'LicenseFilter', 16 | 'LicenseAssignmentFilter', 17 | 'HardwareLifecycleFilter', 18 | ) 19 | 20 | 21 | @strawberry_django.filter(models.Vendor, lookups=True) 22 | class VendorFilter(BaseObjectTypeFilterMixin): 23 | pass 24 | 25 | 26 | @strawberry_django.filter(models.SupportSKU, lookups=True) 27 | class SupportSKUFilter(BaseObjectTypeFilterMixin): 28 | manufacturer: ( 29 | Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None 30 | ) = strawberry_django.filter_field() 31 | manufacturer_id: strawberry.ID | None = strawberry_django.filter_field() 32 | 33 | 34 | @strawberry_django.filter(models.SupportContract, lookups=True) 35 | class SupportContractFilter(BaseObjectTypeFilterMixin): 36 | vendor: ( 37 | Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None 38 | ) = strawberry_django.filter_field() 39 | vendor_id: strawberry.ID | None = strawberry_django.filter_field() 40 | 41 | 42 | @strawberry_django.filter(models.SupportContractAssignment, lookups=True) 43 | class SupportContractAssignmentFilter(BaseObjectTypeFilterMixin): 44 | contract: ( 45 | Annotated[ 46 | 'SupportContractFilter', strawberry.lazy('netbox_lifecycle.graphql.filters') 47 | ] 48 | | None 49 | ) = strawberry_django.filter_field() 50 | contract_id: strawberry.ID | None = strawberry_django.filter_field() 51 | sku: ( 52 | Annotated[ 53 | 'SupportSKUFilter', strawberry.lazy('netbox_lifecycle.graphql.filters') 54 | ] 55 | | None 56 | ) = strawberry_django.filter_field() 57 | sku_id: strawberry.ID | None = strawberry_django.filter_field() 58 | device: ( 59 | Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None 60 | ) = strawberry_django.filter_field() 61 | device_id: strawberry.ID | None = strawberry_django.filter_field() 62 | virtual_machine: ( 63 | Annotated[ 64 | 'VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters') 65 | ] 66 | | None 67 | ) = strawberry_django.filter_field() 68 | virtual_machine_id: strawberry.ID | None = strawberry_django.filter_field() 69 | license: ( 70 | Annotated['LicenseFilter', strawberry.lazy('netbox_lifecycle.graphql.filters')] 71 | | None 72 | ) = strawberry_django.filter_field() 73 | license_id: strawberry.ID | None = strawberry_django.filter_field() 74 | 75 | 76 | @strawberry_django.filter(models.License, lookups=True) 77 | class LicenseFilter(BaseObjectTypeFilterMixin): 78 | manufacturer: ( 79 | Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None 80 | ) = strawberry_django.filter_field() 81 | manufacturer_id: strawberry.ID | None = strawberry_django.filter_field() 82 | 83 | 84 | @strawberry_django.filter(models.LicenseAssignment, lookups=True) 85 | class LicenseAssignmentFilter(BaseObjectTypeFilterMixin): 86 | vendor: ( 87 | Annotated['VendorFilter', strawberry.lazy('netbox_lifecycle.graphql.filters')] 88 | | None 89 | ) = strawberry_django.filter_field() 90 | vendor_id: strawberry.ID | None = strawberry_django.filter_field() 91 | license: ( 92 | Annotated['LicenseFilter', strawberry.lazy('netbox_lifecycle.graphql.filters')] 93 | | None 94 | ) = strawberry_django.filter_field() 95 | license_id: strawberry.ID | None = strawberry_django.filter_field() 96 | device: ( 97 | Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None 98 | ) = strawberry_django.filter_field() 99 | device_id: strawberry.ID | None = strawberry_django.filter_field() 100 | virtual_machine: ( 101 | Annotated[ 102 | 'VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters') 103 | ] 104 | | None 105 | ) = strawberry_django.filter_field() 106 | virtual_machine_id: strawberry.ID | None = strawberry_django.filter_field() 107 | 108 | 109 | @strawberry_django.filter(models.HardwareLifecycle, lookups=True) 110 | class HardwareLifecycleFilter(BaseObjectTypeFilterMixin): 111 | device_type: ( 112 | Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None 113 | ) = strawberry_django.filter_field() 114 | device_type_id: strawberry.ID | None = strawberry_django.filter_field() 115 | module_type: ( 116 | Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None 117 | ) = strawberry_django.filter_field() 118 | module_type_id: strawberry.ID | None = strawberry_django.filter_field() 119 | 120 | assigned_object_type: ( 121 | Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None 122 | ) = strawberry_django.filter_field() 123 | assigned_object_id: strawberry.ID | None = strawberry_django.filter_field() 124 | -------------------------------------------------------------------------------- /netbox_lifecycle/tables/contract.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | 3 | import django_tables2 as tables 4 | 5 | from netbox.tables import NetBoxTable, ChoiceFieldColumn 6 | from netbox_lifecycle.models import ( 7 | SupportContract, 8 | Vendor, 9 | SupportContractAssignment, 10 | SupportSKU, 11 | ) 12 | 13 | __all__ = ( 14 | 'VendorTable', 15 | 'SupportSKUTable', 16 | 'SupportContractTable', 17 | 'SupportContractAssignmentTable', 18 | ) 19 | 20 | 21 | class VendorTable(NetBoxTable): 22 | name = tables.Column(linkify=True, verbose_name=_('Name')) 23 | 24 | class Meta(NetBoxTable.Meta): 25 | model = Vendor 26 | fields = ( 27 | 'pk', 28 | 'name', 29 | ) 30 | default_columns = ( 31 | 'pk', 32 | 'name', 33 | ) 34 | 35 | 36 | class SupportSKUTable(NetBoxTable): 37 | sku = tables.Column( 38 | verbose_name=_('SKU'), 39 | linkify=True, 40 | ) 41 | manufacturer = tables.Column( 42 | verbose_name=_('Manufacturer'), 43 | linkify=True, 44 | ) 45 | 46 | class Meta(NetBoxTable.Meta): 47 | model = SupportSKU 48 | fields = ( 49 | 'pk', 50 | 'manufacturer', 51 | 'sku', 52 | 'description', 53 | 'comments', 54 | ) 55 | default_columns = ( 56 | 'pk', 57 | 'manufacturer', 58 | 'sku', 59 | ) 60 | 61 | 62 | class SupportContractTable(NetBoxTable): 63 | contract_id = tables.Column(linkify=True, verbose_name=_('Contract ID')) 64 | 65 | class Meta(NetBoxTable.Meta): 66 | model = SupportContract 67 | fields = ( 68 | 'pk', 69 | 'contract_id', 70 | 'start', 71 | 'renewal', 72 | 'end', 73 | 'description', 74 | 'comments', 75 | ) 76 | default_columns = ( 77 | 'pk', 78 | 'contract_id', 79 | ) 80 | 81 | 82 | class SupportContractAssignmentTable(NetBoxTable): 83 | contract = tables.Column( 84 | verbose_name=_('Contract'), 85 | linkify=True, 86 | ) 87 | sku = tables.Column( 88 | verbose_name=_('SKU'), 89 | linkify=True, 90 | ) 91 | device_name = tables.Column( 92 | verbose_name=_('Device Name'), 93 | accessor='device__name', 94 | linkify=True, 95 | orderable=True, 96 | ) 97 | device_serial = tables.Column( 98 | verbose_name=_('Serial Number'), 99 | accessor='device__serial', 100 | orderable=True, 101 | ) 102 | device_model = tables.Column( 103 | verbose_name=_('Device Model'), 104 | accessor='device__device_type__model', 105 | linkify=False, 106 | orderable=True, 107 | ) 108 | device_status = ChoiceFieldColumn( 109 | verbose_name=_('Device Status'), 110 | accessor='device__status', 111 | orderable=True, 112 | ) 113 | module_name = tables.Column( 114 | verbose_name=_('Module'), 115 | accessor='module__module_type__model', 116 | linkify=True, 117 | orderable=True, 118 | ) 119 | module_serial = tables.Column( 120 | verbose_name=_('Module Serial'), 121 | accessor='module__serial', 122 | orderable=True, 123 | ) 124 | virtual_machine_name = tables.Column( 125 | verbose_name=_('Virtual Machine'), 126 | accessor='virtual_machine__name', 127 | linkify=True, 128 | orderable=True, 129 | ) 130 | virtual_machine_status = ChoiceFieldColumn( 131 | verbose_name=_('VM Status'), 132 | accessor='virtual_machine__status', 133 | orderable=True, 134 | ) 135 | license_name = tables.Column( 136 | verbose_name=_('License'), 137 | accessor='license__license__name', 138 | linkify=False, 139 | orderable=True, 140 | ) 141 | quantity = tables.Column( 142 | verbose_name=_('License Quantity'), 143 | accessor='license__quantity', 144 | orderable=False, 145 | ) 146 | renewal = tables.Column( 147 | verbose_name=_('Renewal Date'), 148 | accessor='contract__renewal', 149 | ) 150 | end = tables.Column( 151 | verbose_name=_('End Date'), 152 | accessor='end_date', 153 | orderable=False, 154 | ) 155 | 156 | class Meta(NetBoxTable.Meta): 157 | model = SupportContractAssignment 158 | fields = ( 159 | 'pk', 160 | 'contract', 161 | 'sku', 162 | 'device_name', 163 | 'module_name', 164 | 'virtual_machine_name', 165 | 'license_name', 166 | 'device_model', 167 | 'device_serial', 168 | 'module_serial', 169 | 'device_status', 170 | 'virtual_machine_status', 171 | 'quantity', 172 | 'renewal', 173 | 'end', 174 | 'description', 175 | 'comments', 176 | ) 177 | default_columns = ( 178 | 'pk', 179 | 'contract', 180 | 'sku', 181 | 'device_name', 182 | 'module_name', 183 | 'virtual_machine_name', 184 | 'license_name', 185 | 'device_model', 186 | 'device_serial', 187 | ) 188 | -------------------------------------------------------------------------------- /netbox_lifecycle/template_content.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.urls import reverse 4 | 5 | from netbox.plugins import PluginTemplateExtension 6 | 7 | from .models import hardware 8 | 9 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG.get('netbox_lifecycle', {}) 10 | 11 | 12 | class BaseLifecycleContent(PluginTemplateExtension): 13 | """Base class for lifecycle template extensions.""" 14 | 15 | lifecycle_content_type = None # Override: 'devicetype' or 'moduletype' 16 | lifecycle_object_id_attr = None # Override: attribute name for object ID 17 | 18 | def get_lifecycle_card_position(self): 19 | return PLUGIN_SETTINGS.get('lifecycle_card_position', 'right_page') 20 | 21 | def _get_lifecycle_info(self): 22 | obj = self.context.get('object') 23 | content_type = ContentType.objects.get( 24 | app_label='dcim', model=self.lifecycle_content_type 25 | ) 26 | object_id = getattr(obj, self.lifecycle_object_id_attr, obj.id) 27 | return hardware.HardwareLifecycle.objects.filter( 28 | assigned_object_id=object_id, 29 | assigned_object_type_id=content_type.id, 30 | ).first() 31 | 32 | def _render_lifecycle_info(self): 33 | return self.render( 34 | 'netbox_lifecycle/inc/hardware_lifecycle_info.html', 35 | extra_context={'lifecycle_info': self._get_lifecycle_info()}, 36 | ) 37 | 38 | def right_page(self): 39 | if self.get_lifecycle_card_position() == 'right_page': 40 | return self._render_lifecycle_info() 41 | return '' 42 | 43 | def left_page(self): 44 | if self.get_lifecycle_card_position() == 'left_page': 45 | return self._render_lifecycle_info() 46 | return '' 47 | 48 | def full_width_page(self): 49 | if self.get_lifecycle_card_position() == 'full_width_page': 50 | return self._render_lifecycle_info() 51 | return '' 52 | 53 | 54 | class DeviceLifecycleContent(BaseLifecycleContent): 55 | models = ['dcim.device'] 56 | lifecycle_content_type = 'devicetype' 57 | lifecycle_object_id_attr = 'device_type_id' 58 | 59 | def get_contract_card_position(self): 60 | return PLUGIN_SETTINGS.get('contract_card_position', 'right_page') 61 | 62 | def _render_contract_card(self): 63 | obj = self.context.get('object') 64 | return self.render( 65 | 'netbox_lifecycle/inc/contract_card_placeholder.html', 66 | extra_context={ 67 | 'htmx_url': reverse( 68 | 'plugins:netbox_lifecycle:device_contracts_htmx', 69 | kwargs={'pk': obj.pk}, 70 | ), 71 | }, 72 | ) 73 | 74 | def right_page(self): 75 | result = '' 76 | if self.get_lifecycle_card_position() == 'right_page': 77 | result += self._render_lifecycle_info() 78 | if self.get_contract_card_position() == 'right_page': 79 | result += self._render_contract_card() 80 | return result 81 | 82 | def left_page(self): 83 | result = '' 84 | if self.get_lifecycle_card_position() == 'left_page': 85 | result += self._render_lifecycle_info() 86 | if self.get_contract_card_position() == 'left_page': 87 | result += self._render_contract_card() 88 | return result 89 | 90 | def full_width_page(self): 91 | result = '' 92 | if self.get_lifecycle_card_position() == 'full_width_page': 93 | result += self._render_lifecycle_info() 94 | if self.get_contract_card_position() == 'full_width_page': 95 | result += self._render_contract_card() 96 | return result 97 | 98 | 99 | class ModuleLifecycleContent(BaseLifecycleContent): 100 | models = ['dcim.module'] 101 | lifecycle_content_type = 'moduletype' 102 | lifecycle_object_id_attr = 'module_type_id' 103 | 104 | 105 | class DeviceTypeLifecycleContent(BaseLifecycleContent): 106 | models = ['dcim.devicetype'] 107 | lifecycle_content_type = 'devicetype' 108 | lifecycle_object_id_attr = 'id' 109 | 110 | 111 | class ModuleTypeLifecycleContent(BaseLifecycleContent): 112 | models = ['dcim.moduletype'] 113 | lifecycle_content_type = 'moduletype' 114 | lifecycle_object_id_attr = 'id' 115 | 116 | 117 | class VirtualMachineContractContent(PluginTemplateExtension): 118 | """Template extension for VirtualMachine detail pages showing contracts.""" 119 | 120 | models = ['virtualization.virtualmachine'] 121 | 122 | def get_contract_card_position(self): 123 | return PLUGIN_SETTINGS.get('contract_card_position', 'right_page') 124 | 125 | def _render_contract_card(self): 126 | obj = self.context.get('object') 127 | return self.render( 128 | 'netbox_lifecycle/inc/contract_card_placeholder.html', 129 | extra_context={ 130 | 'htmx_url': reverse( 131 | 'plugins:netbox_lifecycle:virtualmachine_contracts_htmx', 132 | kwargs={'pk': obj.pk}, 133 | ), 134 | }, 135 | ) 136 | 137 | def right_page(self): 138 | if self.get_contract_card_position() == 'right_page': 139 | return self._render_contract_card() 140 | return '' 141 | 142 | def left_page(self): 143 | if self.get_contract_card_position() == 'left_page': 144 | return self._render_contract_card() 145 | return '' 146 | 147 | def full_width_page(self): 148 | if self.get_contract_card_position() == 'full_width_page': 149 | return self._render_contract_card() 150 | return '' 151 | 152 | 153 | template_extensions = ( 154 | DeviceLifecycleContent, 155 | ModuleLifecycleContent, 156 | DeviceTypeLifecycleContent, 157 | ModuleTypeLifecycleContent, 158 | VirtualMachineContractContent, 159 | ) 160 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/htmx/device_contracts.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load filters %} 3 | 4 |
5 |
{% trans "Support Contracts" %}
6 | {% if active or future or unspecified or expired_count %} 7 |
8 | 41 |
42 | {% if active %} 43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | {% for assignment in active %} 55 | 56 | 57 | 58 | 59 | 60 | 61 | {% endfor %} 62 | 63 |
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "End Date" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.end_date|date:"Y-m-d" }}
64 |
65 | {% endif %} 66 | {% if future %} 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | {% for assignment in future %} 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | {% endfor %} 88 | 89 |
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Starts" %}{% trans "Ends" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.contract.start|date:"Y-m-d" }}{{ assignment.end_date|date:"Y-m-d" }}
90 |
91 | {% endif %} 92 | {% if unspecified %} 93 |
94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | {% for assignment in unspecified %} 105 | 106 | 107 | 108 | 109 | 110 | 111 | {% endfor %} 112 | 113 |
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Started" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.contract.start|date:"Y-m-d"|default:"-" }}
114 |
115 | {% endif %} 116 | {% if expired_count %} 117 |
118 |
119 |
120 |
121 |
122 | {% endif %} 123 |
124 |
125 | {% else %} 126 |
127 | {% trans "None" %} 128 |
129 | {% endif %} 130 |
131 | -------------------------------------------------------------------------------- /netbox_lifecycle/templates/netbox_lifecycle/htmx/virtualmachine_contracts.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load filters %} 3 | 4 |
5 |
{% trans "Support Contracts" %}
6 | {% if active or future or unspecified or expired_count %} 7 |
8 | 41 |
42 | {% if active %} 43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | {% for assignment in active %} 55 | 56 | 57 | 58 | 59 | 60 | 61 | {% endfor %} 62 | 63 |
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "End Date" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.end_date|date:"Y-m-d" }}
64 |
65 | {% endif %} 66 | {% if future %} 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | {% for assignment in future %} 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | {% endfor %} 88 | 89 |
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Starts" %}{% trans "Ends" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.contract.start|date:"Y-m-d" }}{{ assignment.end_date|date:"Y-m-d" }}
90 |
91 | {% endif %} 92 | {% if unspecified %} 93 |
94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | {% for assignment in unspecified %} 105 | 106 | 107 | 108 | 109 | 110 | 111 | {% endfor %} 112 | 113 |
{% trans "Contract" %}{% trans "SKU" %}{% trans "Vendor" %}{% trans "Started" %}
{{ assignment.contract.contract_id }}{% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %}{{ assignment.contract.vendor|default:"-" }}{{ assignment.contract.start|date:"Y-m-d"|default:"-" }}
114 |
115 | {% endif %} 116 | {% if expired_count %} 117 |
118 |
119 |
120 |
121 |
122 | {% endif %} 123 |
124 |
125 | {% else %} 126 |
127 | {% trans "None" %} 128 |
129 | {% endif %} 130 |
131 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-10 14:23 2 | 3 | import dcim.models.devices 4 | import dcim.models.modules 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import taggit.managers 8 | import utilities.json 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ('dcim', '0171_cabletermination_change_logging'), 17 | ('contenttypes', '0002_remove_content_type_name'), 18 | ('extras', '0092_delete_jobresult'), 19 | ] 20 | 21 | operations = [ 22 | migrations.CreateModel( 23 | name='Vendor', 24 | fields=[ 25 | ( 26 | 'id', 27 | models.BigAutoField( 28 | auto_created=True, primary_key=True, serialize=False 29 | ), 30 | ), 31 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 32 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 33 | ( 34 | 'custom_field_data', 35 | models.JSONField( 36 | blank=True, 37 | default=dict, 38 | encoder=utilities.json.CustomFieldJSONEncoder, 39 | ), 40 | ), 41 | ('name', models.CharField(max_length=100)), 42 | ( 43 | 'tags', 44 | taggit.managers.TaggableManager( 45 | through='extras.TaggedItem', to='extras.Tag' 46 | ), 47 | ), 48 | ], 49 | options={ 50 | 'ordering': ['name'], 51 | }, 52 | ), 53 | migrations.CreateModel( 54 | name='SupportContract', 55 | fields=[ 56 | ( 57 | 'id', 58 | models.BigAutoField( 59 | auto_created=True, primary_key=True, serialize=False 60 | ), 61 | ), 62 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 63 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 64 | ( 65 | 'custom_field_data', 66 | models.JSONField( 67 | blank=True, 68 | default=dict, 69 | encoder=utilities.json.CustomFieldJSONEncoder, 70 | ), 71 | ), 72 | ('contract_id', models.CharField(max_length=100)), 73 | ('start', models.DateField()), 74 | ('renewal', models.DateField()), 75 | ('end', models.DateField()), 76 | ( 77 | 'devices', 78 | models.ManyToManyField( 79 | blank=True, related_name='contracts', to='dcim.device' 80 | ), 81 | ), 82 | ( 83 | 'manufacturer', 84 | models.ForeignKey( 85 | on_delete=django.db.models.deletion.CASCADE, 86 | to='dcim.manufacturer', 87 | ), 88 | ), 89 | ( 90 | 'tags', 91 | taggit.managers.TaggableManager( 92 | through='extras.TaggedItem', to='extras.Tag' 93 | ), 94 | ), 95 | ( 96 | 'vendor', 97 | models.ForeignKey( 98 | on_delete=django.db.models.deletion.CASCADE, 99 | to='netbox_lifecycle.vendor', 100 | ), 101 | ), 102 | ], 103 | options={ 104 | 'ordering': ['contract_id'], 105 | }, 106 | ), 107 | migrations.CreateModel( 108 | name='HardwareLifecycle', 109 | fields=[ 110 | ( 111 | 'id', 112 | models.BigAutoField( 113 | auto_created=True, primary_key=True, serialize=False 114 | ), 115 | ), 116 | ('created', models.DateTimeField(auto_now_add=True, null=True)), 117 | ('last_updated', models.DateTimeField(auto_now=True, null=True)), 118 | ( 119 | 'custom_field_data', 120 | models.JSONField( 121 | blank=True, 122 | default=dict, 123 | encoder=utilities.json.CustomFieldJSONEncoder, 124 | ), 125 | ), 126 | ( 127 | 'assigned_object_id', 128 | models.PositiveBigIntegerField(blank=True, null=True), 129 | ), 130 | ('end_of_sale', models.DateField()), 131 | ('end_of_maintenance', models.DateField(blank=True, null=True)), 132 | ('end_of_security', models.DateField(blank=True, null=True)), 133 | ('last_contract_date', models.DateField(blank=True, null=True)), 134 | ('end_of_support', models.DateField()), 135 | ('notice', models.CharField(blank=True, max_length=500, null=True)), 136 | ( 137 | 'documentation', 138 | models.CharField(blank=True, max_length=500, null=True), 139 | ), 140 | ( 141 | 'assigned_object_type', 142 | models.ForeignKey( 143 | blank=True, 144 | limit_choices_to=( 145 | dcim.models.devices.DeviceType, 146 | dcim.models.modules.ModuleType, 147 | ), 148 | null=True, 149 | on_delete=django.db.models.deletion.PROTECT, 150 | related_name='+', 151 | to='contenttypes.contenttype', 152 | ), 153 | ), 154 | ( 155 | 'tags', 156 | taggit.managers.TaggableManager( 157 | through='extras.TaggedItem', to='extras.Tag' 158 | ), 159 | ), 160 | ], 161 | options={ 162 | 'abstract': False, 163 | }, 164 | ), 165 | ] 166 | -------------------------------------------------------------------------------- /netbox_lifecycle/filtersets/contract.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from django.db.models import Q 3 | from django.utils.translation import gettext as _ 4 | 5 | from dcim.models import Manufacturer, Device, Module 6 | from netbox.filtersets import NetBoxModelFilterSet 7 | from virtualization.models import VirtualMachine 8 | from netbox_lifecycle.models import ( 9 | Vendor, 10 | SupportContract, 11 | SupportContractAssignment, 12 | SupportSKU, 13 | License, 14 | ) 15 | 16 | __all__ = ( 17 | 'SupportContractFilterSet', 18 | 'SupportSKUFilterSet', 19 | 'VendorFilterSet', 20 | 'SupportContractAssignmentFilterSet', 21 | ) 22 | 23 | 24 | class VendorFilterSet(NetBoxModelFilterSet): 25 | 26 | class Meta: 27 | model = Vendor 28 | fields = ( 29 | 'id', 30 | 'q', 31 | 'name', 32 | ) 33 | 34 | def search(self, queryset, name, value): 35 | if not value.strip(): 36 | return queryset 37 | qs_filter = Q(name__icontains=value) 38 | return queryset.filter(qs_filter).distinct() 39 | 40 | 41 | class SupportSKUFilterSet(NetBoxModelFilterSet): 42 | manufacturer_id = django_filters.ModelMultipleChoiceFilter( 43 | field_name='manufacturer', 44 | queryset=Manufacturer.objects.all(), 45 | label=_('Manufacturer'), 46 | ) 47 | manufacturer = django_filters.ModelMultipleChoiceFilter( 48 | field_name='manufacturer__slug', 49 | queryset=Manufacturer.objects.all(), 50 | to_field_name='slug', 51 | label=_('Manufacturer (slug)'), 52 | ) 53 | 54 | class Meta: 55 | model = SupportSKU 56 | fields = ( 57 | 'id', 58 | 'q', 59 | 'sku', 60 | ) 61 | 62 | def search(self, queryset, name, value): 63 | if not value.strip(): 64 | return queryset 65 | qs_filter = Q(sku__icontains=value) | Q(manufacturer__name__icontains=value) 66 | return queryset.filter(qs_filter).distinct() 67 | 68 | 69 | class SupportContractFilterSet(NetBoxModelFilterSet): 70 | vendor_id = django_filters.ModelMultipleChoiceFilter( 71 | field_name='vendor', 72 | queryset=Vendor.objects.all(), 73 | label=_('Vendor'), 74 | ) 75 | vendor = django_filters.ModelMultipleChoiceFilter( 76 | field_name='vendor__name', 77 | queryset=Vendor.objects.all(), 78 | to_field_name='name', 79 | label=_('Vendor (Name)'), 80 | ) 81 | 82 | class Meta: 83 | model = SupportContract 84 | fields = ( 85 | 'id', 86 | 'q', 87 | 'contract_id', 88 | ) 89 | 90 | def search(self, queryset, name, value): 91 | if not value.strip(): 92 | return queryset 93 | qs_filter = Q(vendor__name__icontains=value) | Q(contract_id__icontains=value) 94 | return queryset.filter(qs_filter).distinct() 95 | 96 | 97 | class SupportContractAssignmentFilterSet(NetBoxModelFilterSet): 98 | contract_id = django_filters.ModelMultipleChoiceFilter( 99 | field_name='contract', 100 | queryset=SupportContract.objects.all(), 101 | label=_('Contract'), 102 | ) 103 | contract = django_filters.ModelMultipleChoiceFilter( 104 | field_name='contract__contract_id', 105 | queryset=SupportContract.objects.all(), 106 | to_field_name='contract_id', 107 | label=_('Contract'), 108 | ) 109 | sku_id = django_filters.ModelMultipleChoiceFilter( 110 | field_name='sku', 111 | queryset=SupportSKU.objects.all(), 112 | label=_('SKU'), 113 | ) 114 | sku = django_filters.ModelMultipleChoiceFilter( 115 | field_name='sku__sku', 116 | queryset=SupportSKU.objects.all(), 117 | to_field_name='sku', 118 | label=_('SKU'), 119 | ) 120 | device_id = django_filters.ModelMultipleChoiceFilter( 121 | field_name='device', 122 | queryset=Device.objects.all(), 123 | label=_('Device (ID)'), 124 | ) 125 | device = django_filters.ModelMultipleChoiceFilter( 126 | field_name='device__name', 127 | queryset=Device.objects.all(), 128 | to_field_name='name', 129 | label=_('Device (name)'), 130 | ) 131 | module_id = django_filters.ModelMultipleChoiceFilter( 132 | field_name='module', 133 | queryset=Module.objects.all(), 134 | label=_('Module (ID)'), 135 | ) 136 | module = django_filters.ModelMultipleChoiceFilter( 137 | field_name='module__serial', 138 | queryset=Module.objects.all(), 139 | to_field_name='serial', 140 | label=_('Module (serial)'), 141 | ) 142 | license_id = django_filters.ModelMultipleChoiceFilter( 143 | field_name='license__license', 144 | queryset=License.objects.all(), 145 | label=_('License (ID)'), 146 | ) 147 | license = django_filters.ModelMultipleChoiceFilter( 148 | field_name='license__license__name', 149 | queryset=License.objects.all(), 150 | to_field_name='name', 151 | label=_('License (SKU)'), 152 | ) 153 | virtual_machine_id = django_filters.ModelMultipleChoiceFilter( 154 | field_name='virtual_machine', 155 | queryset=VirtualMachine.objects.all(), 156 | label=_('Virtual Machine (ID)'), 157 | ) 158 | virtual_machine = django_filters.ModelMultipleChoiceFilter( 159 | field_name='virtual_machine__name', 160 | queryset=VirtualMachine.objects.all(), 161 | to_field_name='name', 162 | label=_('Virtual Machine (name)'), 163 | ) 164 | device_status = django_filters.ModelMultipleChoiceFilter( 165 | field_name='device__status', 166 | queryset=Device.objects.all(), 167 | to_field_name='status', 168 | label=_('Device Status'), 169 | ) 170 | 171 | class Meta: 172 | model = SupportContractAssignment 173 | fields = ( 174 | 'id', 175 | 'q', 176 | ) 177 | 178 | def search(self, queryset, name, value): 179 | if not value.strip(): 180 | return queryset 181 | qs_filter = ( 182 | Q(contract__contract_id__icontains=value) 183 | | Q(contract__vendor__name__icontains=value) 184 | | Q(sku__sku__icontains=value) 185 | | Q(device__name__icontains=value) 186 | | Q(module__serial__icontains=value) 187 | | Q(module__module_type__model__icontains=value) 188 | | Q(virtual_machine__name__icontains=value) 189 | | Q(license__device__name__icontains=value) 190 | | Q(license__virtual_machine__name__icontains=value) 191 | | Q(license__license__name__icontains=value) 192 | ) 193 | return queryset.filter(qs_filter).distinct() 194 | -------------------------------------------------------------------------------- /netbox_lifecycle/forms/filtersets.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext as _ 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.db.models import Q 5 | from django.forms import DateField 6 | 7 | from dcim.choices import DeviceStatusChoices 8 | from dcim.models import Device, Manufacturer, Module 9 | from netbox.forms import NetBoxModelFilterSetForm 10 | from virtualization.models import VirtualMachine 11 | from netbox_lifecycle.models import ( 12 | HardwareLifecycle, 13 | SupportContract, 14 | Vendor, 15 | License, 16 | LicenseAssignment, 17 | SupportContractAssignment, 18 | SupportSKU, 19 | ) 20 | from utilities.forms.fields import ( 21 | DynamicModelMultipleChoiceField, 22 | TagFilterField, 23 | ) 24 | from utilities.forms.rendering import FieldSet 25 | from utilities.forms.widgets import APISelectMultiple, DatePicker 26 | 27 | 28 | __all__ = ( 29 | 'HardwareLifecycleFilterForm', 30 | 'SupportSKUFilterForm', 31 | 'SupportContractFilterForm', 32 | 'VendorFilterForm', 33 | 'LicenseFilterForm', 34 | 'LicenseAssignmentFilterForm', 35 | 'SupportContractAssignmentFilterForm', 36 | ) 37 | 38 | 39 | class HardwareLifecycleFilterForm(NetBoxModelFilterSetForm): 40 | model = HardwareLifecycle 41 | fieldsets = ( 42 | FieldSet('q', 'filter_id', 'tag'), 43 | FieldSet('assigned_object_type_id', name=_('Hardware')), 44 | FieldSet( 45 | 'end_of_sale__lt', 46 | 'end_of_maintenance__lt', 47 | 'end_of_security__lt', 48 | 'end_of_support__lt', 49 | name=_('Dates'), 50 | ), 51 | ) 52 | 53 | assigned_object_type_id = DynamicModelMultipleChoiceField( 54 | queryset=ContentType.objects.filter( 55 | Q(app_label='dcim', model='devicetype') 56 | | Q(app_label='dcim', model='moduletype') 57 | ), 58 | required=False, 59 | label=_('Object Type'), 60 | widget=APISelectMultiple( 61 | api_url='/api/extras/content-types/', 62 | ), 63 | ) 64 | end_of_sale__lt = DateField( 65 | required=False, 66 | label=_('End of sale before'), 67 | widget=DatePicker, 68 | ) 69 | end_of_maintenance__lt = DateField( 70 | required=False, 71 | label=_('End of maintenance before'), 72 | ) 73 | end_of_security__lt = DateField( 74 | required=False, 75 | label=_('End of security before'), 76 | widget=DatePicker, 77 | ) 78 | end_of_support__lt = DateField( 79 | required=False, 80 | label=_('End of support before'), 81 | widget=DatePicker, 82 | ) 83 | tag = TagFilterField(model) 84 | 85 | 86 | class SupportSKUFilterForm(NetBoxModelFilterSetForm): 87 | model = SupportSKU 88 | fieldsets = (FieldSet('q', 'filter_id', 'tag', 'manufacturer_id'),) 89 | manufacturer_id = DynamicModelMultipleChoiceField( 90 | queryset=Manufacturer.objects.all(), 91 | required=False, 92 | selector=True, 93 | label=_('Manufacturer'), 94 | ) 95 | tag = TagFilterField(model) 96 | 97 | 98 | class SupportContractFilterForm(NetBoxModelFilterSetForm): 99 | model = SupportContract 100 | fieldsets = ( 101 | FieldSet('q', 'filter_id', 'tag'), 102 | FieldSet('vendor_id', name='Purchase Information'), 103 | ) 104 | vendor_id = DynamicModelMultipleChoiceField( 105 | queryset=Vendor.objects.all(), 106 | required=False, 107 | selector=True, 108 | label=_('Vendor'), 109 | ) 110 | tag = TagFilterField(model) 111 | 112 | 113 | class VendorFilterForm(NetBoxModelFilterSetForm): 114 | model = Vendor 115 | fieldsets = (FieldSet('q', 'filter_id', 'tag'),) 116 | tag = TagFilterField(model) 117 | 118 | 119 | class LicenseFilterForm(NetBoxModelFilterSetForm): 120 | model = License 121 | fieldsets = ( 122 | FieldSet('q', 'filter_id', 'tag'), 123 | FieldSet('manufacturer_id', name='License Information'), 124 | ) 125 | manufacturer_id = DynamicModelMultipleChoiceField( 126 | queryset=Manufacturer.objects.all(), 127 | required=False, 128 | selector=True, 129 | label=_('Manufacturer'), 130 | ) 131 | tag = TagFilterField(model) 132 | 133 | 134 | class SupportContractAssignmentFilterForm(NetBoxModelFilterSetForm): 135 | model = SupportContractAssignment 136 | fieldsets = ( 137 | FieldSet('q', 'filter_id', 'tag'), 138 | FieldSet( 139 | 'contract_id', 140 | 'device_id', 141 | 'module_id', 142 | 'virtual_machine_id', 143 | 'license_id', 144 | 'device_status', 145 | name='Assignment', 146 | ), 147 | ) 148 | contract_id = DynamicModelMultipleChoiceField( 149 | queryset=SupportContract.objects.all(), 150 | required=False, 151 | selector=True, 152 | label=_('Support Contracts'), 153 | ) 154 | license_id = DynamicModelMultipleChoiceField( 155 | queryset=License.objects.all(), 156 | required=False, 157 | selector=True, 158 | label=_('Licenses'), 159 | ) 160 | device_id = DynamicModelMultipleChoiceField( 161 | queryset=Device.objects.all(), 162 | required=False, 163 | selector=True, 164 | label=_('Devices'), 165 | ) 166 | module_id = DynamicModelMultipleChoiceField( 167 | queryset=Module.objects.all(), 168 | required=False, 169 | label=_('Module'), 170 | ) 171 | virtual_machine_id = DynamicModelMultipleChoiceField( 172 | queryset=VirtualMachine.objects.all(), 173 | required=False, 174 | selector=True, 175 | label=_('Virtual Machines'), 176 | ) 177 | device_status = forms.MultipleChoiceField( 178 | label=_('Status'), choices=DeviceStatusChoices, required=False 179 | ) 180 | tag = TagFilterField(model) 181 | 182 | 183 | class LicenseAssignmentFilterForm(NetBoxModelFilterSetForm): 184 | model = LicenseAssignment 185 | fieldsets = ( 186 | FieldSet('q', 'filter_id', 'tag'), 187 | FieldSet( 188 | 'license_id', 189 | 'vendor_id', 190 | 'device_id', 191 | 'virtual_machine_id', 192 | name='Assignment', 193 | ), 194 | ) 195 | license_id = DynamicModelMultipleChoiceField( 196 | queryset=License.objects.all(), 197 | required=False, 198 | selector=True, 199 | label=_('Licenses'), 200 | ) 201 | vendor_id = DynamicModelMultipleChoiceField( 202 | queryset=Vendor.objects.all(), 203 | required=False, 204 | selector=True, 205 | label=_('Vendors'), 206 | ) 207 | device_id = DynamicModelMultipleChoiceField( 208 | queryset=Device.objects.all(), 209 | required=False, 210 | selector=True, 211 | label=_('Devices'), 212 | ) 213 | virtual_machine_id = DynamicModelMultipleChoiceField( 214 | queryset=VirtualMachine.objects.all(), 215 | required=False, 216 | selector=True, 217 | label=_('Virtual Machines'), 218 | ) 219 | tag = TagFilterField(model) 220 | -------------------------------------------------------------------------------- /netbox_lifecycle/migrations/0007_alter_hardwarelifecycle_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-12 14:06 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.db.models.functions.text 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('dcim', '0171_cabletermination_change_logging'), 12 | ( 13 | 'netbox_lifecycle', 14 | '0006_alter_supportcontractassignment_assigned_object_type', 15 | ), 16 | ] 17 | 18 | operations = [ 19 | migrations.AlterModelOptions( 20 | name='hardwarelifecycle', 21 | options={'ordering': ['assigned_object_type']}, 22 | ), 23 | migrations.AddField( 24 | model_name='supportcontractassignment', 25 | name='end', 26 | field=models.DateField(blank=True, null=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='license', 30 | name='manufacturer', 31 | field=models.ForeignKey( 32 | on_delete=django.db.models.deletion.CASCADE, 33 | related_name='licenses', 34 | to='dcim.manufacturer', 35 | ), 36 | ), 37 | migrations.AlterField( 38 | model_name='licenseassignment', 39 | name='device', 40 | field=models.ForeignKey( 41 | on_delete=django.db.models.deletion.CASCADE, 42 | related_name='licenses', 43 | to='dcim.device', 44 | ), 45 | ), 46 | migrations.AlterField( 47 | model_name='licenseassignment', 48 | name='license', 49 | field=models.ForeignKey( 50 | on_delete=django.db.models.deletion.CASCADE, 51 | related_name='assignments', 52 | to='netbox_lifecycle.license', 53 | ), 54 | ), 55 | migrations.AlterField( 56 | model_name='licenseassignment', 57 | name='vendor', 58 | field=models.ForeignKey( 59 | on_delete=django.db.models.deletion.CASCADE, 60 | related_name='licenses', 61 | to='netbox_lifecycle.vendor', 62 | ), 63 | ), 64 | migrations.AlterField( 65 | model_name='supportcontract', 66 | name='end', 67 | field=models.DateField(blank=True, null=True), 68 | ), 69 | migrations.AlterField( 70 | model_name='supportcontract', 71 | name='renewal', 72 | field=models.DateField(blank=True, null=True), 73 | ), 74 | migrations.AlterField( 75 | model_name='supportcontract', 76 | name='start', 77 | field=models.DateField(blank=True, null=True), 78 | ), 79 | migrations.AlterField( 80 | model_name='supportcontract', 81 | name='vendor', 82 | field=models.ForeignKey( 83 | blank=True, 84 | null=True, 85 | on_delete=django.db.models.deletion.SET_NULL, 86 | related_name='contracts', 87 | to='netbox_lifecycle.vendor', 88 | ), 89 | ), 90 | migrations.AlterField( 91 | model_name='supportcontractassignment', 92 | name='contract', 93 | field=models.ForeignKey( 94 | on_delete=django.db.models.deletion.CASCADE, 95 | related_name='assignments', 96 | to='netbox_lifecycle.supportcontract', 97 | ), 98 | ), 99 | migrations.AlterField( 100 | model_name='supportcontractassignment', 101 | name='sku', 102 | field=models.ForeignKey( 103 | blank=True, 104 | null=True, 105 | on_delete=django.db.models.deletion.PROTECT, 106 | related_name='assignments', 107 | to='netbox_lifecycle.supportsku', 108 | ), 109 | ), 110 | migrations.AlterField( 111 | model_name='supportsku', 112 | name='manufacturer', 113 | field=models.ForeignKey( 114 | on_delete=django.db.models.deletion.CASCADE, 115 | related_name='skus', 116 | to='dcim.manufacturer', 117 | ), 118 | ), 119 | migrations.AddConstraint( 120 | model_name='hardwarelifecycle', 121 | constraint=models.UniqueConstraint( 122 | models.F('assigned_object_type'), 123 | models.F('assigned_object_id'), 124 | name='netbox_lifecycle_hardwarelifecycle_unique_object', 125 | violation_error_message='Objects must be unique.', 126 | ), 127 | ), 128 | migrations.AddConstraint( 129 | model_name='license', 130 | constraint=models.UniqueConstraint( 131 | models.F('manufacturer'), 132 | django.db.models.functions.text.Lower('name'), 133 | name='netbox_lifecycle_license_unique_manufacturer_name', 134 | violation_error_message='SKU name must be unique per manufacturer.', 135 | ), 136 | ), 137 | migrations.AddConstraint( 138 | model_name='licenseassignment', 139 | constraint=models.UniqueConstraint( 140 | models.F('license'), 141 | models.F('vendor'), 142 | models.F('device'), 143 | name='netbox_lifecycle_licenseassignment_unique_license_vendor_device', 144 | violation_error_message='License assignment must be unique.', 145 | ), 146 | ), 147 | migrations.AddConstraint( 148 | model_name='supportcontract', 149 | constraint=models.UniqueConstraint( 150 | models.F('vendor'), 151 | django.db.models.functions.text.Lower('contract_id'), 152 | name='netbox_lifecycle_supportcontract_unique_vendor_contract_id', 153 | violation_error_message='Contract must be unique per vendor.', 154 | ), 155 | ), 156 | migrations.AddConstraint( 157 | model_name='supportcontractassignment', 158 | constraint=models.UniqueConstraint( 159 | models.F('contract'), 160 | models.F('sku'), 161 | models.F('assigned_object_type'), 162 | models.F('assigned_object_id'), 163 | name='netbox_lifecycle_supportcontractassignment_unique_assignments', 164 | violation_error_message='Contract assignments must be unique.', 165 | ), 166 | ), 167 | migrations.AddConstraint( 168 | model_name='supportcontractassignment', 169 | constraint=models.UniqueConstraint( 170 | models.F('contract'), 171 | models.F('assigned_object_type'), 172 | models.F('assigned_object_id'), 173 | condition=models.Q(('sku__isnull', True)), 174 | name='netbox_lifecycle_supportcontractassignment_unique_assignment_null_sku', 175 | violation_error_message='Contract assignments to assigned_objects must be unique.', 176 | ), 177 | ), 178 | migrations.AddConstraint( 179 | model_name='supportsku', 180 | constraint=models.UniqueConstraint( 181 | models.F('manufacturer'), 182 | django.db.models.functions.text.Lower('sku'), 183 | name='netbox_lifecycle_supportsku_unique_manufacturer_sku', 184 | violation_error_message='SKU must be unique per manufacturer.', 185 | ), 186 | ), 187 | migrations.AddConstraint( 188 | model_name='vendor', 189 | constraint=models.UniqueConstraint( 190 | django.db.models.functions.text.Lower('name'), 191 | name='netbox_lifecycle_vendor_unique_name', 192 | violation_error_message='Vendor must be unique.', 193 | ), 194 | ), 195 | ] 196 | -------------------------------------------------------------------------------- /netbox_lifecycle/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from dcim.models import Device, DeviceType, Manufacturer 4 | from utilities.testing import create_test_device 5 | 6 | from netbox_lifecycle.forms import * 7 | from netbox_lifecycle.models import * 8 | 9 | 10 | class VendorTestCase(TestCase): 11 | 12 | @classmethod 13 | def setUpTestData(cls): 14 | pass 15 | 16 | def test_vendor(self): 17 | form = VendorForm( 18 | data={ 19 | 'name': 'Vendor 1', 20 | } 21 | ) 22 | self.assertTrue(form.is_valid()) 23 | self.assertTrue(form.save()) 24 | 25 | 26 | class LicenseTestCase(TestCase): 27 | 28 | @classmethod 29 | def setUpTestData(cls): 30 | Manufacturer.objects.create(name='Manufacturer', slug='manufacturer') 31 | 32 | def test_license(self): 33 | form = LicenseForm( 34 | data={'name': 'License 1', 'manufacturer': Manufacturer.objects.first().pk} 35 | ) 36 | self.assertTrue(form.is_valid()) 37 | self.assertTrue(form.save()) 38 | 39 | 40 | class LicenseAssignmentTestCase(TestCase): 41 | 42 | @classmethod 43 | def setUpTestData(cls): 44 | manufacturer = Manufacturer.objects.create( 45 | name='Manufacturer', slug='manufacturer' 46 | ) 47 | create_test_device(name='Device') 48 | Vendor.objects.create(name='Vendor') 49 | License.objects.create(manufacturer=manufacturer, name='License') 50 | 51 | def test_assignment(self): 52 | form = LicenseAssignmentForm( 53 | data={ 54 | 'license': License.objects.first().pk, 55 | 'vendor': Vendor.objects.first().pk, 56 | 'device': Device.objects.first().pk, 57 | } 58 | ) 59 | self.assertTrue(form.is_valid()) 60 | self.assertTrue(form.save()) 61 | 62 | 63 | class SupportContractTestCase(TestCase): 64 | 65 | @classmethod 66 | def setUpTestData(cls): 67 | Vendor.objects.create(name='Vendor') 68 | 69 | def test_contract(self): 70 | form = SupportContractForm( 71 | data={'contract_id': 'Contract-1', 'vendor': Vendor.objects.first().pk} 72 | ) 73 | self.assertTrue(form.is_valid()) 74 | self.assertTrue(form.save()) 75 | 76 | 77 | class SupportSKUTestCase(TestCase): 78 | 79 | @classmethod 80 | def setUpTestData(cls): 81 | Manufacturer.objects.create(name='Manufacturer', slug='manufacturer') 82 | 83 | def test_sku(self): 84 | form = SupportSKUForm( 85 | data={'sku': 'SKU-1', 'manufacturer': Manufacturer.objects.first().pk} 86 | ) 87 | self.assertTrue(form.is_valid()) 88 | self.assertTrue(form.save()) 89 | 90 | 91 | class SupportContractAssignmentTestCase(TestCase): 92 | 93 | @classmethod 94 | def setUpTestData(cls): 95 | vendor = Vendor.objects.create(name='Vendor') 96 | manufacturer = Manufacturer.objects.create( 97 | name='Manufacturer', slug='manufacturer' 98 | ) 99 | device = create_test_device(name='Test Device') 100 | SupportSKU.objects.create(manufacturer=manufacturer, sku='SKU-1') 101 | SupportContract.objects.create(vendor=vendor, contract_id='Contract') 102 | license = License.objects.create(manufacturer=manufacturer) 103 | LicenseAssignment.objects.create(license=license, vendor=vendor, device=device) 104 | 105 | def test_assignment_fail_without_device_or_license(self): 106 | form = SupportContractAssignmentForm( 107 | data={ 108 | 'contract': SupportContract.objects.first().pk, 109 | 'sku': SupportSKU.objects.first().pk, 110 | 'vendor': Vendor.objects.first().pk, 111 | } 112 | ) 113 | self.assertFalse(form.is_valid()) 114 | with self.assertRaises(ValueError): 115 | form.save() 116 | 117 | def test_assignment_with_device(self): 118 | form = SupportContractAssignmentForm( 119 | data={ 120 | 'contract': SupportContract.objects.first().pk, 121 | 'sku': SupportSKU.objects.first().pk, 122 | 'vendor': Vendor.objects.first().pk, 123 | 'device': Device.objects.first().pk, 124 | } 125 | ) 126 | self.assertTrue(form.is_valid()) 127 | self.assertTrue(form.save()) 128 | 129 | def test_assignment_with_license(self): 130 | form = SupportContractAssignmentForm( 131 | data={ 132 | 'contract': SupportContract.objects.first().pk, 133 | 'sku': SupportSKU.objects.first().pk, 134 | 'vendor': Vendor.objects.first().pk, 135 | 'license': LicenseAssignment.objects.first().pk, 136 | } 137 | ) 138 | self.assertTrue(form.is_valid()) 139 | self.assertTrue(form.save()) 140 | 141 | def test_assignment_with_device_and_license(self): 142 | form = SupportContractAssignmentForm( 143 | data={ 144 | 'contract': SupportContract.objects.first().pk, 145 | 'sku': SupportSKU.objects.first().pk, 146 | 'vendor': Vendor.objects.first().pk, 147 | 'device': Device.objects.first().pk, 148 | 'license': LicenseAssignment.objects.first().pk, 149 | } 150 | ) 151 | self.assertTrue(form.is_valid()) 152 | self.assertTrue(form.save()) 153 | 154 | def test_assignment_with_device_and_license_with_different_device(self): 155 | form = SupportContractAssignmentForm( 156 | data={ 157 | 'contract': SupportContract.objects.first().pk, 158 | 'sku': SupportSKU.objects.first().pk, 159 | 'vendor': Vendor.objects.first().pk, 160 | 'device': create_test_device(name='New Test Device'), 161 | 'license': LicenseAssignment.objects.first().pk, 162 | } 163 | ) 164 | self.assertFalse(form.is_valid()) 165 | with self.assertRaises(ValueError): 166 | form.save() 167 | 168 | 169 | class HardwareLifecycleTestCase(TestCase): 170 | 171 | @classmethod 172 | def setUpTestData(cls): 173 | manufacturer = Manufacturer.objects.create( 174 | name='Manufacturer', slug='manufacturer' 175 | ) 176 | cls.device_type = DeviceType.objects.create( 177 | manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' 178 | ) 179 | cls.device_type_2 = DeviceType.objects.create( 180 | manufacturer=manufacturer, model='Device Type 2', slug='device-type-2' 181 | ) 182 | cls.device_type_3 = DeviceType.objects.create( 183 | manufacturer=manufacturer, model='Device Type 3', slug='device-type-3' 184 | ) 185 | 186 | def test_lifecycle_with_all_dates(self): 187 | """Test creating a hardware lifecycle with all date fields populated.""" 188 | form = HardwareLifecycleForm( 189 | data={ 190 | 'device_type': self.device_type.pk, 191 | 'end_of_sale': '2030-01-01', 192 | 'end_of_support': '2035-01-01', 193 | 'end_of_maintenance': '2032-01-01', 194 | 'end_of_security': '2033-01-01', 195 | 'last_contract_attach': '2025-01-01', 196 | 'last_contract_renewal': '2028-01-01', 197 | } 198 | ) 199 | self.assertTrue(form.is_valid()) 200 | instance = form.save() 201 | self.assertEqual(str(instance.end_of_sale), '2030-01-01') 202 | self.assertEqual(str(instance.end_of_support), '2035-01-01') 203 | 204 | def test_lifecycle_with_no_dates(self): 205 | """Test creating a hardware lifecycle with no date fields (all null).""" 206 | form = HardwareLifecycleForm( 207 | data={ 208 | 'device_type': self.device_type_2.pk, 209 | } 210 | ) 211 | self.assertTrue(form.is_valid()) 212 | instance = form.save() 213 | self.assertIsNone(instance.end_of_sale) 214 | self.assertIsNone(instance.end_of_support) 215 | self.assertIsNone(instance.end_of_maintenance) 216 | self.assertIsNone(instance.end_of_security) 217 | 218 | def test_lifecycle_with_partial_dates(self): 219 | """Test creating a hardware lifecycle with only some date fields.""" 220 | form = HardwareLifecycleForm( 221 | data={ 222 | 'device_type': self.device_type_3.pk, 223 | 'end_of_sale': '2030-01-01', 224 | # end_of_support intentionally omitted 225 | } 226 | ) 227 | self.assertTrue(form.is_valid()) 228 | instance = form.save() 229 | self.assertEqual(str(instance.end_of_sale), '2030-01-01') 230 | self.assertIsNone(instance.end_of_support) 231 | --------------------------------------------------------------------------------