├── 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 |
4 |
5 |
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 |
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 | | {% trans "Contract" %} |
8 | {% trans "SKU" %} |
9 | {% trans "Vendor" %} |
10 | {% trans "Ended" %} |
11 |
12 |
13 |
14 | {% for assignment in assignments %}
15 |
16 | | {{ assignment.contract.contract_id }} |
17 | {% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %} |
18 | {{ assignment.contract.vendor|default:"-" }} |
19 | {{ assignment.end_date|date:"Y-m-d"|default:"-" }} |
20 |
21 | {% endfor %}
22 |
23 |
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 |
14 |
15 |
16 |
17 | | Name |
18 | {{ object.name }} |
19 |
20 |
21 | | Description |
22 | {{ object.description }} |
23 |
24 |
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 |
14 |
15 |
16 |
17 | | Manufacturer |
18 | {{ object.manufacturer|linkify }} |
19 |
20 |
21 | | Name |
22 | {{ object.name }} |
23 |
24 |
25 | | Description |
26 | {{ object.description }} |
27 |
28 |
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 |
14 |
15 |
16 |
17 | | Manufacturer |
18 | {{ object.manufacturer|linkify }} |
19 |
20 |
21 | | SKU |
22 | {{ object.sku }} |
23 |
24 |
25 | | Description |
26 | {{ object.description }} |
27 |
28 |
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 |
14 |
15 |
16 |
17 | | License |
18 | {{ object.license|linkify }} |
19 |
20 |
21 | | Vendor |
22 | {{ object.vendor|linkify }} |
23 |
24 |
25 | | Device |
26 | {{ object.device|linkify }} |
27 |
28 |
29 | | Quantity |
30 | {{ object.quantity }} |
31 |
32 |
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 |
14 |
15 |
16 |
17 | | Contract |
18 | {{ object.contract|linkify }} |
19 |
20 |
21 | | SKU |
22 | {{ object.sku|linkify }} |
23 |
24 |
25 | | Device |
26 | {{ object.device|linkify }} |
27 |
28 |
29 | | License |
30 | {{ object.license|linkify }} |
31 |
32 |
33 | | End Date |
34 | {{ object.end }} |
35 |
36 |
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 |
4 |
5 |
6 |
27 |
28 |
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 |
13 |
14 |
15 | | Vendor |
16 | {{ support_contract.contract.vendor|linkify|placeholder }} |
17 |
18 |
19 | | Contract Number |
20 | {{ support_contract.contract|linkify:"contract_id"|placeholder }} |
21 |
22 |
23 | | Support SKU |
24 | {{ support_contract.sku|linkify|placeholder }} |
25 |
26 |
27 | | Start Date |
28 | {{ support_contract.contract.start }} |
29 |
30 |
31 | | End Date |
32 | {% if support_contract.end == None %}
33 | {{ support_contract.contract.end }} |
34 | {% else %}
35 | {{ support_contract.end }} |
36 | {% endif %}
37 |
38 |
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 |
13 |
14 |
15 | | End of Sale |
16 | {{ lifecycle_info.end_of_sale|placeholder }} |
17 |
18 |
19 | | End of Maintenance Updates |
20 | {{ lifecycle_info.end_of_maintenance|placeholder }} |
21 |
22 |
23 | | End of Security Updates |
24 | {{ lifecycle_info.end_of_security|placeholder }} |
25 |
26 |
27 | | Last Support Contract Attach |
28 | {{ lifecycle_info.last_contract_attach|placeholder }} |
29 |
30 |
31 | | Last Support Contract Renewal |
32 | {{ lifecycle_info.last_contract_renewal|placeholder }} |
33 |
34 |
35 | | End of Support |
36 | {{ lifecycle_info.end_of_support|placeholder }} |
37 |
38 |
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 |
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 |
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 |
23 |
24 |
25 |
26 | | Manufacturer |
27 | {{ object.manufacturer|linkify|placeholder }} |
28 |
29 |
30 | | Vendor |
31 | {{ object.vendor|linkify|placeholder }} |
32 |
33 |
34 | | Contract ID |
35 | {{ object.contract_id }} |
36 |
37 |
38 | | Description |
39 | {{ object.description }} |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | | Start |
50 | {{ object.start }} |
51 |
52 |
53 | | Last renewal |
54 | {{ object.renewal }} |
55 |
56 |
57 | | End |
58 | {{ object.end }} |
59 |
60 |
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 |
15 |
16 |
17 |
18 | | Manufacturer |
19 | {{ object.assigned_object.manufacturer|linkify }} |
20 |
21 |
22 | | Object |
23 | {{ object.assigned_object|linkify }} |
24 |
25 |
26 | | Description |
27 | {{ object.description }} |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | | End of Sale |
38 | {{ object.end_of_sale|placeholder }} |
39 |
40 |
41 | | End of Maintenance Updates |
42 | {{ object.end_of_maintenance|placeholder }} |
43 |
44 |
45 | | End of Security Updates |
46 | {{ object.end_of_security|placeholder }} |
47 |
48 |
49 | | Last Support Contract Attach |
50 | {{ object.last_contract_attach|placeholder }} |
51 |
52 |
53 | | Last Support Contract Renewal |
54 | {{ object.last_contract_renewal|placeholder }} |
55 |
56 |
57 | | End of Support |
58 | {{ object.end_of_support|placeholder }} |
59 |
60 |
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 |
6 | {% if active or future or unspecified or expired_count %}
7 |
8 |
41 |
42 | {% if active %}
43 |
44 |
45 |
46 |
47 | | {% trans "Contract" %} |
48 | {% trans "SKU" %} |
49 | {% trans "Vendor" %} |
50 | {% trans "End Date" %} |
51 |
52 |
53 |
54 | {% for assignment in active %}
55 |
56 | | {{ assignment.contract.contract_id }} |
57 | {% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %} |
58 | {{ assignment.contract.vendor|default:"-" }} |
59 | {{ assignment.end_date|date:"Y-m-d" }} |
60 |
61 | {% endfor %}
62 |
63 |
64 |
65 | {% endif %}
66 | {% if future %}
67 |
68 |
69 |
70 |
71 | | {% trans "Contract" %} |
72 | {% trans "SKU" %} |
73 | {% trans "Vendor" %} |
74 | {% trans "Starts" %} |
75 | {% trans "Ends" %} |
76 |
77 |
78 |
79 | {% for assignment in future %}
80 |
81 | | {{ assignment.contract.contract_id }} |
82 | {% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %} |
83 | {{ assignment.contract.vendor|default:"-" }} |
84 | {{ assignment.contract.start|date:"Y-m-d" }} |
85 | {{ assignment.end_date|date:"Y-m-d" }} |
86 |
87 | {% endfor %}
88 |
89 |
90 |
91 | {% endif %}
92 | {% if unspecified %}
93 |
94 |
95 |
96 |
97 | | {% trans "Contract" %} |
98 | {% trans "SKU" %} |
99 | {% trans "Vendor" %} |
100 | {% trans "Started" %} |
101 |
102 |
103 |
104 | {% for assignment in unspecified %}
105 |
106 | | {{ assignment.contract.contract_id }} |
107 | {% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %} |
108 | {{ assignment.contract.vendor|default:"-" }} |
109 | {{ assignment.contract.start|date:"Y-m-d"|default:"-" }} |
110 |
111 | {% endfor %}
112 |
113 |
114 |
115 | {% endif %}
116 | {% if expired_count %}
117 |
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 |
6 | {% if active or future or unspecified or expired_count %}
7 |
8 |
41 |
42 | {% if active %}
43 |
44 |
45 |
46 |
47 | | {% trans "Contract" %} |
48 | {% trans "SKU" %} |
49 | {% trans "Vendor" %} |
50 | {% trans "End Date" %} |
51 |
52 |
53 |
54 | {% for assignment in active %}
55 |
56 | | {{ assignment.contract.contract_id }} |
57 | {% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %} |
58 | {{ assignment.contract.vendor|default:"-" }} |
59 | {{ assignment.end_date|date:"Y-m-d" }} |
60 |
61 | {% endfor %}
62 |
63 |
64 |
65 | {% endif %}
66 | {% if future %}
67 |
68 |
69 |
70 |
71 | | {% trans "Contract" %} |
72 | {% trans "SKU" %} |
73 | {% trans "Vendor" %} |
74 | {% trans "Starts" %} |
75 | {% trans "Ends" %} |
76 |
77 |
78 |
79 | {% for assignment in future %}
80 |
81 | | {{ assignment.contract.contract_id }} |
82 | {% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %} |
83 | {{ assignment.contract.vendor|default:"-" }} |
84 | {{ assignment.contract.start|date:"Y-m-d" }} |
85 | {{ assignment.end_date|date:"Y-m-d" }} |
86 |
87 | {% endfor %}
88 |
89 |
90 |
91 | {% endif %}
92 | {% if unspecified %}
93 |
94 |
95 |
96 |
97 | | {% trans "Contract" %} |
98 | {% trans "SKU" %} |
99 | {% trans "Vendor" %} |
100 | {% trans "Started" %} |
101 |
102 |
103 |
104 | {% for assignment in unspecified %}
105 |
106 | | {{ assignment.contract.contract_id }} |
107 | {% if assignment.sku %}{{ assignment.sku.sku }}{% else %}-{% endif %} |
108 | {{ assignment.contract.vendor|default:"-" }} |
109 | {{ assignment.contract.start|date:"Y-m-d"|default:"-" }} |
110 |
111 | {% endfor %}
112 |
113 |
114 |
115 | {% endif %}
116 | {% if expired_count %}
117 |
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 |
--------------------------------------------------------------------------------