__init__.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import collections
  2. import inspect
  3. from packaging import version
  4. from django.apps import AppConfig
  5. from django.core.exceptions import ImproperlyConfigured
  6. from django.template.loader import get_template
  7. from extras.plugins.utils import import_object
  8. from extras.registry import registry
  9. from netbox.navigation import MenuGroup
  10. from utilities.choices import ButtonColorChoices
  11. # Initialize plugin registry
  12. registry['plugins'] = {
  13. 'graphql_schemas': [],
  14. 'menus': [],
  15. 'menu_items': {},
  16. 'preferences': {},
  17. 'template_extensions': collections.defaultdict(list),
  18. }
  19. #
  20. # Plugin AppConfig class
  21. #
  22. class PluginConfig(AppConfig):
  23. """
  24. Subclass of Django's built-in AppConfig class, to be used for NetBox plugins.
  25. """
  26. # Plugin metadata
  27. author = ''
  28. author_email = ''
  29. description = ''
  30. version = ''
  31. # Root URL path under /plugins. If not set, the plugin's label will be used.
  32. base_url = None
  33. # Minimum/maximum compatible versions of NetBox
  34. min_version = None
  35. max_version = None
  36. # Default configuration parameters
  37. default_settings = {}
  38. # Mandatory configuration parameters
  39. required_settings = []
  40. # Middleware classes provided by the plugin
  41. middleware = []
  42. # Django-rq queues dedicated to the plugin
  43. queues = []
  44. # Django apps to append to INSTALLED_APPS when plugin requires them.
  45. django_apps = []
  46. # Default integration paths. Plugin authors can override these to customize the paths to
  47. # integrated components.
  48. graphql_schema = 'graphql.schema'
  49. menu = 'navigation.menu'
  50. menu_items = 'navigation.menu_items'
  51. template_extensions = 'template_content.template_extensions'
  52. user_preferences = 'preferences.preferences'
  53. def ready(self):
  54. plugin_name = self.name.rsplit('.', 1)[-1]
  55. # Register template content (if defined)
  56. template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
  57. if template_extensions is not None:
  58. register_template_extensions(template_extensions)
  59. # Register navigation menu or menu items (if defined)
  60. if menu := import_object(f"{self.__module__}.{self.menu}"):
  61. register_menu(menu)
  62. if menu_items := import_object(f"{self.__module__}.{self.menu_items}"):
  63. register_menu_items(self.verbose_name, menu_items)
  64. # Register GraphQL schema (if defined)
  65. graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}")
  66. if graphql_schema is not None:
  67. register_graphql_schema(graphql_schema)
  68. # Register user preferences (if defined)
  69. user_preferences = import_object(f"{self.__module__}.{self.user_preferences}")
  70. if user_preferences is not None:
  71. register_user_preferences(plugin_name, user_preferences)
  72. @classmethod
  73. def validate(cls, user_config, netbox_version):
  74. # Enforce version constraints
  75. current_version = version.parse(netbox_version)
  76. if cls.min_version is not None:
  77. min_version = version.parse(cls.min_version)
  78. if current_version < min_version:
  79. raise ImproperlyConfigured(
  80. f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
  81. )
  82. if cls.max_version is not None:
  83. max_version = version.parse(cls.max_version)
  84. if current_version > max_version:
  85. raise ImproperlyConfigured(
  86. f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
  87. )
  88. # Verify required configuration settings
  89. for setting in cls.required_settings:
  90. if setting not in user_config:
  91. raise ImproperlyConfigured(
  92. f"Plugin {cls.__module__} requires '{setting}' to be present in the PLUGINS_CONFIG section of "
  93. f"configuration.py."
  94. )
  95. # Apply default configuration values
  96. for setting, value in cls.default_settings.items():
  97. if setting not in user_config:
  98. user_config[setting] = value
  99. #
  100. # Template content injection
  101. #
  102. class PluginTemplateExtension:
  103. """
  104. This class is used to register plugin content to be injected into core NetBox templates. It contains methods
  105. that are overridden by plugin authors to return template content.
  106. The `model` attribute on the class defines the which model detail page this class renders content for. It
  107. should be set as a string in the form '<app_label>.<model_name>'. render() provides the following context data:
  108. * object - The object being viewed
  109. * request - The current request
  110. * settings - Global NetBox settings
  111. * config - Plugin-specific configuration parameters
  112. """
  113. model = None
  114. def __init__(self, context):
  115. self.context = context
  116. def render(self, template_name, extra_context=None):
  117. """
  118. Convenience method for rendering the specified Django template using the default context data. An additional
  119. context dictionary may be passed as `extra_context`.
  120. """
  121. if extra_context is None:
  122. extra_context = {}
  123. elif not isinstance(extra_context, dict):
  124. raise TypeError("extra_context must be a dictionary")
  125. return get_template(template_name).render({**self.context, **extra_context})
  126. def left_page(self):
  127. """
  128. Content that will be rendered on the left of the detail page view. Content should be returned as an
  129. HTML string. Note that content does not need to be marked as safe because this is automatically handled.
  130. """
  131. raise NotImplementedError
  132. def right_page(self):
  133. """
  134. Content that will be rendered on the right of the detail page view. Content should be returned as an
  135. HTML string. Note that content does not need to be marked as safe because this is automatically handled.
  136. """
  137. raise NotImplementedError
  138. def full_width_page(self):
  139. """
  140. Content that will be rendered within the full width of the detail page view. Content should be returned as an
  141. HTML string. Note that content does not need to be marked as safe because this is automatically handled.
  142. """
  143. raise NotImplementedError
  144. def buttons(self):
  145. """
  146. Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content
  147. should be returned as an HTML string. Note that content does not need to be marked as safe because this is
  148. automatically handled.
  149. """
  150. raise NotImplementedError
  151. def register_template_extensions(class_list):
  152. """
  153. Register a list of PluginTemplateExtension classes
  154. """
  155. # Validation
  156. for template_extension in class_list:
  157. if not inspect.isclass(template_extension):
  158. raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
  159. if not issubclass(template_extension, PluginTemplateExtension):
  160. raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!")
  161. if template_extension.model is None:
  162. raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
  163. registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
  164. #
  165. # Navigation menu links
  166. #
  167. class PluginMenu:
  168. icon_class = 'mdi mdi-puzzle'
  169. def __init__(self, label, groups, icon_class=None):
  170. self.label = label
  171. self.groups = [
  172. MenuGroup(label, items) for label, items in groups
  173. ]
  174. if icon_class is not None:
  175. self.icon_class = icon_class
  176. class PluginMenuItem:
  177. """
  178. This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
  179. specifying additional link buttons that appear to the right of the item in the van menu.
  180. Links are specified as Django reverse URL strings.
  181. Buttons are each specified as a list of PluginMenuButton instances.
  182. """
  183. permissions = []
  184. buttons = []
  185. def __init__(self, link, link_text, permissions=None, buttons=None):
  186. self.link = link
  187. self.link_text = link_text
  188. if permissions is not None:
  189. if type(permissions) not in (list, tuple):
  190. raise TypeError("Permissions must be passed as a tuple or list.")
  191. self.permissions = permissions
  192. if buttons is not None:
  193. if type(buttons) not in (list, tuple):
  194. raise TypeError("Buttons must be passed as a tuple or list.")
  195. self.buttons = buttons
  196. class PluginMenuButton:
  197. """
  198. This class represents a button within a PluginMenuItem. Note that button colors should come from
  199. ButtonColorChoices.
  200. """
  201. color = ButtonColorChoices.DEFAULT
  202. permissions = []
  203. def __init__(self, link, title, icon_class, color=None, permissions=None):
  204. self.link = link
  205. self.title = title
  206. self.icon_class = icon_class
  207. if permissions is not None:
  208. if type(permissions) not in (list, tuple):
  209. raise TypeError("Permissions must be passed as a tuple or list.")
  210. self.permissions = permissions
  211. if color is not None:
  212. if color not in ButtonColorChoices.values():
  213. raise ValueError("Button color must be a choice within ButtonColorChoices.")
  214. self.color = color
  215. def register_menu(menu):
  216. if not isinstance(menu, PluginMenu):
  217. raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu")
  218. registry['plugins']['menus'].append(menu)
  219. def register_menu_items(section_name, class_list):
  220. """
  221. Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)
  222. """
  223. # Validation
  224. for menu_link in class_list:
  225. if not isinstance(menu_link, PluginMenuItem):
  226. raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem")
  227. for button in menu_link.buttons:
  228. if not isinstance(button, PluginMenuButton):
  229. raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton")
  230. registry['plugins']['menu_items'][section_name] = class_list
  231. #
  232. # GraphQL schemas
  233. #
  234. def register_graphql_schema(graphql_schema):
  235. """
  236. Register a GraphQL schema class for inclusion in NetBox's GraphQL API.
  237. """
  238. registry['plugins']['graphql_schemas'].append(graphql_schema)
  239. #
  240. # User preferences
  241. #
  242. def register_user_preferences(plugin_name, preferences):
  243. """
  244. Register a list of user preferences defined by a plugin.
  245. """
  246. registry['plugins']['preferences'][plugin_name] = preferences