10520 merge feature

This commit is contained in:
Arthur 2023-02-16 08:15:43 -08:00
commit 5982da4a86
145 changed files with 3073 additions and 885 deletions

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.4.3
placeholder: v3.4.4
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.4.3
placeholder: v3.4.4
validations:
required: true
- type: dropdown

119
README.md
View File

@ -1,71 +1,59 @@
<div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
The premiere source of truth powering network automation
</div>
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is the leading solution for modeling and documenting modern networks. By
combining the traditional disciplines of IP address management (IPAM) and
datacenter infrastructure management (DCIM) with powerful APIs and extensions,
NetBox provides the ideal "source of truth" to power network automation.
Available as open source software under the Apache 2.0 license, NetBox is
employed by thousands of organizations around the world.
Available as open source software under the Apache 2.0 license, NetBox serves
as the cornerstone for network automation in thousands of organizations.
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
[![Timeline graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg)](https://github.com/netbox-community/netbox/commits)
[![Issue status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg)](https://github.com/netbox-community/netbox/issues)
[![Pull request status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg)](https://github.com/netbox-community/netbox/pulls)
[![Top contributors](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg)](https://github.com/netbox-community/netbox/graphs/contributors)
<br />Stats via [Repography](https://repography.com)
## About NetBox
* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
* **Organization:** Manage tenant and contact assignments natively.
* **Powerful search:** Easily find anything you need using a single global search function.
* **Comprehensive logging:** Leverage both automatic change logging and user-submitted journal entries to track your network's growth over time.
* **Endless customization:** Custom fields, custom links, tags, export templates, custom validation, reports, scripts, and more!
* **Flexible permissions:** An advanced permissions systems enables very flexible delegation of permissions.
* **Integrations:** Easily connect NetBox to your other tooling via its REST & GraphQL APIs.
* **Plugins:** Not finding what you need in the core application? Try one of many community plugins - or build your own!
![Screenshot of NetBox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
Myriad infrastructure components can be modeled in NetBox, including:
## Getting Started
* Hierarchical regions, site groups, sites, and locations
* Racks, devices, and device components
* Cables and wireless connections
* Power distribution
* Data circuits and providers
* Virtual machines and clusters
* IP prefixes, ranges, and addresses
* VRFs and route targets
* L2VPN and overlays
* FHRP groups (VRRP, HSRP, etc.)
* AS numbers
* VLANs and scoped VLAN groups
* Organizational tenants and contacts
* Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now!
* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
* Choose your deployment: [self-hosted](https://github.com/netbox-community/netbox), [Docker](https://github.com/netbox-community/netbox-docker), or [NetBox Cloud](https://netboxlabs.com/netbox-cloud/).
* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
In addition to its extensive built-in models and functionality, NetBox can be
customized and extended through the use of:
## Get Involved
* Custom fields
* Custom links
* Configuration contexts
* Custom model validation rules
* Reports
* Custom scripts
* Export templates
* Conditional webhooks
* Plugins
* Single sign-on (SSO) authentication
* NAPALM integration
* Detailed change logging
* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!
* Join the conversation on [the discussion forum](https://github.com/netbox-community/netbox/discussions) and [Slack](https://netdev.chat/)!
* Already a power user? You can [suggest a feature](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+feature&template=feature_request.yaml) or [report a bug](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+bug&template=bug_report.yaml) on GitHub.
* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
NetBox also features a complete REST API as well as a GraphQL API for easily
integrating with other tools and systems.
The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/).
A public demo instance is available at [demo.netbox.dev](https://demo.netbox.dev).
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available
[on GitHub](https://github.com/netbox-community/netbox).
## Project Stats
<div align="center">
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a>
</div>
## Sponsors
<div align="center">
<h3>Thank you to our sponsors!</h3>
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
@ -76,34 +64,10 @@ complete list of requirements, see `requirements.txt`. The code is available
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech)
</div>
### Discussion
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
### Installation
Please see [the documentation](https://docs.netbox.dev/) for
instructions on installing NetBox. To upgrade NetBox, please download the
[latest release](https://github.com/netbox-community/netbox/releases) and
run `upgrade.sh`.
### Providing Feedback
The best platform for general feedback, assistance, and other discussion is our
[GitHub discussions](https://github.com/netbox-community/netbox/discussions).
To report a bug or request a specific feature, please open a GitHub issue using
the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).
If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
### Screenshots
## Screenshots
![Screenshot of main page (dark mode)](docs/media/screenshots/home-dark.png "Main page (dark mode)")
@ -112,8 +76,3 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
![Screenshot of prefixes hierarchy](docs/media/screenshots/prefixes-list.png "Prefixes hierarchy")
![Screenshot of cable trace](docs/media/screenshots/cable-trace.png "Cable tracing")
### Related projects
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
for a list of relevant community projects.

View File

@ -1,6 +1,6 @@
# HTML sanitizer
# https://github.com/mozilla/bleach
bleach
bleach<6.0
# The Python web framework on which NetBox is built
# https://github.com/django/django

View File

@ -140,6 +140,19 @@ obj.full_clean()
obj.save()
```
## Error handling
Sometimes things go wrong and a script will run into an `Exception`. If that happens and an uncaught exception is raised by the custom script, the execution is aborted and a full stack trace is reported.
Although this is helpful for debugging, in some situations it might be required to cleanly abort the execution of a custom script (e.g. because of invalid input data) and thereby make sure no changes are performed on the database. In this case the script can throw an `AbortScript` exception, which will prevent the stack trace from being reported, but still terminating the script's execution and reporting a given error message.
```python
from utilities.exceptions import AbortScript
if some_error:
raise AbortScript("Some meaningful error message")
```
## Variable Reference
### Default Options

View File

@ -52,4 +52,4 @@ NetBox is built on the enormously popular [Django](http://www.djangoproject.com/
* Try out our [public demo](https://demo.netbox.dev/) if you want to jump right in
* The [installation guide](./installation/index.md) will help you get your own deployment up and running
* Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch approach
* [NetBox Cloud](https://www.getnetbox.io/) is a hosted solution offered by NS1
* [NetBox Cloud](https://netboxlabs.com/netbox-cloud) is a managed solution offered by [NetBox Labs](https://netboxlabs.com/)

View File

@ -0,0 +1,25 @@
# Data Files
A data file object is the representation in NetBox's database of some file belonging to a remote [data source](./datasource.md). Data files are synchronized automatically, and cannot be modified locally (although they can be deleted).
## Fields
### Source
The [data source](./datasource.md) to which this file belongs.
### Path
The path to the file, relative to its source's URL. For example, a file at `/opt/config-data/routing/bgp/peer.yaml` with a source URL of `file:///opt/config-data/` would have its path set to `routing/bgp/peer.yaml`.
### Last Updated
The date and time at which the file most recently updated from its source. Note that this attribute is updated only when the file's contents have been modified. Re-synchronizing the data source will not update this timestamp if the upstream file's data has not changed.
### Size
The file's size, in bytes.
### Hash
A [SHA256 hash](https://en.wikipedia.org/wiki/SHA-2) of the file's data. This can be compared to a hash taken from the original file to determine whether any changes have been made.

View File

@ -0,0 +1,47 @@
# Data Sources
A data source represents some external repository of data which NetBox can consume, such as a git repository. Files within the data source are synchronized to NetBox by saving them in the database as [data file](./datafile.md) objects.
## Fields
### Name
The data source's human-friendly name.
### Type
The type of data source. Supported options include:
* Local directory
* git repository
### URL
The URL identifying the remote source. Some examples are included below.
| Type | Example URL |
|------|-------------|
| Local | file:///var/my/data/source/ |
| git | https://https://github.com/my-organization/my-repo |
### Status
The source's current synchronization status. Note that this cannot be set manually: It is updated automatically when the source is synchronized.
### Enabled
If false, synchronization will be disabled.
### Ignore Rules
A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
| Rule | Description |
|----------------|------------------------------------------|
| `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
### Last Synced
The date and time at which the source was most recently synchronized successfully.

View File

@ -18,6 +18,10 @@ A numeric value which influences the order in which context data is merged. Cont
The context data expressed in JSON format.
### Data File
Config context data may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify local data for the config context: It will be populated automatically from the data file.
### Is Active
If not selected, this config context will be excluded from rendering. This can be convenient to temporarily disable a config context.

View File

@ -12,6 +12,10 @@ The name of the export template. This will appear in the "export" dropdown list
The type of NetBox object to which the export template applies.
### Data File
Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify local content for the template: It will be populated automatically from the data file.
### Template Code
Jinja2 template code for rendering the exported data.

View File

@ -170,6 +170,9 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
## Choice Fields
!!! warning "Obsolete Fields"
NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6.
::: utilities.forms.ChoiceField
options:
members: false

View File

@ -48,7 +48,7 @@ menu_items = (item1, item2, item3)
Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
```python filename="navigation.py"
```python title="navigation.py"
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices

View File

@ -1,19 +1,31 @@
# NetBox v3.4
## v3.4.4 (FUTURE)
## v3.4.5 (FUTURE)
---
## v3.4.4 (2023-02-02)
### Enhancements
* [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice
* [#11152](https://github.com/netbox-community/netbox/issues/11152) - Introduce AbortScript exception to elegantly abort scripts
* [#11554](https://github.com/netbox-community/netbox/issues/11554) - Add module types count to manufacturers list
* [#11585](https://github.com/netbox-community/netbox/issues/11585) - Add IP address filters for services
* [#11598](https://github.com/netbox-community/netbox/issues/11598) - Add buttons to easily switch between rack list and elevations views
### Bug Fixes
* [#11267](https://github.com/netbox-community/netbox/issues/11267) - Avoid catching ImportErrors when loading plugin resources
* [#11487](https://github.com/netbox-community/netbox/issues/11487) - Remove "set null" option from non-writable custom fields during bulk edit
* [#11491](https://github.com/netbox-community/netbox/issues/11491) - Show edit/delete buttons in user tokens table
* [#11528](https://github.com/netbox-community/netbox/issues/11528) - Permit import of devices using uploaded file
* [#11555](https://github.com/netbox-community/netbox/issues/11555) - Avoid inadvertent interpretation of search query as regular expression under global search (previously [#11516](https://github.com/netbox-community/netbox/issues/11516))
* [#11562](https://github.com/netbox-community/netbox/issues/11562) - Correct ordering of virtual chassis interfaces with duplicate names
* [#11574](https://github.com/netbox-community/netbox/issues/11574) - Fix exception when attempting to schedule reports/scripts
* [#11620](https://github.com/netbox-community/netbox/issues/11620) - Correct available filter choices for interface PoE type
* [#11635](https://github.com/netbox-community/netbox/issues/11635) - Pre-populate assigned VRF when following "first available IP" link from prefix view
* [#11650](https://github.com/netbox-community/netbox/issues/11650) - Display error message when attempting to create device component with duplicate name
---

View File

@ -4,11 +4,13 @@
### Enhancements
* [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources
* [#11254](https://github.com/netbox-community/netbox/issues/11254) - Introduce the `X-Request-ID` HTTP header to annotate the unique ID of each request for change logging
* [#11440](https://github.com/netbox-community/netbox/issues/11440) - Add an `enabled` field for device type interfaces
* [#11517](https://github.com/netbox-community/netbox/issues/11517) - Standardize the inclusion of related objects across the entire UI
* [#11584](https://github.com/netbox-community/netbox/issues/11584) - Add a list view for contact assignments
* [#11625](https://github.com/netbox-community/netbox/issues/11625) - Add HTMX support to ObjectEditView
* [#11693](https://github.com/netbox-community/netbox/issues/11693) - Enable syncing export template content from remote sources
### Other Changes

View File

@ -7,8 +7,7 @@ from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
StaticSelect,
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
)
__all__ = (
@ -35,7 +34,7 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label=_('Comments')
)
@ -63,7 +62,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label=_('Comments')
)
@ -101,8 +100,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
choices=add_blank_choice(CircuitStatusChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@ -125,7 +123,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label=_('Comments')
)

View File

@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField
from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, TagFilterField
__all__ = (
'CircuitFilterForm',
@ -107,7 +107,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
},
label=_('Provider network')
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=CircuitStatusChoices,
required=False
)

View File

@ -7,7 +7,6 @@ from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import (
CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField,
StaticSelect,
)
__all__ = (
@ -102,7 +101,6 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
'commit_rate': _("Committed rate"),
}
widgets = {
'status': StaticSelect(),
'install_date': DatePicker(),
'termination_date': DatePicker(),
'commit_rate': SelectSpeedWidget(),
@ -174,7 +172,6 @@ class CircuitTerminationForm(NetBoxModelForm):
'pp_info': _("Patch panel ID and port number(s)")
}
widgets = {
'term_side': StaticSelect(),
'port_speed': SelectSpeedWidget(),
'upstream_speed': SelectSpeedWidget(),
}

View File

@ -1,4 +1,3 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
@ -10,7 +9,6 @@ from dcim.models import CabledObjectModel
from netbox.models import (
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin,
)
from netbox.models.features import WebhooksMixin
__all__ = (
'Circuit',
@ -132,7 +130,6 @@ class CircuitTermination(
CustomFieldsMixin,
CustomLinksMixin,
TagsMixin,
WebhooksMixin,
ChangeLoggedModel,
CabledObjectModel
):

0
netbox/core/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,25 @@
from rest_framework import serializers
from core.models import *
from netbox.api.serializers import WritableNestedSerializer
__all__ = [
'NestedDataFileSerializer',
'NestedDataSourceSerializer',
]
class NestedDataSourceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:datasource-detail')
class Meta:
model = DataSource
fields = ['id', 'url', 'display', 'name']
class NestedDataFileSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='core-api:datafile-detail')
class Meta:
model = DataFile
fields = ['id', 'url', 'display', 'path']

View File

@ -0,0 +1,51 @@
from rest_framework import serializers
from core.choices import *
from core.models import *
from netbox.api.fields import ChoiceField
from netbox.api.serializers import NetBoxModelSerializer
from .nested_serializers import *
__all__ = (
'DataSourceSerializer',
)
class DataSourceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='core-api:datasource-detail'
)
type = ChoiceField(
choices=DataSourceTypeChoices
)
status = ChoiceField(
choices=DataSourceStatusChoices,
read_only=True
)
# Related object counts
file_count = serializers.IntegerField(
read_only=True
)
class Meta:
model = DataSource
fields = [
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
'parameters', 'ignore_rules', 'created', 'last_updated', 'file_count',
]
class DataFileSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='core-api:datafile-detail'
)
source = NestedDataSourceSerializer(
read_only=True
)
class Meta:
model = DataFile
fields = [
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
]

13
netbox/core/api/urls.py Normal file
View File

@ -0,0 +1,13 @@
from netbox.api.routers import NetBoxRouter
from . import views
router = NetBoxRouter()
router.APIRootView = views.CoreRootView
# Data sources
router.register('data-sources', views.DataSourceViewSet)
router.register('data-files', views.DataFileViewSet)
app_name = 'core-api'
urlpatterns = router.urls

52
netbox/core/api/views.py Normal file
View File

@ -0,0 +1,52 @@
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from core import filtersets
from core.models import *
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
from utilities.utils import count_related
from . import serializers
class CoreRootView(APIRootView):
"""
Core API root view
"""
def get_view_name(self):
return 'Core'
#
# Data sources
#
class DataSourceViewSet(NetBoxModelViewSet):
queryset = DataSource.objects.annotate(
file_count=count_related(DataFile, 'source')
)
serializer_class = serializers.DataSourceSerializer
filterset_class = filtersets.DataSourceFilterSet
@action(detail=True, methods=['post'])
def sync(self, request, pk):
"""
Enqueue a job to synchronize the DataSource.
"""
if not request.user.has_perm('extras.sync_datasource'):
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
datasource = get_object_or_404(DataSource, pk=pk)
datasource.enqueue_sync_job(request)
serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
return Response(serializer.data)
class DataFileViewSet(NetBoxReadOnlyModelViewSet):
queryset = DataFile.objects.defer('data').prefetch_related('source')
serializer_class = serializers.DataFileSerializer
filterset_class = filtersets.DataFileFilterSet

8
netbox/core/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "core"
def ready(self):
from . import data_backends, search

34
netbox/core/choices.py Normal file
View File

@ -0,0 +1,34 @@
from django.utils.translation import gettext as _
from utilities.choices import ChoiceSet
#
# Data sources
#
class DataSourceTypeChoices(ChoiceSet):
LOCAL = 'local'
GIT = 'git'
CHOICES = (
(LOCAL, _('Local'), 'gray'),
(GIT, _('Git'), 'blue'),
)
class DataSourceStatusChoices(ChoiceSet):
NEW = 'new'
QUEUED = 'queued'
SYNCING = 'syncing'
COMPLETED = 'completed'
FAILED = 'failed'
CHOICES = (
(NEW, _('New'), 'blue'),
(QUEUED, _('Queued'), 'orange'),
(SYNCING, _('Syncing'), 'cyan'),
(COMPLETED, _('Completed'), 'green'),
(FAILED, _('Failed'), 'red'),
)

View File

@ -0,0 +1,117 @@
import logging
import subprocess
import tempfile
from contextlib import contextmanager
from urllib.parse import quote, urlunparse, urlparse
from django import forms
from django.conf import settings
from django.utils.translation import gettext as _
from netbox.registry import registry
from .choices import DataSourceTypeChoices
from .exceptions import SyncError
__all__ = (
'LocalBackend',
'GitBackend',
)
logger = logging.getLogger('netbox.data_backends')
def register_backend(name):
"""
Decorator for registering a DataBackend class.
"""
def _wrapper(cls):
registry['data_backends'][name] = cls
return cls
return _wrapper
class DataBackend:
parameters = {}
def __init__(self, url, **kwargs):
self.url = url
self.params = kwargs
@property
def url_scheme(self):
return urlparse(self.url).scheme.lower()
@contextmanager
def fetch(self):
raise NotImplemented()
@register_backend(DataSourceTypeChoices.LOCAL)
class LocalBackend(DataBackend):
@contextmanager
def fetch(self):
logger.debug(f"Data source type is local; skipping fetch")
local_path = urlparse(self.url).path # Strip file:// scheme
yield local_path
@register_backend(DataSourceTypeChoices.GIT)
class GitBackend(DataBackend):
parameters = {
'username': forms.CharField(
required=False,
label=_('Username'),
widget=forms.TextInput(attrs={'class': 'form-control'})
),
'password': forms.CharField(
required=False,
label=_('Password'),
widget=forms.TextInput(attrs={'class': 'form-control'})
),
'branch': forms.CharField(
required=False,
label=_('Branch'),
widget=forms.TextInput(attrs={'class': 'form-control'})
)
}
@contextmanager
def fetch(self):
local_path = tempfile.TemporaryDirectory()
# Add authentication credentials to URL (if specified)
username = self.params.get('username')
password = self.params.get('password')
if username and password:
url_components = list(urlparse(self.url))
# Prepend username & password to netloc
url_components[1] = quote(f'{username}@{password}:') + url_components[1]
url = urlunparse(url_components)
else:
url = self.url
# Compile git arguments
args = ['git', 'clone', '--depth', '1']
if branch := self.params.get('branch'):
args.extend(['--branch', branch])
args.extend([url, local_path.name])
# Prep environment variables
env_vars = {}
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
env_vars['http_proxy'] = settings.HTTP_PROXIES.get(self.url_scheme)
logger.debug(f"Cloning git repo: {' '.join(args)}")
try:
subprocess.run(args, check=True, capture_output=True, env=env_vars)
except subprocess.CalledProcessError as e:
raise SyncError(
f"Fetching remote data failed: {e.stderr}"
)
yield local_path.name
local_path.cleanup()

View File

@ -0,0 +1,2 @@
class SyncError(Exception):
pass

64
netbox/core/filtersets.py Normal file
View File

@ -0,0 +1,64 @@
from django.db.models import Q
from django.utils.translation import gettext as _
import django_filters
from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from .choices import *
from .models import *
__all__ = (
'DataFileFilterSet',
'DataSourceFilterSet',
)
class DataSourceFilterSet(NetBoxModelFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=DataSourceTypeChoices,
null_value=None
)
status = django_filters.MultipleChoiceFilter(
choices=DataSourceStatusChoices,
null_value=None
)
class Meta:
model = DataSource
fields = ('id', 'name', 'enabled')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
class DataFileFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
method='search'
)
source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data source (ID)'),
)
source = django_filters.ModelMultipleChoiceFilter(
field_name='source__name',
queryset=DataSource.objects.all(),
to_field_name='name',
label=_('Data source (name)'),
)
class Meta:
model = DataFile
fields = ('id', 'path', 'last_updated', 'size', 'hash')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(path__icontains=value)
)

View File

@ -0,0 +1,4 @@
from .bulk_edit import *
from .bulk_import import *
from .filtersets import *
from .model_forms import *

View File

@ -0,0 +1,47 @@
from django import forms
from django.utils.translation import gettext as _
from core.choices import DataSourceTypeChoices
from core.models import *
from netbox.forms import NetBoxModelBulkEditForm
from utilities.forms import add_blank_choice, BulkEditNullBooleanSelect, CommentField
__all__ = (
'DataSourceBulkEditForm',
)
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
type = forms.ChoiceField(
choices=add_blank_choice(DataSourceTypeChoices),
required=False,
initial=''
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
label=_('Enforce unique space')
)
description = forms.CharField(
max_length=200,
required=False
)
comments = CommentField(
widget=forms.Textarea,
label=_('Comments')
)
parameters = forms.JSONField(
required=False
)
ignore_rules = forms.CharField(
required=False,
widget=forms.Textarea()
)
model = DataSource
fieldsets = (
(None, ('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules')),
)
nullable_fields = (
'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules',
)

View File

@ -0,0 +1,15 @@
from core.models import *
from netbox.forms import NetBoxModelImportForm
__all__ = (
'DataSourceImportForm',
)
class DataSourceImportForm(NetBoxModelImportForm):
class Meta:
model = DataSource
fields = (
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules',
)

View File

@ -0,0 +1,47 @@
from django import forms
from django.utils.translation import gettext as _
from core.choices import *
from core.models import *
from netbox.forms import NetBoxModelFilterSetForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, DynamicModelMultipleChoiceField
__all__ = (
'DataFileFilterForm',
'DataSourceFilterForm',
)
class DataSourceFilterForm(NetBoxModelFilterSetForm):
model = DataSource
fieldsets = (
(None, ('q', 'filter_id')),
('Data Source', ('type', 'status')),
)
type = forms.MultipleChoiceField(
choices=DataSourceTypeChoices,
required=False
)
status = forms.MultipleChoiceField(
choices=DataSourceStatusChoices,
required=False
)
enabled = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class DataFileFilterForm(NetBoxModelFilterSetForm):
model = DataFile
fieldsets = (
(None, ('q', 'filter_id')),
('File', ('source_id',)),
)
source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
required=False,
label=_('Data source')
)

View File

@ -0,0 +1,81 @@
import copy
from django import forms
from core.models import *
from netbox.forms import NetBoxModelForm
from netbox.registry import registry
from utilities.forms import CommentField
__all__ = (
'DataSourceForm',
)
class DataSourceForm(NetBoxModelForm):
comments = CommentField()
class Meta:
model = DataSource
fields = [
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
]
widgets = {
'type': forms.Select(
attrs={
'hx-get': '.',
'hx-include': '#form_fields input',
'hx-target': '#form_fields',
}
),
'ignore_rules': forms.Textarea(
attrs={
'rows': 5,
'class': 'font-monospace',
'placeholder': '.cache\n*.txt'
}
),
}
@property
def fieldsets(self):
fieldsets = [
('Source', ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')),
]
if self.backend_fields:
fieldsets.append(
('Backend', self.backend_fields)
)
return fieldsets
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
backend_classes = registry['data_backends']
if self.is_bound and self.data.get('type') in backend_classes:
type_ = self.data['type']
elif self.initial and self.initial.get('type') in backend_classes:
type_ = self.initial['type']
else:
type_ = self.fields['type'].initial
backend = backend_classes.get(type_)
self.backend_fields = []
for name, form_field in backend.parameters.items():
field_name = f'backend_{name}'
self.backend_fields.append(field_name)
self.fields[field_name] = copy.copy(form_field)
if self.instance and self.instance.parameters:
self.fields[field_name].initial = self.instance.parameters.get(name)
def save(self, *args, **kwargs):
parameters = {}
for name in self.fields:
if name.startswith('backend_'):
parameters[name[8:]] = self.cleaned_data[name]
self.instance.parameters = parameters
return super().save(*args, **kwargs)

View File

View File

@ -0,0 +1,12 @@
import graphene
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
class CoreQuery(graphene.ObjectType):
data_file = ObjectField(DataFileType)
data_file_list = ObjectListField(DataFileType)
data_source = ObjectField(DataSourceType)
data_source_list = ObjectListField(DataSourceType)

View File

@ -0,0 +1,21 @@
from core import filtersets, models
from netbox.graphql.types import BaseObjectType, NetBoxObjectType
__all__ = (
'DataFileType',
'DataSourceType',
)
class DataFileType(BaseObjectType):
class Meta:
model = models.DataFile
exclude = ('data',)
filterset_class = filtersets.DataFileFilterSet
class DataSourceType(NetBoxObjectType):
class Meta:
model = models.DataSource
fields = '__all__'
filterset_class = filtersets.DataSourceFilterSet

29
netbox/core/jobs.py Normal file
View File

@ -0,0 +1,29 @@
import logging
from extras.choices import JobResultStatusChoices
from netbox.search.backends import search_backend
from .choices import *
from .exceptions import SyncError
from .models import DataSource
logger = logging.getLogger(__name__)
def sync_datasource(job_result, *args, **kwargs):
"""
Call sync() on a DataSource.
"""
datasource = DataSource.objects.get(name=job_result.name)
try:
job_result.start()
datasource.sync()
# Update the search cache for DataFiles belonging to this source
search_backend.cache(datasource.datafiles.iterator())
except SyncError as e:
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.save()
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
logging.error(e)

View File

View File

@ -0,0 +1,41 @@
from django.core.management.base import BaseCommand, CommandError
from core.models import DataSource
class Command(BaseCommand):
help = "Synchronize a data source from its remote upstream"
def add_arguments(self, parser):
parser.add_argument('name', nargs='*', help="Data source(s) to synchronize")
parser.add_argument(
"--all", action='store_true', dest='sync_all',
help="Synchronize all data sources"
)
def handle(self, *args, **options):
# Find DataSources to sync
if options['sync_all']:
datasources = DataSource.objects.all()
elif options['name']:
datasources = DataSource.objects.filter(name__in=options['name'])
# Check for invalid names
found_names = {ds['name'] for ds in datasources.values('name')}
if invalid_names := set(options['name']) - found_names:
raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}")
else:
raise CommandError(f"Must specify at least one data source, or set --all.")
if len(options['name']) > 1:
self.stdout.write(f"Syncing {len(datasources)} data sources.")
for i, datasource in enumerate(datasources, start=1):
self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
self.stdout.flush()
datasource.sync()
self.stdout.write(datasource.get_status_display())
self.stdout.flush()
if len(options['name']) > 1:
self.stdout.write(f"Finished.")

View File

@ -0,0 +1,62 @@
# Generated by Django 4.1.5 on 2023-02-02 02:37
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import utilities.json
class Migration(migrations.Migration):
initial = True
dependencies = [
('extras', '0084_staging'),
]
operations = [
migrations.CreateModel(
name='DataSource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('name', models.CharField(max_length=100, unique=True)),
('type', models.CharField(default='local', max_length=50)),
('source_url', models.CharField(max_length=200)),
('status', models.CharField(default='new', editable=False, max_length=50)),
('enabled', models.BooleanField(default=True)),
('ignore_rules', models.TextField(blank=True)),
('parameters', models.JSONField(blank=True, null=True)),
('last_synced', models.DateTimeField(blank=True, editable=False, null=True)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'ordering': ('name',),
},
),
migrations.CreateModel(
name='DataFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('last_updated', models.DateTimeField(editable=False)),
('path', models.CharField(editable=False, max_length=1000)),
('size', models.PositiveIntegerField(editable=False)),
('hash', models.CharField(editable=False, max_length=64, validators=[django.core.validators.RegexValidator(message='Length must be 64 hexadecimal characters.', regex='^[0-9a-f]{64}$')])),
('data', models.BinaryField()),
('source', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='datafiles', to='core.datasource')),
],
options={
'ordering': ('source', 'path'),
},
),
migrations.AddConstraint(
model_name='datafile',
constraint=models.UniqueConstraint(fields=('source', 'path'), name='core_datafile_unique_source_path'),
),
]

View File

View File

@ -0,0 +1 @@
from .data import *

313
netbox/core/models/data.py Normal file
View File

@ -0,0 +1,313 @@
import logging
import os
import yaml
from fnmatch import fnmatchcase
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from extras.models import JobResult
from netbox.models import PrimaryModel
from netbox.models.features import ChangeLoggingMixin
from netbox.registry import registry
from utilities.files import sha256_hash
from utilities.querysets import RestrictedQuerySet
from ..choices import *
from ..exceptions import SyncError
from ..signals import post_sync, pre_sync
__all__ = (
'DataFile',
'DataSource',
)
logger = logging.getLogger('netbox.core.data')
class DataSource(PrimaryModel):
"""
A remote source, such as a git repository, from which DataFiles are synchronized.
"""
name = models.CharField(
max_length=100,
unique=True
)
type = models.CharField(
max_length=50,
choices=DataSourceTypeChoices,
default=DataSourceTypeChoices.LOCAL
)
source_url = models.CharField(
max_length=200,
verbose_name=_('URL')
)
status = models.CharField(
max_length=50,
choices=DataSourceStatusChoices,
default=DataSourceStatusChoices.NEW,
editable=False
)
enabled = models.BooleanField(
default=True
)
ignore_rules = models.TextField(
blank=True,
help_text=_("Patterns (one per line) matching files to ignore when syncing")
)
parameters = models.JSONField(
blank=True,
null=True
)
last_synced = models.DateTimeField(
blank=True,
null=True,
editable=False
)
class Meta:
ordering = ('name',)
def __str__(self):
return f'{self.name}'
def get_absolute_url(self):
return reverse('core:datasource', args=[self.pk])
@property
def docs_url(self):
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
def get_type_color(self):
return DataSourceTypeChoices.colors.get(self.type)
def get_status_color(self):
return DataSourceStatusChoices.colors.get(self.status)
@property
def url_scheme(self):
return urlparse(self.source_url).scheme.lower()
@property
def ready_for_sync(self):
return self.enabled and self.status not in (
DataSourceStatusChoices.QUEUED,
DataSourceStatusChoices.SYNCING
)
def clean(self):
# Ensure URL scheme matches selected type
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
raise ValidationError({
'url': f"URLs for local sources must start with file:// (or omit the scheme)"
})
def enqueue_sync_job(self, request):
"""
Enqueue a background job to synchronize the DataSource by calling sync().
"""
# Set the status to "syncing"
self.status = DataSourceStatusChoices.QUEUED
# Enqueue a sync job
job_result = JobResult.enqueue_job(
import_string('core.jobs.sync_datasource'),
name=self.name,
obj_type=ContentType.objects.get_for_model(DataSource),
user=request.user,
)
return job_result
def get_backend(self):
backend_cls = registry['data_backends'].get(self.type)
backend_params = self.parameters or {}
return backend_cls(self.source_url, **backend_params)
def sync(self):
"""
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
"""
if not self.ready_for_sync:
raise SyncError(f"Cannot initiate sync; data source not ready/enabled")
# Emit the pre_sync signal
pre_sync.send(sender=self.__class__, instance=self)
self.status = DataSourceStatusChoices.SYNCING
DataSource.objects.filter(pk=self.pk).update(status=self.status)
# Replicate source data locally
backend = self.get_backend()
with backend.fetch() as local_path:
logger.debug(f'Syncing files from source root {local_path}')
data_files = self.datafiles.all()
known_paths = {df.path for df in data_files}
logger.debug(f'Starting with {len(known_paths)} known files')
# Check for any updated/deleted files
updated_files = []
deleted_file_ids = []
for datafile in data_files:
try:
if datafile.refresh_from_disk(source_root=local_path):
updated_files.append(datafile)
except FileNotFoundError:
# File no longer exists
deleted_file_ids.append(datafile.pk)
continue
# Bulk update modified files
updated_count = DataFile.objects.bulk_update(updated_files, ('last_updated', 'size', 'hash', 'data'))
logger.debug(f"Updated {updated_count} files")
# Bulk delete deleted files
deleted_count, _ = DataFile.objects.filter(pk__in=deleted_file_ids).delete()
logger.debug(f"Deleted {updated_count} files")
# Walk the local replication to find new files
new_paths = self._walk(local_path) - known_paths
# Bulk create new files
new_datafiles = []
for path in new_paths:
datafile = DataFile(source=self, path=path)
datafile.refresh_from_disk(source_root=local_path)
datafile.full_clean()
new_datafiles.append(datafile)
created_count = len(DataFile.objects.bulk_create(new_datafiles, batch_size=100))
logger.debug(f"Created {created_count} data files")
# Update status & last_synced time
self.status = DataSourceStatusChoices.COMPLETED
self.last_synced = timezone.now()
DataSource.objects.filter(pk=self.pk).update(status=self.status, last_synced=self.last_synced)
# Emit the post_sync signal
post_sync.send(sender=self.__class__, instance=self)
def _walk(self, root):
"""
Return a set of all non-excluded files within the root path.
"""
logger.debug(f"Walking {root}...")
paths = set()
for path, dir_names, file_names in os.walk(root):
path = path.split(root)[1].lstrip('/') # Strip root path
if path.startswith('.'):
continue
for file_name in file_names:
if not self._ignore(file_name):
paths.add(os.path.join(path, file_name))
logger.debug(f"Found {len(paths)} files")
return paths
def _ignore(self, filename):
"""
Returns a boolean indicating whether the file should be ignored per the DataSource's configured
ignore rules.
"""
if filename.startswith('.'):
return True
for rule in self.ignore_rules.splitlines():
if fnmatchcase(filename, rule):
return True
return False
class DataFile(models.Model):
"""
The database representation of a remote file fetched from a remote DataSource. DataFile instances should be created,
updated, or deleted only by calling DataSource.sync().
"""
created = models.DateTimeField(
auto_now_add=True
)
last_updated = models.DateTimeField(
editable=False
)
source = models.ForeignKey(
to='core.DataSource',
on_delete=models.CASCADE,
related_name='datafiles',
editable=False
)
path = models.CharField(
max_length=1000,
editable=False,
help_text=_("File path relative to the data source's root")
)
size = models.PositiveIntegerField(
editable=False
)
hash = models.CharField(
max_length=64,
editable=False,
validators=[
RegexValidator(regex='^[0-9a-f]{64}$', message=_("Length must be 64 hexadecimal characters."))
],
help_text=_("SHA256 hash of the file data")
)
data = models.BinaryField()
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('source', 'path')
constraints = (
models.UniqueConstraint(
fields=('source', 'path'),
name='%(app_label)s_%(class)s_unique_source_path'
),
)
def __str__(self):
return self.path
def get_absolute_url(self):
return reverse('core:datafile', args=[self.pk])
@property
def data_as_string(self):
try:
return self.data.tobytes().decode('utf-8')
except UnicodeDecodeError:
return None
def get_data(self):
"""
Attempt to read the file data as JSON/YAML and return a native Python object.
"""
# TODO: Something more robust
return yaml.safe_load(self.data_as_string)
def refresh_from_disk(self, source_root):
"""
Update instance attributes from the file on disk. Returns True if any attribute
has changed.
"""
file_path = os.path.join(source_root, self.path)
file_hash = sha256_hash(file_path).hexdigest()
# Update instance file attributes & data
if is_modified := file_hash != self.hash:
self.last_updated = timezone.now()
self.size = os.path.getsize(file_path)
self.hash = file_hash
with open(file_path, 'rb') as f:
self.data = f.read()
return is_modified

21
netbox/core/search.py Normal file
View File

@ -0,0 +1,21 @@
from netbox.search import SearchIndex, register_search
from . import models
@register_search
class DataSourceIndex(SearchIndex):
model = models.DataSource
fields = (
('name', 100),
('source_url', 300),
('description', 500),
('comments', 5000),
)
@register_search
class DataFileIndex(SearchIndex):
model = models.DataFile
fields = (
('path', 200),
)

10
netbox/core/signals.py Normal file
View File

@ -0,0 +1,10 @@
import django.dispatch
__all__ = (
'post_sync',
'pre_sync',
)
# DataSource signals
pre_sync = django.dispatch.Signal()
post_sync = django.dispatch.Signal()

View File

@ -0,0 +1 @@
from .data import *

View File

@ -0,0 +1,52 @@
import django_tables2 as tables
from core.models import *
from netbox.tables import NetBoxTable, columns
__all__ = (
'DataFileTable',
'DataSourceTable',
)
class DataSourceTable(NetBoxTable):
name = tables.Column(
linkify=True
)
type = columns.ChoiceFieldColumn()
status = columns.ChoiceFieldColumn()
enabled = columns.BooleanColumn()
tags = columns.TagColumn(
url_name='core:datasource_list'
)
file_count = tables.Column(
verbose_name='Files'
)
class Meta(NetBoxTable.Meta):
model = DataSource
fields = (
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created',
'last_updated', 'file_count',
)
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
class DataFileTable(NetBoxTable):
source = tables.Column(
linkify=True
)
path = tables.Column(
linkify=True
)
last_updated = columns.DateTimeColumn()
actions = columns.ActionsColumn(
actions=('delete',)
)
class Meta(NetBoxTable.Meta):
model = DataFile
fields = (
'pk', 'id', 'source', 'path', 'last_updated', 'size', 'hash',
)
default_columns = ('pk', 'source', 'path', 'size', 'last_updated')

View File

View File

@ -0,0 +1,93 @@
from django.urls import reverse
from django.utils import timezone
from utilities.testing import APITestCase, APIViewTestCases
from ..choices import *
from ..models import *
class AppTest(APITestCase):
def test_root(self):
url = reverse('core-api:api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
self.assertEqual(response.status_code, 200)
class DataSourceTest(APIViewTestCases.APIViewTestCase):
model = DataSource
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'enabled': False,
'description': 'foo bar baz',
}
@classmethod
def setUpTestData(cls):
data_sources = (
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
)
DataSource.objects.bulk_create(data_sources)
cls.create_data = [
{
'name': 'Data Source 4',
'type': DataSourceTypeChoices.GIT,
'source_url': 'https://example.com/git/source4'
},
{
'name': 'Data Source 5',
'type': DataSourceTypeChoices.GIT,
'source_url': 'https://example.com/git/source5'
},
{
'name': 'Data Source 6',
'type': DataSourceTypeChoices.GIT,
'source_url': 'https://example.com/git/source6'
},
]
class DataFileTest(
APIViewTestCases.GetObjectViewTestCase,
APIViewTestCases.ListObjectsViewTestCase,
APIViewTestCases.GraphQLTestCase
):
model = DataFile
brief_fields = ['display', 'id', 'path', 'url']
@classmethod
def setUpTestData(cls):
datasource = DataSource.objects.create(
name='Data Source 1',
type=DataSourceTypeChoices.LOCAL,
source_url='file:///var/tmp/source1/'
)
data_files = (
DataFile(
source=datasource,
path='dir1/file1.txt',
last_updated=timezone.now(),
size=1000,
hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
),
DataFile(
source=datasource,
path='dir1/file2.txt',
last_updated=timezone.now(),
size=2000,
hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
),
DataFile(
source=datasource,
path='dir1/file3.txt',
last_updated=timezone.now(),
size=3000,
hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
),
)
DataFile.objects.bulk_create(data_files)

View File

@ -0,0 +1,120 @@
from datetime import datetime
from django.test import TestCase
from django.utils import timezone
from utilities.testing import ChangeLoggedFilterSetTests
from ..choices import *
from ..filtersets import *
from ..models import *
class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = DataSource.objects.all()
filterset = DataSourceFilterSet
@classmethod
def setUpTestData(cls):
data_sources = (
DataSource(
name='Data Source 1',
type=DataSourceTypeChoices.LOCAL,
source_url='file:///var/tmp/source1/',
status=DataSourceStatusChoices.NEW,
enabled=True
),
DataSource(
name='Data Source 2',
type=DataSourceTypeChoices.LOCAL,
source_url='file:///var/tmp/source2/',
status=DataSourceStatusChoices.SYNCING,
enabled=True
),
DataSource(
name='Data Source 3',
type=DataSourceTypeChoices.GIT,
source_url='https://example.com/git/source3',
status=DataSourceStatusChoices.COMPLETED,
enabled=False
),
)
DataSource.objects.bulk_create(data_sources)
def test_name(self):
params = {'name': ['Data Source 1', 'Data Source 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
params = {'type': [DataSourceTypeChoices.LOCAL]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_status(self):
params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = DataFile.objects.all()
filterset = DataFileFilterSet
@classmethod
def setUpTestData(cls):
data_sources = (
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
)
DataSource.objects.bulk_create(data_sources)
data_files = (
DataFile(
source=data_sources[0],
path='dir1/file1.txt',
last_updated=datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
size=1000,
hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
),
DataFile(
source=data_sources[1],
path='dir1/file2.txt',
last_updated=datetime(2023, 1, 2, 0, 0, 0, tzinfo=timezone.utc),
size=2000,
hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
),
DataFile(
source=data_sources[2],
path='dir1/file3.txt',
last_updated=datetime(2023, 1, 3, 0, 0, 0, tzinfo=timezone.utc),
size=3000,
hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
),
)
DataFile.objects.bulk_create(data_files)
def test_source(self):
sources = DataSource.objects.all()
params = {'source_id': [sources[0].pk, sources[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'source': [sources[0].name, sources[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_path(self):
params = {'path': ['dir1/file1.txt', 'dir1/file2.txt']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_size(self):
params = {'size': [1000, 2000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_hash(self):
params = {'hash': [
'442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1',
'a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2',
]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -0,0 +1,90 @@
from django.utils import timezone
from utilities.testing import ViewTestCases, create_tags
from ..choices import *
from ..models import *
class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = DataSource
@classmethod
def setUpTestData(cls):
data_sources = (
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
)
DataSource.objects.bulk_create(data_sources)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Data Source X',
'type': DataSourceTypeChoices.GIT,
'source_url': 'http:///exmaple/com/foo/bar/',
'description': 'Something',
'comments': 'Foo bar baz',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
f"name,type,source_url,enabled",
f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false",
)
cls.csv_update_data = (
"id,name,description",
f"{data_sources[0].pk},Data Source 7,New description7",
f"{data_sources[1].pk},Data Source 8,New description8",
f"{data_sources[2].pk},Data Source 9,New description9",
)
cls.bulk_edit_data = {
'enabled': False,
'description': 'New description',
}
class DataFileTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
):
model = DataFile
@classmethod
def setUpTestData(cls):
datasource = DataSource.objects.create(
name='Data Source 1',
type=DataSourceTypeChoices.LOCAL,
source_url='file:///var/tmp/source1/'
)
data_files = (
DataFile(
source=datasource,
path='dir1/file1.txt',
last_updated=timezone.now(),
size=1000,
hash='442da078f0111cbdf42f21903724f6597c692535f55bdfbbea758a1ae99ad9e1'
),
DataFile(
source=datasource,
path='dir1/file2.txt',
last_updated=timezone.now(),
size=2000,
hash='a78168c7c97115bafd96450ed03ea43acec495094c5caa28f0d02e20e3a76cc2'
),
DataFile(
source=datasource,
path='dir1/file3.txt',
last_updated=timezone.now(),
size=3000,
hash='12b8827a14c4d5a2f30b6c6e2b7983063988612391c6cbe8ee7493b59054827a'
),
)
DataFile.objects.bulk_create(data_files)

22
netbox/core/urls.py Normal file
View File

@ -0,0 +1,22 @@
from django.urls import include, path
from utilities.urls import get_model_urls
from . import views
app_name = 'core'
urlpatterns = (
# Data sources
path('data-sources/', views.DataSourceListView.as_view(), name='datasource_list'),
path('data-sources/add/', views.DataSourceEditView.as_view(), name='datasource_add'),
path('data-sources/import/', views.DataSourceBulkImportView.as_view(), name='datasource_import'),
path('data-sources/edit/', views.DataSourceBulkEditView.as_view(), name='datasource_bulk_edit'),
path('data-sources/delete/', views.DataSourceBulkDeleteView.as_view(), name='datasource_bulk_delete'),
path('data-sources/<int:pk>/', include(get_model_urls('core', 'datasource'))),
# Data files
path('data-files/', views.DataFileListView.as_view(), name='datafile_list'),
path('data-files/delete/', views.DataFileBulkDeleteView.as_view(), name='datafile_bulk_delete'),
path('data-files/<int:pk>/', include(get_model_urls('core', 'datafile'))),
)

118
netbox/core/views.py Normal file
View File

@ -0,0 +1,118 @@
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from utilities.utils import count_related
from utilities.views import register_model_view
from . import filtersets, forms, tables
from .models import *
#
# Data sources
#
class DataSourceListView(generic.ObjectListView):
queryset = DataSource.objects.annotate(
file_count=count_related(DataFile, 'source')
)
filterset = filtersets.DataSourceFilterSet
filterset_form = forms.DataSourceFilterForm
table = tables.DataSourceTable
@register_model_view(DataSource)
class DataSourceView(generic.ObjectView):
queryset = DataSource.objects.all()
def get_extra_context(self, request, instance):
related_models = (
(DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
)
return {
'related_models': related_models,
}
@register_model_view(DataSource, 'sync')
class DataSourceSyncView(BaseObjectView):
queryset = DataSource.objects.all()
def get_required_permission(self):
return 'core.sync_datasource'
def get(self, request, pk):
# Redirect GET requests to the object view
datasource = get_object_or_404(self.queryset, pk=pk)
return redirect(datasource.get_absolute_url())
def post(self, request, pk):
datasource = get_object_or_404(self.queryset, pk=pk)
job_result = datasource.enqueue_sync_job(request)
messages.success(request, f"Queued job #{job_result.pk} to sync {datasource}")
return redirect(datasource.get_absolute_url())
@register_model_view(DataSource, 'edit')
class DataSourceEditView(generic.ObjectEditView):
queryset = DataSource.objects.all()
form = forms.DataSourceForm
@register_model_view(DataSource, 'delete')
class DataSourceDeleteView(generic.ObjectDeleteView):
queryset = DataSource.objects.all()
class DataSourceBulkImportView(generic.BulkImportView):
queryset = DataSource.objects.all()
model_form = forms.DataSourceImportForm
table = tables.DataSourceTable
class DataSourceBulkEditView(generic.BulkEditView):
queryset = DataSource.objects.annotate(
count_files=count_related(DataFile, 'source')
)
filterset = filtersets.DataSourceFilterSet
table = tables.DataSourceTable
form = forms.DataSourceBulkEditForm
class DataSourceBulkDeleteView(generic.BulkDeleteView):
queryset = DataSource.objects.annotate(
count_files=count_related(DataFile, 'source')
)
filterset = filtersets.DataSourceFilterSet
table = tables.DataSourceTable
#
# Data files
#
class DataFileListView(generic.ObjectListView):
queryset = DataFile.objects.defer('data')
filterset = filtersets.DataFileFilterSet
filterset_form = forms.DataFileFilterForm
table = tables.DataFileTable
actions = ('bulk_delete',)
@register_model_view(DataFile)
class DataFileView(generic.ObjectView):
queryset = DataFile.objects.all()
@register_model_view(DataFile, 'delete')
class DataFileDeleteView(generic.ObjectDeleteView):
queryset = DataFile.objects.all()
class DataFileBulkDeleteView(generic.BulkDeleteView):
queryset = DataFile.objects.defer('data')
filterset = filtersets.DataFileFilterSet
table = tables.DataFileTable

View File

@ -309,6 +309,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
class DeviceTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer()
default_platform = NestedPlatformSerializer(required=False, allow_null=True)
u_height = serializers.DecimalField(
max_digits=4,
decimal_places=1,
@ -324,7 +325,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
class Meta:
model = DeviceType
fields = [
'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
]

View File

@ -436,6 +436,16 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
to_field_name='slug',
label=_('Manufacturer (slug)'),
)
default_platform_id = django_filters.ModelMultipleChoiceFilter(
queryset=Platform.objects.all(),
label=_('Default platform (ID)'),
)
default_platform = django_filters.ModelMultipleChoiceFilter(
field_name='default_platform__slug',
queryset=Platform.objects.all(),
to_field_name='slug',
label=_('Default platform (slug)'),
)
has_front_image = django_filters.BooleanFilter(
label=_('Has a front image'),
method='_has_front_image'

View File

@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget,
DynamicModelMultipleChoiceField, form_from_model, SelectSpeedWidget,
)
__all__ = (
@ -96,8 +96,7 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
choices=add_blank_choice(SiteStatusChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
@ -130,15 +129,14 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
)
time_zone = TimeZoneFormField(
choices=add_blank_choice(TimeZoneFormField().choices),
required=False,
widget=StaticSelect()
required=False
)
description = forms.CharField(
max_length=200,
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -166,8 +164,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
choices=add_blank_choice(LocationStatusChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@ -238,8 +235,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
choices=add_blank_choice(RackStatusChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
role = DynamicModelChoiceField(
queryset=RackRole.objects.all(),
@ -256,13 +252,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
)
type = forms.ChoiceField(
choices=add_blank_choice(RackTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
width = forms.ChoiceField(
choices=add_blank_choice(RackWidthChoices),
required=False,
widget=StaticSelect()
required=False
)
u_height = forms.IntegerField(
required=False,
@ -283,8 +277,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
)
outer_unit = forms.ChoiceField(
choices=add_blank_choice(RackDimensionUnitChoices),
required=False,
widget=StaticSelect()
required=False
)
mounting_depth = forms.IntegerField(
required=False,
@ -301,15 +294,14 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
description = forms.CharField(
max_length=200,
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -333,8 +325,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
queryset=User.objects.order_by(
'username'
),
required=False,
widget=StaticSelect()
required=False
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@ -345,7 +336,7 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -374,6 +365,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
queryset=Manufacturer.objects.all(),
required=False
)
default_platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
required=False
)
part_number = forms.CharField(
required=False
)
@ -388,8 +383,7 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
)
airflow = forms.ChoiceField(
choices=add_blank_choice(DeviceAirflowChoices),
required=False,
widget=StaticSelect()
required=False
)
weight = forms.DecimalField(
min_value=0,
@ -398,21 +392,20 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
description = forms.CharField(
max_length=200,
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
model = DeviceType
fieldsets = (
('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')),
('Device Type', ('manufacturer', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')),
('Weight', ('weight', 'weight_unit')),
)
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
@ -433,15 +426,14 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
description = forms.CharField(
max_length=200,
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -528,13 +520,11 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
)
status = forms.ChoiceField(
choices=add_blank_choice(DeviceStatusChoices),
required=False,
widget=StaticSelect()
required=False
)
airflow = forms.ChoiceField(
choices=add_blank_choice(DeviceAirflowChoices),
required=False,
widget=StaticSelect()
required=False
)
serial = forms.CharField(
max_length=50,
@ -546,7 +536,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -576,8 +566,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
choices=add_blank_choice(ModuleStatusChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
serial = forms.CharField(
max_length=50,
@ -589,7 +578,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -604,13 +593,11 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
type = forms.ChoiceField(
choices=add_blank_choice(CableTypeChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
status = forms.ChoiceField(
choices=add_blank_choice(LinkStatusChoices),
required=False,
widget=StaticSelect(),
initial=''
)
tenant = DynamicModelChoiceField(
@ -631,15 +618,14 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
length_unit = forms.ChoiceField(
choices=add_blank_choice(CableLengthUnitChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
description = forms.CharField(
max_length=200,
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -663,7 +649,7 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -709,7 +695,7 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -732,26 +718,22 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
status = forms.ChoiceField(
choices=add_blank_choice(PowerFeedStatusChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
type = forms.ChoiceField(
choices=add_blank_choice(PowerFeedTypeChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
supply = forms.ChoiceField(
choices=add_blank_choice(PowerFeedSupplyChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
phase = forms.ChoiceField(
choices=add_blank_choice(PowerFeedPhaseChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
voltage = forms.IntegerField(
required=False
@ -771,7 +753,7 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label=_('Comments')
)
@ -798,8 +780,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm):
)
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
nullable_fields = ('label', 'type', 'description')
@ -816,8 +797,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
)
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
description = forms.CharField(
required=False
@ -837,8 +817,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm):
)
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
maximum_draw = forms.IntegerField(
min_value=1,
@ -874,8 +853,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm):
)
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
@ -883,8 +861,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm):
)
feed_leg = forms.ChoiceField(
choices=add_blank_choice(PowerOutletFeedLegChoices),
required=False,
widget=StaticSelect()
required=False
)
description = forms.CharField(
required=False
@ -915,8 +892,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
)
type = forms.ChoiceField(
choices=add_blank_choice(InterfaceTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
enabled = forms.NullBooleanField(
required=False,
@ -934,14 +910,12 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
choices=add_blank_choice(InterfacePoEModeChoices),
required=False,
initial='',
widget=StaticSelect(),
label=_('PoE mode')
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect(),
label=_('PoE type')
)
@ -959,8 +933,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm):
)
type = forms.ChoiceField(
choices=add_blank_choice(PortTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
color = ColorField(
required=False
@ -983,8 +956,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm):
)
type = forms.ChoiceField(
choices=add_blank_choice(PortTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
color = ColorField(
required=False
@ -1199,14 +1171,12 @@ class InterfaceBulkEditForm(
choices=add_blank_choice(InterfacePoEModeChoices),
required=False,
initial='',
widget=StaticSelect(),
label=_('PoE mode')
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect(),
label=_('PoE type')
)
mark_connected = forms.NullBooleanField(
@ -1216,8 +1186,7 @@ class InterfaceBulkEditForm(
mode = forms.ChoiceField(
choices=add_blank_choice(InterfaceModeChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
@ -1417,8 +1386,7 @@ class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm):
)
status = forms.ChoiceField(
required=False,
choices=add_blank_choice(VirtualDeviceContextStatusChoices),
widget=StaticSelect()
choices=add_blank_choice(VirtualDeviceContextStatusChoices)
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),

View File

@ -281,12 +281,17 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
queryset=Manufacturer.objects.all(),
to_field_name='name'
)
default_platform = forms.ModelChoiceField(
queryset=Platform.objects.all(),
to_field_name='name',
required=False,
)
class Meta:
model = DeviceType
fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
'description', 'comments',
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'description', 'comments',
]

View File

@ -10,8 +10,8 @@ from ipam.models import ASN, L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import (
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField,
StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm,
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
)
from wireless.choices import *
@ -150,7 +150,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=SiteStatusChoices,
required=False
)
@ -208,7 +208,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
},
label=_('Parent')
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=LocationStatusChoices,
required=False
)
@ -258,15 +258,15 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
},
label=_('Location')
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=RackStatusChoices,
required=False
)
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=RackTypeChoices,
required=False
)
width = MultipleChoiceField(
width = forms.MultipleChoiceField(
choices=RackWidthChoices,
required=False
)
@ -378,7 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
model = DeviceType
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
('Hardware', ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')),
('Images', ('has_front_image', 'has_rear_image')),
('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
@ -391,91 +391,96 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Manufacturer')
)
default_platform_id = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
label=_('Default platform')
)
part_number = forms.CharField(
required=False
)
subdevice_role = MultipleChoiceField(
subdevice_role = forms.MultipleChoiceField(
choices=add_blank_choice(SubdeviceRoleChoices),
required=False
)
airflow = MultipleChoiceField(
airflow = forms.MultipleChoiceField(
choices=add_blank_choice(DeviceAirflowChoices),
required=False
)
has_front_image = forms.NullBooleanField(
required=False,
label='Has a front image',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
has_rear_image = forms.NullBooleanField(
required=False,
label='Has a rear image',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_server_ports = forms.NullBooleanField(
required=False,
label='Has console server ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_ports = forms.NullBooleanField(
required=False,
label='Has power ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_outlets = forms.NullBooleanField(
required=False,
label='Has power outlets',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
interfaces = forms.NullBooleanField(
required=False,
label='Has interfaces',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
pass_through_ports = forms.NullBooleanField(
required=False,
label='Has pass-through ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
device_bays = forms.NullBooleanField(
required=False,
label='Has device bays',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
module_bays = forms.NullBooleanField(
required=False,
label='Has module bays',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
inventory_items = forms.NullBooleanField(
required=False,
label='Has inventory items',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -512,42 +517,42 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_server_ports = forms.NullBooleanField(
required=False,
label='Has console server ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_ports = forms.NullBooleanField(
required=False,
label='Has power ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_outlets = forms.NullBooleanField(
required=False,
label='Has power outlets',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
interfaces = forms.NullBooleanField(
required=False,
label='Has interfaces',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
pass_through_ports = forms.NullBooleanField(
required=False,
label='Has pass-through ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -657,11 +662,11 @@ class DeviceFilterForm(
null_option='None',
label=_('Platform')
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=DeviceStatusChoices,
required=False
)
airflow = MultipleChoiceField(
airflow = forms.MultipleChoiceField(
choices=add_blank_choice(DeviceAirflowChoices),
required=False
)
@ -678,56 +683,56 @@ class DeviceFilterForm(
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
virtual_chassis_member = forms.NullBooleanField(
required=False,
label='Virtual chassis member',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_server_ports = forms.NullBooleanField(
required=False,
label='Has console server ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_ports = forms.NullBooleanField(
required=False,
label='Has power ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_outlets = forms.NullBooleanField(
required=False,
label='Has power outlets',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
interfaces = forms.NullBooleanField(
required=False,
label='Has interfaces',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
pass_through_ports = forms.NullBooleanField(
required=False,
label='Has pass-through ports',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -750,14 +755,14 @@ class VirtualDeviceContextFilterForm(
label=_('Device'),
fetch_trigger='open'
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
required=False,
choices=add_blank_choice(VirtualDeviceContextStatusChoices)
)
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -785,7 +790,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
label=_('Type'),
fetch_trigger='open'
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=ModuleStatusChoices,
required=False
)
@ -878,11 +883,11 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
},
label=_('Device')
)
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=add_blank_choice(CableTypeChoices),
required=False
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
required=False,
choices=add_blank_choice(LinkStatusChoices)
)
@ -980,24 +985,21 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
},
label=_('Rack')
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=PowerFeedStatusChoices,
required=False
)
type = forms.ChoiceField(
choices=add_blank_choice(PowerFeedTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
supply = forms.ChoiceField(
choices=add_blank_choice(PowerFeedSupplyChoices),
required=False,
widget=StaticSelect()
required=False
)
phase = forms.ChoiceField(
choices=add_blank_choice(PowerFeedPhaseChoices),
required=False,
widget=StaticSelect()
required=False
)
voltage = forms.IntegerField(
required=False
@ -1018,13 +1020,13 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
class CabledFilterForm(forms.Form):
cabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
occupied = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -1033,7 +1035,7 @@ class CabledFilterForm(forms.Form):
class PathEndpointFilterForm(CabledFilterForm):
connected = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -1047,11 +1049,11 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
required=False
)
speed = MultipleChoiceField(
speed = forms.MultipleChoiceField(
choices=ConsolePortSpeedChoices,
required=False
)
@ -1066,11 +1068,11 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
required=False
)
speed = MultipleChoiceField(
speed = forms.MultipleChoiceField(
choices=ConsolePortSpeedChoices,
required=False
)
@ -1085,7 +1087,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=PowerPortTypeChoices,
required=False
)
@ -1100,7 +1102,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=PowerOutletTypeChoices,
required=False
)
@ -1127,11 +1129,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
},
label=_('Virtual Device Context')
)
kind = MultipleChoiceField(
kind = forms.MultipleChoiceField(
choices=InterfaceKindChoices,
required=False
)
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=InterfaceTypeChoices,
required=False
)
@ -1140,19 +1142,19 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
label='Speed',
widget=SelectSpeedWidget()
)
duplex = MultipleChoiceField(
duplex = forms.MultipleChoiceField(
choices=InterfaceDuplexChoices,
required=False
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mgmt_only = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -1164,22 +1166,22 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
required=False,
label='WWN'
)
poe_mode = MultipleChoiceField(
poe_mode = forms.MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label='PoE mode'
)
poe_type = MultipleChoiceField(
choices=InterfacePoEModeChoices,
poe_type = forms.MultipleChoiceField(
choices=InterfacePoETypeChoices,
required=False,
label='PoE type'
)
rf_role = MultipleChoiceField(
rf_role = forms.MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,
label='Wireless role'
)
rf_channel = MultipleChoiceField(
rf_channel = forms.MultipleChoiceField(
choices=WirelessChannelChoices,
required=False,
label='Wireless channel'
@ -1219,7 +1221,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
('Cable', ('cabled', 'occupied')),
)
model = FrontPort
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
required=False
)
@ -1237,7 +1239,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Cable', ('cabled', 'occupied')),
)
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
required=False
)
@ -1296,7 +1298,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
)
discovered = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)

View File

@ -12,8 +12,8 @@ from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
SlugField, StaticSelect, SelectSpeedWidget,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK,
SlugField, SelectSpeedWidget,
)
from virtualization.models import Cluster, ClusterGroup
from wireless.models import WirelessLAN, WirelessLANGroup
@ -129,8 +129,7 @@ class SiteForm(TenancyForm, NetBoxModelForm):
slug = SlugField()
time_zone = TimeZoneFormField(
choices=add_blank_choice(TimeZoneFormField().choices),
required=False,
widget=StaticSelect()
required=False
)
comments = CommentField()
@ -149,18 +148,16 @@ class SiteForm(TenancyForm, NetBoxModelForm):
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
)
widgets = {
'physical_address': SmallTextarea(
'physical_address': forms.Textarea(
attrs={
'rows': 3,
}
),
'shipping_address': SmallTextarea(
'shipping_address': forms.Textarea(
attrs={
'rows': 3,
}
),
'status': StaticSelect(),
'time_zone': StaticSelect(),
}
help_texts = {
'name': _("Full name of the site"),
@ -218,9 +215,6 @@ class LocationForm(TenancyForm, NetBoxModelForm):
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
'tags',
)
widgets = {
'status': StaticSelect(),
}
class RackRoleForm(NetBoxModelForm):
@ -287,13 +281,6 @@ class RackForm(TenancyForm, NetBoxModelForm):
'facility_id': _("The unique rack ID assigned by the facility"),
'u_height': _("Height in rack units"),
}
widgets = {
'status': StaticSelect(),
'type': StaticSelect(),
'width': StaticSelect(),
'outer_unit': StaticSelect(),
'weight_unit': StaticSelect(),
}
class RackReservationForm(TenancyForm, NetBoxModelForm):
@ -340,8 +327,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
user = forms.ModelChoiceField(
queryset=User.objects.order_by(
'username'
),
widget=StaticSelect()
)
)
comments = CommentField()
@ -378,13 +364,17 @@ class DeviceTypeForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all()
)
default_platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
required=False
)
slug = SlugField(
slug_source='model'
)
comments = CommentField()
fieldsets = (
('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags')),
('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags', 'default_platform')),
('Chassis', (
'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
)),
@ -395,18 +385,15 @@ class DeviceTypeForm(NetBoxModelForm):
model = DeviceType
fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags',
'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'comments', 'tags', 'default_platform'
]
widgets = {
'airflow': StaticSelect(),
'subdevice_role': StaticSelect(),
'front_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
}),
'rear_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
}),
'weight_unit': StaticSelect(),
}
@ -427,10 +414,6 @@ class ModuleTypeForm(NetBoxModelForm):
'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags',
]
widgets = {
'weight_unit': StaticSelect(),
}
class DeviceRoleForm(NetBoxModelForm):
slug = SlugField()
@ -594,13 +577,6 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
'local_context_data': _("Local config context data overwrites all source contexts in the final rendered "
"config context"),
}
widgets = {
'face': StaticSelect(),
'status': StaticSelect(),
'airflow': StaticSelect(),
'primary_ip4': StaticSelect(),
'primary_ip6': StaticSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -734,11 +710,6 @@ class CableForm(TenancyForm, NetBoxModelForm):
'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
'comments', 'tags',
]
widgets = {
'status': StaticSelect,
'type': StaticSelect,
'length_unit': StaticSelect,
}
error_messages = {
'length': {
'max_value': 'Maximum length is 32767 (any unit)'
@ -853,12 +824,6 @@ class PowerFeedForm(NetBoxModelForm):
'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments',
'tags',
]
widgets = {
'status': StaticSelect(),
'type': StaticSelect(),
'supply': StaticSelect(),
'phase': StaticSelect(),
}
#
@ -1022,9 +987,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'description',
]
widgets = {
'type': StaticSelect,
}
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
@ -1037,9 +999,6 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'description',
]
widgets = {
'type': StaticSelect,
}
class PowerPortTemplateForm(ModularComponentTemplateForm):
@ -1054,9 +1013,6 @@ class PowerPortTemplateForm(ModularComponentTemplateForm):
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
]
widgets = {
'type': StaticSelect(),
}
class PowerOutletTemplateForm(ModularComponentTemplateForm):
@ -1077,10 +1033,6 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
]
widgets = {
'type': StaticSelect(),
'feed_leg': StaticSelect(),
}
class InterfaceTemplateForm(ModularComponentTemplateForm):
@ -1094,11 +1046,6 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type',
]
widgets = {
'type': StaticSelect(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
}
class FrontPortTemplateForm(ModularComponentTemplateForm):
@ -1124,9 +1071,6 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description',
]
widgets = {
'type': StaticSelect(),
}
class RearPortTemplateForm(ModularComponentTemplateForm):
@ -1139,9 +1083,6 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description',
]
widgets = {
'type': StaticSelect(),
}
class ModuleBayTemplateForm(ComponentTemplateForm):
@ -1249,10 +1190,6 @@ class ConsolePortForm(ModularDeviceComponentForm):
fields = [
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
]
widgets = {
'type': StaticSelect(),
'speed': StaticSelect(),
}
class ConsoleServerPortForm(ModularDeviceComponentForm):
@ -1268,10 +1205,6 @@ class ConsoleServerPortForm(ModularDeviceComponentForm):
fields = [
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
]
widgets = {
'type': StaticSelect(),
'speed': StaticSelect(),
}
class PowerPortForm(ModularDeviceComponentForm):
@ -1289,9 +1222,6 @@ class PowerPortForm(ModularDeviceComponentForm):
'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
'description', 'tags',
]
widgets = {
'type': StaticSelect(),
}
class PowerOutletForm(ModularDeviceComponentForm):
@ -1316,10 +1246,6 @@ class PowerOutletForm(ModularDeviceComponentForm):
'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
'tags',
]
widgets = {
'type': StaticSelect(),
'feed_leg': StaticSelect(),
}
class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
@ -1424,14 +1350,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
]
widgets = {
'type': StaticSelect(),
'speed': SelectSpeedWidget(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
'duplex': StaticSelect(),
'mode': StaticSelect(),
'rf_role': StaticSelect(),
'rf_channel': StaticSelect(),
}
labels = {
'mode': '802.1Q Mode',
@ -1464,9 +1383,6 @@ class FrontPortForm(ModularDeviceComponentForm):
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
'description', 'tags',
]
widgets = {
'type': StaticSelect(),
}
class RearPortForm(ModularDeviceComponentForm):
@ -1481,9 +1397,6 @@ class RearPortForm(ModularDeviceComponentForm):
fields = [
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
]
widgets = {
'type': StaticSelect(),
}
class ModuleBayForm(DeviceComponentForm):
@ -1514,8 +1427,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
installed_device = forms.ModelChoiceField(
queryset=Device.objects.all(),
label=_('Child Device'),
help_text=_("Child devices must first be created and assigned to the site/rack of the parent device."),
widget=StaticSelect(),
help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.")
)
def __init__(self, device_bay, *args, **kwargs):
@ -1764,8 +1676,3 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier',
'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags'
]
widgets = {
'status': StaticSelect(),
'primary_ip4': StaticSelect(),
'primary_ip6': StaticSelect(),
}

View File

@ -0,0 +1,19 @@
# Generated by Django 4.1.6 on 2023-02-10 18:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0168_interface_template_enabled'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='default_platform',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.platform'),
),
]

View File

@ -9,7 +9,6 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
@ -33,7 +32,7 @@ __all__ = (
)
class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
class ComponentTemplateModel(ChangeLoggedModel):
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,

View File

@ -82,6 +82,14 @@ class DeviceType(PrimaryModel, WeightMixin):
slug = models.SlugField(
max_length=100
)
default_platform = models.ForeignKey(
to='dcim.Platform',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
verbose_name='Default platform'
)
part_number = models.CharField(
max_length=50,
blank=True,
@ -121,7 +129,7 @@ class DeviceType(PrimaryModel, WeightMixin):
)
clone_fields = (
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
)
prerequisite_models = (
'dcim.Manufacturer',
@ -165,6 +173,7 @@ class DeviceType(PrimaryModel, WeightMixin):
'manufacturer': self.manufacturer.name,
'model': self.model,
'slug': self.slug,
'default_platform': self.default_platform.name if self.default_platform else None,
'part_number': self.part_number,
'u_height': float(self.u_height),
'is_full_depth': self.is_full_depth,
@ -801,6 +810,10 @@ class Device(PrimaryModel, ConfigContextModel):
if is_new and not self.airflow:
self.airflow = self.device_type.airflow
# Inherit default_platform from DeviceType if not set
if is_new and not self.platform:
self.platform = self.device_type.default_platform
super().save(*args, **kwargs)
# If this is a new Device, instantiate all the related components per the DeviceType definition

View File

@ -77,6 +77,9 @@ class DeviceTypeTable(NetBoxTable):
manufacturer = tables.Column(
linkify=True
)
default_platform = tables.Column(
linkify=True
)
is_full_depth = columns.BooleanColumn(
verbose_name='Full Depth'
)
@ -100,7 +103,7 @@ class DeviceTypeTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.DeviceType
fields = (
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@ -699,9 +699,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Manufacturer.objects.bulk_create(manufacturers)
platforms = (
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1]),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2]),
)
Platform.objects.bulk_create(platforms)
device_types = (
DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
DeviceType(manufacturer=manufacturers[0], default_platform=platforms[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
DeviceType(manufacturer=manufacturers[1], default_platform=platforms[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
)
DeviceType.objects.bulk_create(device_types)
@ -785,6 +792,13 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_default_platform(self):
platforms = Platform.objects.all()[:2]
params = {'default_platform_id': [platforms[0].pk, platforms[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'default_platform': [platforms[0].slug, platforms[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_has_front_image(self):
params = {'has_front_image': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@ -503,6 +503,12 @@ class DeviceTypeTestCase(
)
Manufacturer.objects.bulk_create(manufacturers)
platforms = (
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0]),
Platform(name='Platform 2', slug='platform-3', manufacturer=manufacturers[1]),
)
Platform.objects.bulk_create(platforms)
DeviceType.objects.bulk_create([
DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]),
DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturers[0]),
@ -513,6 +519,7 @@ class DeviceTypeTestCase(
cls.form_data = {
'manufacturer': manufacturers[1].pk,
'default_platform': platforms[0].pk,
'model': 'Device Type X',
'slug': 'device-type-x',
'part_number': '123ABC',
@ -525,6 +532,7 @@ class DeviceTypeTestCase(
cls.bulk_edit_data = {
'manufacturer': manufacturers[1].pk,
'default_platform': platforms[1].pk,
'u_height': 3,
'is_full_depth': False,
}
@ -673,6 +681,7 @@ class DeviceTypeTestCase(
"""
IMPORT_DATA = """
manufacturer: Generic
default_platform: Platform
model: TEST-1000
slug: test-1000
u_height: 2
@ -755,8 +764,11 @@ inventory-items:
manufacturer: Generic
"""
# Create the manufacturer
Manufacturer(name='Generic', slug='generic').save()
# Create the manufacturer and platform
manufacturer = Manufacturer(name='Generic', slug='generic')
manufacturer.save()
platform = Platform(name='Platform', slug='test-platform', manufacturer=manufacturer)
platform.save()
# Add all required permissions to the test user
self.add_permissions(
@ -783,6 +795,7 @@ inventory-items:
device_type = DeviceType.objects.get(model='TEST-1000')
self.assertEqual(device_type.comments, 'Test comment')
self.assertEqual(device_type.default_platform.pk, platform.pk)
# Verify all of the components were created
self.assertEqual(device_type.consoleporttemplates.count(), 3)

View File

@ -595,6 +595,7 @@ class RackListView(generic.ObjectListView):
filterset = filtersets.RackFilterSet
filterset_form = forms.RackFilterForm
table = tables.RackTable
template_name = 'dcim/rack_list.html'
class RackElevationListView(generic.ObjectListView):

View File

@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer
from dcim.api.nested_serializers import (
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@ -141,12 +142,19 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
many=True
)
data_source = NestedDataSourceSerializer(
required=False
)
data_file = NestedDataFileSerializer(
read_only=True
)
class Meta:
model = ExportTemplate
fields = [
'id', 'url', 'display', 'content_types', 'name', 'description', 'template_code', 'mime_type',
'file_extension', 'as_attachment', 'created', 'last_updated',
'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
'last_updated',
]
@ -358,13 +366,20 @@ class ConfigContextSerializer(ValidatedModelSerializer):
required=False,
many=True
)
data_source = NestedDataSourceSerializer(
required=False
)
data_file = NestedDataFileSerializer(
read_only=True
)
class Meta:
model = ConfigContext
fields = [
'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
'created', 'last_updated',
]

View File

@ -17,6 +17,7 @@ from extras.models import CustomField
from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
@ -91,9 +92,9 @@ class CustomLinkViewSet(NetBoxModelViewSet):
# Export templates
#
class ExportTemplateViewSet(NetBoxModelViewSet):
class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = ExportTemplate.objects.all()
queryset = ExportTemplate.objects.prefetch_related('data_source', 'data_file')
serializer_class = serializers.ExportTemplateSerializer
filterset_class = filtersets.ExportTemplateFilterSet
@ -147,9 +148,10 @@ class JournalEntryViewSet(NetBoxModelViewSet):
# Config contexts
#
class ConfigContextViewSet(NetBoxModelViewSet):
class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
queryset = ConfigContext.objects.prefetch_related(
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants',
'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', 'data_source',
'data_file',
)
serializer_class = serializers.ConfigContextSerializer
filterset_class = filtersets.ConfigContextFilterSet

View File

@ -8,6 +8,7 @@ EXTRAS_FEATURES = [
'export_templates',
'job_results',
'journaling',
'synced_data',
'tags',
'webhooks'
]

View File

@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
from core.models import DataFile, DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup
@ -126,10 +127,18 @@ class ExportTemplateFilterSet(BaseFilterSet):
field_name='content_types__id'
)
content_types = ContentTypeFilter()
data_source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data source (ID)'),
)
data_file_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data file (ID)'),
)
class Meta:
model = ExportTemplate
fields = ['id', 'content_types', 'name', 'description']
fields = ['id', 'content_types', 'name', 'description', 'data_synced']
def search(self, queryset, name, value):
if not value.strip():
@ -422,10 +431,18 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
to_field_name='slug',
label=_('Tag (slug)'),
)
data_source_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data source (ID)'),
)
data_file_id = django_filters.ModelMultipleChoiceFilter(
queryset=DataSource.objects.all(),
label=_('Data file (ID)'),
)
class Meta:
model = ConfigContext
fields = ['id', 'name', 'is_active']
fields = ['id', 'name', 'is_active', 'data_synced']
def search(self, queryset, name, value):
if not value.strip():

View File

@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
from extras.choices import *
from extras.models import *
from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, StaticSelect,
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField,
)
__all__ = (
@ -41,8 +41,7 @@ class CustomFieldBulkEditForm(BulkEditForm):
label=_("UI visibility"),
choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False,
initial='',
widget=StaticSelect()
initial=''
)
nullable_fields = ('group_name', 'description',)
@ -66,8 +65,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
)
button_class = forms.ChoiceField(
choices=add_blank_choice(CustomLinkButtonClassChoices),
required=False,
widget=StaticSelect()
required=False
)

View File

@ -3,6 +3,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from core.models import DataFile, DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
@ -11,8 +12,8 @@ from netbox.forms.base import NetBoxModelFilterSetForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField,
ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField,
StaticSelect, TagFilterField,
ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm,
TagFilterField,
)
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .mixins import SavedFiltersMixin
@ -20,9 +21,9 @@ from .mixins import SavedFiltersMixin
__all__ = (
'ConfigContextFilterForm',
'CustomFieldFilterForm',
'JobResultFilterForm',
'CustomLinkFilterForm',
'ExportTemplateFilterForm',
'JobResultFilterForm',
'JournalEntryFilterForm',
'LocalConfigContextFilterForm',
'ObjectChangeFilterForm',
@ -43,7 +44,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('Object type')
)
type = MultipleChoiceField(
type = forms.MultipleChoiceField(
choices=CustomFieldTypeChoices,
required=False,
label=_('Field type')
@ -56,15 +57,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
)
required = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
ui_visibility = forms.ChoiceField(
choices=add_blank_choice(CustomFieldVisibilityChoices),
required=False,
label=_('UI visibility'),
widget=StaticSelect()
label=_('UI visibility')
)
@ -83,7 +83,7 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm):
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
required=False,
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=JobResultStatusChoices,
required=False
)
@ -141,13 +141,13 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
new_window = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -159,8 +159,22 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('Data', ('data_source_id', 'data_file_id')),
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
required=False,
label=_('Data source')
)
data_file_id = DynamicModelMultipleChoiceField(
queryset=DataFile.objects.all(),
required=False,
label=_('Data file'),
query_params={
'source_id': '$data_source_id'
}
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
@ -175,7 +189,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
)
as_attachment = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -193,13 +207,13 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
shared = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -220,32 +234,32 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('Object type')
)
http_method = MultipleChoiceField(
http_method = forms.MultipleChoiceField(
choices=WebhookHttpMethodChoices,
required=False,
label=_('HTTP method')
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_create = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_update = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_delete = forms.NullBooleanField(
required=False,
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -263,11 +277,25 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag_id')),
('Data', ('data_source_id', 'data_file_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Device', ('device_type_id', 'platform_id', 'role_id')),
('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
('Tenant', ('tenant_group_id', 'tenant_id'))
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
required=False,
label=_('Data source')
)
data_file_id = DynamicModelMultipleChoiceField(
queryset=DataFile.objects.all(),
required=False,
label=_('Data file'),
query_params={
'source_id': '$data_source_id'
}
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@ -340,7 +368,7 @@ class LocalConfigContextFilterForm(forms.Form):
local_context_data = forms.NullBooleanField(
required=False,
label=_('Has local config context data'),
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -381,8 +409,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
)
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),
required=False,
widget=StaticSelect()
required=False
)
tag = TagFilterField(model)
@ -406,8 +433,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
)
action = forms.ChoiceField(
choices=add_blank_choice(ObjectChangeActionChoices),
required=False,
widget=StaticSelect()
required=False
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),

View File

@ -2,13 +2,15 @@ from django.contrib.contenttypes.models import ContentType
from django import forms
from django.utils.translation import gettext as _
from core.models import DataFile, DataSource
from extras.models import *
from extras.choices import CustomFieldVisibilityChoices
from utilities.forms.fields import DynamicModelMultipleChoiceField
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
__all__ = (
'CustomFieldsMixin',
'SavedFiltersMixin',
'SyncedDataMixin',
)
@ -72,3 +74,19 @@ class SavedFiltersMixin(forms.Form):
'usable': True,
}
)
class SyncedDataMixin(forms.Form):
data_source = DynamicModelChoiceField(
queryset=DataSource.objects.all(),
required=False,
label=_('Data source')
)
data_file = DynamicModelChoiceField(
queryset=DataFile.objects.all(),
required=False,
label=_('File'),
query_params={
'source_id': '$data_source',
}
)

View File

@ -5,13 +5,14 @@ from django.utils.translation import gettext as _
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.forms.mixins import SyncedDataMixin
from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
DynamicModelMultipleChoiceField, JSONField, SlugField,
)
from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -57,11 +58,6 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
'type': _("The type of data stored in this field. For object/multi-object fields, select the related object "
"type below.")
}
widgets = {
'type': StaticSelect(),
'filter_logic': StaticSelect(),
'ui_visibility': StaticSelect(),
}
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
@ -79,7 +75,6 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
model = CustomLink
fields = '__all__'
widgets = {
'button_class': StaticSelect(),
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
}
@ -95,19 +90,28 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates')
)
template_code = forms.CharField(
required=False,
widget=forms.Textarea(attrs={'class': 'font-monospace'})
)
fieldsets = (
('Export Template', ('name', 'content_types', 'description')),
('Template', ('template_code',)),
('Content', ('data_source', 'data_file', 'template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
)
class Meta:
model = ExportTemplate
fields = '__all__'
widgets = {
'template_code': forms.Textarea(attrs={'class': 'font-monospace'}),
}
def clean(self):
super().clean()
if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
raise forms.ValidationError("Must specify either local content or a data file")
return self.cleaned_data
class SavedFilterForm(BootstrapMixin, forms.ModelForm):
@ -162,7 +166,6 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
'type_delete': 'Deletions',
}
widgets = {
'http_method': StaticSelect(),
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
@ -183,7 +186,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
]
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
regions = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False
@ -236,10 +239,13 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
queryset=Tag.objects.all(),
required=False
)
data = JSONField()
data = JSONField(
required=False
)
fieldsets = (
('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
('Data Source', ('data_source', 'data_file')),
('Assignment', (
'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
@ -251,9 +257,17 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
fields = (
'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'tenants', 'tags',
'tenants', 'tags', 'data_source', 'data_file',
)
def clean(self):
super().clean()
if not self.cleaned_data.get('data') and not self.cleaned_data.get('data_file'):
raise forms.ValidationError("Must specify either local data or a data file")
return self.cleaned_data
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
@ -267,8 +281,7 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class JournalEntryForm(NetBoxModelForm):
kind = forms.ChoiceField(
choices=add_blank_choice(JournalEntryKindChoices),
required=False,
widget=StaticSelect()
required=False
)
comments = CommentField()

View File

@ -45,12 +45,16 @@ class ScriptForm(BootstrapMixin, forms.Form):
self.fields['_interval'] = interval
self.fields['_commit'] = commit
def clean__schedule_at(self):
def clean(self):
scheduled_time = self.cleaned_data['_schedule_at']
if scheduled_time and scheduled_time < timezone.now():
if scheduled_time and scheduled_time < local_now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
return scheduled_time
# When interval is used without schedule at, raise an exception
if self.cleaned_data['_interval'] and not scheduled_time:
raise forms.ValidationError(_('Scheduled time must be set when recurs is used.'))
return self.cleaned_data
@property
def requires_input(self):

View File

@ -9,7 +9,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
APPS = ('circuits', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless')
APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless')
BANNER_TEXT = """### NetBox interactive shell ({node})
### Python {python} | Django {django} | NetBox {netbox}

View File

@ -0,0 +1,55 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('extras', '0084_staging'),
]
operations = [
# ConfigContexts
migrations.AddField(
model_name='configcontext',
name='data_file',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'),
),
migrations.AddField(
model_name='configcontext',
name='data_path',
field=models.CharField(blank=True, editable=False, max_length=1000),
),
migrations.AddField(
model_name='configcontext',
name='data_source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
),
migrations.AddField(
model_name='configcontext',
name='data_synced',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
# ExportTemplates
migrations.AddField(
model_name='exporttemplate',
name='data_file',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile'),
),
migrations.AddField(
model_name='exporttemplate',
name='data_path',
field=models.CharField(blank=True, editable=False, max_length=1000),
),
migrations.AddField(
model_name='exporttemplate',
name='data_source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource'),
),
migrations.AddField(
model_name='exporttemplate',
name='data_synced',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]

View File

@ -2,13 +2,13 @@ from django.conf import settings
from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
from extras.querysets import ConfigContextQuerySet
from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from netbox.models.features import SyncedDataMixin
from utilities.utils import deepmerge
__all__ = (
'ConfigContext',
'ConfigContextModel',
@ -19,7 +19,7 @@ __all__ = (
# Config contexts
#
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
class ConfigContext(SyncedDataMixin, ChangeLoggedModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
@ -130,6 +130,13 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
)
def sync_data(self):
"""
Synchronize context data from the designated DataFile (if any).
"""
self.data = self.data_file.get_data()
self.data_synced = timezone.now()
class ConfigContextModel(models.Model):
"""

View File

@ -17,12 +17,12 @@ from django.utils.translation import gettext as _
from extras.choices import *
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from netbox.search import FieldTypes
from utilities import filters
from utilities.forms import (
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
JSONField, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
JSONField, LaxURLField, add_blank_choice,
)
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
@ -54,7 +54,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return self.get_queryset().filter(content_types=content_type)
class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
content_types = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
@ -372,7 +372,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
(False, 'False'),
)
field = forms.NullBooleanField(
required=required, initial=initial, widget=StaticSelect(choices=choices)
required=required, initial=initial, widget=forms.Select(choices=choices)
)
# Date
@ -393,14 +393,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelect()
)
field = field_class(choices=choices, required=required, initial=initial)
else:
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
)
field = field_class(choices=choices, required=required, initial=initial)
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:

View File

@ -11,6 +11,7 @@ from django.core.validators import MinValueValidator, ValidationError
from django.db import models
from django.http import HttpResponse, QueryDict
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.translation import gettext as _
@ -25,7 +26,8 @@ from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, SyncedDataMixin,
TagsMixin,
)
from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2
@ -44,7 +46,7 @@ __all__ = (
)
class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class Webhook(ExportTemplatesMixin, ChangeLoggedModel):
"""
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
@ -201,7 +203,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
return render_jinja2(self.payload_url, context)
class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
code to be rendered with an object as context.
@ -280,7 +282,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
}
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, ChangeLoggedModel):
content_types = models.ManyToManyField(
to=ContentType,
related_name='export_templates',
@ -334,6 +336,13 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
'name': f'"{self.name}" is a reserved name. Please choose a different name.'
})
def sync_data(self):
"""
Synchronize template content from the designated DataFile (if any).
"""
self.template_code = self.data_file.data_as_string
self.data_synced = timezone.now()
def render(self, queryset):
"""
Render the contents of the template.
@ -367,7 +376,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
return response
class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
A set of predefined keyword parameters that can be reused to filter for specific objects.
"""
@ -438,7 +447,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
return qd.urlencode()
class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
class ImageAttachment(ChangeLoggedModel):
"""
An uploaded image which is associated with an object.
"""
@ -514,7 +523,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
return objectchange
class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin, ExportTemplatesMixin, ChangeLoggedModel):
class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
@ -634,7 +643,7 @@ class JobResult(models.Model):
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.name, RQ_QUEUE_DEFAULT)
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.model, RQ_QUEUE_DEFAULT)
queue = django_rq.get_queue(rq_queue_name)
job = queue.fetch_job(str(self.job_id))
@ -642,7 +651,10 @@ class JobResult(models.Model):
job.cancel()
def get_absolute_url(self):
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
try:
return reverse(f'extras:{self.obj_type.model}_result', args=[self.pk])
except NoReverseMatch:
return None
def get_status_color(self):
return JobResultStatusChoices.colors.get(self.status)
@ -693,7 +705,7 @@ class JobResult(models.Model):
schedule_at: Schedule the job to be executed at the passed date and time
interval: Recurrence interval (in minutes)
"""
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.model, RQ_QUEUE_DEFAULT)
queue = django_rq.get_queue(rq_queue_name)
status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
job_result: JobResult = JobResult.objects.create(

View File

@ -5,7 +5,7 @@ from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
from netbox.models.features import ExportTemplatesMixin
from utilities.choices import ColorChoices
from utilities.fields import ColorField
@ -14,7 +14,7 @@ from utilities.fields import ColorField
# Tags
#
class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
class Tag(ExportTemplatesMixin, ChangeLoggedModel, TagBase):
id = models.BigAutoField(
primary_key=True
)

View File

@ -1,4 +1,5 @@
import collections
from importlib import import_module
from django.apps import AppConfig
from django.conf import settings
@ -21,6 +22,15 @@ registry['plugins'] = {
'template_extensions': collections.defaultdict(list),
}
DEFAULT_RESOURCE_PATHS = {
'search_indexes': 'search.indexes',
'graphql_schema': 'graphql.schema',
'menu': 'navigation.menu',
'menu_items': 'navigation.menu_items',
'template_extensions': 'template_content.template_extensions',
'user_preferences': 'preferences.preferences',
}
#
# Plugin AppConfig class
@ -58,58 +68,53 @@ class PluginConfig(AppConfig):
# Django apps to append to INSTALLED_APPS when plugin requires them.
django_apps = []
# Default integration paths. Plugin authors can override these to customize the paths to
# integrated components.
search_indexes = 'search.indexes'
graphql_schema = 'graphql.schema'
menu = 'navigation.menu'
menu_items = 'navigation.menu_items'
template_extensions = 'template_content.template_extensions'
user_preferences = 'preferences.preferences'
# Optional plugin resources
search_indexes = None
graphql_schema = None
menu = None
menu_items = None
template_extensions = None
user_preferences = None
def _load_resource(self, name):
# Import from the configured path, if defined.
if getattr(self, name):
return import_string(f"{self.__module__}.{self.name}")
# Fall back to the resource's default path. Return None if the module has not been provided.
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
default_module, resource_name = default_path.rsplit('.', 1)
try:
module = import_module(default_module)
return getattr(module, resource_name, None)
except ModuleNotFoundError:
pass
def ready(self):
plugin_name = self.name.rsplit('.', 1)[-1]
# Register search extensions (if defined)
try:
search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
search_indexes = self._load_resource('search_indexes') or []
for idx in search_indexes:
register_search(idx)
except ImportError:
pass
# Register template content (if defined)
try:
template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
if template_extensions := self._load_resource('template_extensions'):
register_template_extensions(template_extensions)
except ImportError:
pass
# Register navigation menu and/or menu items (if defined)
try:
menu = import_string(f"{self.__module__}.{self.menu}")
if menu := self._load_resource('menu'):
register_menu(menu)
except ImportError:
pass
try:
menu_items = import_string(f"{self.__module__}.{self.menu_items}")
if menu_items := self._load_resource('menu_items'):
register_menu_items(self.verbose_name, menu_items)
except ImportError:
pass
# Register GraphQL schema (if defined)
try:
graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}")
if graphql_schema := self._load_resource('graphql_schema'):
register_graphql_schema(graphql_schema)
except ImportError:
pass
# Register user preferences (if defined)
try:
user_preferences = import_string(f"{self.__module__}.{self.user_preferences}")
if user_preferences := self._load_resource('user_preferences'):
register_user_preferences(plugin_name, user_preferences)
except ImportError:
pass
@classmethod
def validate(cls, user_config, netbox_version):

View File

@ -1,9 +1,11 @@
from importlib import import_module
from django.apps import apps
from django.conf import settings
from django.conf.urls import include
from django.contrib.admin.views.decorators import staff_member_required
from django.urls import path
from django.utils.module_loading import import_string
from django.utils.module_loading import import_string, module_has_submodule
from . import views
@ -19,24 +21,21 @@ plugin_admin_patterns = [
# Register base/API URL patterns for each plugin
for plugin_path in settings.PLUGINS:
plugin = import_module(plugin_path)
plugin_name = plugin_path.split('.')[-1]
app = apps.get_app_config(plugin_name)
base_url = getattr(app, 'base_url') or app.label
# Check if the plugin specifies any base URLs
try:
if module_has_submodule(plugin, 'urls'):
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
plugin_patterns.append(
path(f"{base_url}/", include((urlpatterns, app.label)))
)
except ImportError:
pass
# Check if the plugin specifies any API URLs
try:
if module_has_submodule(plugin, 'api.urls'):
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
plugin_api_patterns.append(
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
)
except ImportError:
pass

View File

@ -21,7 +21,7 @@ from extras.models import JobResult
from extras.signals import clear_webhooks
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction
from utilities.exceptions import AbortScript, AbortTransaction
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .context_managers import change_logging
from .forms import ScriptForm
@ -470,6 +470,14 @@ def run_script(data, request, commit=True, *args, **kwargs):
except AbortTransaction:
script.log_info("Database changes have been reverted automatically.")
clear_webhooks.send(request)
except AbortScript as e:
script.log_failure(
f"Script aborted with error: {e}"
)
script.log_info("Database changes have been reverted due to error.")
logger.error(f"Script aborted with error: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
clear_webhooks.send(request)
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(

View File

@ -90,15 +90,24 @@ class ExportTemplateTable(NetBoxTable):
)
content_types = columns.ContentTypesColumn()
as_attachment = columns.BooleanColumn()
data_source = tables.Column(
linkify=True
)
data_file = tables.Column(
linkify=True
)
is_synced = columns.BooleanColumn(
verbose_name='Synced'
)
class Meta(NetBoxTable.Meta):
model = ExportTemplate
fields = (
'pk', 'id', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
'created', 'last_updated',
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
'pk', 'name', 'content_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
)
@ -188,21 +197,30 @@ class TaggedItemTable(NetBoxTable):
class ConfigContextTable(NetBoxTable):
data_source = tables.Column(
linkify=True
)
data_file = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True
)
is_active = columns.BooleanColumn(
verbose_name='Active'
)
is_synced = columns.BooleanColumn(
verbose_name='Synced'
)
class Meta(NetBoxTable.Meta):
model = ConfigContext
fields = (
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
'last_updated',
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
class ObjectChangeTable(NetBoxTable):

View File

@ -29,6 +29,7 @@ urlpatterns = [
path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'),
path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'),
path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'),
path('export-templates/sync/', views.ExportTemplateBulkSyncDataView.as_view(), name='exporttemplate_bulk_sync'),
path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
# Saved filters
@ -60,6 +61,7 @@ urlpatterns = [
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
# Image attachments

View File

@ -121,6 +121,8 @@ class ExportTemplateListView(generic.ObjectListView):
filterset = filtersets.ExportTemplateFilterSet
filterset_form = forms.ExportTemplateFilterForm
table = tables.ExportTemplateTable
template_name = 'extras/exporttemplate_list.html'
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync')
@register_model_view(ExportTemplate)
@ -158,6 +160,10 @@ class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
table = tables.ExportTemplateTable
class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView):
queryset = ExportTemplate.objects.all()
#
# Saved filters
#
@ -352,7 +358,8 @@ class ConfigContextListView(generic.ObjectListView):
filterset = filtersets.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm
table = tables.ConfigContextTable
actions = ('add', 'bulk_edit', 'bulk_delete')
template_name = 'extras/configcontext_list.html'
actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync')
@register_model_view(ConfigContext)
@ -416,6 +423,10 @@ class ConfigContextBulkDeleteView(generic.BulkDeleteView):
table = tables.ConfigContextTable
class ConfigContextBulkSyncDataView(generic.BulkSyncDataView):
queryset = ConfigContext.objects.all()
class ObjectConfigContextView(generic.ObjectView):
base_template = None
template_name = 'extras/object_configcontext.html'

View File

@ -9,8 +9,8 @@ from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField,
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
NumericArrayField,
)
__all__ = (
@ -48,7 +48,7 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -69,7 +69,7 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -116,7 +116,7 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -145,7 +145,7 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -205,8 +205,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
)
status = forms.ChoiceField(
choices=add_blank_choice(PrefixStatusChoices),
required=False,
widget=StaticSelect()
required=False
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
@ -227,7 +226,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -254,8 +253,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
)
status = forms.ChoiceField(
choices=add_blank_choice(IPRangeStatusChoices),
required=False,
widget=StaticSelect()
required=False
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
@ -266,7 +264,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -296,13 +294,11 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
)
status = forms.ChoiceField(
choices=add_blank_choice(IPAddressStatusChoices),
required=False,
widget=StaticSelect()
required=False
)
role = forms.ChoiceField(
choices=add_blank_choice(IPAddressRoleChoices),
required=False,
widget=StaticSelect()
required=False
)
dns_name = forms.CharField(
max_length=255,
@ -314,7 +310,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -331,8 +327,7 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
protocol = forms.ChoiceField(
choices=add_blank_choice(FHRPGroupProtocolChoices),
required=False,
widget=StaticSelect()
required=False
)
group_id = forms.IntegerField(
min_value=0,
@ -342,7 +337,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
auth_type = forms.ChoiceField(
choices=add_blank_choice(FHRPGroupAuthTypeChoices),
required=False,
widget=StaticSelect(),
label=_('Authentication type')
)
auth_key = forms.CharField(
@ -359,7 +353,7 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -430,8 +424,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
)
status = forms.ChoiceField(
choices=add_blank_choice(VLANStatusChoices),
required=False,
widget=StaticSelect()
required=False
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
@ -442,7 +435,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -459,8 +452,7 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
protocol = forms.ChoiceField(
choices=add_blank_choice(ServiceProtocolChoices),
required=False,
widget=StaticSelect()
required=False
)
ports = NumericArrayField(
base_field=forms.IntegerField(
@ -474,7 +466,7 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)
@ -492,8 +484,7 @@ class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
type = forms.ChoiceField(
choices=add_blank_choice(L2VPNTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@ -504,7 +495,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
widget=forms.Textarea,
label='Comments'
)

View File

@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm
from utilities.forms import (
add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import VirtualMachine
@ -87,7 +87,7 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
is_private = forms.NullBooleanField(
required=False,
label=_('Private'),
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -104,8 +104,7 @@ class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
family = forms.ChoiceField(
required=False,
choices=add_blank_choice(IPAddressFamilyChoices),
label=_('Address family'),
widget=StaticSelect()
label=_('Address family')
)
rir_id = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(),
@ -164,10 +163,9 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
family = forms.ChoiceField(
required=False,
choices=add_blank_choice(IPAddressFamilyChoices),
label=_('Address family'),
widget=StaticSelect()
label=_('Address family')
)
mask_length = MultipleChoiceField(
mask_length = forms.MultipleChoiceField(
required=False,
choices=PREFIX_MASK_LENGTH_CHOICES,
label=_('Mask length')
@ -183,7 +181,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
label=_('Present in VRF')
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=PrefixStatusChoices,
required=False
)
@ -215,14 +213,14 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
is_pool = forms.NullBooleanField(
required=False,
label=_('Is a pool'),
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mark_utilized = forms.NullBooleanField(
required=False,
label=_('Marked as 100% utilized'),
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -239,8 +237,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
family = forms.ChoiceField(
required=False,
choices=add_blank_choice(IPAddressFamilyChoices),
label=_('Address family'),
widget=StaticSelect()
label=_('Address family')
)
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
@ -248,7 +245,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Assigned VRF'),
null_option='Global'
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=IPRangeStatusChoices,
required=False
)
@ -282,14 +279,12 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
family = forms.ChoiceField(
required=False,
choices=add_blank_choice(IPAddressFamilyChoices),
label=_('Address family'),
widget=StaticSelect()
label=_('Address family')
)
mask_length = forms.ChoiceField(
required=False,
choices=IPADDRESS_MASK_LENGTH_CHOICES,
label=_('Mask length'),
widget=StaticSelect()
label=_('Mask length')
)
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
@ -312,18 +307,18 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
label=_('Assigned VM'),
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=IPAddressStatusChoices,
required=False
)
role = MultipleChoiceField(
role = forms.MultipleChoiceField(
choices=IPAddressRoleChoices,
required=False
)
assigned_to_interface = forms.NullBooleanField(
required=False,
label=_('Assigned to an interface'),
widget=StaticSelect(
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@ -340,7 +335,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
name = forms.CharField(
required=False
)
protocol = MultipleChoiceField(
protocol = forms.MultipleChoiceField(
choices=FHRPGroupProtocolChoices,
required=False
)
@ -349,7 +344,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
required=False,
label='Group ID'
)
auth_type = MultipleChoiceField(
auth_type = forms.MultipleChoiceField(
choices=FHRPGroupAuthTypeChoices,
required=False,
label='Authentication type'
@ -444,7 +439,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
},
label=_('VLAN group')
)
status = MultipleChoiceField(
status = forms.MultipleChoiceField(
choices=VLANStatusChoices,
required=False
)
@ -474,8 +469,7 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
)
protocol = forms.ChoiceField(
choices=add_blank_choice(ServiceProtocolChoices),
required=False,
widget=StaticSelect()
required=False
)
port = forms.IntegerField(
required=False,
@ -497,8 +491,7 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
)
type = forms.ChoiceField(
choices=add_blank_choice(L2VPNTypeChoices),
required=False,
widget=StaticSelect()
required=False
)
import_target_id = DynamicModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),

View File

@ -13,7 +13,7 @@ from tenancy.forms import TenancyForm
from utilities.exceptions import PermissionsViolation
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple,
DynamicModelMultipleChoiceField, NumericArrayField, SlugField,
)
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
@ -254,9 +254,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant',
'description', 'comments', 'tags',
]
widgets = {
'status': StaticSelect(),
}
class IPRangeForm(TenancyForm, NetBoxModelForm):
@ -282,9 +279,6 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'description',
'comments', 'tags',
]
widgets = {
'status': StaticSelect(),
}
class IPAddressForm(TenancyForm, NetBoxModelForm):
@ -411,10 +405,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description',
'comments', 'tags',
]
widgets = {
'status': StaticSelect(),
'role': StaticSelect(),
}
def __init__(self, *args, **kwargs):
@ -510,10 +500,6 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
]
widgets = {
'status': StaticSelect(),
'role': StaticSelect(),
}
class IPAddressAssignForm(BootstrapMixin, forms.Form):
@ -559,11 +545,6 @@ class FHRPGroupForm(NetBoxModelForm):
'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'ip_vrf', 'ip_address', 'ip_status', 'description',
'comments', 'tags',
)
widgets = {
'protocol': StaticSelect(),
'auth_type': StaticSelect(),
'ip_status': StaticSelect(),
}
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
@ -700,9 +681,6 @@ class VLANGroupForm(NetBoxModelForm):
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
]
widgets = {
'scope_type': StaticSelect,
}
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
@ -740,7 +718,6 @@ class VLANForm(TenancyForm, NetBoxModelForm):
('virtualization.cluster', 'Cluster'),
),
required=False,
widget=StaticSelect,
label=_('Group scope')
)
group = DynamicModelChoiceField(
@ -800,9 +777,6 @@ class VLANForm(TenancyForm, NetBoxModelForm):
'status': _("Operational status of this VLAN"),
'role': _("The primary function of this VLAN"),
}
widgets = {
'status': StaticSelect(),
}
class ServiceTemplateForm(NetBoxModelForm):
@ -824,9 +798,6 @@ class ServiceTemplateForm(NetBoxModelForm):
class Meta:
model = ServiceTemplate
fields = ('name', 'protocol', 'ports', 'description', 'comments', 'tags')
widgets = {
'protocol': StaticSelect(),
}
class ServiceForm(NetBoxModelForm):
@ -865,10 +836,6 @@ class ServiceForm(NetBoxModelForm):
'ipaddresses': _("IP address assignment is optional. If no IPs are selected, the service is assumed to be "
"reachable via all IPs assigned to the device."),
}
widgets = {
'protocol': StaticSelect(),
'ipaddresses': StaticSelectMultiple(),
}
class ServiceCreateForm(ServiceForm):
@ -934,9 +901,6 @@ class L2VPNForm(TenancyForm, NetBoxModelForm):
'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description',
'comments', 'tags'
)
widgets = {
'type': StaticSelect(),
}
class L2VPNTerminationForm(NetBoxModelForm):

View File

@ -5,7 +5,6 @@ from django.db import models
from django.urls import reverse
from netbox.models import ChangeLoggedModel, PrimaryModel
from netbox.models.features import WebhooksMixin
from ipam.choices import *
from ipam.constants import *
@ -73,7 +72,7 @@ class FHRPGroup(PrimaryModel):
return reverse('ipam:fhrpgroup', args=[self.pk])
class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel):
class FHRPGroupAssignment(ChangeLoggedModel):
interface_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE

View File

@ -0,0 +1,30 @@
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from utilities.permissions import get_permission_for_model
__all__ = (
'SyncedDataMixin',
)
class SyncedDataMixin:
@action(detail=True, methods=['post'])
def sync(self, request, pk):
"""
Provide a /sync API endpoint to synchronize an object's data from its associated DataFile (if any).
"""
permission = get_permission_for_model(self.queryset.model, 'sync')
if not request.user.has_perm(permission):
raise PermissionDenied(f"Missing permission: {permission}")
obj = get_object_or_404(self.queryset, pk=pk)
if obj.data_file:
obj.sync_data()
obj.save()
serializer = self.serializer_class(obj, context={'request': request})
return Response(serializer.data)

View File

@ -27,6 +27,7 @@ class APIRootView(APIView):
return Response({
'circuits': reverse('circuits-api:api-root', request=request, format=format),
'core': reverse('core-api:api-root', request=request, format=format),
'dcim': reverse('dcim-api:api-root', request=request, format=format),
'extras': reverse('extras-api:api-root', request=request, format=format),
'ipam': reverse('ipam-api:api-root', request=request, format=format),

View File

@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
from netbox.search import LookupTypes
from netbox.search.backends import search_backend
from utilities.forms import BootstrapMixin, StaticSelect, StaticSelectMultiple
from utilities.forms import BootstrapMixin
from .base import *
@ -32,14 +32,12 @@ class SearchForm(BootstrapMixin, forms.Form):
obj_types = forms.MultipleChoiceField(
choices=[],
required=False,
label=_('Object type(s)'),
widget=StaticSelectMultiple()
label=_('Object type(s)')
)
lookup = forms.ChoiceField(
choices=LOOKUP_CHOICES,
initial=LookupTypes.PARTIAL,
required=False,
widget=StaticSelect()
required=False
)
def __init__(self, *args, **kwargs):

View File

@ -1,6 +1,7 @@
import graphene
from circuits.graphql.schema import CircuitsQuery
from core.graphql.schema import CoreQuery
from dcim.graphql.schema import DCIMQuery
from extras.graphql.schema import ExtrasQuery
from ipam.graphql.schema import IPAMQuery
@ -14,6 +15,7 @@ from wireless.graphql.schema import WirelessQuery
class Query(
UsersQuery,
CircuitsQuery,
CoreQuery,
DCIMQuery,
ExtrasQuery,
IPAMQuery,

View File

@ -38,7 +38,7 @@ class NetBoxFeatureSet(
# Base model classes
#
class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model):
class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, WebhooksMixin, models.Model):
"""
Base model for ancillary models; provides limited functionality for models which don't
support NetBox's full feature set.

View File

@ -2,11 +2,12 @@ from collections import defaultdict
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db.models.signals import class_prepared
from django.dispatch import receiver
from django.core.validators import ValidationError
from django.db import models
from django.db.models.signals import class_prepared
from django.dispatch import receiver
from django.utils.translation import gettext as _
from taggit.managers import TaggableManager
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
@ -25,6 +26,7 @@ __all__ = (
'ExportTemplatesMixin',
'JobResultsMixin',
'JournalingMixin',
'SyncedDataMixin',
'TagsMixin',
'WebhooksMixin',
)
@ -313,12 +315,82 @@ class WebhooksMixin(models.Model):
abstract = True
class SyncedDataMixin(models.Model):
"""
Enables population of local data from a DataFile object, synchronized from a remote DatSource.
"""
data_source = models.ForeignKey(
to='core.DataSource',
on_delete=models.PROTECT,
blank=True,
null=True,
related_name='+',
help_text=_("Remote data source")
)
data_file = models.ForeignKey(
to='core.DataFile',
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name='+'
)
data_path = models.CharField(
max_length=1000,
blank=True,
editable=False,
help_text=_("Path to remote file (relative to data source root)")
)
data_synced = models.DateTimeField(
blank=True,
null=True,
editable=False
)
class Meta:
abstract = True
@property
def is_synced(self):
return self.data_file and self.data_synced >= self.data_file.last_updated
def clean(self):
if self.data_file:
self.sync_data()
self.data_path = self.data_file.path
if self.data_source and not self.data_file:
raise ValidationError({
'data_file': _(f"Must specify a data file when designating a data source.")
})
if self.data_file and not self.data_source:
self.data_source = self.data_file.source
super().clean()
def resolve_data_file(self):
"""
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
either attribute is unset, or if no matching DataFile is found.
"""
from core.models import DataFile
if self.data_source and self.data_path:
try:
return DataFile.objects.get(source=self.data_source, path=self.data_path)
except DataFile.DoesNotExist:
pass
def sync_data(self):
raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
FEATURES_MAP = (
('custom_fields', CustomFieldsMixin),
('custom_links', CustomLinksMixin),
('export_templates', ExportTemplatesMixin),
('job_results', JobResultsMixin),
('journaling', JournalingMixin),
('synced_data', SyncedDataMixin),
('tags', TagsMixin),
('webhooks', WebhooksMixin),
)
@ -344,3 +416,9 @@ def _register_features(sender, **kwargs):
'changelog',
kwargs={'model': sender}
)('netbox.views.generic.ObjectChangeLogView')
if issubclass(sender, SyncedDataMixin):
register_model_view(
sender,
'sync',
kwargs={'model': sender}
)('netbox.views.generic.ObjectSyncDataView')

View File

@ -287,6 +287,7 @@ OTHER_MENU = Menu(
MenuGroup(
label=_('Integrations'),
items=(
get_model_item('core', 'datasource', _('Data Sources')),
get_model_item('extras', 'webhook', _('Webhooks')),
MenuItem(
link='extras:report_list',

Some files were not shown because too many files have changed in this diff Show More