diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index a222272c2..5649eb9be 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -66,6 +66,22 @@ CUSTOM_VALIDATORS = { --- +## DEFAULT_USER_PREFERENCES + +This is a dictionary defining the default preferences to be set for newly-created user accounts. For example, to set the default page size for all users to 100, define the following: + +```python +DEFAULT_USER_PREFERENCES = { + "pagination": { + "per_page": 100 + } +} +``` + +For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`. + +--- + ## ENFORCE_GLOBAL_UNIQUE Default: False diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index 0595bc358..a707eb6ad 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -4,8 +4,9 @@ The `users.UserConfig` model holds individual preferences for each user in the f ## Available Preferences -| Name | Description | -| ---- | ----------- | -| extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) | -| pagination.per_page | The number of items to display per page of a paginated table | -| tables.TABLE_NAME.columns | The ordered list of columns to display when viewing the table | +| Name | Description | +|-------------------------|-------------| +| data_format | Preferred format when rendering raw data (JSON or YAML) | +| pagination.per_page | The number of items to display per page of a paginated table | +| tables.${table}.columns | The ordered list of columns to display when viewing the table | +| ui.colormode | Light or dark mode in the user interface | diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 89436a321..d20f73cb6 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -99,22 +99,23 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i #### PluginConfig Attributes -| Name | Description | -| ---- | ----------- | -| `name` | Raw plugin name; same as the plugin's source directory | -| `verbose_name` | Human-friendly name for the plugin | -| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | -| `description` | Brief description of the plugin's purpose | -| `author` | Name of plugin's author | -| `author_email` | Author's public email address | -| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | -| `required_settings` | A list of any configuration parameters that **must** be defined by the user | -| `default_settings` | A dictionary of configuration parameters and their default values | -| `min_version` | Minimum version of NetBox with which the plugin is compatible | -| `max_version` | Maximum version of NetBox with which the plugin is compatible | -| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | -| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | -| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | +| Name | Description | +| ---- |---------------------------------------------------------------------------------------------------------------| +| `name` | Raw plugin name; same as the plugin's source directory | +| `verbose_name` | Human-friendly name for the plugin | +| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | +| `description` | Brief description of the plugin's purpose | +| `author` | Name of plugin's author | +| `author_email` | Author's public email address | +| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | +| `required_settings` | A list of any configuration parameters that **must** be defined by the user | +| `default_settings` | A dictionary of configuration parameters and their default values | +| `min_version` | Minimum version of NetBox with which the plugin is compatible | +| `max_version` | Maximum version of NetBox with which the plugin is compatible | +| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | +| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | +| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | +| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) | All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index b6ee01db9..2c98d2a81 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -33,6 +33,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): ('NAPALM', { 'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'), }), + ('User Preferences', { + 'fields': ('DEFAULT_USER_PREFERENCES',), + }), ('Miscellaneous', { 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'), }), diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index f9a7856ea..5b02b5ab7 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -15,6 +15,7 @@ from extras.plugins.utils import import_object # Initialize plugin registry stores registry['plugin_template_extensions'] = collections.defaultdict(list) registry['plugin_menu_items'] = {} +registry['plugin_preferences'] = {} # @@ -54,6 +55,7 @@ class PluginConfig(AppConfig): # integrated components. template_extensions = 'template_content.template_extensions' menu_items = 'navigation.menu_items' + user_preferences = 'preferences.preferences' def ready(self): @@ -67,6 +69,12 @@ class PluginConfig(AppConfig): if menu_items is not None: register_menu_items(self.verbose_name, menu_items) + # Register user preferences + user_preferences = import_object(f"{self.__module__}.{self.user_preferences}") + if user_preferences is not None: + plugin_name = self.name.rsplit('.', 1)[1] + register_user_preferences(plugin_name, user_preferences) + @classmethod def validate(cls, user_config, netbox_version): @@ -242,3 +250,14 @@ def register_menu_items(section_name, class_list): raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") registry['plugin_menu_items'][section_name] = class_list + + +# +# User preferences +# + +def register_user_preferences(plugin_name, preferences): + """ + Register a list of user preferences defined by a plugin. + """ + registry['plugin_preferences'][plugin_name] = preferences diff --git a/netbox/extras/tests/dummy_plugin/preferences.py b/netbox/extras/tests/dummy_plugin/preferences.py new file mode 100644 index 000000000..f925ee6e0 --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/preferences.py @@ -0,0 +1,20 @@ +from users.preferences import UserPreference + + +preferences = { + 'pref1': UserPreference( + label='First preference', + choices=( + ('foo', 'Foo'), + ('bar', 'Bar'), + ) + ), + 'pref2': UserPreference( + label='Second preference', + choices=( + ('a', 'A'), + ('b', 'B'), + ('c', 'C'), + ) + ), +} diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py index 2508ffb83..4bea9933e 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/extras/tests/test_plugins.py @@ -74,6 +74,15 @@ class PluginTest(TestCase): self.assertIn(SiteContent, registry['plugin_template_extensions']['dcim.site']) + def test_user_preferences(self): + """ + Check that plugin UserPreferences are registered. + """ + self.assertIn('dummy_plugin', registry['plugin_preferences']) + user_preferences = registry['plugin_preferences']['dummy_plugin'] + self.assertEqual(type(user_preferences), dict) + self.assertEqual(list(user_preferences.keys()), ['pref1', 'pref2']) + def test_middleware(self): """ Check that plugin middleware is registered. diff --git a/netbox/extras/views.py b/netbox/extras/views.py index a2bc92f88..256709c6a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -296,9 +296,9 @@ class ConfigContextView(generic.ObjectView): if request.GET.get('format') in ['json', 'yaml']: format = request.GET.get('format') if request.user.is_authenticated: - request.user.config.set('extras.configcontext.format', format, commit=True) + request.user.config.set('data_format', format, commit=True) elif request.user.is_authenticated: - format = request.user.config.get('extras.configcontext.format', 'json') + format = request.user.config.get('data_format', 'json') else: format = 'json' @@ -341,9 +341,9 @@ class ObjectConfigContextView(generic.ObjectView): if request.GET.get('format') in ['json', 'yaml']: format = request.GET.get('format') if request.user.is_authenticated: - request.user.config.set('extras.configcontext.format', format, commit=True) + request.user.config.set('data_format', format, commit=True) elif request.user.is_authenticated: - format = request.user.config.get('extras.configcontext.format', 'json') + format = request.user.config.get('data_format', 'json') else: format = 'json' diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index b4f16bf28..d3ebc7bff 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -131,6 +131,15 @@ PARAMS = ( field=forms.JSONField ), + # User preferences + ConfigParam( + name='DEFAULT_USER_PREFERENCES', + label='Default preferences', + default={}, + description="Default preferences for new users", + field=forms.JSONField + ), + # Miscellaneous ConfigParam( name='MAINTENANCE_MODE', diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py new file mode 100644 index 000000000..4cad8cf24 --- /dev/null +++ b/netbox/netbox/preferences.py @@ -0,0 +1,49 @@ +from extras.registry import registry +from users.preferences import UserPreference +from utilities.paginator import EnhancedPaginator + + +def get_page_lengths(): + return [ + (v, str(v)) for v in EnhancedPaginator.default_page_lengths + ] + + +PREFERENCES = { + + # User interface + 'ui.colormode': UserPreference( + label='Color mode', + choices=( + ('light', 'Light'), + ('dark', 'Dark'), + ), + default='light', + ), + 'pagination.per_page': UserPreference( + label='Page length', + choices=get_page_lengths(), + description='The number of objects to display per page', + coerce=lambda x: int(x) + ), + + # Miscellaneous + 'data_format': UserPreference( + label='Data format', + choices=( + ('json', 'JSON'), + ('yaml', 'YAML'), + ), + ), + +} + +# Register plugin preferences +if registry['plugin_preferences']: + plugin_preferences = {} + + for plugin_name, preferences in registry['plugin_preferences'].items(): + for name, userpreference in preferences.items(): + PREFERENCES[f'plugins.{plugin_name}.{name}'] = userpreference + + PREFERENCES.update(plugin_preferences) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 95fd99270..740fbe7e7 100644 --- a/netbox/project-static/dist/netbox.js +++ b/netbox/project-static/dist/netbox.js @@ -1,12 +1,12 @@ -(()=>{var a_=Object.create;var as=Object.defineProperty,l_=Object.defineProperties,c_=Object.getOwnPropertyDescriptor,u_=Object.getOwnPropertyDescriptors,f_=Object.getOwnPropertyNames,Qf=Object.getOwnPropertySymbols,d_=Object.getPrototypeOf,Zf=Object.prototype.hasOwnProperty,h_=Object.prototype.propertyIsEnumerable;var Xl=(tn,en,nn)=>en in tn?as(tn,en,{enumerable:!0,configurable:!0,writable:!0,value:nn}):tn[en]=nn,Jn=(tn,en)=>{for(var nn in en||(en={}))Zf.call(en,nn)&&Xl(tn,nn,en[nn]);if(Qf)for(var nn of Qf(en))h_.call(en,nn)&&Xl(tn,nn,en[nn]);return tn},la=(tn,en)=>l_(tn,u_(en)),ed=tn=>as(tn,"__esModule",{value:!0});var Cn=(tn,en)=>()=>(en||tn((en={exports:{}}).exports,en),en.exports),p_=(tn,en)=>{ed(tn);for(var nn in en)as(tn,nn,{get:en[nn],enumerable:!0})},m_=(tn,en,nn)=>{if(en&&typeof en=="object"||typeof en=="function")for(let rn of f_(en))!Zf.call(tn,rn)&&rn!=="default"&&as(tn,rn,{get:()=>en[rn],enumerable:!(nn=c_(en,rn))||nn.enumerable});return tn},Rr=tn=>m_(ed(as(tn!=null?a_(d_(tn)):{},"default",tn&&tn.__esModule&&"default"in tn?{get:()=>tn.default,enumerable:!0}:{value:tn,enumerable:!0})),tn);var ar=(tn,en,nn)=>(Xl(tn,typeof en!="symbol"?en+"":en,nn),nn);var Fr=(tn,en,nn)=>new Promise((rn,on)=>{var an=dn=>{try{cn(nn.next(dn))}catch(fn){on(fn)}},ln=dn=>{try{cn(nn.throw(dn))}catch(fn){on(fn)}},cn=dn=>dn.done?rn(dn.value):Promise.resolve(dn.value).then(an,ln);cn((nn=nn.apply(tn,en)).next())});var Nh=Cn((exports,module)=>{(function(tn,en){typeof define=="function"&&define.amd?define([],en):tn.htmx=en()})(typeof self!="undefined"?self:exports,function(){return function(){"use strict";var D={onLoad:t,process:rt,on:N,off:I,trigger:lt,ajax:$t,find:w,findAll:S,closest:O,values:function(tn,en){var nn=Ot(tn,en||"post");return nn.values},remove:E,addClass:C,removeClass:R,toggleClass:q,takeClass:L,defineExtension:Qt,removeExtension:er,logAll:b,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:!1,scrollBehavior:"smooth"},parseInterval:h,_:e,createEventSource:function(tn){return new EventSource(tn,{withCredentials:!0})},createWebSocket:function(tn){return new WebSocket(tn,[])},version:"1.6.1"},r=["get","post","put","delete","patch"],n=r.map(function(tn){return"[hx-"+tn+"], [data-hx-"+tn+"]"}).join(", ");function h(tn){if(tn!=null)return tn.slice(-2)=="ms"?parseFloat(tn.slice(0,-2))||void 0:tn.slice(-1)=="s"?parseFloat(tn.slice(0,-1))*1e3||void 0:parseFloat(tn)||void 0}function c(tn,en){return tn.getAttribute&&tn.getAttribute(en)}function s(tn,en){return tn.hasAttribute&&(tn.hasAttribute(en)||tn.hasAttribute("data-"+en))}function F(tn,en){return c(tn,en)||c(tn,"data-"+en)}function l(tn){return tn.parentElement}function P(){return document}function d(tn,en){return en(tn)?tn:l(tn)?d(l(tn),en):null}function X(tn,en){var nn=null;if(d(tn,function(rn){return nn=F(rn,en)}),nn!=="unset")return nn}function v(tn,en){var nn=tn.matches||tn.matchesSelector||tn.msMatchesSelector||tn.mozMatchesSelector||tn.webkitMatchesSelector||tn.oMatchesSelector;return nn&&nn.call(tn,en)}function i(tn){var en=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i,nn=en.exec(tn);return nn?nn[1].toLowerCase():""}function o(tn,en){for(var nn=new DOMParser,rn=nn.parseFromString(tn,"text/html"),on=rn.body;en>0;)en--,on=on.firstChild;return on==null&&(on=P().createDocumentFragment()),on}function u(tn){if(D.config.useTemplateFragments){var en=o("
"+tn+"",0);return en.querySelector("template").content}else{var nn=i(tn);switch(nn){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return o("