netbox-disk-manager 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. netbox_disk_manager/__init__.py +56 -0
  2. netbox_disk_manager/api/__init__.py +0 -0
  3. netbox_disk_manager/api/serializers.py +35 -0
  4. netbox_disk_manager/api/urls.py +11 -0
  5. netbox_disk_manager/api/views.py +16 -0
  6. netbox_disk_manager/config.py +56 -0
  7. netbox_disk_manager/filtersets.py +52 -0
  8. netbox_disk_manager/forms.py +97 -0
  9. netbox_disk_manager/graphql/__init__.py +0 -0
  10. netbox_disk_manager/graphql/filters.py +25 -0
  11. netbox_disk_manager/graphql/schema.py +18 -0
  12. netbox_disk_manager/graphql/types.py +30 -0
  13. netbox_disk_manager/management/__init__.py +0 -0
  14. netbox_disk_manager/management/commands/__init__.py +0 -0
  15. netbox_disk_manager/management/commands/ndm_backfill_disks.py +86 -0
  16. netbox_disk_manager/migrations/0001_initial.py +69 -0
  17. netbox_disk_manager/migrations/__init__.py +0 -0
  18. netbox_disk_manager/models.py +104 -0
  19. netbox_disk_manager/navigation.py +39 -0
  20. netbox_disk_manager/search.py +22 -0
  21. netbox_disk_manager/tables.py +32 -0
  22. netbox_disk_manager/template_content.py +135 -0
  23. netbox_disk_manager/templates/netbox_disk_manager/disk.html +82 -0
  24. netbox_disk_manager/templates/netbox_disk_manager/inc/device_disks_panel.html +52 -0
  25. netbox_disk_manager/templates/netbox_disk_manager/inc/module_disk_panel.html +38 -0
  26. netbox_disk_manager/tests/__init__.py +0 -0
  27. netbox_disk_manager/tests/test_api.py +104 -0
  28. netbox_disk_manager/tests/test_backfill.py +59 -0
  29. netbox_disk_manager/tests/test_graphql.py +59 -0
  30. netbox_disk_manager/tests/test_models.py +90 -0
  31. netbox_disk_manager/tests/test_panels.py +119 -0
  32. netbox_disk_manager/tests/test_views.py +66 -0
  33. netbox_disk_manager/tests/utils.py +63 -0
  34. netbox_disk_manager/urls.py +24 -0
  35. netbox_disk_manager/views.py +59 -0
  36. netbox_disk_manager-0.1.0.dist-info/METADATA +140 -0
  37. netbox_disk_manager-0.1.0.dist-info/RECORD +40 -0
  38. netbox_disk_manager-0.1.0.dist-info/WHEEL +5 -0
  39. netbox_disk_manager-0.1.0.dist-info/licenses/LICENSE +190 -0
  40. netbox_disk_manager-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,56 @@
