From 33ca352fd937111c3718d8faafaf21497d3b414b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Mar 2020 12:20:25 -0400 Subject: [PATCH 1/8] Initial documentation for plugins framework --- docs/configuration/optional-settings.md | 18 +++++++ docs/plugins/development.md | 58 ++++++++++++++++++++++ docs/plugins/index.md | 64 +++++++++++++++++++++++++ mkdocs.yml | 4 +- 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 docs/plugins/development.md create mode 100644 docs/plugins/index.md diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 576065434..a678ac26d 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -299,6 +299,24 @@ Determine how many objects to display per page within each list of objects. --- +## PLUGINS_CONFIG + +Default: Empty + +This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. + +Note that `PLUGINS_ENABLED` must be set to True for this to take effect. + +--- + +## PLUGINS_ENABLED + +Default: `False` + +Enable [NetBox plugins](../../plugins/). + +--- + ## PREFER_IPV4 Default: False diff --git a/docs/plugins/development.md b/docs/plugins/development.md new file mode 100644 index 000000000..babaf0711 --- /dev/null +++ b/docs/plugins/development.md @@ -0,0 +1,58 @@ +# Plugin Development + +This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. + +## Initial Setup + +### Plugin Structure + +Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin might look like this: + +```no-highlight +plugin_name/ + - plugin_name/ + - templates/ + - *.html + - __init__.py + - middleware.py + - navigation.py + - signals.py + - template_content.py + - urls.py + - views.py + - setup.py +``` + +The top level is the project root, which is typically synonymous with the git repository. A file named `setup.py` must exist at the top level to register the plugin within NetBox and to define meta data. You might also find miscellaneous other files within the project root, such as `.gitignore`. + +The second level directory houses the actual plugin code, arranged in a set of Python files. These are arbitrary, however the plugin _must_ include a `__init__.py` file which provides an AppConfig subclass. + +### Create setup.py + +The first step is to write our Python [setup script](https://docs.python.org/3.6/distutils/setupscript.html), which facilitates the installation of the plugin. This is standard practice for Python applications, with the only really noteworthy bit being the declared `entry_points`: The plugin must define an entry point for `netbox.plugin` pointing to the NetBoxPluginMeta class. + +```python +from setuptools import setup, find_packages + +setup( + name='netbox-animal-sounds', + version='0.1', + description='Show animals and the sounds they make', + url='https://github.com/organization/animal-sounds', + author='Author Name', + author_email='author@example.com', + license='Apache 2.0', + + install_requires=[], + packages=find_packages(exclude=['tests', 'tests.*']), + include_package_data=True, + entry_points={ + 'netbox.plugin': 'netbox_animal_sounds=netbox_animal_sounds:NetBoxPluginMeta' + } +) + +``` + +### Define an AppConfig + + diff --git a/docs/plugins/index.md b/docs/plugins/index.md new file mode 100644 index 000000000..7e6913329 --- /dev/null +++ b/docs/plugins/index.md @@ -0,0 +1,64 @@ +# Plugins + +Plugins are packaged [Django](https://docs.djangoproject.com/) apps that can be installed alongside NetBox to provide custom functionality not present in the core application. Plugins can introduce their own models and views, but cannot interfere with existing components. A NetBox user may opt to install plugins provided by the community or build his or her own. + +Plugins are supported on NetBox v2.8 and later. + +## Capabilities + +The NetBox plugin architecture allows for the following: + +* **Add new data models.** A plugin can introduce one or more models to hold data. (A model is essentially a SQL table.) +* **Add new URLs and views.** Plugins can register URLS under the `/plugins` root path to provide browsable views for users. +* **Add content to existing model templates.** A template content class can be used to inject custom HTML content within the view of a core NetBox model. This content can appear in the left side, right side, or bottom of the page. +* **Add navigation menu items.** Each plugin can register new links in the navigation menu. Each link may have a set of buttons for specific actions, similar to the built-in navigation items. +* **Add custom middleware.** Custom Django middleware can be registered by each plugin. +* **Declare configuration parameters.** Each plugin can define required, optional, and default configuration parameters within its unique namespace. +* **Limit installation by NetBox version.** A plugin can specify a minimum and/or maximum NetBox version with which it is compatible. + +## Limitations + +Either by policy or by technical limitation, the interaction of plugins with NetBox core is restricted in certain ways. These include: + +* **Modify core models.** Plugins may not alter, remove, or override core NetBox models in any way. This rule is in place to ensure the integrity of the core data model. +* **Register URLs outside the `/plugins` root.`** All plugin URLs are restricted to this path to prevent name/path collisions. +* **Override core templates.** The only avenue available for injecting content into core templates is the provided +* **Modify core settings.** A configuration registry is provided for plugins, however they cannot alter or delete the core configuration. +* **Disable core components.** Plugins are not permitted to disable or hide core NetBox components. + +## Installing Plugins + +The instructions below detail the process for installing and enabling a NetBox plugin. + +### Install Package + +TODO + +### Enable Plugins + +In `configuration.py`, set the `PLUGINS_ENABLED` parameter to True (if not already set): + +```python +PLUGINS_ENABLED = True +``` + +### Configure Plugin + +If the plugin requires any configuration, define it in `configuration.py` under the `PLUGINS_CONFIG` parameter. The available configuration parameters should be detailed in the plugin's README file. + +```no-highlight +PLUGINS_CONFIG = { + 'plugin_name': { + 'foo': 'bar', + 'buzz': 'bazz' + } +} +``` + +### Restart WSGI Service + +Restart the WSGI service to detect the new plugin: + +```no-highlight +# sudo systemctl restart netbox +``` diff --git a/mkdocs.yml b/mkdocs.yml index d980cc80c..d7e803149 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,7 +11,6 @@ markdown_extensions: - admonition: - markdown_include.include: headingOffset: 1 - nav: - Introduction: 'index.md' - Installation: @@ -53,6 +52,9 @@ nav: - Reports: 'additional-features/reports.md' - Tags: 'additional-features/tags.md' - Webhooks: 'additional-features/webhooks.md' + - Plugins: + - Using Plugins: 'plugins/index.md' + - Developing Plugins: 'plugins/development.md' - Administration: - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' From 2188b0982c5a92f74d0a5bae3e378c2456124640 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 23 Mar 2020 12:00:10 -0400 Subject: [PATCH 2/8] More work on plugins development docs --- docs/plugins/development.md | 60 +++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/docs/plugins/development.md b/docs/plugins/development.md index babaf0711..300522d67 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -4,9 +4,9 @@ This documentation covers the development of custom plugins for NetBox. Plugins ## Initial Setup -### Plugin Structure +## Plugin Structure -Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin might look like this: +Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this: ```no-highlight plugin_name/ @@ -20,39 +20,75 @@ plugin_name/ - template_content.py - urls.py - views.py + - README - setup.py ``` -The top level is the project root, which is typically synonymous with the git repository. A file named `setup.py` must exist at the top level to register the plugin within NetBox and to define meta data. You might also find miscellaneous other files within the project root, such as `.gitignore`. +The top level is the project root, which is typically synonymous with the git repository. Within the root should exist several files: -The second level directory houses the actual plugin code, arranged in a set of Python files. These are arbitrary, however the plugin _must_ include a `__init__.py` file which provides an AppConfig subclass. +* `setup.py` - This is a standard Python installation script used to install the plugin package within the Python environment. +* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown. +* The plugin source directory, with the same name as your plugin. + +The plugin source directory contains all of the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class. ### Create setup.py -The first step is to write our Python [setup script](https://docs.python.org/3.6/distutils/setupscript.html), which facilitates the installation of the plugin. This is standard practice for Python applications, with the only really noteworthy bit being the declared `entry_points`: The plugin must define an entry point for `netbox.plugin` pointing to the NetBoxPluginMeta class. +`setup.py` is the [setup script](https://docs.python.org/3.6/distutils/setupscript.html) we'll use to install our plugin once it's finished. This script essentially just calls the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to information the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: ```python -from setuptools import setup, find_packages +from setuptools import find_packages, setup setup( name='netbox-animal-sounds', version='0.1', description='Show animals and the sounds they make', - url='https://github.com/organization/animal-sounds', + url='https://github.com/example-org/animal-sounds', author='Author Name', author_email='author@example.com', license='Apache 2.0', - install_requires=[], - packages=find_packages(exclude=['tests', 'tests.*']), + packages=find_packages(), include_package_data=True, entry_points={ - 'netbox.plugin': 'netbox_animal_sounds=netbox_animal_sounds:NetBoxPluginMeta' + 'netbox_plugins': 'netbox_animal_sounds=netbox_animal_sounds:AnimalSoundsConfig' } ) - ``` -### Define an AppConfig +Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). +The key requirement for a NetBox plugin is the presence of an entry point for `netbox_plugins` pointing to the `PluginConfig` subclass, which we'll define next. +### Define a PluginConfig + +The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/). It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: + +```python +from extras.plugins import PluginConfig + +class AnimalSoundsConfig(PluginConfig): + name = 'netbox_animal_sounds' + verbose_name = 'Animal Sounds Plugin' + version = '0.1' + author = 'Author Name' + description = 'Show animals and the sounds they make' + url_slug = 'animal-sounds' + required_settings = [] + default_settings = { + 'loud': False + } +``` + +#### PluginConfig Attributes + +* `name` - Raw plugin name; same as the plugin's source directory +* `author_name` - Name of plugin's author +* `verbose_name` - Human-friendly name +* `version` - Plugin version +* `description` - Brief description of the plugin's purpose +* `url_slug` - Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. +* `required_settings`: A list of configuration parameters that **must** be defined by the user +* `default_settings`: A dictionary of configuration parameter names and their default values +* `middleware`: A list of middleware classes to append after NetBox's build-in middleware. +* `caching_config`: Plugin-specific cache configuration From 0b77702626f6f4fc65da67a15d0bf3e0229d871d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 23 Mar 2020 13:28:56 -0400 Subject: [PATCH 3/8] Add docs for plugin models, views --- docs/media/plugins/plugin_admin_ui.png | Bin 0 -> 23831 bytes docs/plugins/development.md | 102 +++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 docs/media/plugins/plugin_admin_ui.png diff --git a/docs/media/plugins/plugin_admin_ui.png b/docs/media/plugins/plugin_admin_ui.png new file mode 100644 index 0000000000000000000000000000000000000000..44802c5fca3eb41f7f84cfe50f25de6ca0cf55e6 GIT binary patch literal 23831 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 literal 0 HcmV?d00001 diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 300522d67..584e00cae 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -90,5 +90,107 @@ class AnimalSoundsConfig(PluginConfig): * `url_slug` - Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. * `required_settings`: A list of configuration parameters that **must** be defined by the user * `default_settings`: A dictionary of configuration parameter names 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. * `caching_config`: Plugin-specific cache configuration + +## Database Models + +Plugins can define their own Django models to record user data. A model is a Python representation of a database table. Model instances can be created, manipulated, and deleted using the [Django ORM](https://docs.djangoproject.com/en/stable/topics/db/). Models are typically defined within a plugin's `models.py` file, though this is not a strict requirement. + +Below is a simple example `models.py` file showing a model with two character fields: + +```python +from django.db import models + +class Animal(models.Model): + name = models.CharField(max_length=50) + sound = models.CharField(max_length=50) + + def __str__(self): + return self.name +``` + +Once you have defined the model(s) for your plugin, you'll need to create the necessary database schema migrations as well. This can be done using the Django `makemigrations` management command: + +```no-highlight +$ ./manage.py makemigrations netbox_animal_sounds +Migrations for 'netbox_animal_sounds': + /home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py + - Create model Animal +``` + +Once the migration has been created, we can apply it locally with the `migrate` command: + +```no-highlight +$ ./manage.py migrate netbox_animal_sounds +Operations to perform: + Apply all migrations: netbox_animal_sounds +Running migrations: + Applying netbox_animal_sounds.0001_initial... OK +``` + +For more information on database migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/). + +### Using the Django Admin Interface + +Plugins can optionally expose their models via Django's built-in [administrative interface](https://docs.djangoproject.com/en/stable/ref/contrib/admin/). This can greatly improve troubleshooting ability, particularly during development. An example `admin.py` file for the above model is shown below: + +```python +from django.contrib import admin +from .models import Animal + +@admin.register(Animal) +class AnimalAdmin(admin.ModelAdmin): + list_display = ('name', 'sound') +``` + +This will display the plugin and its model in the admin UI. Staff users can create, change, and delete model instances via the admin UI without needing to create a custom view. + +![NetBox plugin in the admin UI](../media/plugins/plugin_admin_ui.png) + +## Views + +A view is a particular page tied to a URL within NetBox. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`: + +```python +from django.shortcuts import render +from django.views.generic import View +from .models import Animal + +class RandomAnimalSoundView(View): + + def get(self, request): + animal = Animal.objects.order_by('?').first() + + return render(request, 'animal_sound.html', { + 'animal': animal, + }) +``` + +This view retrieves a random animal from the database and and passes it as a context variable when rendering ta template named `animal_sound.html`. To create this template, create a `templates/` directory within the plugin source directory and save the following: + +```jinja2 +{% extends '_base.html' %} + +{% block content %} +The {{ animal.name }} says {{ animal.sound }} +{% endblock %} +``` + +!!! note + Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of. + +Finally, to make the view accessible to users, we need to register a URL for it. We do this in `urls.py`: + +```python +from django.urls import path +from .views import RandomAnimalSoundView + +urlpatterns = [ + path('random-sound/', RandomAnimalSoundView.as_view()) +] +``` + +This makes our view accessible at the URL `/plugins/animal-sounds/random-sound/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. From 0a8b09a11a7d9ce437e52a5d24a755fce3033d52 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 23 Mar 2020 13:58:45 -0400 Subject: [PATCH 4/8] Add docs for plugin API endpoints --- .../plugins/plugin_rest_api_endpoint.png | Bin 0 -> 30034 bytes docs/plugins/development.md | 47 ++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 docs/media/plugins/plugin_rest_api_endpoint.png diff --git a/docs/media/plugins/plugin_rest_api_endpoint.png b/docs/media/plugins/plugin_rest_api_endpoint.png new file mode 100644 index 0000000000000000000000000000000000000000..7cdf34cc85b20184e3276dbbdb458138c6c225f2 GIT binary patch literal 30034 zcmd431z42tyDqAtAT83;2m;cgfYPNPN{2KIB1j5DrwSrn5~DPTNOyM(IWTm03`ooX z0}RdnM*qLH)>;2pd!K#IUVARRxR|fr_lf(tpZEw?Q+Y;AaEIW^l`F(bit-v)u3SR^ z|L@$y1-`NE4ida_h53q-yo{E6($?&(rlwny$i)RAM$c<+dF77Elcbr@FD}zxowvd> z6|!4JZ_IXlz8aUl5H1=nmo=4v>?!<|GZcO!BwTwJpXR#P2SfG+zV*5HZp4!BWZ2)o ze=i=u%;&V-fWn|m3)r|>@Ktw}o2!J1zP1ve^1WHd5BkR)bmm&FUBdeN`SyRvT#C|1 zM6CpDc#m0=tnu9Y45s?h_BgbAF$uczt{>ziYAN_Q32X(P^U!ShaIu3=@%Kl|AoTru zFG$wtr|7BBkG}og)&J*?C%Ms+;00knRu+y>?{LJw?clF9{I$W)!k_)sE5ArU*QXip@=L?{s4XjlS^m= zHSRr5`p;)$l_Zx~wgtM_L-=;ZG(waoyn8~Vvs*xnwSXq#;QtRD{&h9|RFVZ1TE=h= zY1G56^s#(V^cxOuaj5$>2L9c>tT!oj27Ri`p~pRI|4?=$cKv%H9&38-bv@NAjJtG( zt26IkgkExlsG6=jy@KgV{xkcDSBF`eNmNW45fSTAJxty8=FOgMRhD|}eeJ*4-abfj zI*iBP@{@S)1HxCTW@R=vjOM}<{lVa)>w$cL7FWT)ZS=4G{kw)Oy8&4y;`@8iDaTi4 z&x8miN_h2EnH2YR?_#cR5F#!N3SC_BY~u+j_YRmQZETx3{0Yq$uaHB)}64BJL&2} zHxCKHAyNWl08<(OxcvH_V_H@2wi_<+&&-3a( zqe2tbX7DQsY&GFaq3A}DO~l#4*PAXqLTljXcA|4zRY%v7Md_M~Nmaik+o7jSnl2GD zHbWD9wwTv;k~jRsj19quvuzH$V1s|j>-cf*$Q*eL9#0 zD&A~ew)O(hWI-2`Lkc()N{POH^y?5Q)u(r|Ep(NU|NZ17R}p&3`wjA5;$(ubv;Ope zZ4R{HjluhmMLw9-ms@W0DA%U7u76=m=zc(2R7iH~2jU9aDuvBy^V2WW2fuTd*TIV4 zcG8n{CQV;9i1vf0Ck)H`z>Ib^B`M&pYJaZOWnK=DxPi7x`SqrzYH#Q3HAN(|K1z&wcF zy->uR=eEA7r<(8T%hQLg6$}jMrl!6PPD}(L5N{req>lD^3Z3yZgQ4O?e?sMd*8gW= zOLeIC&Yu13sO%Za>+o#1@kfn~O{X{1u)iGU#Wz=FVl0^O(v8a3L=RlmSAD_r1JYDJ z*ZLJUJ7K#6;}E4nzw7&FK(0W6CMgn;-6%3*T#xjv(3^TkU{^-SiO`GpfU}oF>m6G# zs}53ioAB0uOM0K4nxLPDO_Od-Bkj(x-`Sbtt9qRfw|LjSn(k>en91BogQM`p zHCq{{E2d*HtLI7?JP5O||B$*oE`JP&)W5pkpo)RLz;&*d_9W@{w@5F7@8t()Ng5}@ z6yPH6DcdIcoi8s(Io{n8!FWmL2SLTX|7l#h0L^(&?VgCd!)R4WqUE57zc zVm=TgX^{BqSQoF&@gW|~GT=70BA8Rd8Veki9cgSPo4B_E4gBv2ul4v5nnfgx#RrX7*UwO9X^X*I~ zz3KZR^d4INvUivW*GzC&94mN((P92q>raEun01fXb?u2Cnt}flv2l@ zpHabMC8$fmadPr>=j323B$bm4_zdbFVx#djb?kva-7~geY`Qd7dm|rG1|i3u(eRcB z@%z4J@1aT9gPcUge~x^*N(KIXXMtvr%I_!R%4)!}Yi**v-tjM0*>=RY%XK+;7c$gE z&K&hkR1ZGW;q9pHjaoWA4^J#{G^dYvq{5H<@WOdf=hF?o#~gVW*7Ts-RHXXMS_%>~z>D&_6hp)mNE5>;{8! zza9)ljg>>m8Ax(8!h89R?TpivP67f)9xf8P*;Qq3M29Vf5r3ykLWd>f&Srb^=4!R? zIS0LveMblH+pf_F5&qojhNtsQ+6xH7BbT!M#0JU{4PE*vhZ z%9+5{B*rZu>+%+#ZWyI|wq(9YJM9)C`g9_GDN49(paI+V(|=b|NWPr@Btc{!lYOc& z^&sR}!?y_#x=xb?H9803aI48tNP|y8vkCK^>c$NVtF4s4ItUZbGn3{pGcCrMuyBJ$ z8MFpt`-J&SesySyZVD>ABA$i&EywL}v1mxF|E8)T_s(!Nn1@79p(+Z{)9qz9)?cp+ zvIoM?1HS~P=5iOy=#X_@UWgd}MD4|9a!9do{o*O4wz%6}O}^W4m(x0V=lHQ7)ca0& z=Tel*GOR8sMif7<72Pw0lBfkboDs)?UGDyn9?E>txM;@cmzvQV-QcA+~anSwd zjlPsqh^G@Fhvs1`#Q93cce$>HbC{~2S9r<~f*J3he)RBn9GF{S!N6jxsqxULTIEBv zRWcgY_{E`aTY5?ND;=>y)yuvg4(Q`PyRk}MXxi@T2{P3shsT;*I_@o8Wm}EUd~kU) zChRTeiJVt|ATncN$C&LNQa(eFxTY$#&qpO*4R(r5g0_5b5V*JfNl@X^tsTuT9ozx@ z$}o6NsJ64ly-qX(U%mRBUeaIrcn&H)alG!Q`z<$j;NkaEciKyJ#xAuFB-69?A0I=h(IsM&L8@GBU${78(@(opL)zlMVAiC@k*QJT7W__$ zMvor{xiyJ?Yfd&7bIez-gO{9g_B1@gltjF8tXOhGQH#4d-Adj2;wfp4iME*uJ(C(S zmy3H=%r1!M7C6#5`mK&y=|-|7+UA-4(hR7PJ&R~o6XW>6m%}MBd(elASV>_6UWq^j z2v#v@hLCIeQLu7d&^uB0b`D$&8;0GM$hb7FK_5)NSl^=63g^XkF)Hv@UTfLfWi3z%ZK3 z*C^0~&-|d!AI3#a-<>a4jA*ib<;JK~2Q_#bAyo)e61Z@ekQWhbIk0h2OT^V7d$w2$ z{~{Lcyho*9=c9Wdv35{?aqoWW#2S6#w;_p?d{O$k%zn?Gdp9fTPy3ASeZC(FlNi4O zHEb}#BM93lDVd%WKzQkVk+^XnsnWqDn}yQJ268--6*>&-=82Cu?VjHDHlN({GZ8CS zseS+^F0{Pn+pdLpyr+G*<|&mhtAOXsjgaZ@Tpb0E=#Hd~s+ti$OuZq&aW%waY#ZN& zO6ik(hJ*^-OX}LWug4KDV%9=YN&I6*hYHb+3C2|nCNwB$owsBQH0O36RSA*|FF}%& z3nwh@P^E%WoN=q@P#Gn=e0S(j%cMoEwId+=&bHdb>I1iVR-tgDm}Rt!05KQWTScX* zoYQR2idPI{lzEMtZwa~ULdbix_^lnzC`r99j~h-ozSF?1q2u_vGJC#XmX|_3mb+CQ zi-&|RKT&ZXcoRshEo;M))jFOy)-mI81v>_bFv>K-b>e@0@Tz`wz(lj-b0)W4?YtNC zWIXmE&GK?o7Lf6S>1rExK!sEzBos8G)Iuj9$-_?G6~?iY-q?m93!>JxM122pAjMybz-$EmeEP!q=y0bVuDd{M<%e>H zTg4&!5q-C?8$v@@TI~Dz90Gn@SME`1>cPu%%T>6A6 zX>_6wllr(ht8a!W1NrWpfHB+DWuS{C8A`rNqJFMt8zINE@cVg{AYZQ!+=vuvg5l~f zt6aVXzB+nx8Zei@3(iinqEhh>cujNd+fr+d3vVTB$UDwXX~0q`B1SMVHPe0DM71Bt z6OWchg=$Z};N5Z7#%|Oy=4yMVMg8t?XLNyhw zp0shXPv_z;y682%fcCfx6<nd(59_2rT>##s;j{6O^?uz4kMu z)OyFCy5LXV^U{gfTxQcRGr#NN&Jiab`bn?HQDEJ8SAj+D2#FjoCQdqUiK z+eHC&NCuzVDRHIv&f^{Fu>iUt8jti@o+NoN8ga&^`ZW<*6B;_IBbAVo*qqD*5B-*d zH0XN1A3GwV2}9i#xwsON^0R8Y4ow_pF!U~GdToZPE-nitk|h3Pdsa6I;<@+5t2fY+ zwBk@SHFnVyM1l2Y<7<8qd|=OKsYfG zWU`SA&Hw1S{VAE;C+=AkB##np+GGMyE{EU&?`E}BPm7=4=?41qP5v`a*eS0w4cm$TuNTiKb!9Neo2jr zIm+==hye&4`-NtEC#tBkTDQJKjzOk89u2QE?YIrB_A(4WR(OcLMee` zonq59x^=^3pz4BolmcA@qVXLJKnZSgaEn!F_a;;`t=yS3FOXVH!%_;ymXeQkRt4f8 z+uP-;_u1U>>Fd5^Cg)*@fRM5V#7TE23A(4tSLwx*!q29+t9_jKZ+{^eeDdgn7{@4g zgFAeI?qPo&CFoj#!ldE@Yazw%7WP?fXG2{J^|deItmkwT{^BMwp*va}_ahmkz0XwG z7{vX1SSw=ty(Gz#f7X+&-#KqV2+b4tE;LECRFz&oJ-2?=sP3gwX$x?`4R8v?Mfsc| zaijUb{OyMML!`pcqU=M`$LXBtGhZx&W}hony-2EiCd%d=xNKW;99Vizfb(Ay_~Q2<$&cejmbz#1e-ePw zH0T-A<&kQ=yI@3#;OW0*3smJ0oaR4SM_Bkvvo}xmcFFHypQ0a%;6)Vj^70#pEgz%F z_78}EXAkN@ygwm0?}vXGuD|rZ_?nVJqNMV(-?HGpvla7SwR+Z)b&HG_UqVt+j7vpa zRBXupW44@x)^B|r+Va1fGA}~UM)P!8y7jjG{f7T>nHbw}OYZ~ppZTwj1BERhsWYyp zdFFT7Lkv+k%_-xr2HmMkO*QV?EzLDKsKj~y$a<`6CY^81Y@U`6dw)`Wu z#_|N%+oj{#Xh3c(b@ha<e72S-Z%PTX9qRX3-)n87<3$|ydqVoC z&G;lgiYk4iNcaFkD?s`VibttTb#_y1i0OlqBhM9Um88V++50B6$uvGZ!)Jc$-b` zeh;3`MK{)qGj?|Sft;T7dIo1NHv&05VH3ro9Hqf6@Pxzsro~hI81L&;7)&{cFFRuj z8IU1rDoD$$6fA&&q0?|TqAj0%93E{@qu|(bp^ajvr5z>Ve2%xb#HRSvuEI0zT6bx{ z#PBw7Sih7wYK2HE@<}?U5vNKNGrM7un?)VFSV!KO8x@Ca43U5yeG168mch;=I4XsP`o+;tnAEgG(pIiTWMOdrxMd zGmoghdi7#auq!f(5-itBwz85WtzvC^Gqk2~tJkrs&|rb;mKQaw8`p)uMh}Bf!Gw4a z^B@c%ypOsfqM5VD+;w2)V0B&HkHZ!iwG6Ty>#xOuVo|!$CXW?c3 z@5cz^qd>VxkSl#85u{HTOgFjpMTVFu6P-a$cD~ujZ_J5l9+yo>FR*LV6PX_=E5{{4JBpcScot$F=9A zuW`3s9=%F6ejoH*Howez%@g`9#XuZgKr_KDC zV#?-47}IoQzJXx{xkZ@SBEa;kL^)-Zi7IUX~R4bq>R#yH;Vo9Ec7!%-2`kJy@u(~Mf zY)ae5l%tuo<~T;hm5&ykKc<50nPjso7)B>c%+z7qq?=^PPB>D|kJCgu)U-iQUl&rI z_slH)($&Qovlz={mtR|UNsSO3iHDGRY+S9SL{xY zh+p^9gqs6yi23bp;)++)Az;QzRu*Vjx!0Fsv3m`OTq-o(k%gfby-|UpB0qHTvrsJ& zUf~**sZHDW`7`7<f&Zl60O)Rl#v*s?I(jVDM?JvE3bGmWv?{ zQtneQigYz`;!&wOe!T*fQQ?%y-t^+-xOG=a;F}khLnGwp?dj6~il#!Cm*lbz8(ZAs=c{W;*vS2=pW5SjxiB24xE^SK@3~Dh`EsKtbsESX9`TdJQ1}yp$@Mq(qHUv?>G(6Ay_Kz$(doUkqSn{1 z70CS^X%aUiFw^m&qW)LAEr7~~`O#wDHMWOG^Y_W9pfj#ao0_?5t7@TJg$HQ8aScLiW~;~`VGg(}Cwk8Lez<>v-v<=2E4 z6Dnn>oPgl@i03*t!u+R791HCOHIXU8vO$xpCbohowW^MGL~E{SIw#4<*Vl>~4|?!C z%~YbIO_8#Z9@Ib(L-ZR10qk~2UA$nJ*~(Xf;!R}O$-GI9muKb+;$qH=!#C8Xn&EKU zxpkd*Gearekgobpnn7x%@$Jt{rg`=illGhPbH@ZOs$frAl6i(huJv?&1d5&V=V=;% z6koqjS0ODOT;&T#8$MpHhfEqRO&3AP9D8yL-oSYggp*W4sZ-!jLBgB5&7UQpli3iA zlzrzj6YxC^>eOZ8%g-fP6_Of$w)&J!EtZeG?p>vOB`7r?j9gUOCskci?+d?C2qE<K;FBe zUL;rl`B?#k`j-yO#o+?BkIA?fz>Ofu&W3QCb3V4{SQO546C9^4k?3fIU30OJkoSWF zL4#AArw%gBYtp*VSQ_#fv*8k?16$RKjeIhwmvnOy60!mzijA{izW12+=_Cf)yrlO$ z>&ur`x^D(%53gt&orrMf@;|8$F{`$< zD8T|XiY!`d;cyNKSoVun6eAj~fR-XfuNT!VHZZ)I2q};`gup8%K#W_*P8%ULJqQ1X^^n5cddDx<%`|-8j$Jg~C zj21)^VMGZgEKmFJbVvt}0p-%;akrAQ!h5ckSQR$Hy2 zn6q_Er5#(16RzsTFmvddnx@G%NIk|Gb)Bu@^^C01oZU>)2rOb!l~t#Acdo)6WYH5$ z{jloIGAHzi*P8HY@hSaVbYsu6Io%YWfo)w_&{-}%m7PIv*YfGa!|VV&KEz!&?px8$ zIP5seQsbYfz~{nL=Y&GNTc!(Md9Skpr$;wxU9JhJOXY2mdB>;k0pxsfc*3X7_WT#S8SU&WI zw+!N?Qz^l{X#TTT;Ah^n1f}9(**CuKXQt}4YDo)gkncv0(78_=Nk&1B6r)yX+OrUu zP;H8e&D1&NI5U{ zxA#!*TPFPJFK+k=E`W`;VmebOO*;h&6K0xgfvCww$@~D_!FmYE%NtTJRL1b`8m=`( zcw9Z4rRx~~0qhjOHakp-WY)Fu&V%tFe(90H`zsRrDT`gR1oE$ic}`5YPNu&VKU#2s zCA@;2i}xRVC^RqhYuty<9Juwl*g0DOJfvbGHb*jn?AIGM!(YZ@I*eEgQ9WBxCBw#x zGRAmqM}=@1Z@b!+GA0SbzGkP!*wX@lpjy2&6{I#@v8SvY%a7R;9S8{!-@LCE&Couc z?JXsWWw2H*p#S+Az~3_|#gLkYGTg`AB?4WyS0M6`o+nfXInh@S09L7@aBOA8v~T5d zpQs~rU2h%k9M9Y`&jES<;K2sfuKG@%u~ee~CCh`XD`d}5%h65KGkG%KbMUuW>gS^a za~D#-o!}ye&55ME0muFq`6csFhdW5i6|IR`1!M z%+sZ3txxom2_CONXRaz&Ue#!24LP>L%f;qrYj^MJd}QNbodyy&w8AS-oeL!jq@zRz z4kdb5uzdQS9J=+zFi6Ec+dwSy zL77Qp43C0?C${TAlEM0D;{0A7ZI3<)iZwaF9&9GXF3;yL!=))KfKeIEq?<|)nqTj_ z)l%ARSN3WRQ5IsBvN|7%u;uamzO%)8c1Pd(=;7hB?mt25#g}C zrQXwjVp`#v_KbVrPwowRNoiFk%mU|>8}seg4p@a~Q5a0ACy-~Sm?YzbEHz78N|c(j zD>`zhS#G}{o!Zn4@ZZ&iC9m_S1h7GuoD+q~!K`dtBpx~(u_>V&$22jHwY7|hFq+vW zPNH=2(JM-GpTw2%rg|cobwWPm*uyp@do_s#+EaEYhK`A(n#W>+MBtsQ`aMU9s#}}f zM(|AH&%=ZOS=BVU!RY?JfXiaa&QdK-_kB<%v30sL(b8t-rwNzwY&Kj2fGxE?nU4|Q zxneNvuz;7SUtNdNVYxr7U5urUH|vnO+)I(xJ-0kIK8zE%jMlU+a3g$?U;r*MB81bS zi$IY;&83iH5aD}JU4XwC^E~=T+E3UnQaSdH9<% z;IA#2wVvLC!nCDm7QkaF>?XMpWA&myF6%UPMvtl?$OP&vv)>E4D(`Z1Xf|%Xc)-R* z3rs6<_R!>NS`@`C#wXDz4$TzsY*>4;hKP+8ZM!@(eMf0l0O?$r(R6U=QveDzR>)D` zMrET#lH$0mY(A!JunIHB+I5vgRG(49p(ZRYB~+kRrz;L%X1Bjrd%tq&1Q`kq={!RyY z>~7D<_mna~ZbLQ_@bqoTF-u|hrX9zLcN1EHOxKVs7)=;9ES%#uHj1p5LD{eWvE;eZ z(3;vOw(q;=Q=HuJM=NW9I?I^d=})2VsFk}vHIvjRKG%LMGTgoFB zZ)9LZpHgraI;HbX<$(S+)hpQO=}2V_tmfr=P$5w|kDd;bE?k?f_x94Bp@GK;2q~HR zAx{w~p1pWkD-u`B`1!EfFz-@XI53x6d%}7D_83z`z`?H@LHqX+C$hhq`D<<{TD@!5 zhE)l>r}Sb|C-L#^3^Jh}?*K87XRhW%Wr-&BD}Ty2 z=yNNA)amY==R+*D(ljxmp12)U9>#M|@7%xTQI5Yo#rVdL8)0&k4?ztVKsI$<<*GpT zqXiJL0f$7bCPpjh4+^+&f?5G&yk9T9P$70RX^p#eIe8*M?}JI8f8V5u;J9O0dTx8z zPw;FpKgJt~pz8&7t^uWvlX?xs*g^GEB<&{dmhiC1`3Pl}tNhO`ATfifDp z4F}wF`hp1{4Rza^&rX{8NE4cJ2}Gb#SDwwY#H{PI_>PK?+}vl^Kp2SIcr42U)#)b> z<|2!l?#r*#*l$Clf%aB%jmf#EbQ6=&wE2ZLH{;_ZHG2OpFB~t}DF6sPBWZI#@?>T^ z7O^m+ZUB1vU6rdq!esri$!Bxq{lj`M7>7E51Dl0Jxt(iVt=qbMX6JD&9+l)k!h6^5 zKv7_NhF`jN)1G;w{t0F^i4@2s8W?2JSLGxQM7JCFo@Zd<6g)Q^r(D9pay&7r<8`{Z zz$kT(w1iWbU{u96BR;gSHPg+E*gyRqI*Bmqm<2VcHhWpB4JTfunQKq#amFlUdhFjWtfnw3Fm19>$#^$q6F!5(rOgLCh54OJxYQ=QFre0{Pr3EylB2BXEa}=7Dv6(Ml_* zF%V`dZ?KAN(hXhT*B{x#3B)Ex^1$07(mu=C*X3IAM~;!YmVzc?9d*(1dO<h@=V zZ_M9qJ@^ITw*8(?fZrnN^ryO4Z&3s>Km$oT!*F4mxd-LZk*loJ`Zk|HNkqsjj92O6c1B`;22oqV{Lq!RH- zCZ3sYDay=#T=HVp#eg=);w7g__$OdG^Y-(BNigM8y^k#;D~;RI9uHo3zqbutyEB~( zb=(NOwh6RWiFnu*f?#=_0Oi@L70DDsyw55n*c4JG161!)^Rk$Te+5E*j=2+M>f}S= zC6PNbE>y+B+dyP8GHU)MheG{Vmf}xZXDL2qB`(%4*mB&bpch>7vB6AGYAG3Cl)h=2#!ita~S0pa6B&)reqX;#Xuq{Q_7we&txfUi@fp08()=0vgMyr9C19R!%_ZBQx7 zLuWdn>!O%ZeTs_ev`t?=Yb31)U?H%yLqitf(Xe0ksFRzArkzPNZ+}Vy6_GNN*}-5V zF3M_2WPmq$t#`M1n4@HxvutF4z`C;7@6vpR(5vtHi&pb zm{RE@Hfzv^ZCeW_2yLuUAqPEQ)35U05kH*yd%28j6GCB*9(tg32cgjCK=EQQg5Jz6LbA>&2>AwgD$}i>G;=diFH6}4rq<5Oc*rEhq1}Uv$v<% zr-HppyntxM^T=2iR8lp{dLLVi1d=(|01;MwAKAmJayS;< zQS2!09Dwxc@42!(jW-K=`Kcxg-vb}FEAVyff#@19B4j9HxJ09wyNCyw%Og}A8_T*; z-4_ zZ|KCAb5~4wmIL1SW^2eOPC-4XSgJ9y0AM$YfZE>Ncy3GRAr82se0hApF^8Y$+zV@B ze>2{26yu-S)tc5y25St~c7M`p&%}Q|3j5FD^Z!LB_3ttGf9EDQ%>+}Q!kaEBsS6Edx-0I?FzFxTJ^L*r z{eVJ9(}wJ7T+>oZdyf<+R;wAK_ne{Lx#S{6uLdY_Nkf3h5@c0j_E%+rapL{Xui|1C7J<1gY7NwjTqKeA#lIL1@>z2GTe!Q4fH zJH;gbXfuCP&!9%hKOI^AOHa+YK!G1;ddF7vt}!<+P1w<9m~S$7<6`TQ`6^)pedvGKzHRO+3k%Eoih(0DPqmY(yM^Z>>iU$kW|X5 z>+iBU;RX7cEQ0SehJW~pI->%wjynvH`!JGN+#wH69KdX+_BKkLHr$s-NCrv}r4V98 zuHQIn0Bf54+z3yVRZju2c``kHBHn52Hq#OsiAfW2qO(;`HCtbk+MPNmYa`u#;K<~8 z*DX!NniRLxVE4J$;YO$2`sDCZzsEt@+NQ1)qbw>#nP%3!6&-n)hVge07|gP8)n4*g zxhHVF>MqbtL(7pI~xf`ngkjPv3QkyaD zi0?!87?QL#G3Pj+jk5En0z-qxzxeih(VhUqaf)52D~1=cX{uH!rK_Ga?DEI~A8C z#xNWPO^UU8=vlbe%=>kiXLQ*LkMZrlEOX-(fJR1 z7Z0Pa;VGmeB5&_Mm+Fw53$O%QE=J=6OlGa_sV4`pVwB{bsd2`^xJ~tm>0+6E6X@wm zj*hsK=&XTWhO%vK0nZfO^ULj;LwBqrb0!tEfROE*5z{m$R(Q5k+Iq%T>_+NIDS4&x z+tLmrl0+#!KjWRD_!fIy#vR)-^LU>HqK+6kN?)!k*|ytQT!k9`>TFMuSod%vPf@Uy zn_nvkoltH($}8oJkU4W|BvR)BpqQ)m2U@3G%(=5OzxqU(Sie)};+PrhE5`-hAQhDf zVBS1-L~1!v1}ilkeBizDUynS$Sq}&l53R{y{hSc`*DD5p`Ro4#B<4?n?4^o{;fD{G zDuzC`1gQ$_=e9OLBmAFig z_}uw*39{5Qjy<6;SDqE`hvbot!`?S4BnbV&&Mg$8BF_R*k+GoZLh-KPamTVw@Yea= zNQo+5%!U3Rk(TQNV!FTr#$_c_$WUYg6GLk+-*|~meb7umqisQ$n|6j&Ye29W2ZhC* zLw_;TX)s<91xM^66_LVrbLf2bD0cmWk%y-1Y{@rG?J{R6MC9VRapsA5> zGRUj!wbIfZ79&fV@^AW0z9SRCOHzxaedv44FR*f06Zr?73mEb$A>2LX_LScGm~-s& z{iAhe$JJBcDjc%}X4Y4I6)iR5o}I!xDNL7|3Xsv28K1Q*1~DFa#}{Jn+imsP2vd>g z{pZD%0^Yo82p(2nr_R$a7?*%_e-l`;t*q*b@WhPdhi&OlPG@3;EOE9)(wXIz1q7}M%UJ8<~fA;XE}5lLIH*v?s}Z;|<;E{v%*(k1aS?)N(} zR+L`7A;%AYkRYW|AE;a+YaWCJ!<$-^#~IO!Iz$SnHpR_{v5jIhVNU`QYDFCTW54cf zW!gY=M)JRQDkST(RX`?LkypAU=dePu{}d^G*&_Dt1u(X=3=uEJ^O;lyM%GQ@EHW{fHYvQk zZM-xWrgSL{b@&p|UIMQwgC==)b9t^-YRvU@nw#W2eD!Y3+Xvg2`k$7xCQuAgT?=X5C0v(Dz*lOTAPK% zr-y^apA+KVm24R(;`USN*|8J%!wA)H-FEa-6};Vm5BFmX+&g`|-)Pg?zVcl1!m=yk z(z5SlU` ziId_`p#7YApjv<1$pjnf?h)yz$o;AgXbhr%QFyfMCi^z;y?8dHPF%j{^B-Pzkv|8z z*p}S)xuDqS*_8})u8ybxaqbBbT^=!|Nz9q(1Yjb?UFRE{W=?Gs97}(5E8fBXzz7QInTFyPe?!O|PWVQTi!OW0Gw(pb z@!iJf;*kT-?Ni+lFDyuG2j}0eRxvFHPh)8iz!`o>&R?DWpySr!cd*Q|@9jNp>0;=k zzF-7P1vn5LZtuwBMr3us#ZB^#(^)#phe1#8yG-N=A>%h>rw|81-XCs`{+oZ)`? zlJWxnyl$;=jRHI_OpdugnT7dmJxTQl@#tjVS72;aT-B`KtMc<667nBsnKUV+Lh~X! zANg;D(VuA^Q&4>RjUwwW^`TJlD6G^zt7FFE9m6_*^`KUON87X>6{Fzc=B)PE80*hF zv`W5Tt($S#zK#bN#AbP|3)=%ca`1yQ3iLcYU1Wp)$4r%a?-4rnv*4vp{M#3op7?T1 zC>eNNY8wCUP?Gk=fJSIdUJ!Q(`RKDhz9|1H6KZ(*MlF>RTmU((``Eqm!ERk@nn}P5 zU@FDiZv&R6eTo^ZcJn7TkzQQf2)K)aH*0_0&$^d~=1U8V6Ng82^J;pEf`03gV|Ew1 zf24X#FB9rb4urs;R1<}O-bGg?%^AN#;97|0!R&puoarz_I-RT8qjAw5GeXR8U~$y z@X%ETsgC58o<&}UTVi9c?DS!VdtuKI;H(XU_rlI{6W;>9_!K2Q-}zbK-NK!x7~3{9 zQ^oQZELBfE0Rw)GDM1}yuZqnp1twEM8c5eP(WZ(#tx^_RX%CwyP?q8WMqgX|?rymB z%wO)%Tr{nlZ}>S>f80<9!1?*EloU)IXlDOMa25yK};W!jHI>k24${&o`3Szbd zvx`OEKu77CGA nX!O8#z+O$cRXymBo<*($v^sy$OZpa{VT@(Gd(`I@rTFAQ{et{ zum-~QG^uS{89+!1%1s59$cfHtW7dSigK?%uNNGGVw>mzrF{X;Yv%=R6!E&IN?iXl* zSgeIE;N9glhBq0tHC;FnvyT@pf_fK1q*8y!wz_|Z;E~?D$mSgs0Pi6~K`l4^p!H0d zOuVbNBmOfA2d*1>U2NYe+?!g&-owoNfC}w^Nk^fx)pX-9X5ZT|uei778D$*+s60ek zx=&^?r2ZZ0ss?`+JU(XGSJ+mACVw{Kp2X%iDH6QMQ}=^1%=o3B7Y052G-bi(%mG5@ zwbv&y?@g!DYq;<+ibtaB^K6VxP;>@fH}_&&nPeDi!NC1JMOB)<1@-*^akE7CI?55D z56FA7U%&fLAP>qKSe;2{c`np(<;aSO`QCMB!@->9ba!s5F!Obt?!-)KO_Y=>$yR+dM$A5S;}VG?o39A4-3Ww zN9Riq-qMtqBjW@9FLy4{+zs~VGcw>$PF7Ay#(^zAUl7$%RPnP<`jr7;Wu3$$mNKvBaVL#&@`htcqwY;rgy?VdmP!U6 zhAm;+bycq;zHg+b=)QeeIAjg3&ys07AHQOwL)Q{~GVu-OAT@fPEtNZgIKB(JdgV&} z>QrFO@Akx> zuASQYT~)A|Do&BeGEb$=UWU&Ccvr6Me6yiPtU>5BD>=AMEvKm}er;-ZFlkh#n@lcd zHdk7}U%oAatNq9`K`g``zQEMzQX(SE-=gdd=~|+OB<1Kb;i|Nl)xRwZhY(GvCnKk` zaji(bKkp%k(}pK^gZ|Soy2JGpS)fdS{*n82$W^sT_+F^4Z^TV7AWDbh=GK|93&mG8 zi_nuB@$t_y&8KpT1f`NfZOk8zzS013u=bGDnHeVsa}E3KfAiQrJh<<(C}0vzN%R)n zPmIjf&gWMxsbC7Vwn41ZQ)}NL$`kO}6%e6j+MJ`#7X)@#jg%}FC>lleJoug@l@I(o z>#D#1%%-YWd5V@^gYT9O->vvbk;2kR@45c>EM;OD;Vsp!=IR!>vvqkB!9Pn6#0noG z8<$9JHuDgtI=2N^y&ImNp2Tf_wKi`!wM3 zl`HS7ZXuXBTj{S{X*RftCe=b%Xf*jjMjBpgT)LGE?Ct_*d$0c%Lo?35{DF4ezW6!W zsl*!eYodZ1Rwlwrp zr7q-E`>Z=VuK^b)eiCpXx53)pJIb4AVMkpm;7r3Cz_YO1yi$10Z0<(^N^b3k!cFj#1TBg ziC_`b+x9s2#^w$tQq$v02GsDIq4T;X;=5xoQkNQQ)fUQ$FDLmOaQ%~!JO&u2yGmkX z5wmV!ptL<%84+z!xIm!DuU363Iq6oCx2hxG&d8K1CAXCKU#_I;`Y#v^TbN94k%E^D z^Aql-2{wk0lqcKQM11`mJ*TwG<$@w4NoyfHsHb#l0oplIx$cb5c#Yww{?$t=>Vsy~zEWm^>Lg>we7A_*mQu;zso5PyHEl)w=P$V0HD9R7r)D4o|Q6<>u;* ze5fRhq|SGq{)x7!E5-y)cjvd&>`t#;kzTlH~*7h4?o z_}G($nstjbSp!?Ydd>_m;b(V*!&@X`4daVrZ3cSA^nqbcecdj-aMm$X!e%L;U|<^b z{_&JxCPE*D{>q6l;8ZK~VTkwm7{b%NvEqpZGg~X~eT0>7g8RC)?Tc$3OjD>UVaD;u z?GrO|j!jCyb#N@Xte35?@Ah|wJLZ~*$8FMc=9Uoqg@gd--2o1pO ziuIas@ee3{e)Hk1EOfNiQPaLUhL;wY;D+gYTK%npwzY*D|7qC`>x5x2FC#N-tgGz{ z;$7YB<}3$;gTZm(tg}b+bJXx7rVexZH^au^isiEUG^b-?4t2>?4PDs!iQQx*Ft_=%;;T*T^ZRej zJ#u*f@AVm%=wtg`NQD=F|1!tRASYRITpw={ZMzd@o*hn*Dy|Y=MVhu#j&OnoUGLEM zCDtLonx9FYN#f2IHEk`KiH~XbGWTTesL_e=qeCyPGeuI0234c20UmH zQ0lt1;MX0)zHV$h+s5rAfU_jLiW%J#uY|h!P{z6|l%a}Rb-OR#fX{VYMKs=X*hG*s zkPcUjdZr8!dJ837*Pam+Rk9p4-d_<-4-dd3qnQh=Y{PZMihz)Fm`~y2acUR}fnxgi zO{3>719L$}W5xCpMFB8+C@|g@<@Sv%9mzMlAC#wY6klASphUYq$ljk_a4bAV9XE&F z7fTadiaUnl{-2i0hol-aD&2tRfo-^{df0Pg)+?!d&%c3K0p4@&}$ZKgdd^6 zzYIou22Hl&sZ%YneT8+8=kw5B$tI4uS~nWYH}5?S>$tNlBCm`wCD+!3EaGKZZ{XwW z`8}GfnWu-uynYJBKm)ahe~#pxTVDPsKZ12rlziPuu%O^P|K~}|jT5Yq6fbDoID%uF?m>6NkUl3P;cz(xz`a6v%oJnT}Z7t zs`OE5wf&_Capn#g;>)UxmD$SqLg?A8>#Do0>G60oh{}oZscTi>28izRJdpErbs&Rx zwRmt5tmu!TB7zt!6eaXmPHbN#`hgX{`2PDfsuk`~2mEQ`hnbSm+iuG$N=@xmSsE17 z{@LZQ3oE&D_}Kf(Fx3dn|1BQQt~G++MAd?T2o^oRv@}d|LT?8^8HoMBqADrB%9TTZ z7e;!t1TbvOWv5%v_<%q9skz@9_(KTvudM2^i~jzo#=*$_Rz5U8gOR-3{`v>)P9LboD%NiVs@T`oyVP+aDuAojLx0dzF=UW}FJUHZMx-(8<{Re9Hm zU?xPcu347aM>;^-;=3cgEu?}>#ppCZ}pm8%Y;oy6e191r~Z{pGI#QBZx{qrHke zaJ+!BGzcqd$)9I?cpdRX_d3sgppGWIuK-MDD&X}V_ z#@}ox^qb3?XDVd!lL!c>h>$DFtFPbQJ_cb%i^s9U?%qj^CaM{^~^$yApB64+LFEHg> zRUv$?wl<%oCO}HZ+BfLce+2sPJ$)x->^hwTMz!!grpY92YT7vl=ugtjlO5!LY1nhJ z6{$y^RMMiNoW@mYDUaV9-tifUP@xCtACT9S(>OT7m!bg#`UB!1OY5w5Wq;D6vZ^N~ z;nyTNKlmpSOlPA#J?x_!w$_rlPhR(_);sb?ley0DMv95I0Ax;R2^`>O3Xo)}D`p${ zYJWHaX*yT#|KyOZmsGu^`@kIg?%rkO^er%-xh7{U#LD#HY;mPwy&Ziop+YS|Lzh#> zKDO1*I>M>RkzjrHBA%I5yg*T!z9HC)xo5F3DD&9-z5@zivr7nfe~^MUdUb9abX2Xf zfQF5=QL)q?AH~5OZaCXzk4uPz13MT$Ya|IX_oiVZ;rDoS_MH^&Jev}h&gp0gg{Jek zU81zp3#T|YIlu&8?%77iQ(ye&Pu!h>%JGG$RRZcS%LKRISGrn z#ex+*RRIWRNGC=&Q!A0d?A_wJ`T{4zI03F~vkCVv+FLF)S1z64-XM-uM1IEnYQmiQ zr^}lN3%#f(hUH>2;2RBsHY`ec_MdJON2hwXsezuL*&7SPO{5#STae{gGi?ITqTKdNG*AD4xmdZdx zz$b$a0|vBBGM_nqLmhj5$pu{8W=PvILBn3q4}rMuunK#-_LCfV&s`C$2tFqdo#k*e zb8r*k%YF&k{?YO*7MP2W><@o>ilzTdxZdiDt9^SiS!qPvu{iDuE^|x(FSQqtc8Ap6 zR*J334_C-_3tu@(zjF+VV1C)@It(+2DTDEOs85?6kmc^l!dmAKOI*-fgMo0kO}Bqh z5%h+nTkofbR+E7k8#bKj=t6MNUqD(XHfm@&rA52p+dMK+!xPbEV_SUVYY7|fI9E+= z%q9Jed5C!2-PLE>;I22wg0_@OUP<(`+%NR{*>QhpiJpw`_t=?0QU_rb0EQs{Eo%o5 zbiGjyZdGICjySmrcN;r`{lG+s`P}UXxGKCv8sr_nUSjJ*eL@sI){@DZMx-&Gv@YYF=Ak=nb6T{!MtA4)e^n_bPH zuTL19?ETmb<{x7FS6LImXmjR4n)q4~!nG`FY6F;*`=LZG5@mq}0MbI#Ab8iRZ_RsX ze*TNhG*Ik4G?Tu&7vP|0n&+@Elmu3-vNvTjpmXC>qXx5WvgIYLK?>N;4}kv6u?IC- z_UfBv{#0d5@<#=Uq&$RKapPmzc+?^d9Xwl%=BDB1I2r{l=(Drk$kAK_guqISu`qk) z>!;O3-X#}wUukkBOfsP?v~7l;n-oSwGk+3uK~1Y)pAJlnUPpOx<2F#dY|>UPAXr8N z!Nk4<+4(=ARSW|$VC(x5wI{BZsH^0?8#OH=g9%Sv`cJs9#-ef`-B+c{=Pi5N8^SKx zVkla{dPB3=etyGBZvM|ZS}7?NgY|%$Zz|yv7M1YLKul>Y&%N;mFWn8y!FRf`q&I&r z5gpiznVWpynu(mLA9 z64RxYNtA2#K(DTrCEvuzobL*?qkp>Pr5y2lTR$tV++d6{h8};q7B!u%A^N2lGg+ba zbiV6?+QT_nd*9`E(FDcYSn3Y|DVnpMC9a%AjlI6=z`E>yL)M5DV%PkwSmrgkK$TbQ z@NOl#W>D9@M2yJ3;t?oqoRcW!N1R04W(qajTH{AjA~gVww=yj8&gNI(_6A$!!R%5*GzZoC{{i4{)p4aLk-21v*vJzS-p%vtp*vlV+Am7GnYlE zd_x)Da(*SXr|#tK;^HqgQgOK9RfVyPNP!p-d#W z7FF0!<3{Sux32PHF}{1kwfZxz?mSA@bnA$-%_e$yZOC~^@h~zTaVq4c@S+~98HSzN zcXsn!UrJZ{owN3b)OQF7r562A&*V66^R*yMs0j#&2Nui%Gn;o2Pn_VrP3Dez5|1kR z{M`b{Uvn=iQ-d5B$|!xMxE0VD2hgDcsxxlwf`&jCn*biee&$AF{A#-Tbq?Dkd-y&f zmWNS0?1-HBibYs4Yp3j2Y4WGaSmOKC=1;oE72j%Hb6zF-gr{&bT{~KyP?oS(lgjgh zDua5&PKI5V@W=>dgk~16+yuv8MM7Y3G!blTgZ{PYt(WP$4g7&n;tAxP;_!@W@X+O` zj8lbfJ`!%hY}}K{e|q{$|6pN`>t@AC0nip4Y1C$kiyk5F!ANMjMjeB^A<+tSHqrZ7F!EpRREmV@m&YFq_E;dQ8|j1tn{g6l28f5lIIc2IK@tl-b8k8D#-Qm#*{)|zwCp z-@G!zzj|SxLi|!-9<(eg%U14dyQWg;Yy~<{dfczdXc}z9-^f*oD!khz@BVCac^H{I z7@bT1@b8#~B1>7{*o_=%79YC_{9pdgB) zy*6%^RrW$`W)h$vwjqH&B@lSz{2v`BXAPMGQKKNRcu(fI{W2N&&W5r^9->aCpv5ph zHzt7o0ATiYq5?G*!?qlT$C-?^-90_S7}3TiD-PHvAEZSB1}o=l~faaiv?Jl^*-HjPb62 zXKz~R)g|2Gg-3{Fpo~t*hzWd-lt%T?u;y4d2(`Z^MO@h(U56}BQaFHWG9@JTaN^#) z)2O8VVsn^zW|=928a}dMe_K*ngEf=9EaTW63D-**Q3jk&!oE15G$8RIJJ0ULXW2&K zA1-JPD6IrOxt%*hMn+Fjv9-}=dNG=Osz^a15u*0NJV^ko?763)4Jvj_niq=_%NVKPLp3h zK7MuA8+yVt*H?DcMW87NKxg(ZIZ)r{7l)l4h8mwGNB^RE&aGgnMYXb6kC71988`Uu zGImUH|4oOa6eYvz6S`uKa;G1~g`5*7J8=;jEO-i2aX|VZSv_$@!q^%OG(wDhrY0KSP+}+%SO|#8U^^-nTuVy*mPKCrDWaXugSld+Y z)ga^mL5Mh}h+~i`rPTq&F%xq+!r_RZGFi^A@VAxYTu4Bsu^8SkiJlF7=l9qPbYGes^8<}`|@tx#Ru>nzvbx?Tk0YfvX+zzi* zojY;ssG}4v4Nf7^Fw!lo`4y}5hm7PpumSm3qJR??4I zGcF$Q#Q}yaq;izH=<#853D4HQ5Wa2<)CiNp~TbB60rC&i~ z|MZc6NybjLEIJgyE!L;$sGk)NEGcR=^;hdx_M01P!z%kPc{0uNx{2@nE05O&=@}x& z{%u01NpQSkM2Sc%4WO4~DY)owk_osXLFP%Ly+!?qQ{;EY$me~#A?bB5XK6=1=h3$% zh%iJrR8Y5(Qdw&V@Rvs65zVyl1~PL-;tgf_J16GTnOP2sxOfMhJvaIT8_<7b(TFVTncw|D z%&QlxCb)~F2N~;?LSpk}1T_y}9ZEl7V=s+5)$u2X(w7pXiVKP|1ok>jHY7-qW1N|5bvtS8&LpWMpP7$(r&jTJYxQfd9Kwr z6p_@qa-dV6vSd0t6jlN*^Rj9k3qfl>iu>kdjeLIlyYTsAM=8AE+2y`uQr(GC-NzBo zN_Di+joCZ=Qvwb6+nx<%wrbIM8?E8Vyl=ZL3zTsQ{yz;Pe+yo{ocvnL+TP+>!GYbp`*Tk z`hYzD`*;4IGA~=E53BjVp3*uSuNR8~p6MSHnjFgFcKH{{mzCc*1V2IZ?~o|n5_#q# zpq^1$pweJeasQF(!+v39E`b68n4X?>cU0doA)xerQg>Eqf>WsmW&K-vF}Xos(veWL zklo$_oh4q?zXGk55fo0vgNF;AsOv9}C{+41H$yW`u{e!k9YM|{VWZB8zL)cm@YC|> zYU3D1b0QdcH`tBLd_~lpr3HoN__6tm_%q>dqnBIwWDAcMZFfNdP4F2*kj)kpc7^3+ zl{odKutA*;`!ddus0U!nX+E*uCcEW_!ywjq&R1;n05?5F zYB0yn95Ta2$M0&DEn&V+Z$SEi9})QT+|~>I-s--DfU;g_sMp+F8%@_xAezCI*kuV& z5P*W5LYNiXpZNN>(ss+ z;r;!J#;`6wa|fXN$*QD1lxU3UKCcvz1Q(%VcLT9TlTHIAfRqk0;*nCpO|uS0?l^6- z2ghRoCJxe+v_!)0eEOvezNNwLW2WX(120xXtN;ZuG6fpZt?k|E-)EqgBaruvO^}5Q?VqXAmY%Q&37ulO^<5>Q;LSP6ew5iWL6n zF8=x}6(9dujd4r#W8+V^^D-mJ+jVn-YPNw180q4hJCM2=LCRieiUoLy7rHz)-0;b( z*-*h1kOdku5hP>;%H_Nu!{4Q{G#**U+0Ij?oW+uElrI?CziBht^i4VQ4{qb=I)uA# zv(&Q-_gB7LC|qHhr*869dhU9Ppf|s5!-FfObW3C+g;|{hnPj z#V-7c;}QfVk}cvO%k)Cfl`rpk80J@9eugYSbaS^=NxziiX%Q;idtX8l3WHBK3bG4A irzHMRY4B`ocf=mGBKX1U%iv9+^Cs5D73VJh^uGXo8srH8 literal 0 HcmV?d00001 diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 584e00cae..0f82f0dff 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -194,3 +194,50 @@ urlpatterns = [ ``` This makes our view accessible at the URL `/plugins/animal-sounds/random-sound/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. + +## REST API Endpoints + +Plugins can declare custom endpoints on NetBox's REST API. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple. + +First, we'll create a serializer for our `Animal` model, in `api/serializers.py`: + +```python +from rest_framework.serializers import ModelSerializer +from netbox_animal_sounds.models import Animal + +class AnimalSerializer(ModelSerializer): + + class Meta: + model = Animal + fields = ('id', 'name', 'sound') +``` + +Next, we'll create a generic API viewset that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`: + +```python +from rest_framework.viewsets import ModelViewSet +from netbox_animal_sounds.models import Animal +from .serializers import AnimalSerializer + +class AnimalViewSet(ModelViewSet): + queryset = Animal.objects.all() + serializer_class = AnimalSerializer +``` + +Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`. + +```python +from rest_framework import routers +from .views import AnimalViewSet + +router = routers.DefaultRouter() +router.register('animals', AnimalViewSet) +urlpatterns = router.urls +``` + +With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined. + +![NetBox REST API plugin endpoint](../media/plugins/plugin_rest_api_endpoint.png) + +!!! note + This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have. From 5ec1b31804084f42af921b0f158d5ee400aaa958 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Mar 2020 09:41:46 -0400 Subject: [PATCH 5/8] Add disclaimer/warning to PLUGINS_ENABLED --- docs/configuration/optional-settings.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index a678ac26d..e76dae231 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -315,6 +315,9 @@ Default: `False` Enable [NetBox plugins](../../plugins/). +!!! warning + Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled. + --- ## PREFER_IPV4 From eedda6e648c99c7a3d2e5f0ba15fdaddec70a2de Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Mar 2020 09:42:24 -0400 Subject: [PATCH 6/8] Incorporate John's feedback --- docs/plugins/development.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 0f82f0dff..63f1b5de6 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -2,6 +2,15 @@ This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. +Plugins can do a lot, including: + +* Create Django models to store data in the database +* Provide their own "pages" (views) in the web user interface +* Establish their own REST API endpoints +* Add custom request/response middleware + +However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint, there's no need to define a new model. + ## Initial Setup ## Plugin Structure @@ -84,6 +93,7 @@ class AnimalSoundsConfig(PluginConfig): * `name` - Raw plugin name; same as the plugin's source directory * `author_name` - Name of plugin's author +* `author_email` - Author's public email address * `verbose_name` - Human-friendly name * `version` - Plugin version * `description` - Brief description of the plugin's purpose @@ -95,9 +105,17 @@ class AnimalSoundsConfig(PluginConfig): * `middleware`: A list of middleware classes to append after NetBox's build-in middleware. * `caching_config`: Plugin-specific cache configuration +### Install the Plugin for Development + +To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' "develop" mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): + +```no-highlight +$ python setup.py develop +``` + ## Database Models -Plugins can define their own Django models to record user data. A model is a Python representation of a database table. Model instances can be created, manipulated, and deleted using the [Django ORM](https://docs.djangoproject.com/en/stable/topics/db/). Models are typically defined within a plugin's `models.py` file, though this is not a strict requirement. +If your plugin introduces a new type of object in NetBox, you'll probably want to create a Django model for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using the [Django ORM](https://docs.djangoproject.com/en/stable/topics/db/). Models are typically defined within a plugin's `models.py` file, though this is not a strict requirement. Below is a simple example `models.py` file showing a model with two character fields: @@ -112,7 +130,10 @@ class Animal(models.Model): return self.name ``` -Once you have defined the model(s) for your plugin, you'll need to create the necessary database schema migrations as well. This can be done using the Django `makemigrations` management command: +Once you have defined the model(s) for your plugin, you'll need to create the necessary database schema migrations as well. This can be done using the Django `makemigrations` management command. + +!!! note + A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory. ```no-highlight $ ./manage.py makemigrations netbox_animal_sounds @@ -121,7 +142,7 @@ Migrations for 'netbox_animal_sounds': - Create model Animal ``` -Once the migration has been created, we can apply it locally with the `migrate` command: +Once you're satisfied the migration is ready and all model changes have been accounted for, we can apply it locally with the `migrate` command: ```no-highlight $ ./manage.py migrate netbox_animal_sounds @@ -152,7 +173,7 @@ This will display the plugin and its model in the admin UI. Staff users can crea ## Views -A view is a particular page tied to a URL within NetBox. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`: +If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`: ```python from django.shortcuts import render @@ -197,7 +218,7 @@ This makes our view accessible at the URL `/plugins/animal-sounds/random-sound/` ## REST API Endpoints -Plugins can declare custom endpoints on NetBox's REST API. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple. +Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple. First, we'll create a serializer for our `Animal` model, in `api/serializers.py`: From 745ac294a5fa2dbb0530faec2b1d08817e22290b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Mar 2020 14:21:08 -0400 Subject: [PATCH 7/8] Tweak plugin template docs --- docs/plugins/development.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 63f1b5de6..9c30fe0b7 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -21,7 +21,8 @@ Although the specific structure of a plugin is largely left to the discretion of plugin_name/ - plugin_name/ - templates/ - - *.html + - plugin_name/ + - *.html - __init__.py - middleware.py - navigation.py @@ -185,18 +186,22 @@ class RandomAnimalSoundView(View): def get(self, request): animal = Animal.objects.order_by('?').first() - return render(request, 'animal_sound.html', { + return render(request, 'netbox_animal_sounds/animal_sound.html', { 'animal': animal, }) ``` -This view retrieves a random animal from the database and and passes it as a context variable when rendering ta template named `animal_sound.html`. To create this template, create a `templates/` directory within the plugin source directory and save the following: +This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal_sound.html`. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin root directory. We use the plugin's name as a subdirectory to guard against naming collisions with other plugins. Then, create `animal_sound.html`: ```jinja2 {% extends '_base.html' %} {% block content %} -The {{ animal.name }} says {{ animal.sound }} +{% if animal %} + The {{ animal.name }} says {{ animal.sound }} +{% else %} + No animals have been created yet! +{% endif %} {% endblock %} ``` From 579c365808e27f114910ec50c09a0eb14a2bddf3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 24 Mar 2020 15:22:57 -0400 Subject: [PATCH 8/8] Extend plugins docs to include nav menu links --- docs/plugins/development.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 9c30fe0b7..52f9e106e 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -6,6 +6,7 @@ Plugins can do a lot, including: * Create Django models to store data in the database * Provide their own "pages" (views) in the web user interface +* Inject template content and navigation links * Establish their own REST API endpoints * Add custom request/response middleware @@ -215,7 +216,7 @@ from django.urls import path from .views import RandomAnimalSoundView urlpatterns = [ - path('random-sound/', RandomAnimalSoundView.as_view()) + path('random-sound/', RandomAnimalSoundView.as_view(), name='random_sound') ] ``` @@ -267,3 +268,27 @@ With these three components in place, we can request `/api/plugins/animal-sounds !!! note This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have. + +## Navigation Menu Items + +To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu. This is done by instantiating NetBox's `PluginNavMenuLink` class. Each instance of this class appears in the navigation menu under the header for its plugin. We'll create a link in the file `navigation.py`: + +```python +from extras.plugins import PluginNavMenuLink + +class RandomSoundLink(PluginNavMenuLink): + link = 'plugins:netbox_animal_sounds:random_sound' + link_text = 'Random sound' +``` + +Once we have our menu item defined, we need to register it in `signals.py`: + +```python +from django.dispatch import receiver +from extras.plugins.signals import register_nav_menu_link_classes +from .navigation import RandomSoundLink + +@receiver(register_nav_menu_link_classes) +def nav_menu_link_classes(**kwargs): + return [RandomSoundLink] +```