diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index b96870252..7270c005a 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -18,7 +18,7 @@ BANNER_TEXT = """### NetBox interactive shell ({node}) node=platform.node(), python=platform.python_version(), django=get_version(), - netbox=settings.VERSION + netbox=settings.RELEASE.name ) diff --git a/netbox/core/views.py b/netbox/core/views.py index ded49c0b8..af705c8d1 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -539,7 +539,7 @@ class SystemView(UserPassesTestMixin, View): except (ProgrammingError, IndexError): pass stats = { - 'netbox_version': settings.VERSION, + 'netbox_release': settings.RELEASE, 'django_version': DJANGO_VERSION, 'python_version': platform.python_version(), 'postgresql_version': psql_version, diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index add81a318..cc11664e6 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -329,7 +329,7 @@ class RSSFeedWidget(DashboardWidget): try: response = requests.get( url=self.config['feed_url'], - headers={'User-Agent': f'NetBox/{settings.VERSION}'}, + headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'}, proxies=settings.HTTP_PROXIES, timeout=3 ) diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index cfbe82f14..d58d1affe 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -66,7 +66,7 @@ class StatusView(APIView): return Response({ 'django-version': DJANGO_VERSION, 'installed-apps': installed_apps, - 'netbox-version': settings.VERSION, + 'netbox-version': settings.RELEASE.full_version, 'plugins': get_installed_plugins(), 'python-version': platform.python_version(), 'rq-workers-running': Worker.count(get_connection('default')), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a481249b6..40cfc3b39 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.config import PARAMS as CONFIG_PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.plugins import PluginConfig +from utilities.release import load_release_data from utilities.string import trailing_slash @@ -25,7 +26,8 @@ from utilities.string import trailing_slash # Environment setup # -VERSION = '4.0.6-dev' +RELEASE = load_release_data() +VERSION = RELEASE.full_version # Retained for backward compatibility HOSTNAME = platform.node() # Set the base directory two levels up BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -533,7 +535,7 @@ if SENTRY_ENABLED: # Initialize the SDK sentry_sdk.init( dsn=SENTRY_DSN, - release=VERSION, + release=RELEASE.full_version, sample_rate=SENTRY_SAMPLE_RATE, traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, send_default_pii=True, @@ -553,7 +555,7 @@ if SENTRY_ENABLED: DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] CENSUS_URL = 'https://census.netbox.dev/api/v1/' CENSUS_PARAMS = { - 'version': VERSION, + 'version': RELEASE.full_version, 'python_version': sys.version.split()[0], 'deployment_id': DEPLOYMENT_ID, } @@ -611,7 +613,7 @@ FILTERS_NULL_CHOICE_VALUE = 'null' # Django REST framework (API) # -REST_FRAMEWORK_VERSION = '.'.join(VERSION.split('-')[0].split('.')[:2]) # Use major.minor as API version +REST_FRAMEWORK_VERSION = '.'.join(RELEASE.version.split('-')[0].split('.')[:2]) # Use major.minor as API version REST_FRAMEWORK = { 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], 'COERCE_DECIMAL_TO_STRING': False, @@ -656,7 +658,7 @@ REST_FRAMEWORK = { SPECTACULAR_SETTINGS = { 'TITLE': 'NetBox REST API', 'LICENSE': {'name': 'Apache v2 License'}, - 'VERSION': VERSION, + 'VERSION': RELEASE.full_version, 'COMPONENT_SPLIT_REQUEST': True, 'REDOC_DIST': 'SIDECAR', 'SERVERS': [{ @@ -802,7 +804,7 @@ for plugin_name in PLUGINS: # Validate user-provided configuration settings and assign defaults if plugin_name not in PLUGINS_CONFIG: PLUGINS_CONFIG[plugin_name] = {} - plugin_config.validate(PLUGINS_CONFIG[plugin_name], VERSION) + plugin_config.validate(PLUGINS_CONFIG[plugin_name], RELEASE.version) # Add middleware plugin_middleware = plugin_config.middleware diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index c85f257fa..351fef9e2 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -166,11 +166,11 @@ class PluginTest(TestCase): required_settings = ['foo'] # Validation should pass when all required settings are present - DummyConfigWithRequiredSettings.validate({'foo': True}, settings.VERSION) + DummyConfigWithRequiredSettings.validate({'foo': True}, settings.RELEASE.version) # Validation should fail when a required setting is missing with self.assertRaises(ImproperlyConfigured): - DummyConfigWithRequiredSettings.validate({}, settings.VERSION) + DummyConfigWithRequiredSettings.validate({}, settings.RELEASE.version) def test_default_settings(self): """ @@ -183,12 +183,12 @@ class PluginTest(TestCase): # Populate the default value if setting has not been specified user_config = {} - DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION) + DummyConfigWithDefaultSettings.validate(user_config, settings.RELEASE.version) self.assertEqual(user_config['bar'], 123) # Don't overwrite specified values user_config = {'bar': 456} - DummyConfigWithDefaultSettings.validate(user_config, settings.VERSION) + DummyConfigWithDefaultSettings.validate(user_config, settings.RELEASE.version) self.assertEqual(user_config['bar'], 456) def test_graphql(self): diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index a5e5e36f4..b0175ec04 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -57,7 +57,7 @@ _patterns = [ path( "api/schema/", - cache_page(timeout=86400, key_prefix=f"api_schema_{settings.VERSION}")( + cache_page(timeout=86400, key_prefix=f"api_schema_{settings.RELEASE.version}")( SpectacularAPIView.as_view() ), name="schema", diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py index a0f783ed6..9e8ed5a3a 100644 --- a/netbox/netbox/views/errors.py +++ b/netbox/netbox/views/errors.py @@ -54,7 +54,7 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME): return HttpResponseServerError(template.render({ 'error': error, 'exception': str(type_), - 'netbox_version': settings.VERSION, + 'netbox_version': settings.RELEASE.full_version, 'python_version': platform.python_version(), 'plugins': get_installed_plugins(), })) diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py index 9678b71e3..569fcf728 100644 --- a/netbox/netbox/views/misc.py +++ b/netbox/netbox/views/misc.py @@ -50,7 +50,7 @@ class HomeView(View): latest_release = cache.get('latest_release') if latest_release: release_version, release_url = latest_release - if release_version > version.parse(settings.VERSION): + if release_version > version.parse(settings.RELEASE.version): new_release = { 'version': str(release_version), 'url': release_url, diff --git a/netbox/release.yaml b/netbox/release.yaml new file mode 100644 index 000000000..83bddf615 --- /dev/null +++ b/netbox/release.yaml @@ -0,0 +1,2 @@ +version: "4.1.0" +designation: "dev" diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 7a7a4fe99..1030c469b 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -20,7 +20,7 @@ {# Initialize color mode #} {% django_htmx_script %} diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 940f74346..397553446 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -188,12 +188,9 @@ Blocks: {# Footer text #} {# /Footer text #} diff --git a/netbox/templates/core/system.html b/netbox/templates/core/system.html index 320038ac6..4d133ca53 100644 --- a/netbox/templates/core/system.html +++ b/netbox/templates/core/system.html @@ -28,8 +28,13 @@
{% trans "System Status" %}
- - + + diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py index 89e3a967d..66e74739a 100644 --- a/netbox/utilities/error_handlers.py +++ b/netbox/utilities/error_handlers.py @@ -53,7 +53,7 @@ def handle_rest_api_exception(request, *args, **kwargs): data = { 'error': str(error), 'exception': type_.__name__, - 'netbox_version': settings.VERSION, + 'netbox_version': settings.RELEASE.full_version, 'python_version': platform.python_version(), } return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/netbox/utilities/release.py b/netbox/utilities/release.py new file mode 100644 index 000000000..dacec1010 --- /dev/null +++ b/netbox/utilities/release.py @@ -0,0 +1,57 @@ +import datetime +import os +import yaml +from dataclasses import dataclass +from typing import Union + +from django.core.exceptions import ImproperlyConfigured + +RELEASE_PATH = 'release.yaml' +LOCAL_RELEASE_PATH = 'local/release.yaml' + + +@dataclass +class ReleaseInfo: + version: str + edition: str = 'Community' + published: Union[datetime.date, None] = None + designation: Union[str, None] = None + + @property + def full_version(self): + if self.designation: + return f"{self.version}-{self.designation}" + return self.version + + @property + def name(self): + return f"NetBox {self.edition} v{self.full_version}" + + +def load_release_data(): + """ + Load any locally-defined release attributes and return a ReleaseInfo instance. + """ + base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + # Load canonical release attributes + with open(os.path.join(base_path, RELEASE_PATH), 'r') as release_file: + data = yaml.safe_load(release_file) + + # Overlay any local release date (if defined) + try: + with open(os.path.join(base_path, LOCAL_RELEASE_PATH), 'r') as release_file: + local_data = yaml.safe_load(release_file) + except FileNotFoundError: + local_data = {} + if type(local_data) is not dict: + raise ImproperlyConfigured( + f"{LOCAL_RELEASE_PATH}: Local release data must be defined as a dictionary." + ) + data.update(local_data) + + # Convert the published date to a date object + if 'published' in data: + data['published'] = datetime.date.fromisoformat(data['published']) + + return ReleaseInfo(**data)
{% trans "NetBox version" %}{{ stats.netbox_version }}{% trans "NetBox release" %} + {{ stats.netbox_release.name }} + {% if stats.netbox_release.published %} + ({{ stats.netbox_release.published|isodate }}) + {% endif %} +
{% trans "Python version" %}