1
+ """NetBox plugin tracking disk PSIDs independently of module placement.
2
+
3
+ NetBox models a disk as a ``dcim.Module`` sitting in a module bay. A module
4
+ cannot be moved to another bay or detached without being deleted, so anything
5
+ stored *on* the module - the PSID in particular - is lost the moment the disk
6
+ is reseated in another chassis. This plugin keeps one record per physical
7
+ drive, keyed by manufacturer and serial, and matches it to whatever module
8
+ currently carries that serial.
9
+ """
10
+
11
+ from netbox.plugins import PluginConfig
12
+
13
+ __version__ = '0.1.0'
14
+
15
+
16
+ class DiskManagerConfig(PluginConfig):
17
+ name = 'netbox_disk_manager'
18
+ verbose_name = 'Disk Manager'
19
+ description = 'Track disk PSIDs by manufacturer, model and serial, independent of module placement'
20
+ version = __version__
21
+ author = 'SF Compute'
22
+ base_url = 'disk-manager'
23
+
24
+ # 4.7 is the line this plugin is developed and tested against. The upper
25
+ # bound is deliberate: a new minor should be tested before it is allowed.
26
+ min_version = '4.7.0'
27
+ max_version = '4.7.99'
28
+
29
+ menu = 'navigation.menu'
30
+ search_indexes = 'search.indexes'
31
+ # Points at the list inside the module, not the module itself.
32
+ graphql_schema = 'graphql.schema.schema'
33
+
34
+ default_settings = {
35
+ # Show a top-level "Disks" entry in the sidebar. False tucks the plugin
36
+ # under the generic Plugins menu instead.
37
+ 'top_level_menu': True,
38
+ # Names of the dcim.ModuleTypeProfile entries whose modules count as
39
+ # disks. Drives the panels on device and module pages and the backfill
40
+ # command. NetBox ships a profile called "Hard disk".
41
+ 'module_profiles': ['Hard disk'],
42
+ # A PSID must fully match this pattern after surrounding whitespace is
43
+ # stripped. The default is permissive on purpose: vendors differ on
44
+ # length, and a label that cannot be recorded is worse than a lax check.
45
+ 'psid_regex': r'^[A-Za-z0-9]+$',
46
+ }
47
+
48
+ def ready(self):
49
+ super().ready()
50
+
51
+ from .config import validate_config
52
+
53
+ validate_config()
54
+
55
+
56
+ config = DiskManagerConfig
File without changes
@@ -0,0 +1,35 @@
1
+ """REST API serializers."""
2
+
3
+ from dcim.api.serializers import ManufacturerSerializer
4
+ from netbox.api.serializers import PrimaryModelSerializer
5
+ from rest_framework import serializers
6
+
7
+ from ..models import Disk
8
+
9
+ __all__ = ('DiskSerializer',)
10
+
11
+
12
+ class DiskSerializer(PrimaryModelSerializer):
13
+ url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_disk_manager-api:disk-detail')
14
+ manufacturer = ManufacturerSerializer(nested=True)
15
+
16
+ class Meta:
17
+ model = Disk
18
+ fields = (
19
+ 'id',
20
+ 'url',
21
+ 'display_url',
22
+ 'display',
23
+ 'manufacturer',
24
+ 'model',
25
+ 'serial',
26
+ 'psid',
27
+ 'description',
28
+ 'comments',
29
+ 'owner',
30
+ 'tags',
31
+ 'custom_fields',
32
+ 'created',
33
+ 'last_updated',
34
+ )
35
+ brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'serial')
@@ -0,0 +1,11 @@
1
+ """API routing. Mounts at /api/plugins/disk-manager/."""
2
+
3
+ from netbox.api.routers import NetBoxRouter
4
+
5
+ from . import views
6
+
7
+ router = NetBoxRouter()
8
+ router.register('disks', views.DiskViewSet)
9
+
10
+ app_name = 'netbox_disk_manager-api'
11
+ urlpatterns = router.urls
@@ -0,0 +1,16 @@
1
+ """REST API viewsets. Standard NetBox CRUD; permissions come from NetBox's
2
+ ObjectPermission machinery via NetBoxModelViewSet."""
3
+
4
+ from netbox.api.viewsets import NetBoxModelViewSet
5
+
6
+ from .. import filtersets
7
+ from ..models import Disk
8
+ from .serializers import DiskSerializer
9
+
10
+ __all__ = ('DiskViewSet',)
11
+
12
+
13
+ class DiskViewSet(NetBoxModelViewSet):
14
+ queryset = Disk.objects.select_related('manufacturer')
15
+ serializer_class = DiskSerializer
16
+ filterset_class = filtersets.DiskFilterSet
@@ -0,0 +1,56 @@
1
+ """Access to and validation of PLUGINS_CONFIG.
2
+
3
+ Nothing here touches the database: ``validate_config()`` runs from
4
+ ``PluginConfig.ready()``, which happens before the database necessarily exists
5
+ on a fresh install.
6
+ """
7
+
8
+ import re
9
+
10
+ from django.conf import settings
11
+ from django.core.exceptions import ImproperlyConfigured
12
+
13
+ PLUGIN_NAME = 'netbox_disk_manager'
14
+
15
+
16
+ def get_config():
17
+ """Return this plugin's merged configuration.
18
+
19
+ ``PluginConfig.validate()`` has already layered ``default_settings`` under
20
+ the user's ``PLUGINS_CONFIG`` entry by the time anything calls this, so the
21
+ result always has every key.
22
+ """
23
+ return settings.PLUGINS_CONFIG[PLUGIN_NAME]
24
+
25
+
26
+ def get_module_profiles():
27
+ """The configured ModuleTypeProfile names, as a tuple."""
28
+ return tuple(get_config()['module_profiles'])
29
+
30
+
31
+ def get_psid_pattern():
32
+ """The compiled PSID pattern. Cheap enough to compile per call; it is only hit on save."""
33
+ return re.compile(get_config()['psid_regex'])
34
+
35
+
36
+ def validate_config():
37
+ """Fail loudly at startup rather than confusingly at first use."""
38
+ config = get_config()
39
+
40
+ profiles = config.get('module_profiles')
41
+ if isinstance(profiles, str) or not isinstance(profiles, list | tuple):
42
+ raise ImproperlyConfigured(f"{PLUGIN_NAME}: 'module_profiles' must be a list of profile names.")
43
+ for name in profiles:
44
+ if not isinstance(name, str) or not name.strip():
45
+ raise ImproperlyConfigured(f"{PLUGIN_NAME}: 'module_profiles' entries must be non-empty strings.")
46
+
47
+ pattern = config.get('psid_regex')
48
+ if not isinstance(pattern, str) or not pattern:
49
+ raise ImproperlyConfigured(f"{PLUGIN_NAME}: 'psid_regex' must be a non-empty regular expression string.")
50
+ try:
51
+ re.compile(pattern)
52
+ except re.error as exc:
53
+ raise ImproperlyConfigured(f"{PLUGIN_NAME}: 'psid_regex' is not a valid regular expression: {exc}") from exc
54
+
55
+ if not isinstance(config.get('top_level_menu'), bool):
56
+ raise ImproperlyConfigured(f"{PLUGIN_NAME}: 'top_level_menu' must be True or False.")
@@ -0,0 +1,52 @@
1
+ """Filtersets.
2
+
3
+ Registered with ``@register_filterset`` so the UI filter form offers lookup
4
+ modifiers (``__ic``, ``__n``, ``__empty`` and friends) rather than bare
5
+ equality.
6
+ """
7
+
8
+ import django_filters
9
+ from dcim.models import Manufacturer
10
+ from django.db.models import Q
11
+ from netbox.filtersets import NetBoxModelFilterSet
12
+ from utilities.filters import MultiValueCharFilter
13
+ from utilities.filtersets import register_filterset
14
+
15
+ from .models import Disk
16
+
17
+ __all__ = ('DiskFilterSet',)
18
+
19
+
20
+ @register_filterset
21
+ class DiskFilterSet(NetBoxModelFilterSet):
22
+ manufacturer_id = django_filters.ModelMultipleChoiceFilter(
23
+ queryset=Manufacturer.objects.all(),
24
+ label='Manufacturer (ID)',
25
+ )
26
+ manufacturer = django_filters.ModelMultipleChoiceFilter(
27
+ field_name='manufacturer__slug',
28
+ queryset=Manufacturer.objects.all(),
29
+ to_field_name='slug',
30
+ label='Manufacturer (slug)',
31
+ )
32
+ # Case-insensitive on purpose: the same drive is reported in different
33
+ # cases by different tools, and the uniqueness rule is case-insensitive.
34
+ serial = MultiValueCharFilter(lookup_expr='iexact')
35
+ has_psid = django_filters.BooleanFilter(
36
+ method='filter_has_psid',
37
+ label='Has PSID',
38
+ )
39
+
40
+ class Meta:
41
+ model = Disk
42
+ fields = ('id', 'model', 'psid', 'description')
43
+
44
+ def search(self, queryset, name, value):
45
+ if not value.strip():
46
+ return queryset
47
+ return queryset.filter(Q(serial__icontains=value) | Q(model__icontains=value) | Q(description__icontains=value))
48
+
49
+ def filter_has_psid(self, queryset, name, value):
50
+ if value:
51
+ return queryset.exclude(psid='')
52
+ return queryset.filter(psid='')
@@ -0,0 +1,97 @@
1
+ """Forms.
2
+
3
+ Three things are deliberate here:
4
+
5
+ * ``manufacturer`` is a dynamic select backed by ``dcim.Manufacturer``, so the
6
+ record shares the vocabulary modules already use.
7
+ * The bulk edit form has no ``model`` field. ``NetBoxModelBulkEditForm`` uses a
8
+ class attribute of that name for the model class, and rewriting the model
9
+ string across many drives at once is not a thing anyone wants anyway.
10
+ * The filter form has no ``model`` field for the same reason; ``q`` searches
11
+ it.
12
+ """
13
+
14
+ from dcim.models import Manufacturer
15
+ from django import forms
16
+ from django.utils.translation import gettext_lazy as _
17
+ from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
18
+ from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
19
+ from utilities.forms.fields import (
20
+ CommentField,
21
+ CSVModelChoiceField,
22
+ DynamicModelChoiceField,
23
+ DynamicModelMultipleChoiceField,
24
+ TagFilterField,
25
+ )
26
+ from utilities.forms.rendering import FieldSet
27
+
28
+ from .models import Disk
29
+
30
+ __all__ = ('DiskBulkEditForm', 'DiskFilterForm', 'DiskForm', 'DiskImportForm')
31
+
32
+
33
+ class DiskForm(NetBoxModelForm):
34
+ manufacturer = DynamicModelChoiceField(
35
+ queryset=Manufacturer.objects.all(),
36
+ label=_('Manufacturer'),
37
+ )
38
+ comments = CommentField()
39
+
40
+ fieldsets = (FieldSet('manufacturer', 'model', 'serial', 'psid', 'description', 'tags', name=_('Disk')),)
41
+
42
+ class Meta:
43
+ model = Disk
44
+ fields = ('manufacturer', 'model', 'serial', 'psid', 'description', 'comments', 'tags')
45
+
46
+
47
+ class DiskImportForm(NetBoxModelImportForm):
48
+ manufacturer = CSVModelChoiceField(
49
+ queryset=Manufacturer.objects.all(),
50
+ to_field_name='name',
51
+ help_text=_('Manufacturer name'),
52
+ )
53
+
54
+ class Meta:
55
+ model = Disk
56
+ fields = ('manufacturer', 'model', 'serial', 'psid', 'description', 'comments', 'tags')
57
+
58
+
59
+ class DiskBulkEditForm(NetBoxModelBulkEditForm):
60
+ manufacturer = DynamicModelChoiceField(
61
+ queryset=Manufacturer.objects.all(),
62
+ required=False,
63
+ label=_('Manufacturer'),
64
+ )
65
+ description = forms.CharField(
66
+ max_length=200,
67
+ required=False,
68
+ label=_('Description'),
69
+ )
70
+ comments = CommentField()
71
+
72
+ model = Disk
73
+ fieldsets = (FieldSet('manufacturer', 'description', name=_('Disk')),)
74
+ nullable_fields = ('description', 'comments')
75
+
76
+
77
+ class DiskFilterForm(NetBoxModelFilterSetForm):
78
+ model = Disk
79
+ fieldsets = (
80
+ FieldSet('q', 'filter_id', 'tag'),
81
+ FieldSet('manufacturer_id', 'serial', 'has_psid', name=_('Attributes')),
82
+ )
83
+ manufacturer_id = DynamicModelMultipleChoiceField(
84
+ queryset=Manufacturer.objects.all(),
85
+ required=False,
86
+ label=_('Manufacturer'),
87
+ )
88
+ serial = forms.CharField(
89
+ required=False,
90
+ label=_('Serial number'),
91
+ )
92
+ has_psid = forms.NullBooleanField(
93
+ required=False,
94
+ label=_('Has PSID'),
95
+ widget=forms.Select(choices=BOOLEAN_WITH_BLANK_CHOICES),
96
+ )
97
+ tag = TagFilterField(model)
File without changes
@@ -0,0 +1,25 @@
1
+ """GraphQL filters."""
2
+
3
+ from typing import Annotated
4
+
5
+ import strawberry
6
+ import strawberry_django
7
+ from netbox.graphql.filters import PrimaryModelFilter
8
+ from strawberry_django import StrFilterLookup
9
+
10
+ from .. import models
11
+
12
+ __all__ = ('DiskFilter',)
13
+
14
+
15
+ @strawberry_django.filter_type(models.Disk, lookups=True)
16
+ class DiskFilter(PrimaryModelFilter):
17
+ model: StrFilterLookup | None = strawberry_django.filter_field()
18
+ serial: StrFilterLookup | None = strawberry_django.filter_field()
19
+ psid: StrFilterLookup | None = strawberry_django.filter_field()
20
+ manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( # noqa: F821
21
+ strawberry_django.filter_field()
22
+ )
23
+ manufacturer_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( # noqa: F821
24
+ strawberry_django.filter_field()
25
+ )
@@ -0,0 +1,18 @@
1
+ """GraphQL queries. Field names are prefixed so they cannot collide with core
2
+ or with another plugin."""
3
+
4
+ import strawberry
5
+ import strawberry_django
6
+
7
+ from .types import DiskType
8
+
9
+ __all__ = ('schema',)
10
+
11
+
12
+ @strawberry.type(name='Query')
13
+ class DiskManagerQuery:
14
+ ndm_disk: DiskType = strawberry_django.field()
15
+ ndm_disk_list: list[DiskType] = strawberry_django.field()
16
+
17
+
18
+ schema = [DiskManagerQuery]
@@ -0,0 +1,30 @@
1
+ """GraphQL types.
2
+
3
+ Fields are enumerated rather than pulled in with ``__all__``, so adding a model
4
+ field can never silently add it to the public schema.
5
+ """
6
+
7
+ from typing import Annotated
8
+
9
+ import strawberry
10
+ import strawberry_django
11
+ from netbox.graphql.types import PrimaryObjectType
12
+
13
+ from .. import models
14
+ from .filters import DiskFilter
15
+
16
+ __all__ = ('DiskType',)
17
+
18
+
19
+ @strawberry_django.type(
20
+ models.Disk,
21
+ fields=['id', 'model', 'serial', 'psid', 'description', 'comments', 'created', 'last_updated'],
22
+ filters=DiskFilter,
23
+ pagination=True,
24
+ )
25
+ class DiskType(PrimaryObjectType):
26
+ @strawberry_django.field(description='The manufacturer of the drive.')
27
+ def manufacturer(self) -> Annotated['ManufacturerType', strawberry.lazy('dcim.graphql.types')]: # noqa: F821
28
+ # An explicit resolver both names the type and binds the relation;
29
+ # a bare annotation or a `fields` entry alone does only one of the two.
30
+ return self.manufacturer
File without changes
File without changes
@@ -0,0 +1,86 @@
1
+ """Create disk records from the disk modules already in NetBox.
2
+
3
+ Dry run by default: prints what it would do and writes nothing. ``--apply``
4
+ performs the writes, in one transaction.
5
+
6
+ One record per physical drive is the invariant. When a serial is already
7
+ recorded under *any* manufacturer the module is skipped and reported, even if
8
+ the module's own type names a different manufacturer; that mismatch is a
9
+ module-type data problem to fix in NetBox, not a second drive.
10
+ """
11
+
12
+ from django.core.management.base import BaseCommand
13
+ from django.db import transaction
14
+ from django.db.models.functions import Upper
15
+
16
+ from netbox_disk_manager.config import get_module_profiles
17
+ from netbox_disk_manager.models import Disk
18
+
19
+
20
+ class Command(BaseCommand):
21
+ help = 'Create a Disk record (without PSID) for every disk module that does not have one yet.'
22
+
23
+ def add_arguments(self, parser):
24
+ parser.add_argument(
25
+ '--apply',
26
+ action='store_true',
27
+ help='Write the records. Without this flag the command only reports what it would do.',
28
+ )
29
+
30
+ def handle(self, *args, **options):
31
+ from dcim.models import Module
32
+
33
+ apply = options['apply']
34
+ profiles = get_module_profiles()
35
+
36
+ modules = (
37
+ Module.objects.filter(module_type__profile__name__in=profiles)
38
+ .exclude(serial='')
39
+ .select_related('device', 'module_bay', 'module_type__manufacturer', 'module_type__profile')
40
+ .order_by('pk')
41
+ )
42
+
43
+ # Every serial already recorded, uppercased, regardless of manufacturer.
44
+ recorded = {
45
+ serial: manufacturer
46
+ for serial, manufacturer in Disk.objects.annotate(serial_upper=Upper('serial')).values_list(
47
+ 'serial_upper', 'manufacturer__name'
48
+ )
49
+ }
50
+
51
+ created = skipped = 0
52
+ verb = 'create' if apply else 'would create'
53
+
54
+ with transaction.atomic():
55
+ for module in modules:
56
+ key = module.serial.strip().upper()
57
+ where = f'{module.device.name}/{module.module_bay.name}'
58
+ if key in recorded:
59
+ skipped += 1
60
+ self.stdout.write(f'skip: {where} serial={module.serial} already recorded under {recorded[key]}')
61
+ continue
62
+
63
+ manufacturer = module.module_type.manufacturer
64
+ self.stdout.write(
65
+ f'{verb}: {where} manufacturer={manufacturer.name} model={module.module_type.model} '
66
+ f'serial={module.serial}'
67
+ )
68
+ if apply:
69
+ disk = Disk(
70
+ manufacturer=manufacturer,
71
+ model=module.module_type.model,
72
+ serial=module.serial.strip(),
73
+ psid='',
74
+ )
75
+ disk.full_clean()
76
+ disk.save()
77
+ # Recorded either way so a second module with the same serial
78
+ # in the same run is reported as a skip, matching --apply.
79
+ recorded[key] = manufacturer.name
80
+ created += 1
81
+
82
+ self.stdout.write(
83
+ self.style.SUCCESS(f'{"created" if apply else "would create"}={created} skipped={skipped}')
84
+ if apply
85
+ else f'would create={created} skipped={skipped} (dry run; pass --apply to write)'
86
+ )
@@ -0,0 +1,69 @@
1
+ # Generated by Django 6.1 on 2026-11-11 17:59
2
+
3
+ import django.db.models.deletion
4
+ import django.db.models.functions.text
5
+ import netbox.models.deletion
6
+ import taggit.managers
7
+ import utilities.json
8
+ from django.db import migrations, models
9
+
10
+
11
+ class Migration(migrations.Migration):
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ('dcim', '0250_cooling_infrastructure'),
16
+ ('extras', '0144_customfield_status'),
17
+ ('users', '0016_default_ordering_indexes'),
18
+ ]
19
+
20
+ operations = [
21
+ migrations.CreateModel(
22
+ name='Disk',
23
+ fields=[
24
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
25
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
26
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
27
+ (
28
+ 'custom_field_data',
29
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
30
+ ),
31
+ ('description', models.CharField(blank=True, max_length=200)),
32
+ ('comments', models.TextField(blank=True)),
33
+ ('model', models.CharField(max_length=100)),
34
+ ('serial', models.CharField(db_index=True, max_length=100)),
35
+ ('psid', models.CharField(blank=True, max_length=64)),
36
+ (
37
+ 'manufacturer',
38
+ models.ForeignKey(
39
+ on_delete=django.db.models.deletion.PROTECT, related_name='disks', to='dcim.manufacturer'
40
+ ),
41
+ ),
42
+ (
43
+ 'owner',
44
+ models.ForeignKey(
45
+ blank=True,
46
+ null=True,
47
+ on_delete=django.db.models.deletion.PROTECT,
48
+ related_name='+',
49
+ to='users.owner',
50
+ ),
51
+ ),
52
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
53
+ ],
54
+ options={
55
+ 'verbose_name': 'disk',
56
+ 'verbose_name_plural': 'disks',
57
+ 'ordering': ('manufacturer', 'model', 'serial'),
58
+ 'constraints': [
59
+ models.UniqueConstraint(
60
+ models.F('manufacturer'),
61
+ django.db.models.functions.text.Upper('serial'),
62
+ name='netbox_disk_manager_disk_unique_manufacturer_serial_ci',
63
+ violation_error_message='A disk with this serial already exists for this manufacturer.',
64
+ )
65
+ ],
66
+ },
67
+ bases=(netbox.models.deletion.DeleteMixin, models.Model),
68
+ ),
69
+ ]
File without changes
@@ -0,0 +1,104 @@
1
+ """The Disk model.
2
+
3
+ One row per physical drive, keyed by manufacturer and serial. The record is
4
+ deliberately *not* a foreign key to ``dcim.Module``: a module is deleted when a
5
+ disk leaves a bay, and the whole point is to outlive that. Instead the link is
6
+ computed at render time by matching the serial (and manufacturer) against
7
+ whatever module currently carries it.
8
+ """
9
+
10
+ from django.core.exceptions import ValidationError
11
+ from django.db import models
12
+ from django.db.models.functions import Upper
13
+ from django.utils.translation import gettext_lazy as _
14
+ from netbox.models import PrimaryModel
15
+
16
+ from .config import get_psid_pattern
17
+
18
+ __all__ = ('Disk',)
19
+
20
+
21
+ class Disk(PrimaryModel):
22
+ manufacturer = models.ForeignKey(
23
+ to='dcim.Manufacturer',
24
+ on_delete=models.PROTECT,
25
+ related_name='disks',
26
+ verbose_name=_('manufacturer'),
27
+ )
28
+ model = models.CharField(
29
+ max_length=100,
30
+ verbose_name=_('model'),
31
+ help_text=_('Model as printed on the label or reported by the drive, e.g. SSDPF2KX038T1.'),
32
+ )
33
+ serial = models.CharField(
34
+ max_length=100,
35
+ db_index=True,
36
+ verbose_name=_('serial number'),
37
+ help_text=_('Matched case-insensitively against the serial of modules in NetBox.'),
38
+ )
39
+ psid = models.CharField(
40
+ max_length=64,
41
+ blank=True,
42
+ verbose_name=_('PSID'),
43
+ help_text=_('Physical Security ID printed on the drive label. Leave blank if not yet collected.'),
44
+ )
45
+
46
+ clone_fields = ('manufacturer', 'model')
47
+
48
+ class Meta:
49
+ ordering = ('manufacturer', 'model', 'serial')
50
+ constraints = [
51
+ # Case-insensitive: vendors print serials in one case and tooling
52
+ # reports them in another, and both refer to the same drive.
53
+ models.UniqueConstraint(
54
+ models.F('manufacturer'),
55
+ Upper('serial'),
56
+ name='%(app_label)s_%(class)s_unique_manufacturer_serial_ci',
57
+ violation_error_message=_('A disk with this serial already exists for this manufacturer.'),
58
+ ),
59
+ ]
60
+ verbose_name = _('disk')
61
+ verbose_name_plural = _('disks')
62
+
63
+ def __str__(self):
64
+ return f'{self.manufacturer} {self.model} ({self.serial})'
65
+
66
+ def clean(self):
67
+ super().clean()
68
+
69
+ self.serial = (self.serial or '').strip()
70
+ self.psid = (self.psid or '').strip()
71
+ self.model = (self.model or '').strip()
72
+
73
+ if not self.serial:
74
+ raise ValidationError({'serial': _('A serial number is required.')})
75
+
76
+ if self.psid and not get_psid_pattern().fullmatch(self.psid):
77
+ raise ValidationError({'psid': _('PSID contains characters that are not allowed.')})
78
+
79
+ # Django validates the functional unique constraint too, but it reports
80
+ # against the whole form; this pins the message to the serial field.
81
+ if self.manufacturer_id is not None:
82
+ duplicates = Disk.objects.filter(manufacturer_id=self.manufacturer_id, serial__iexact=self.serial)
83
+ if self.pk:
84
+ duplicates = duplicates.exclude(pk=self.pk)
85
+ if duplicates.exists():
86
+ raise ValidationError({'serial': _('A disk with this serial already exists for this manufacturer.')})
87
+
88
+ @property
89
+ def has_psid(self):
90
+ return bool(self.psid)
91
+
92
+ def get_modules(self):
93
+ """Modules in NetBox that currently carry this drive.
94
+
95
+ Normally zero or one. More than one means the same drive was recorded
96
+ in two places, which is a data problem worth surfacing, not hiding.
97
+ """
98
+ from dcim.models import Module
99
+
100
+ return (
101
+ Module.objects.filter(serial__iexact=self.serial, module_type__manufacturer_id=self.manufacturer_id)
102
+ .select_related('device', 'module_bay', 'module_type')
103
+ .order_by('device__name', 'module_bay__name')
104
+ )