diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0e6bde8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI +on: [push, pull_request] +permissions: + contents: read +jobs: + build: + name: Check Build + runs-on: ubuntu-latest + env: + NETBOX_CONFIGURATION: netbox.configuration_lifecycle + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11'] + services: + redis: + image: redis + ports: + - 6379:6379 + postgres: + image: postgres + env: + POSTGRES_USER: netbox + POSTGRES_PASSWORD: netbox + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Check out NetBox + uses: actions/checkout@v4 + with: + repository: 'netbox-community/netbox' + ref: 'master' + path: 'netbox' + + + - name: Check out repo + uses: actions/checkout@v4 + with: + path: 'netbox-lifecycle' + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies & set up configuration + run: | + python -m pip install --upgrade pip + pip install -r netbox/requirements.txt + pip install pycodestyle coverage tblib + pip install -e netbox-lifecycle + + - name: Copy configuration + run: | + cp netbox-lifecycle/contrib/configuration_lifecycle.py netbox/netbox/netbox/configuration_lifecycle.py + + - name: Collect static files + run: python netbox/netbox/manage.py collectstatic --no-input + + - name: Check for missing migrations + run: python netbox/netbox/manage.py makemigrations --check + + - name: Check PEP8 compliance + run: pycodestyle --ignore=W504,E501 netbox-lifecycle/netbox_lifecycle + + - name: Run tests + run: coverage run --source="netbox-lifecycle/netbox_lifecycle/" netbox/netbox/manage.py test netbox-lifecycle/netbox_lifecycle/ --parallel + + - name: Show coverage report + run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*' diff --git a/contrib/configuration_lifecycle.py b/contrib/configuration_lifecycle.py new file mode 100644 index 0000000..fb0283f --- /dev/null +++ b/contrib/configuration_lifecycle.py @@ -0,0 +1,47 @@ +################################################################### +# This file serves as a base configuration for testing purposes # +# only. It is not intended for production use. # +################################################################### + +ALLOWED_HOSTS = ['*'] + +DATABASE = { + 'NAME': 'netbox', + 'USER': 'netbox', + 'PASSWORD': 'netbox', + 'HOST': 'localhost', + 'PORT': '', + 'CONN_MAX_AGE': 300, +} + +PLUGINS = [ + 'netbox_lifecycle', +] + +REDIS = { + 'tasks': { + 'HOST': 'localhost', + 'PORT': 6379, + 'USERNAME': '', + 'PASSWORD': '', + 'DATABASE': 0, + 'SSL': False, + }, + 'caching': { + 'HOST': 'localhost', + 'PORT': 6379, + 'USERNAME': '', + 'PASSWORD': '', + 'DATABASE': 1, + 'SSL': False, + } +} + +SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +DEFAULT_PERMISSIONS = {} + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': True +} diff --git a/netbox_lifecycle/api/views/contract.py b/netbox_lifecycle/api/views/contract.py index c069715..03de3ab 100644 --- a/netbox_lifecycle/api/views/contract.py +++ b/netbox_lifecycle/api/views/contract.py @@ -29,4 +29,4 @@ class SupportContractViewSet(ModelViewSet): class SupportContractAssignmentViewSet(ModelViewSet): queryset = SupportContractAssignment.objects.all() - serializer_class = SupportContractAssignmentSerializer \ No newline at end of file + serializer_class = SupportContractAssignmentSerializer diff --git a/netbox_lifecycle/api/views/hardware.py b/netbox_lifecycle/api/views/hardware.py index 07cf66a..5261f6d 100644 --- a/netbox_lifecycle/api/views/hardware.py +++ b/netbox_lifecycle/api/views/hardware.py @@ -11,4 +11,4 @@ class HardwareLifecycleViewSet(ModelViewSet): queryset = HardwareLifecycle.objects.all() - serializer_class = HardwareLifecycleSerializer \ No newline at end of file + serializer_class = HardwareLifecycleSerializer diff --git a/netbox_lifecycle/filtersets/contract.py b/netbox_lifecycle/filtersets/contract.py index 2c29c15..41fdbf4 100644 --- a/netbox_lifecycle/filtersets/contract.py +++ b/netbox_lifecycle/filtersets/contract.py @@ -1,5 +1,4 @@ import django_filters -from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ @@ -15,8 +14,6 @@ 'SupportContractAssignmentFilterSet' ) -from utilities.filters import MultiValueCharFilter, MultiValueNumberFilter - class VendorFilterSet(NetBoxModelFilterSet): @@ -60,7 +57,6 @@ def search(self, queryset, name, value): return queryset.filter(qs_filter).distinct() - class SupportContractFilterSet(NetBoxModelFilterSet): vendor_id = django_filters.ModelMultipleChoiceFilter( field_name='vendor', diff --git a/netbox_lifecycle/filtersets/hardware.py b/netbox_lifecycle/filtersets/hardware.py index 8b78a46..7601aed 100644 --- a/netbox_lifecycle/filtersets/hardware.py +++ b/netbox_lifecycle/filtersets/hardware.py @@ -12,8 +12,6 @@ 'HardwareLifecycleFilterSet', ) -from utilities.filters import MultiValueCharFilter, MultiValueNumberFilter - class HardwareLifecycleFilterSet(NetBoxModelFilterSet): assigned_object_type_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox_lifecycle/migrations/0003_remove_supportcontract_devices_and_more.py b/netbox_lifecycle/migrations/0003_remove_supportcontract_devices_and_more.py index 6b61c94..44c66c2 100644 --- a/netbox_lifecycle/migrations/0003_remove_supportcontract_devices_and_more.py +++ b/netbox_lifecycle/migrations/0003_remove_supportcontract_devices_and_more.py @@ -5,6 +5,7 @@ import taggit.managers import utilities.json + def migrate_to_assignments(apps, schema_editor): SupportContractDeviceAssignment = apps.get_model('netbox_lifecycle', 'SupportContractDeviceAssignment') SupportContract = apps.get_model('netbox_lifecycle', 'SupportContract') @@ -13,6 +14,7 @@ def migrate_to_assignments(apps, schema_editor): for device in contract.devices.all(): SupportContractDeviceAssignment.objects.create(contract=contract, device=device) + def migrate_from_assignments(apps, schema_editor): SupportContractDeviceAssignment = apps.get_model('netbox_lifecycle', 'SupportContractDeviceAssignment') diff --git a/netbox_lifecycle/migrations/0004_supportcontractassignment_and_more.py b/netbox_lifecycle/migrations/0004_supportcontractassignment_and_more.py index 1666865..817b481 100644 --- a/netbox_lifecycle/migrations/0004_supportcontractassignment_and_more.py +++ b/netbox_lifecycle/migrations/0004_supportcontractassignment_and_more.py @@ -4,6 +4,7 @@ import taggit.managers import utilities.json + def migrate_to_assignments(apps, schema_editor): from django.contrib.contenttypes.models import ContentType SupportContractDeviceAssignment = apps.get_model('netbox_lifecycle', 'SupportContractDeviceAssignment') @@ -22,6 +23,7 @@ def migrate_to_assignments(apps, schema_editor): contract=contract.contract, ) + def migrate_from_assignments(apps, schema_editor): SupportContractDeviceAssignment = apps.get_model('netbox_lifecycle', 'SupportContractDeviceAssignment') SupportContractAssignment = apps.get_model('netbox_lifecycle', 'SupportContractAssignment') diff --git a/netbox_lifecycle/migrations/0011_alter_supportcontractassignment.py b/netbox_lifecycle/migrations/0011_alter_supportcontractassignment.py index 4619e18..151e39e 100644 --- a/netbox_lifecycle/migrations/0011_alter_supportcontractassignment.py +++ b/netbox_lifecycle/migrations/0011_alter_supportcontractassignment.py @@ -21,6 +21,7 @@ def migrate_assigned_object_forward(apps, schema_editor): assignment.license = license_assignment assignment.save() + def migrate_assigned_object_reverse(apps, schema_editor): SupportContractAssignment = apps.get_model('netbox_lifecycle', 'SupportContractAssignment') ContentType = apps.get_model('contenttypes', 'ContentType') diff --git a/netbox_lifecycle/models/contract.py b/netbox_lifecycle/models/contract.py index 98be075..1d0eed5 100644 --- a/netbox_lifecycle/models/contract.py +++ b/netbox_lifecycle/models/contract.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Q from django.db.models.functions import Lower @@ -72,6 +73,7 @@ def __str__(self): def get_absolute_url(self): return reverse('plugins:netbox_lifecycle:supportsku', args=[self.pk]) + class SupportContract(NetBoxModel): vendor = models.ForeignKey( to='netbox_lifecycle.Vendor', @@ -177,3 +179,17 @@ def get_device_status_color(self): if self.device is None: return return DeviceStatusChoices.colors.get(self.device.status) + + def clean(self): + if self.device and self.license and SupportContractAssignment.objects.filter( + contract=self.contract, device=self.device, license=self.license, sku=self.sku + ).exclude(pk=self.pk).count() > 0: + raise ValidationError('Device or License must be unique') + elif self.device and not self.license and SupportContractAssignment.objects.filter( + contract=self.contract, device=self.device, license=self.license + ).exclude(pk=self.pk).count() > 0: + raise ValidationError('Device must be unique') + elif not self.device and self.license and SupportContractAssignment.objects.filter( + contract=self.contract, device=self.device, license=self.license + ).exclude(pk=self.pk).count() > 0: + raise ValidationError('License must be unique') diff --git a/netbox_lifecycle/models/netbox_polymprohic.py b/netbox_lifecycle/models/netbox_polymprohic.py index becc823..7260bf3 100644 --- a/netbox_lifecycle/models/netbox_polymprohic.py +++ b/netbox_lifecycle/models/netbox_polymprohic.py @@ -1,8 +1,8 @@ -from polymorphic.managers import PolymorphicManager from polymorphic.models import PolymorphicModel from polymorphic.query import PolymorphicQuerySet from netbox.models import NetBoxModel + from utilities.querysets import RestrictedQuerySet @@ -12,5 +12,6 @@ class RestrictedPolymorphicQuerySet(PolymorphicQuerySet, RestrictedQuerySet): class NetBoxPolymorphicModel(NetBoxModel, PolymorphicModel): objects = RestrictedPolymorphicQuerySet.as_manager() + class Meta: - abstract = True \ No newline at end of file + abstract = True diff --git a/netbox_lifecycle/tests/__init__.py b/netbox_lifecycle/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_lifecycle/tests/test_models.py b/netbox_lifecycle/tests/test_models.py new file mode 100644 index 0000000..626d1e3 --- /dev/null +++ b/netbox_lifecycle/tests/test_models.py @@ -0,0 +1,192 @@ +import datetime + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from dcim.models import Manufacturer, Site, DeviceRole, DeviceType, Device +from netbox_lifecycle.models import ( + Vendor, SupportContract, SupportSKU, SupportContractAssignment, License, LicenseAssignment +) + + +class SupportContractTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + + vendors = ( + Vendor(name='Vendor 1'), + Vendor(name='Vendor 2'), + ) + Vendor.objects.bulk_create(vendors) + + def test_contract_creation(self): + contract = SupportContract( + vendor=Vendor.objects.first(), + contract_id='1234', + start=datetime.date.today(), + renewal=datetime.date.today() + datetime.timedelta(days=1), + end=datetime.date.today() + datetime.timedelta(days=2) + ) + contract.full_clean() + contract.save() + + def test_supportcontract_duplicate_ids(self): + contract1 = SupportContract( + vendor=Vendor.objects.first(), + contract_id='1234', + ) + contract1.clean() + contract1.save() + + contract2 = SupportContract( + vendor=Vendor.objects.first(), + contract_id='1234', + ) + + # Two support contracts assigned to the same Vendor with the same contract_id should fail validation + with self.assertRaises(ValidationError): + contract2.full_clean() + + # Two support contracts assigned to the different Vendors with the same contract_id should pass validation + contract2.vendor = Vendor.objects.last() + contract2.full_clean() + contract2.save() + + # Two support contracts assigned to the different contract_id should pass validation + contract2.vendor = Vendor.objects.first() + contract2.contract_id = '5678' + contract2.full_clean() + contract2.save() + + +class SupportSKUTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + def test_contract_creation(self): + sku = SupportSKU( + manufacturer=Manufacturer.objects.first(), + sku='Support-1', + ) + sku.full_clean() + sku.save() + + def test_supportcontract_duplicate_ids(self): + sku1 = SupportSKU( + manufacturer=Manufacturer.objects.first(), + sku='Support-1', + ) + sku1.clean() + sku1.save() + + sku2 = SupportSKU( + manufacturer=Manufacturer.objects.first(), + sku='Support-1', + ) + + # Two support contracts assigned to the same Manufacturer with the same sku should fail validation + with self.assertRaises(ValidationError): + sku2.full_clean() + + # Two support contracts assigned to the different Vendors with the same contract_id should pass validation + sku2.manufacturer = Manufacturer.objects.last() + sku2.full_clean() + sku2.save() + + # Two support contracts assigned to the different contract_id should pass validation + sku2.manufacturer = Manufacturer.objects.first() + sku2.sku = 'Support-2' + sku2.full_clean() + sku2.save() + + +class SupportContractAssignmentTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + site = Site.objects.create(name='Test Site', slug='test-site') + role = DeviceRole.objects.create(name='Test Role', slug='test-role') + device_type = DeviceType.objects.create( + model='Test DeviceType', slug='test-devicetype', manufacturer=manufacturer + ) + device = Device.objects.create(name='Test Device 1', device_type=device_type, role=role, site=site) + + vendor = Vendor.objects.create(name='Vendor 1') + license = License.objects.create(manufacturer=manufacturer, name='Test License') + + skus = ( + SupportSKU( + manufacturer=manufacturer, + sku='SKU 1', + ), + SupportSKU( + manufacturer=manufacturer, + sku='SKU 2', + ), + ) + SupportSKU.objects.bulk_create(skus) + + contract = SupportContract.objects.create( + vendor=Vendor.objects.first(), + contract_id='1234', + start=datetime.date.today(), + renewal=datetime.date.today() + datetime.timedelta(days=1), + end=datetime.date.today() + datetime.timedelta(days=2) + ) + + LicenseAssignment.objects.create( + license=license, + vendor=vendor, + device=device, + quantity=1 + ) + + def test_contractassignment_creation(self): + contract = SupportContract.objects.first() + sku = SupportSKU.objects.first() + device = Device.objects.first() + license = LicenseAssignment.objects.first() + + contract = SupportContractAssignment( + contract=contract, + sku=sku, + device=device + ) + contract.full_clean() + contract.save() + + def test_supportcontract_duplicate_ids(self): + contract = SupportContract.objects.first() + sku = SupportSKU.objects.first() + device = Device.objects.first() + license = LicenseAssignment.objects.first() + + contract1 = SupportContractAssignment( + contract=contract, + sku=sku, + device=device + ) + contract1.full_clean() + contract1.save() + + contract2 = SupportContractAssignment( + contract=contract, + sku=sku, + device=device + ) + + with self.assertRaises(ValidationError): + contract2.full_clean() + + contract2.license = license + contract2.full_clean() + contract2.save() diff --git a/netbox_lifecycle/views/hardware.py b/netbox_lifecycle/views/hardware.py index d2627cf..61a037a 100644 --- a/netbox_lifecycle/views/hardware.py +++ b/netbox_lifecycle/views/hardware.py @@ -14,6 +14,7 @@ 'HardwareLifecycleDeleteView', ) + @register_model_view(HardwareLifecycle, name='list') class HardwareLifecycleListView(ObjectListView): queryset = HardwareLifecycle.objects.all() @@ -32,7 +33,6 @@ def get_extra_context(self, request, instance): } - @register_model_view(HardwareLifecycle, 'edit') class HardwareLifecycleEditView(ObjectEditView): template_name = 'netbox_lifecycle/hardwarelifecycle_edit.html' @@ -40,9 +40,6 @@ class HardwareLifecycleEditView(ObjectEditView): form = HardwareLifecycleForm - - @register_model_view(HardwareLifecycle, 'delete') class HardwareLifecycleDeleteView(ObjectDeleteView): queryset = HardwareLifecycle.objects.all() - diff --git a/netbox_lifecycle/views/license.py b/netbox_lifecycle/views/license.py index a1edf7f..b9f469b 100644 --- a/netbox_lifecycle/views/license.py +++ b/netbox_lifecycle/views/license.py @@ -21,6 +21,7 @@ 'LicenseAssignmentBulkDeleteView', ) + @register_model_view(License, name='list') class LicenseListView(ObjectListView): queryset = License.objects.all()