9 Commits
17 changed files with 209 additions and 463 deletions
+7 -3
View File
@@ -6,6 +6,11 @@ Software Lifecycle Management
-------------- --------------
Known Issues
~~~~~~~~~~~~~~~~~~
- WARNING: This plugin is only tested with Netbox 3.2 at this time.
- CSV/Bulk imports for SoftwareProduct, Version and Installation are currently broken (WIP)
Installation Guide Installation Guide
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
@@ -20,13 +25,13 @@ to install the Netbox SLM plugin:
1. Add ``netbox_slm`` to the ``PLUGINS`` list in 1. Add ``netbox_slm`` to the ``PLUGINS`` list in
``configuration/extra.py``. ``configuration/extra.py``.
2. Create a ``plugin_requirements.txt`` with ``netbox-slm==0.94`` as 2. Create a ``plugin_requirements.txt`` with ``netbox-slm==0.99`` as
contents. contents.
3. Create a ``Dockerfile-SLM`` with contents: 3. Create a ``Dockerfile-SLM`` with contents:
.. code:: dockerfile .. code:: dockerfile
FROM netboxcommunity/netbox:v3.X.X FROM netboxcommunity/netbox:v3.2.X
COPY ./plugin_requirements.txt / COPY ./plugin_requirements.txt /
RUN /opt/netbox/venv/bin/pip install --no-warn-script-location -r /plugin_requirements.txt RUN /opt/netbox/venv/bin/pip install --no-warn-script-location -r /plugin_requirements.txt
@@ -96,7 +101,6 @@ Steps
$ git clone https://github.com/netbox-community/netbox $ git clone https://github.com/netbox-community/netbox
$ cd netbox $ cd netbox
$ git checkout f5356b8 (to pin 3.0.9)
install the virtual environment install the virtual environment
+13
View File
@@ -0,0 +1,13 @@
# Security Policy
## Current status
Netbox SLM is a plugin for Netbox with mostly default configuration. We advise to monitor Netbox security findings and update accordingly.
## Supported Versions
Only the latest version of Netbox SLM is currently being supported with security updates. We aim to keep the plugin compatible with the latest Netbox version, but have no incentive to patch older tags at this point.
## Reporting a Vulnerability
Please report security vulnerabilities by opening a ticket. The aim is to get back to you within 24 hours with a confirmation of the issue and a brief action plan or a request for more information.
-223
View File
@@ -1,223 +0,0 @@
Software Lifecycle Management
-----------------------------
*Netbox Plugin for lifecycle management of software components, including versions and installations*
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
--------------
Installation Guide
~~~~~~~~~~~~~~~~~~
When using the Docker version of Netbox, first follow the `netbox-docker
quickstart <https://github.com/netbox-community/netbox-docker#quickstart>`__
instructions to clone the netbox-docker repo and set up the
``docker-compose.override.yml``.
Next, follow these instructions (based on the generic `Netbox plugin
instructions <https://github.com/netbox-community/netbox-docker/wiki/Using-Netbox-Plugins>`__)
to install the Netbox SLM plugin:
1. Add ``netbox_slm`` to the ``PLUGINS`` list in
``configuration/configuration.py``.
2. Create a ``plugin_requirements.txt`` with ``netbox-slm==0.93`` as
contents.
3. Create a ``Dockerfile-SLM`` with contents:
.. code:: dockerfile
FROM netboxcommunity/netbox:v3.0.9 # The Netbox SLM plugin currently only works with v3.0.9 of Netbox due to backwards incompatible changes in newer version of Netbox
COPY ./plugin_requirements.txt /
RUN /opt/netbox/venv/bin/pip install --no-warn-script-location -r /plugin_requirements.txt
4. Create a ``docker-compose.override.yml`` with contents:
.. code:: yaml
version: '3.4'
services:
netbox:
ports:
- 8000:8080
build:
context: .
dockerfile: Dockerfile-SLM
image: netbox:slm
netbox-worker:
image: netbox:slm
netbox-housekeeping:
image: netbox:slm
Now, build the image: ``docker compose build --no-cache``
And finally, run Netbox with the SLM plugin: ``docker compose up``
Releasing Guide
~~~~~~~~~~~~~~~
To draft a release;
update the setup.py file to reflect the new version, then from the *src*
directory run
::
# make sure to update the version in netbox_slm/__init__.py and setup.cfg
$ python setup.py sdist
$ twine upload dist/*
On Github.com create a similar tag and version. These steps could be
automated with a github workflow.
n.b. Currently the plugin is configured to use a personal pypi account,
this should be changed.
Developer Guide (local installation)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
*Follow the steps below on your local system to run netbox and the
netbox_slm plugin in developer mode*
Setup
~~~~~
The goal below is to run all Netbox components in Docker and run a local
Netbox Django copy with auto-reload to develop the plugin pointing to
the Dockerized postgres and redis instances, basically ignoring the
netbox docker runtime server.
Gotchas
~~~~~~~~
*Netboxs exception handler seems to supress initial Exceptions,
obfuscating missing imports or other Django related issues by re-raising
an opague error, e.g. netbox_slm plugin namespace doesnotexist. Its
recommended to often check the runtime server or patch Netboxs global
exception handler.*
Steps
~~~~~
::
from your projects directory clone the netbox repository
$ git clone https://github.com/netbox-community/netbox
$ cd netbox
$ git checkout f5356b8 (to pin 3.0.9)
install the virtual environment
$ pipenv shell
$ pipenv install
create and edit netbox/configuration.py (based on the template file) add these lines at the end of the file;
DEBUG = True
SECRET_KEY = 'dummy'
DEVELOPER = True
PLUGINS = [
'netbox_slm',
]
The Netbox installation above will be used to run Django management
commands like runserver, makemigrations and migrate, which will be
explained in the next steps below;
::
from your projects directory clone the netbox-slm repository
$ git clone https://github.com/ICTU/netbox_slm
$ cd netbox_slm
$ ./start-netbox.sh
This will start Netbox locally (requires Docker) and forward the redis
and postgres ports to the localhost (make sure theres no processes
using these ports or change the dockerfiles accordingly)
Note, you can also start and stop netbox by hand:
::
$ cd netbox-docker
$ docker-compose up -d
or stop the stack with
$ docker-compose down
# to start fresh:
$ docker-compose down
$ docker volume rm netbox-docker_netbox-postgres-data # et cetera
$ docker-compose up -d --force-recreate
this will require you to re-run the migrate commando's for netbox-slm, see further down below
Go back to the netbox configuration.py file and update the postgres and
redis connection strings (username, password) to the ones the netbox
docker backend is using, for example (using default user and passwords
from the netbox docker example):
::
<<collapsed>>
# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
DATABASE = {
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
'CONN_MAX_AGE': 300, # Max database connection age
}
# Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate
# configuration exists for each. Full connection details are required in both sections, and it is strongly recommended
# to use two separate database IDs.
REDIS = {
'tasks': {
'HOST': 'localhost',
'PORT': 6379,
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': 'H733Kdjndks81',
'DATABASE': 0,
'SSL': False,
# Set this to True to skip TLS certificate verification
# This can expose the connection to attacks, be careful
# 'INSECURE_SKIP_TLS_VERIFY': False,
},
'caching': {
'HOST': 'localhost',
'PORT': 6379,
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': 'H733Kdjndks81',
'DATABASE': 1,
'SSL': False,
# Set this to True to skip TLS certificate verification
# This can expose the connection to attacks, be careful
# 'INSECURE_SKIP_TLS_VERIFY': False,
}
}
<<collapsed>>
Now you can run commands from the netbox repository like this;
::
$ cd netbox/netbox
$ export PYTHONPATH=../../netbox-slm/src/
$ python3 manage.py migrate netbox_slm
$ python3 manage.py runserver 8001
Visit http://127.0.0.1:8001 in the browesr to see the auto reloading
version of the netbox UI. Port 8000 is taken by the docker ran variant.
+1 -1
View File
@@ -5,7 +5,7 @@ class SLMConfig(PluginConfig):
name = 'netbox_slm' name = 'netbox_slm'
verbose_name = 'Software Lifecycle Management' verbose_name = 'Software Lifecycle Management'
description = 'Software Lifecycle Management' description = 'Software Lifecycle Management'
version = '0.95' version = '0.99'
author = 'Hedde van der Heide' author = 'Hedde van der Heide'
author_email = 'hedde.vanderheide@ictu.nl' author_email = 'hedde.vanderheide@ictu.nl'
base_url = 'slm' base_url = 'slm'
+6 -9
View File
@@ -1,14 +1,11 @@
from rest_framework import serializers from netbox.api.viewsets import NetBoxModelViewSet
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from netbox.api.viewsets import NetBoxModelViewSet
from netbox_slm.api.serializers import ( from netbox_slm.api.serializers import (
SoftwareProductSerializer, SoftwareProductVersionSerializer, SoftwareProductInstallationSerializer, SoftwareProductSerializer, SoftwareProductVersionSerializer, SoftwareProductInstallationSerializer,
) )
from netbox_slm.filters import ( from netbox_slm.filtersets import (
SoftwareProductFilter, SoftwareProductVersionFilter, SoftwareProductInstallationFilter, SoftwareProductFilterSet, SoftwareProductVersionFilterSet, SoftwareProductInstallationFilterSet,
) )
from netbox_slm.models import ( from netbox_slm.models import (
SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation,
@@ -27,16 +24,16 @@ class NetboxSLMRootView(APIRootView):
class SoftwareProductViewSet(NetBoxModelViewSet): class SoftwareProductViewSet(NetBoxModelViewSet):
queryset = SoftwareProduct.objects.all() queryset = SoftwareProduct.objects.all()
serializer_class = SoftwareProductSerializer serializer_class = SoftwareProductSerializer
filterset_class = SoftwareProductFilter filterset_class = SoftwareProductFilterSet
class SoftwareProductVersionViewSet(NetBoxModelViewSet): class SoftwareProductVersionViewSet(NetBoxModelViewSet):
queryset = SoftwareProductVersion.objects.all() queryset = SoftwareProductVersion.objects.all()
serializer_class = SoftwareProductVersionSerializer serializer_class = SoftwareProductVersionSerializer
filterset_class = SoftwareProductVersionFilter filterset_class = SoftwareProductVersionFilterSet
class SoftwareProductInstallationViewSet(NetBoxModelViewSet): class SoftwareProductInstallationViewSet(NetBoxModelViewSet):
queryset = SoftwareProductInstallation.objects.all() queryset = SoftwareProductInstallation.objects.all()
serializer_class = SoftwareProductInstallationSerializer serializer_class = SoftwareProductInstallationSerializer
filterset_class = SoftwareProductInstallationFilter filterset_class = SoftwareProductInstallationFilterSet
-50
View File
@@ -1,50 +0,0 @@
from django.forms import BoundField
from django.urls import reverse
from utilities.forms import DynamicModelMultipleChoiceField
class CustomDynamicModelMultipleChoiceField(DynamicModelMultipleChoiceField):
def get_bound_field(self, form, field_name):
bound_field = BoundField(form, self, field_name)
# Set initial value based on prescribed child fields (if not already set)
if not self.initial and self.initial_params:
filter_kwargs = {}
for kwarg, child_field in self.initial_params.items():
value = form.initial.get(child_field.lstrip("$"))
if value:
filter_kwargs[kwarg] = value
if filter_kwargs:
self.initial = self.queryset.filter(**filter_kwargs).first()
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
# will be populated on-demand via the APISelect widget.
data = bound_field.value()
if data:
field_name = getattr(self, "to_field_name") or "pk"
filter = self.filter(field_name=field_name)
try:
self.queryset = filter.filter(self.queryset, data)
except (TypeError, ValueError):
# Catch any error caused by invalid initial data passed from the user
self.queryset = self.queryset.none()
else:
self.queryset = self.queryset.none()
# Set the data URL on the APISelect widget (if not already set)
widget = bound_field.field.widget
if not widget.attrs.get("data-url"):
app_label = self.queryset.model._meta.app_label
model_name = self.queryset.model._meta.model_name
#
# Custom url for work with plugin api
#
data_url = reverse(
"plugins-api:{}-api:{}-list".format(app_label, model_name)
)
widget.attrs["data-url"] = data_url
return bound_field
-75
View File
@@ -1,75 +0,0 @@
import django_filters
from django.db.models import Q
from django.utils.translation import gettext as _
from extras.filters import TagFilter
from netbox.filtersets import NetBoxModelFilterSet
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
from utilities.forms import DynamicModelMultipleChoiceField
class BaseFilter(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method="search",
label="Search",
)
class SoftwareProductFilter(BaseFilter):
"""Filter capabilities for SoftwareProduct instances."""
name = django_filters.CharFilter(
lookup_expr="icontains",
)
# tag = TagFilter()
class Meta:
model = SoftwareProduct
fields = ("name",) # "tag")
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) # | Q(status__icontains=value)
return queryset.filter(qs_filter)
class SoftwareProductVersionFilter(BaseFilter):
name = django_filters.CharFilter(
lookup_expr="icontains", label="Version"
)
# software_product_id = django_filters.ModelMultipleChoiceFilter(
# queryset=SoftwareProduct.objects.all(),
# label='SoftwareProduct (ID)',
# )
# software_product = django_filters.ModelMultipleChoiceFilter(
# field_name='software_product__name',
# queryset=SoftwareProduct.objects.all(),
# to_field_name='name',
# label='SoftwareProduct (name)',
# )
# tag = TagFilter()
class Meta:
model = SoftwareProductVersion
fields = ("name", "software_product") # "tag")
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) # | Q(status__icontains=value)
return queryset.filter(qs_filter)
class SoftwareProductInstallationFilter(BaseFilter):
class Meta:
model = SoftwareProductInstallation
fields = tuple() # "tag")
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = Q(software_product__name__icontains=value) | Q(version__name__icontains=value)
return queryset.filter(qs_filter)
+23
View File
@@ -0,0 +1,23 @@
from netbox.filtersets import NetBoxModelFilterSet
from netbox_slm.models import *
class SoftwareProductFilterSet(NetBoxModelFilterSet):
"""Filter capabilities for SoftwareProduct instances."""
class Meta:
model = SoftwareProduct
fields = tuple()
class SoftwareProductVersionFilterSet(NetBoxModelFilterSet):
"""Filter capabilities for SoftwareProductVersion instances."""
class Meta:
model = SoftwareProductVersion
fields = tuple()
class SoftwareProductInstallationFilterSet(NetBoxModelFilterSet):
"""Filter capabilities for SoftwareProductInstallation instances."""
class Meta:
model = SoftwareProductInstallation
fields = tuple()
+48 -39
View File
@@ -1,16 +1,18 @@
from django import forms from django import forms
from django.db.models import Q
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.models import Manufacturer, Device
from netbox.forms import ( from netbox.forms import (
NetBoxModelForm, NetBoxModelForm,
NetBoxModelCSVForm, NetBoxModelCSVForm,
NetBoxModelBulkEditForm, NetBoxModelBulkEditForm,
NetBoxModelFilterSetForm, NetBoxModelFilterSetForm,
) )
from dcim.models import Manufacturer, Device
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
from utilities.forms import ( from utilities.forms import (
BootstrapMixin, DynamicModelChoiceField, APISelect, DynamicModelMultipleChoiceField DynamicModelChoiceField, APISelect, TagFilterField
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@@ -21,9 +23,6 @@ class SoftwareProductForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False, required=False,
# initial_params={
# 'device_types': 'device_type'
# }
) )
class Meta: class Meta:
@@ -32,24 +31,26 @@ class SoftwareProductForm(NetBoxModelForm):
class SoftwareProductFilterForm(NetBoxModelFilterSetForm): class SoftwareProductFilterForm(NetBoxModelFilterSetForm):
"""Form for filtering SoftwareProduct instances."""
model = SoftwareProduct model = SoftwareProduct
fieldsets = (
q = forms.CharField(required=False, label="Search") (None, ('q', 'tag')),
name = forms.CharField(
required=False,
label="Name",
) )
# tag = TagFilterField(SoftwareProduct) tag = TagFilterField(model)
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | \
Q(manufacturer__name__icontains=value)
return queryset.filter(qs_filter)
class SoftwareProductCSVForm(NetBoxModelCSVForm): class SoftwareProductCSVForm(NetBoxModelCSVForm):
class Meta: class Meta:
model = SoftwareProduct model = SoftwareProduct
fields = ("name",) fields = ("name", "manufacturer",)
class SoftwareProductBulkEditForm(NetBoxModelBulkEditForm): class SoftwareProductBulkEditForm(NetBoxModelBulkEditForm):
@@ -78,24 +79,23 @@ class SoftwareProductVersionForm(NetBoxModelForm):
model = SoftwareProductVersion model = SoftwareProductVersion
fields = ("name", "software_product", "tags") fields = ("name", "software_product", "tags")
# def clean(self):
# import pdb;pdb.set_trace()
# return super(SoftwareProductVersionForm, self).clean()
class SoftwareProductVersionFilterForm(NetBoxModelFilterSetForm): class SoftwareProductVersionFilterForm(NetBoxModelFilterSetForm):
"""Form for filtering SoftwareProductVersion instances."""
model = SoftwareProductVersion model = SoftwareProductVersion
fieldsets = (
q = forms.CharField(required=False, label="Search") (None, ('q', 'tag')),
name = forms.CharField(
required=False,
label="Name",
) )
# tag = TagFilterField(SoftwareProduct) tag = TagFilterField(model)
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | \
Q(software_product__name__icontains=value) | \
Q(software_product__manufacturer__name__icontains=value)
return queryset.filter(qs_filter)
class SoftwareProductVersionCSVForm(NetBoxModelCSVForm): class SoftwareProductVersionCSVForm(NetBoxModelCSVForm):
@@ -121,16 +121,10 @@ class SoftwareProductInstallationForm(NetBoxModelForm):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
# initial_params={
# 'device_types': 'device_type'
# }
) )
virtualmachine = DynamicModelChoiceField( virtualmachine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
required=False, required=False,
# initial_params={
# 'device_types': 'device_type'
# }
) )
software_product = DynamicModelChoiceField( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
@@ -154,6 +148,14 @@ class SoftwareProductInstallationForm(NetBoxModelForm):
model = SoftwareProductInstallation model = SoftwareProductInstallation
fields = ("device", "virtualmachine", "software_product", "version", "tags") fields = ("device", "virtualmachine", "software_product", "version", "tags")
def clean_version(self):
version = self.cleaned_data['version']
software_product = self.cleaned_data['software_product']
if version not in software_product.softwareproductversion_set.all():
raise forms.ValidationError(_(f"Version `{version}` doesn't exist on {software_product}, make sure you've "
f"selected a compatible version or first select the software product."))
return version
def clean(self): def clean(self):
if not any([self.cleaned_data['device'], self.cleaned_data['virtualmachine']]): if not any([self.cleaned_data['device'], self.cleaned_data['virtualmachine']]):
raise forms.ValidationError(_("Installation requires atleast one virtualmachine or device destination.")) raise forms.ValidationError(_("Installation requires atleast one virtualmachine or device destination."))
@@ -161,13 +163,21 @@ class SoftwareProductInstallationForm(NetBoxModelForm):
class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm): class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
"""Form for filtering SoftwareProductInstallation instances."""
model = SoftwareProductInstallation model = SoftwareProductInstallation
fieldsets = (
(None, ('q', 'tag')),
)
q = forms.CharField(required=False, label="Search") tag = TagFilterField(model)
# tag = TagFilterField(SoftwareProduct) def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = Q(software_product__name__icontains=value) | \
Q(software_product__manufacturer__name__icontains=value) | \
Q(version__name__icontains=value)
return queryset.filter(qs_filter)
class SoftwareProductInstallationCSVForm(NetBoxModelCSVForm): class SoftwareProductInstallationCSVForm(NetBoxModelCSVForm):
@@ -189,4 +199,3 @@ class SoftwareProductInstallationBulkEditForm(NetBoxModelBulkEditForm):
fieldsets = ( fieldsets = (
(None, ('software_product', 'version',)), (None, ('software_product', 'version',)),
) )
# nullable_fields = ('',)
@@ -0,0 +1,46 @@
# Generated by Django 3.2.9 on 2022-04-15 07:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0153_created_datetimefield'),
('virtualization', '0029_created_datetimefield'),
('netbox_slm', '0003_auto_20220407_1209'),
]
operations = [
migrations.AlterField(
model_name='softwareproduct',
name='manufacturer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.manufacturer'),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.device'),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='software_product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_slm.softwareproduct'),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='version',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_slm.softwareproductversion'),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='virtualmachine',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='virtualization.virtualmachine'),
),
migrations.AlterField(
model_name='softwareproductversion',
name='software_product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_slm.softwareproduct'),
),
]
+1 -13
View File
@@ -1,23 +1,18 @@
from django.db import models from django.db import models
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import safestring from django.utils import safestring
# from extras.utils import extras_features # todo this changed in netbox 3.1.X and higher, currently not used but checkout why
from netbox.models import NetBoxModel from netbox.models import NetBoxModel
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
# @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SoftwareProduct(NetBoxModel): class SoftwareProduct(NetBoxModel):
"""
A SoftwareProduct represents ...
"""
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
description = models.CharField(max_length=255, null=True, blank=True) description = models.CharField(max_length=255, null=True, blank=True)
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
to='dcim.Manufacturer', to='dcim.Manufacturer',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='software_products',
null=True, blank=True null=True, blank=True
) )
@@ -37,12 +32,10 @@ class SoftwareProduct(NetBoxModel):
)) if count else "0" )) if count else "0"
# @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SoftwareProductVersion(NetBoxModel): class SoftwareProductVersion(NetBoxModel):
software_product = models.ForeignKey( software_product = models.ForeignKey(
to='netbox_slm.SoftwareProduct', to='netbox_slm.SoftwareProduct',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='softwareproduct_versions'
) )
name = models.CharField(max_length=64) name = models.CharField(max_length=64)
@@ -62,31 +55,26 @@ class SoftwareProductVersion(NetBoxModel):
)) if count else "0" )) if count else "0"
# @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SoftwareProductInstallation(NetBoxModel): class SoftwareProductInstallation(NetBoxModel):
device = models.ForeignKey( device = models.ForeignKey(
to='dcim.Device', to='dcim.Device',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='softwareproduct_installations',
null=True, null=True,
blank=True blank=True
) )
virtualmachine = models.ForeignKey( virtualmachine = models.ForeignKey(
to='virtualization.VirtualMachine', to='virtualization.VirtualMachine',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='softwareproduct_installations',
null=True, null=True,
blank=True blank=True
) )
software_product = models.ForeignKey( software_product = models.ForeignKey(
to='netbox_slm.SoftwareProduct', to='netbox_slm.SoftwareProduct',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='software_products'
) )
version = models.ForeignKey( version = models.ForeignKey(
to='netbox_slm.SoftwareProductVersion', to='netbox_slm.SoftwareProductVersion',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='softwareproduct_versions'
) )
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
+1
View File
@@ -1,6 +1,7 @@
from extras.plugins import PluginMenuButton, PluginMenuItem from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices from utilities.choices import ButtonColorChoices
menu_items = ( menu_items = (
PluginMenuItem( PluginMenuItem(
link='plugins:netbox_slm:softwareproduct_list', link='plugins:netbox_slm:softwareproduct_list',
+21
View File
@@ -1,5 +1,6 @@
import django_tables2 as tables import django_tables2 as tables
from django.db.models import Count
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
from netbox.tables import NetBoxTable, ChoiceFieldColumn, ToggleColumn, columns from netbox.tables import NetBoxTable, ChoiceFieldColumn, ToggleColumn, columns
@@ -45,6 +46,12 @@ class SoftwareProductTable(NetBoxTable):
"installations", "installations",
) )
def order_installations(self, queryset, is_descending):
queryset = queryset.annotate(
count=Count('softwareproductinstallation__id')
).order_by(("-" if is_descending else "") + "count")
return queryset, True
class SoftwareProductVersionTable(NetBoxTable): class SoftwareProductVersionTable(NetBoxTable):
"""Table for displaying SoftwareProductVersion objects.""" """Table for displaying SoftwareProductVersion objects."""
@@ -90,6 +97,12 @@ class SoftwareProductVersionTable(NetBoxTable):
"installations", "installations",
) )
def order_installations(self, queryset, is_descending):
queryset = queryset.annotate(
count=Count('softwareproductinstallation__id')
).order_by(("-" if is_descending else "") + "count")
return queryset, True
class SoftwareProductInstallationTable(NetBoxTable): class SoftwareProductInstallationTable(NetBoxTable):
"""Table for displaying SoftwareProductInstallation objects.""" """Table for displaying SoftwareProductInstallation objects."""
@@ -142,5 +155,13 @@ class SoftwareProductInstallationTable(NetBoxTable):
"tags", "tags",
) )
def order_platform(self, queryset, is_descending):
queryset = queryset.order_by(("device" if is_descending else "virtualmachine"))
return queryset, True
def order_type(self, queryset, is_descending):
queryset = queryset.order_by(("device" if is_descending else "virtualmachine"))
return queryset, True
def render_software_product(self, value, **kwargs): def render_software_product(self, value, **kwargs):
return f"{kwargs['record'].software_product.manufacturer.name} - {value}" return f"{kwargs['record'].software_product.manufacturer.name} - {value}"
@@ -24,6 +24,8 @@
<a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}"> <a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}">
<span class="badge" style="color: #ffffff; background-color: #9e9e9e">{{ version }}</span> <span class="badge" style="color: #ffffff; background-color: #9e9e9e">{{ version }}</span>
</a> </a>
{% empty %}
n/a
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
+1
View File
@@ -1,5 +1,6 @@
from django.urls import path from django.urls import path
from netbox.views.generic import ObjectChangeLogView from netbox.views.generic import ObjectChangeLogView
from netbox_slm import views from netbox_slm import views
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
+35 -46
View File
@@ -1,25 +1,19 @@
from netbox.views import generic from netbox.views import generic
from netbox_slm.filters import SoftwareProductFilter, SoftwareProductVersionFilter, SoftwareProductInstallationFilter from netbox_slm import filtersets
from netbox_slm.forms import ( from netbox_slm import forms
SoftwareProductForm, SoftwareProductFilterForm, SoftwareProductCSVForm, SoftwareProductBulkEditForm, from netbox_slm import tables
SoftwareProductVersionForm, SoftwareProductVersionFilterForm, SoftwareProductVersionCSVForm,
SoftwareProductVersionBulkEditForm, SoftwareProductInstallationForm, SoftwareProductInstallationFilterForm,
SoftwareProductInstallationCSVForm, SoftwareProductInstallationBulkEditForm
)
from netbox_slm.models import ( from netbox_slm.models import (
SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
) )
from netbox_slm.tables import SoftwareProductTable, SoftwareProductVersionTable, SoftwareProductInstallationTable
class SoftwareProductListView(generic.ObjectListView): class SoftwareProductListView(generic.ObjectListView):
"""View for listing all existing SoftwareProducts.""" """View for listing all existing SoftwareProducts."""
queryset = SoftwareProduct.objects.all() queryset = SoftwareProduct.objects.all()
filterset = SoftwareProductFilter filterset = filtersets.SoftwareProductFilterSet
filterset_form = SoftwareProductFilterForm filterset_form = forms.SoftwareProductFilterForm
table = SoftwareProductTable table = tables.SoftwareProductTable
# template_name = "netbox_slm/object_list.html"
class SoftwareProductView(generic.ObjectView): class SoftwareProductView(generic.ObjectView):
@@ -28,7 +22,7 @@ class SoftwareProductView(generic.ObjectView):
queryset = SoftwareProduct.objects.all() queryset = SoftwareProduct.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
versions = instance.softwareproduct_versions.all() versions = instance.softwareproductversion_set.all()
return {"versions": versions} return {"versions": versions}
@@ -36,7 +30,7 @@ class SoftwareProductEditView(generic.ObjectEditView):
"""View for editing and creating a SoftwareProduct instance.""" """View for editing and creating a SoftwareProduct instance."""
queryset = SoftwareProduct.objects.all() queryset = SoftwareProduct.objects.all()
form = SoftwareProductForm form = forms.SoftwareProductForm
class SoftwareProductDeleteView(generic.ObjectDeleteView): class SoftwareProductDeleteView(generic.ObjectDeleteView):
@@ -47,30 +41,30 @@ class SoftwareProductDeleteView(generic.ObjectDeleteView):
class SoftwareProductBulkImportView(generic.BulkImportView): class SoftwareProductBulkImportView(generic.BulkImportView):
queryset = SoftwareProduct.objects.all() queryset = SoftwareProduct.objects.all()
model_form = SoftwareProductCSVForm model_form = forms.SoftwareProductCSVForm
table = SoftwareProductTable table = tables.SoftwareProductTable
class SoftwareProductBulkEditView(generic.BulkEditView): class SoftwareProductBulkEditView(generic.BulkEditView):
queryset = SoftwareProduct.objects.all() queryset = SoftwareProduct.objects.all()
filterset = SoftwareProductFilter filterset = filtersets.SoftwareProductFilterSet
table = SoftwareProductTable filterset_form = forms.SoftwareProductFilterForm
form = SoftwareProductBulkEditForm table = tables.SoftwareProductTable
form = forms.SoftwareProductBulkEditForm
class SoftwareProductBulkDeleteView(generic.BulkDeleteView): class SoftwareProductBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareProduct.objects.all() queryset = SoftwareProduct.objects.all()
table = SoftwareProductTable table = tables.SoftwareProductTable
class SoftwareProductVersionListView(generic.ObjectListView): class SoftwareProductVersionListView(generic.ObjectListView):
"""View for listing all existing SoftwareProductVersions.""" """View for listing all existing SoftwareProductVersions."""
queryset = SoftwareProductVersion.objects.all() queryset = SoftwareProductVersion.objects.all()
filterset = SoftwareProductVersionFilter filterset = filtersets.SoftwareProductVersionFilterSet
filterset_form = SoftwareProductVersionFilterForm filterset_form = forms.SoftwareProductVersionFilterForm
table = SoftwareProductVersionTable table = tables.SoftwareProductVersionTable
# template_name = "netbox_slm/object_list.html"
class SoftwareProductVersionView(generic.ObjectView): class SoftwareProductVersionView(generic.ObjectView):
@@ -87,7 +81,7 @@ class SoftwareProductVersionEditView(generic.ObjectEditView):
"""View for editing and creating a SoftwareProductVersion instance.""" """View for editing and creating a SoftwareProductVersion instance."""
queryset = SoftwareProductVersion.objects.all() queryset = SoftwareProductVersion.objects.all()
form = SoftwareProductVersionForm form = forms.SoftwareProductVersionForm
class SoftwareProductVersionDeleteView(generic.ObjectDeleteView): class SoftwareProductVersionDeleteView(generic.ObjectDeleteView):
@@ -98,30 +92,29 @@ class SoftwareProductVersionDeleteView(generic.ObjectDeleteView):
class SoftwareProductVersionBulkImportView(generic.BulkImportView): class SoftwareProductVersionBulkImportView(generic.BulkImportView):
queryset = SoftwareProductVersion.objects.all() queryset = SoftwareProductVersion.objects.all()
model_form = SoftwareProductVersionCSVForm model_form = forms.SoftwareProductVersionCSVForm
table = SoftwareProductVersionTable table = tables.SoftwareProductVersionTable
class SoftwareProductVersionBulkEditView(generic.BulkEditView): class SoftwareProductVersionBulkEditView(generic.BulkEditView):
queryset = SoftwareProductVersion.objects.all() queryset = SoftwareProductVersion.objects.all()
filterset = SoftwareProductVersionFilter filterset = filtersets.SoftwareProductVersionFilterSet
table = SoftwareProductVersionTable table = tables.SoftwareProductVersionTable
form = SoftwareProductVersionBulkEditForm form = forms.SoftwareProductVersionBulkEditForm
class SoftwareProductVersionBulkDeleteView(generic.BulkDeleteView): class SoftwareProductVersionBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareProductVersion.objects.all() queryset = SoftwareProductVersion.objects.all()
table = SoftwareProductVersionTable table = tables.SoftwareProductVersionTable
class SoftwareProductInstallationListView(generic.ObjectListView): class SoftwareProductInstallationListView(generic.ObjectListView):
"""View for listing all existing SoftwareProductInstallations.""" """View for listing all existing SoftwareProductInstallations."""
queryset = SoftwareProductInstallation.objects.all() queryset = SoftwareProductInstallation.objects.all()
filterset = SoftwareProductInstallationFilter filterset = filtersets.SoftwareProductInstallationFilterSet
filterset_form = SoftwareProductInstallationFilterForm filterset_form = forms.SoftwareProductInstallationFilterForm
table = SoftwareProductInstallationTable table = tables.SoftwareProductInstallationTable
# template_name = "netbox_slm/object_list.html"
class SoftwareProductInstallationView(generic.ObjectView): class SoftwareProductInstallationView(generic.ObjectView):
@@ -129,16 +122,12 @@ class SoftwareProductInstallationView(generic.ObjectView):
queryset = SoftwareProductInstallation.objects.all() queryset = SoftwareProductInstallation.objects.all()
# def get_extra_context(self, request, instance):
# records = instance.record_set.all()
# return {"records": records}
class SoftwareProductInstallationEditView(generic.ObjectEditView): class SoftwareProductInstallationEditView(generic.ObjectEditView):
"""View for editing and creating a SoftwareProductInstallation instance.""" """View for editing and creating a SoftwareProductInstallation instance."""
queryset = SoftwareProductInstallation.objects.all() queryset = SoftwareProductInstallation.objects.all()
form = SoftwareProductInstallationForm form = forms.SoftwareProductInstallationForm
class SoftwareProductInstallationDeleteView(generic.ObjectDeleteView): class SoftwareProductInstallationDeleteView(generic.ObjectDeleteView):
@@ -149,17 +138,17 @@ class SoftwareProductInstallationDeleteView(generic.ObjectDeleteView):
class SoftwareProductInstallationBulkImportView(generic.BulkImportView): class SoftwareProductInstallationBulkImportView(generic.BulkImportView):
queryset = SoftwareProductInstallation.objects.all() queryset = SoftwareProductInstallation.objects.all()
model_form = SoftwareProductInstallationCSVForm model_form = forms.SoftwareProductInstallationCSVForm
table = SoftwareProductInstallationTable table = tables.SoftwareProductInstallationTable
class SoftwareProductInstallationBulkEditView(generic.BulkEditView): class SoftwareProductInstallationBulkEditView(generic.BulkEditView):
queryset = SoftwareProductInstallation.objects.all() queryset = SoftwareProductInstallation.objects.all()
filterset = SoftwareProductInstallationFilter filterset = filtersets.SoftwareProductInstallationFilterSet
table = SoftwareProductInstallationTable table = tables.SoftwareProductInstallationTable
form = SoftwareProductInstallationBulkEditForm form = forms.SoftwareProductInstallationBulkEditForm
class SoftwareProductInstallationBulkDeleteView(generic.BulkDeleteView): class SoftwareProductInstallationBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareProductInstallation.objects.all() queryset = SoftwareProductInstallation.objects.all()
table = SoftwareProductInstallationTable table = tables.SoftwareProductInstallationTable
+1 -1
View File
@@ -1,6 +1,6 @@
[metadata] [metadata]
name = netbox-slm name = netbox-slm
version = 0.95 version = 0.99
description = Software Lifecycle Management Netbox Plugin. description = Software Lifecycle Management Netbox Plugin.
# long_description = file: README.rst # long_description = file: README.rst
# long_description_content_type='text/x-rst' # long_description_content_type='text/x-rst'