Răsfoiți Sursa

Misc. cleanup ahead of the v4.7.0 release (#23084)

Jeremy Stretch 19 ore în urmă
părinte
comite
6345ed1de2

+ 10 - 0
docs/administration/management-commands.md

@@ -34,6 +34,16 @@ Populate the cached file size for image attachments that predate the `image_size
 python3 netbox/manage.py populate_image_sizes
 python3 netbox/manage.py populate_image_sizes
 ```
 ```
 
 
+## rebuild_config_context_cache
+
+Pre-render and cache the merged config context data for all devices and virtual machines. The [upgrade script](../installation/upgrading.md) runs this automatically, so it is not usually necessary to invoke it by hand. It is useful to complete an interrupted run, or (with `--force`) to repair the cache after a bulk write which bypassed NetBox's change handling (cache invalidation is driven by model signals, which a direct `queryset.update()` does not emit).
+
+By default, only those objects whose cache is empty are rendered, so the command is safe to interrupt and re-run. This also means that a default run will not correct a cache which is populated but stale, as a write which bypassed cache invalidation leaves it: Pass `--force` to re-render every object regardless of its current cache. Either form may be run on a live system, as any object whose cache is empty falls back to rendering its config context on demand. See [Context Data](../features/context-data.md) for details.
+
+```
+python3 netbox/manage.py rebuild_config_context_cache [--force]
+```
+
 ## rebuild_prefixes
 ## rebuild_prefixes
 
 
 Rebuild the IPAM prefix hierarchy, recalculating the depth and child counts for all prefixes.
 Rebuild the IPAM prefix hierarchy, recalculating the depth and child counts for all prefixes.

+ 20 - 6
docs/release-notes/version-4.7.md

@@ -14,32 +14,44 @@
 !!! warning "Redis 6.0 or Later Required"
 !!! warning "Redis 6.0 or Later Required"
     This release of NetBox drops support for Redis 5.x.
     This release of NetBox drops support for Redis 5.x.
 
 
+!!! warning "Extended Upgrade Duration"
+    Two steps in this upgrade scale with the size of the database, and may take considerably longer than a typical NetBox upgrade.
+
+    The migration which replaces django-mptt with `ltree` adds and alters columns on each hierarchical table before backfilling it in a single statement. The schema changes hold an `ACCESS EXCLUSIVE` lock, which blocks reads as well as writes for their duration; the backfill which follows locks every row it updates, blocking concurrent writes to those rows until it commits. On a large deployment this can last several minutes. The table most likely to be affected is `dcim_inventoryitem`, which can hold millions of rows; the others (region, sitegroup, location, devicerole, platform, modulebay, inventoryitemtemplate, tenantgroup, contactgroup, and wirelesslangroup) are typically far smaller.
+
+    The upgrade script then runs the `rebuild_config_context_cache` management command, which issues one `UPDATE` per device and virtual machine.
+
+    Plan a maintenance window accordingly, and note that these migrations are not reversible in practice: Reversing them restores the MPTT columns but does not repopulate them.
+
 ### Breaking Changes
 ### Breaking Changes
 
 
 * PostgreSQL 14 is no longer supported. NetBox now requires PostgreSQL 15 or later: The upgrade script will abort when connected to an earlier release. (NetBox v4.6 reported this as a warning.)
 * PostgreSQL 14 is no longer supported. NetBox now requires PostgreSQL 15 or later: The upgrade script will abort when connected to an earlier release. (NetBox v4.6 reported this as a warning.)
 * Redis 5.x is no longer supported. NetBox now requires Redis 6.0 or later.
 * Redis 5.x is no longer supported. NetBox now requires Redis 6.0 or later.
 * Selection and multiple selection custom field values are now returned as objects specifying both the raw value and its human-friendly label (e.g. `{"value": "datacenter", "label": "Data Center"}`) in both the REST and GraphQL APIs. These fields continue to accept the raw value on write.
 * Selection and multiple selection custom field values are now returned as objects specifying both the raw value and its human-friendly label (e.g. `{"value": "datacenter", "label": "Data Center"}`) in both the REST and GraphQL APIs. These fields continue to accept the raw value on write.
-* The `protocol` and `ports` fields on the `ipam.Service` and `ipam.ServiceTemplate` models have been replaced by a unified `port_mappings` field, which supports multiple protocols per service. The legacy fields are retained (as deprecated) in the REST and GraphQL APIs, but at the ORM level they are now read-only properties derived from `port_mappings`: Passing `protocol` or `ports` to the model raises a `TypeError`, and assigning to `service.ports` raises an `AttributeError`.
+* The `protocol` and `ports` fields on the `ipam.Service` and `ipam.ServiceTemplate` models have been replaced by a unified `port_mappings` field, which supports multiple protocols per service. The legacy fields are retained (as deprecated) in the REST and GraphQL APIs, but at the ORM level they are now read-only properties derived from `port_mappings`: Passing `protocol` or `ports` to the model raises a `TypeError`, and assigning to `service.ports` raises an `AttributeError`. This restriction applies only at the ORM level: The REST API continues to accept the legacy pair on write, translating it into `port_mappings`.
 * Because `protocol` is now filtered against the `port_mappings` array rather than a dedicated model field, the character-based REST filter lookups previously generated for it (`protocol__ic`, `protocol__isw`, `protocol__empty`, etc.) are no longer available. The `port__empty` lookup has been removed as well.
 * Because `protocol` is now filtered against the `port_mappings` array rather than a dedicated model field, the character-based REST filter lookups previously generated for it (`protocol__ic`, `protocol__isw`, `protocol__empty`, etc.) are no longer available. The `port__empty` lookup has been removed as well.
 * The GraphQL filters for `ipam.Service` and `ipam.ServiceTemplate` have changed shape: The nested `ports` integer lookup has been replaced by the flat `port`, `port__gt`, `port__gte`, `port__lt`, and `port__lte` parameters (each accepting a list of values), alongside the new `port_mappings` parameter. Additionally, the members of `ServiceProtocolEnum` have been renamed to drop a spurious `ROLE_` prefix (e.g. `ROLE_TCP` is now `TCP`).
 * The GraphQL filters for `ipam.Service` and `ipam.ServiceTemplate` have changed shape: The nested `ports` integer lookup has been replaced by the flat `port`, `port__gt`, `port__gte`, `port__lt`, and `port__lte` parameters (each accepting a list of values), alongside the new `port_mappings` parameter. Additionally, the members of `ServiceProtocolEnum` have been renamed to drop a spurious `ROLE_` prefix (e.g. `ROLE_TCP` is now `TCP`).
 * Config context data is now pre-rendered and cached for each device and virtual machine, and is always included in their REST API representations. The `DeviceWithConfigContextSerializer` and `VirtualMachineWithConfigContextSerializer` classes have been removed (merged into the base serializers), and the `?exclude=config_context` query parameter is now silently ignored.
 * Config context data is now pre-rendered and cached for each device and virtual machine, and is always included in their REST API representations. The `DeviceWithConfigContextSerializer` and `VirtualMachineWithConfigContextSerializer` classes have been removed (merged into the base serializers), and the `?exclude=config_context` query parameter is now silently ignored.
 * Failed bulk create and update operations via the REST API now return a structured response of the form `{"detail": ..., "errors": [{"index": N, "errors": {...}}]}`, correlating each error with the index of the offending object in the submitted list. (Bulk operations remain all-or-none.)
 * Failed bulk create and update operations via the REST API now return a structured response of the form `{"detail": ..., "errors": [{"index": N, "errors": {...}}]}`, correlating each error with the index of the offending object in the submitted list. (Bulk operations remain all-or-none.)
 * API token plaintexts can no longer be specified by the client when creating a token via the REST API. The `token` field is now read-only, and any value supplied is ignored. (This restriction was already in effect in the web UI.)
 * API token plaintexts can no longer be specified by the client when creating a token via the REST API. The `token` field is now read-only, and any value supplied is ignored. (This restriction was already in effect in the web UI.)
 * Executing a custom script via the REST API now requires that the calling token have its write ability enabled.
 * Executing a custom script via the REST API now requires that the calling token have its write ability enabled.
+* The `username` argument has been removed from `extras.webhooks.send_webhook()` (the value remains available to webhook templates as `request.user`). Any webhook jobs still enqueued when the workers are restarted will fail with a `TypeError`, so the background queues should be allowed to drain before upgrading.
 * Updates to the global search cache are now deferred to a background task. As a result, a newly created or modified object may not appear in search results for a brief period. (When no background worker is running, the index is updated synchronously as before.)
 * Updates to the global search cache are now deferred to a background task. As a result, a newly created or modified object may not appear in search results for a brief period. (When no background worker is running, the index is updated synchronously as before.)
-* Nested group models (Region, SiteGroup, Location, DeviceRole, Platform, TenantGroup, ContactGroup, WirelessLANGroup, etc.) are now backed by a PostgreSQL `ltree` column rather than django-mptt. The MPTT-backed `NestedGroupModel` base class is retained for backward compatibility with plugins, but is deprecated: New code should use `NestedLtreeGroupModel` instead.
+* Hierarchical models are now backed by a PostgreSQL `ltree` column rather than django-mptt. This covers the nested group models (Region, SiteGroup, Location, DeviceRole, Platform, TenantGroup, ContactGroup, WirelessLANGroup, etc.) as well as ModuleBay, InventoryItem, and InventoryItemTemplate. The `lft`, `rght`, `tree_id`, and `level` columns have been dropped from every migrated model: `level` remains available as a Python property, but can no longer be used in a queryset filter or `order_by()` clause. NetBox's `ltree` implementation deliberately covers only the subset of MPTT's API which NetBox itself uses (`get_ancestors()`, `get_descendants()`, `get_children()`, and `add_related_count()`); methods such as `get_root()`, `get_family()`, `is_leaf_node()`, `move_to()`, and `insert_at()` are no longer available. The MPTT-backed `NestedGroupModel` base class is retained for backward compatibility with plugins, but is deprecated: New code should use `NestedLtreeGroupModel` instead.
 * django-tables2 has been upgraded to v3.0, which renames its `querystring` template tag to `querystring_replace` and removes the `RelatedLinkColumn` class.
 * django-tables2 has been upgraded to v3.0, which renames its `querystring` template tag to `querystring_replace` and removes the `RelatedLinkColumn` class.
+* `social-auth-app-django` and `social-auth-core` have been upgraded to v6.0 and v5.1 respectively, each a major release. Deployments which employ single sign-on should test authentication against a non-production instance before upgrading.
 * The `request` object passed to custom link templates is now a sanitized subset of the current request. Only the `id`, `path`, `path_info`, `method`, `GET`, and `user` attributes are available; cookies, headers, and session state are no longer accessible.
 * The `request` object passed to custom link templates is now a sanitized subset of the current request. Only the `id`, `path`, `path_info`, `method`, `GET`, and `user` attributes are available; cookies, headers, and session state are no longer accessible.
 * URL custom field values are now validated against the [`ALLOWED_URL_SCHEMES`](../configuration/security.md#allowed_url_schemes) configuration parameter. A value entered without a scheme is assumed to use `https` and stored as an absolute URL.
 * URL custom field values are now validated against the [`ALLOWED_URL_SCHEMES`](../configuration/security.md#allowed_url_schemes) configuration parameter. A value entered without a scheme is assumed to use `https` and stored as an absolute URL.
 * Webhooks now support a configurable timeout. If you have lowered `RQ_DEFAULT_TIMEOUT` to 60 seconds or less, you must also set [`WEBHOOK_DEFAULT_TIMEOUT`](../configuration/miscellaneous.md#webhook_default_timeout) to a lower value; NetBox will refuse to start otherwise.
 * Webhooks now support a configurable timeout. If you have lowered `RQ_DEFAULT_TIMEOUT` to 60 seconds or less, you must also set [`WEBHOOK_DEFAULT_TIMEOUT`](../configuration/miscellaneous.md#webhook_default_timeout) to a lower value; NetBox will refuse to start otherwise.
 * Specifying an email server under the [`EMAIL`](../configuration/system.md#email) configuration parameter is now mandatory in order to send mail: A deployment which does not define `EMAIL['SERVER']` will raise an `InvalidMailer` exception when attempting to send, rather than failing at the SMTP connection.
 * Specifying an email server under the [`EMAIL`](../configuration/system.md#email) configuration parameter is now mandatory in order to send mail: A deployment which does not define `EMAIL['SERVER']` will raise an `InvalidMailer` exception when attempting to send, rather than failing at the SMTP connection.
-* The upgrade script now runs the `rebuild_config_context_cache` management command to populate the new config context cache. This may extend the duration of the upgrade for deployments with a large number of devices and virtual machines.
+* NetBox now populates Django's `MAILERS` setting rather than the individual `EMAIL_*` settings which it supersedes. `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_SSL`, `EMAIL_USE_TLS`, `EMAIL_TIMEOUT`, `EMAIL_SSL_CERTFILE`, and `EMAIL_SSL_KEYFILE` are no longer defined, and `EMAIL_BACKEND` is no longer consulted. Plugin code which reads any of these, or which calls `django.core.mail.get_connection()` with an explicit backend (now raising a `RuntimeError`), must be updated. The `EMAIL` configuration parameter itself is unchanged.
+* The upgrade script now runs the `rebuild_config_context_cache` management command to populate the new config context cache. This issues one `UPDATE` per device and virtual machine, and may extend the duration of the upgrade considerably for deployments with a large number of either. The command skips objects whose cache is already populated, so it is safe to interrupt and re-run; it may also be deferred until after NetBox is back online, as any object whose cache is empty falls back to rendering its config context on demand.
 * Creating a custom field which has a default value, and deleting a custom field, are now deferred to a background job where the field's assigned object types hold more than [`BULK_UPDATE_CHUNK_SIZE`](../configuration/system.md#bulk_update_chunk_size) objects in total.
 * Creating a custom field which has a default value, and deleting a custom field, are now deferred to a background job where the field's assigned object types hold more than [`BULK_UPDATE_CHUNK_SIZE`](../configuration/system.md#bulk_update_chunk_size) objects in total.
 * The obsolete `populate_custom_field_defaults()` method has been removed from `CustomFieldsMixin`.
 * The obsolete `populate_custom_field_defaults()` method has been removed from `CustomFieldsMixin`.
 * `CustomField.objects.get_for_model()` and the `custom_fields` property of `CustomFieldsMixin` now return a list rather than a queryset, and `get_for_model()` returns only those fields which are active: Any whose stored data is being updated by a background job is omitted (see [field status](../customization/custom-fields.md#field-status)) unless selected via its `statuses` argument.
 * `CustomField.objects.get_for_model()` and the `custom_fields` property of `CustomFieldsMixin` now return a list rather than a queryset, and `get_for_model()` returns only those fields which are active: Any whose stored data is being updated by a background job is omitted (see [field status](../customization/custom-fields.md#field-status)) unless selected via its `statuses` argument.
 * Removal of deprecated behavior
 * Removal of deprecated behavior
     * The `housekeeping` management command has been removed. (Its constituent tasks are performed by the individual management commands introduced in NetBox v4.6.)
     * The `housekeeping` management command has been removed. (Its constituent tasks are performed by the individual management commands introduced in NetBox v4.6.)
-    * NetBox's custom `querystring` template tag has been removed in favor of Django's built-in tag of the same name.
+    * NetBox's custom `querystring` template tag has been removed in favor of Django's built-in tag of the same name. The two are not interchangeable: Django's tag reads the current request from the template context, so the `request` argument must be dropped (`{% querystring request page=1 %}` becomes `{% querystring page=1 %}`; passing `request` raises a `TemplateSyntaxError`). It also returns a bare `?` where NetBox's tag returned an empty string.
     * The legacy Sentry configuration parameters `SENTRY_DSN`, `SENTRY_SAMPLE_RATE`, `SENTRY_SEND_DEFAULT_PII`, and `SENTRY_TRACES_SAMPLE_RATE` have been removed. Use `SENTRY_CONFIG` instead.
     * The legacy Sentry configuration parameters `SENTRY_DSN`, `SENTRY_SAMPLE_RATE`, `SENTRY_SEND_DEFAULT_PII`, and `SENTRY_TRACES_SAMPLE_RATE` have been removed. Use `SENTRY_CONFIG` instead.
     * The obsolete `DEFAULT_ACTION_PERMISSIONS` constant has been removed.
     * The obsolete `DEFAULT_ACTION_PERMISSIONS` constant has been removed.
     * Support for legacy view action mappings has been dropped, and the `LEGACY_ACTIONS` constant has been removed.
     * Support for legacy view action mappings has been dropped, and the `LEGACY_ACTIONS` constant has been removed.
@@ -229,16 +241,18 @@ Event rule conditions can now inspect the pre-change and post-change snapshots c
     * Add optional integer field `timeout`
     * Add optional integer field `timeout`
 * `ipam.Service`
 * `ipam.Service`
     * Add the `port_mappings` list field
     * Add the `port_mappings` list field
-    * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise
+    * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise. They remain writable: A request may specify either `port_mappings` or the legacy pair, but not both in conflict
     * The brief representation now includes `port_mappings` in place of `protocol` and `ports`
     * The brief representation now includes `port_mappings` in place of `protocol` and `ports`
 * `ipam.ServiceTemplate`
 * `ipam.ServiceTemplate`
     * Add the `port_mappings` list field
     * Add the `port_mappings` list field
-    * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise
+    * The `protocol` and `ports` fields are deprecated; they are populated only for single-protocol services and return null otherwise. They remain writable: A request may specify either `port_mappings` or the legacy pair, but not both in conflict
     * The brief representation now includes `port_mappings` in place of `protocol` and `ports`
     * The brief representation now includes `port_mappings` in place of `protocol` and `ports`
 * `users.Token`
 * `users.Token`
     * The `token` field is now read-only; a plaintext value can no longer be specified on creation
     * The `token` field is now read-only; a plaintext value can no longer be specified on creation
 * `virtualization.VirtualMachine`
 * `virtualization.VirtualMachine`
     * Add read-only JSON field `config_context` (previously available only via `VirtualMachineWithConfigContextSerializer`)
     * Add read-only JSON field `config_context` (previously available only via `VirtualMachineWithConfigContextSerializer`)
+* `virtualization.VMInterface`
+    * The `mac_address` field is now writable, and creates or updates the interface's primary MAC address
 
 
 ### GraphQL API Changes
 ### GraphQL API Changes
 
 

+ 5 - 1
netbox/core/apps.py

@@ -29,7 +29,11 @@ class CoreConfig(AppConfig):
 
 
     def ready(self):
     def ready(self):
         from core.api import schema  # noqa: F401
         from core.api import schema  # noqa: F401
-        from core.checks import check_duplicate_indexes, check_redis_version  # noqa: F401
+        from core.checks import (  # noqa: F401
+            check_duplicate_indexes,
+            check_postgresql_version,
+            check_redis_version,
+        )
         from netbox import context_managers  # noqa: F401
         from netbox import context_managers  # noqa: F401
         from netbox.models.features import register_models
         from netbox.models.features import register_models
         from netbox.search import signals as search_signals  # noqa: F401
         from netbox.search import signals as search_signals  # noqa: F401

+ 92 - 0
netbox/core/checks.py

@@ -1,13 +1,26 @@
+import logging
+
 from django.apps import apps
 from django.apps import apps
 from django.core.cache import cache
 from django.core.cache import cache
 from django.core.checks import Error, Tags, register
 from django.core.checks import Error, Tags, register
+from django.core.exceptions import ImproperlyConfigured
+from django.db import DatabaseError, InterfaceError, NotSupportedError, OperationalError, connections
 from django.db.models import Index, UniqueConstraint
 from django.db.models import Index, UniqueConstraint
 
 
 __all__ = (
 __all__ = (
     'check_duplicate_indexes',
     'check_duplicate_indexes',
+    'check_postgresql_version',
     'check_redis_version',
     'check_redis_version',
 )
 )
 
 
+# The minimum major version of PostgreSQL required by NetBox. `SHOW server_version_num` reports the
+# server version as a single integer of the form MMmmmm (e.g. 150004 for PostgreSQL 15.4), so the
+# major version is scaled by 10000 when comparing against it.
+POSTGRESQL_MIN_VERSION = 15
+POSTGRESQL_VERSION_MULTIPLIER = 10000
+
+logger = logging.getLogger('netbox.core.checks')
+
 
 
 @register(Tags.models)
 @register(Tags.models)
 def check_duplicate_indexes(app_configs, **kwargs):
 def check_duplicate_indexes(app_configs, **kwargs):
@@ -43,6 +56,85 @@ def check_duplicate_indexes(app_configs, **kwargs):
     return errors
     return errors
 
 
 
 
+@register(Tags.database)
+def check_postgresql_version(app_configs, databases=None, **kwargs):
+    """
+    Report an error if the PostgreSQL version is less than POSTGRESQL_MIN_VERSION.
+    """
+    errors = []
+
+    # Validate only those database aliases which Django has asked us to check. A value of None means
+    # that no database may be touched during this run, so there is nothing to validate: commands which
+    # do intend to use a connection declare its alias (e.g. `migrate` passes the value of --database).
+    # This mirrors Django's own database checks; see checks.database.check_database_backends().
+    if databases is None:
+        return errors
+
+    for alias in databases:
+        connection = connections[alias]
+
+        # A plugin may register a connection to some other type of database; only PostgreSQL
+        # connections are subject to NetBox's minimum version requirement.
+        if connection.vendor != 'postgresql':
+            continue
+
+        try:
+            with connection.cursor() as cursor:
+                cursor.execute('SHOW server_version_num')
+                row = cursor.fetchone()
+        except NotSupportedError:
+            # Django refuses to use the connection at all when the server predates the minimum version
+            # which Django itself supports (BaseDatabaseWrapper.check_database_version_supported()), so
+            # the query above never runs. The PostgreSQL backend registers no validation checks of its
+            # own, so report the requirement here rather than letting the raw exception surface as a
+            # traceback the first time something touches the database.
+            errors.append(
+                Error(
+                    f"Database '{alias}': The configured PostgreSQL version is not supported. NetBox "
+                    f"requires PostgreSQL {POSTGRESQL_MIN_VERSION} or later.",
+                    hint=f'Please upgrade to PostgreSQL {POSTGRESQL_MIN_VERSION} or later.',
+                    id='netbox.E001',
+                )
+            )
+            continue
+        except (ImproperlyConfigured, InterfaceError, OperationalError):
+            # The database is unreachable, has yet to be provisioned, or the connection is no longer
+            # usable. (InterfaceError is a sibling of DatabaseError, not a subclass, so it must be
+            # named explicitly.) Leave the version unverified rather than reporting a spurious error.
+            continue
+        except DatabaseError:
+            # The server is reachable but rejected the query (e.g. a connection pooler which intercepts
+            # SHOW). Record why the version could not be determined rather than failing silently.
+            logger.warning(f"Database '{alias}': Failed to determine the PostgreSQL version.", exc_info=True)
+            continue
+
+        if not row:
+            logger.warning(f"Database '{alias}': `SHOW server_version_num` returned no result.")
+            continue
+
+        try:
+            pg_version = int(row[0])
+        except (TypeError, ValueError):
+            # `SHOW server_version_num` reports an integer, but a pooler which answers the statement
+            # itself may report a dotted version instead. Leave the version unverified rather than
+            # raising out of the check and aborting the calling command.
+            logger.warning(f"Database '{alias}': Unable to parse the PostgreSQL version from {row[0]!r}.")
+            continue
+
+        if pg_version < POSTGRESQL_MIN_VERSION * POSTGRESQL_VERSION_MULTIPLIER:
+            major_version = pg_version // POSTGRESQL_VERSION_MULTIPLIER
+            errors.append(
+                Error(
+                    f"Database '{alias}': PostgreSQL {major_version} is not supported. NetBox requires "
+                    f"PostgreSQL {POSTGRESQL_MIN_VERSION} or later.",
+                    hint=f'Please upgrade to PostgreSQL {POSTGRESQL_MIN_VERSION} or later.',
+                    id='netbox.E001',
+                )
+            )
+
+    return errors
+
+
 @register(Tags.caches)
 @register(Tags.caches)
 def check_redis_version(app_configs, **kwargs):
 def check_redis_version(app_configs, **kwargs):
     """
     """

+ 175 - 0
netbox/core/tests/test_checks.py

@@ -0,0 +1,175 @@
+from unittest.mock import MagicMock, patch
+
+from django.db import InterfaceError, NotSupportedError, OperationalError, ProgrammingError
+from django.test import TestCase
+
+from core.checks import POSTGRESQL_MIN_VERSION, POSTGRESQL_VERSION_MULTIPLIER, check_postgresql_version
+
+# `SHOW server_version_num` results representing the oldest supported and newest unsupported releases
+SUPPORTED_VERSION = POSTGRESQL_MIN_VERSION * POSTGRESQL_VERSION_MULTIPLIER
+UNSUPPORTED_VERSION = (POSTGRESQL_MIN_VERSION - 1) * POSTGRESQL_VERSION_MULTIPLIER + 10
+OBSOLETE_VERSION = (POSTGRESQL_MIN_VERSION - 2) * POSTGRESQL_VERSION_MULTIPLIER + 1
+
+
+class PostgreSQLVersionCheckTestCase(TestCase):
+    """
+    Test the system check which enforces NetBox's minimum PostgreSQL version.
+    """
+    @staticmethod
+    def mock_connection(server_version_num=None, exception=None, vendor='postgresql'):
+        """
+        Return a mock database connection which yields the given `SHOW server_version_num` result, or
+        which raises `exception` when a cursor is requested.
+        """
+        connection = MagicMock()
+        connection.vendor = vendor
+        if exception is not None:
+            connection.cursor.side_effect = exception
+        else:
+            cursor = MagicMock()
+            cursor.fetchone.return_value = (str(server_version_num),)
+            connection.cursor.return_value.__enter__.return_value = cursor
+        return connection
+
+    @staticmethod
+    def mock_connections(**connections):
+        """
+        Return a patcher replacing the connection handler with the given alias-to-connection mapping.
+        """
+        return patch('core.checks.connections', connections)
+
+    def test_supported_version(self):
+        """
+        No error is reported for the minimum supported PostgreSQL version or later.
+        """
+        for version in (
+            SUPPORTED_VERSION,
+            SUPPORTED_VERSION + 2,
+            SUPPORTED_VERSION + (2 * POSTGRESQL_VERSION_MULTIPLIER),
+        ):
+            with self.subTest(version=version):
+                with self.mock_connections(default=self.mock_connection(version)):
+                    self.assertEqual(check_postgresql_version(None, databases=['default']), [])
+
+    def test_unsupported_version(self):
+        """
+        An error is reported for any release preceding the minimum supported version.
+        """
+        with self.mock_connections(default=self.mock_connection(UNSUPPORTED_VERSION)):
+            errors = check_postgresql_version(None, databases=['default'])
+        self.assertEqual(len(errors), 1)
+        self.assertEqual(errors[0].id, 'netbox.E001')
+        self.assertIn(f'PostgreSQL {POSTGRESQL_MIN_VERSION - 1} is not supported', errors[0].msg)
+
+    def test_connection_rejected_by_django(self):
+        """
+        Django's backend refuses to connect at all when the server predates its own minimum supported
+        version, so the version query never runs. The check must still report the requirement.
+        """
+        error = NotSupportedError('PostgreSQL 14 or later is required (found 13.10).')
+        with self.mock_connections(default=self.mock_connection(exception=error)):
+            errors = check_postgresql_version(None, databases=['default'])
+        self.assertEqual(len(errors), 1)
+        self.assertEqual(errors[0].id, 'netbox.E001')
+        self.assertIn(f'NetBox requires PostgreSQL {POSTGRESQL_MIN_VERSION} or later', errors[0].msg)
+
+    def test_database_unavailable(self):
+        """
+        An unreachable database leaves the version unverified rather than reporting a spurious error.
+        """
+        exception = OperationalError('could not connect to server')
+        with self.mock_connections(default=self.mock_connection(exception=exception)):
+            self.assertEqual(check_postgresql_version(None, databases=['default']), [])
+
+    def test_version_query_rejected(self):
+        """
+        A reachable server which rejects the version query leaves the version unverified, but logs why.
+        """
+        exception = ProgrammingError('unrecognized configuration parameter')
+        with self.mock_connections(default=self.mock_connection(exception=exception)):
+            with self.assertLogs('netbox.core.checks', level='WARNING') as cm:
+                self.assertEqual(check_postgresql_version(None, databases=['default']), [])
+        self.assertIn('Failed to determine the PostgreSQL version', cm.output[0])
+
+    def test_connection_unusable(self):
+        """
+        An unusable connection leaves the version unverified rather than raising out of the check.
+        InterfaceError descends from Error rather than DatabaseError, so it is handled explicitly.
+        """
+        exception = InterfaceError('connection already closed')
+        with self.mock_connections(default=self.mock_connection(exception=exception)):
+            self.assertEqual(check_postgresql_version(None, databases=['default']), [])
+
+    def test_version_unparseable(self):
+        """
+        A version which cannot be parsed as an integer (e.g. as reported by an intervening connection
+        pooler) leaves the version unverified, but logs why.
+        """
+        connection = self.mock_connection(SUPPORTED_VERSION)
+        connection.cursor.return_value.__enter__.return_value.fetchone.return_value = ('15.4',)
+        with self.mock_connections(default=connection):
+            with self.assertLogs('netbox.core.checks', level='WARNING') as cm:
+                self.assertEqual(check_postgresql_version(None, databases=['default']), [])
+        self.assertIn('Unable to parse the PostgreSQL version', cm.output[0])
+
+    def test_version_query_empty(self):
+        """
+        A version query which returns no result leaves the version unverified, but logs why.
+        """
+        connection = self.mock_connection(SUPPORTED_VERSION)
+        connection.cursor.return_value.__enter__.return_value.fetchone.return_value = None
+        with self.mock_connections(default=connection):
+            with self.assertLogs('netbox.core.checks', level='WARNING') as cm:
+                self.assertEqual(check_postgresql_version(None, databases=['default']), [])
+        self.assertIn('returned no result', cm.output[0])
+
+    def test_specified_aliases(self):
+        """
+        Only the database aliases supplied by Django are checked, and each is identified by name.
+        """
+        connections = {
+            'default': self.mock_connection(SUPPORTED_VERSION + 2),
+            'legacy': self.mock_connection(UNSUPPORTED_VERSION),
+        }
+        with self.mock_connections(**connections):
+            errors = check_postgresql_version(None, databases=['legacy'])
+        self.assertEqual(len(errors), 1)
+        self.assertIn("Database 'legacy'", errors[0].msg)
+        connections['default'].cursor.assert_not_called()
+
+    def test_multiple_aliases(self):
+        """
+        Every alias supplied by Django is checked.
+        """
+        connections = {
+            'default': self.mock_connection(UNSUPPORTED_VERSION),
+            'legacy': self.mock_connection(OBSOLETE_VERSION),
+        }
+        with self.mock_connections(**connections):
+            errors = check_postgresql_version(None, databases=['default', 'legacy'])
+        self.assertEqual(len(errors), 2)
+        self.assertIn("Database 'default'", errors[0].msg)
+        self.assertIn("Database 'legacy'", errors[1].msg)
+
+    def test_no_aliases(self):
+        """
+        No connection is opened when Django supplies no aliases, indicating that this run may not touch
+        the database (e.g. `collectstatic`, or any command which declares no aliases of its own).
+        """
+        connections = {
+            'default': self.mock_connection(UNSUPPORTED_VERSION),
+            'legacy': self.mock_connection(OBSOLETE_VERSION),
+        }
+        with self.mock_connections(**connections):
+            self.assertEqual(check_postgresql_version(None, databases=None), [])
+        connections['default'].cursor.assert_not_called()
+        connections['legacy'].cursor.assert_not_called()
+
+    def test_non_postgresql_connection(self):
+        """
+        Connections to other types of databases (e.g. those registered by a plugin) are ignored.
+        """
+        connection = self.mock_connection(vendor='mysql')
+        with self.mock_connections(default=connection):
+            self.assertEqual(check_postgresql_version(None, databases=['default']), [])
+        connection.cursor.assert_not_called()