mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-23 07:56:44 -06:00
Merge branch 'feature' into 8356-vm-virtual-disk
This commit is contained in:
commit
6f295a1fc9
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.6.4
|
placeholder: v3.6.5
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.6.4
|
placeholder: v3.6.5
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: 🌍 Translation
|
||||||
|
description: Request support for a new language in the user interface
|
||||||
|
labels: ["type: translation"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: >
|
||||||
|
**NOTE:** This template is used only for proposing the addition of *new* languages. Please do
|
||||||
|
not use it to request changes to existing translations.
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Language
|
||||||
|
description: What is the name of the language in English?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: ISO 639-1 code
|
||||||
|
description: >
|
||||||
|
What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
|
||||||
|
assigned to the language?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Volunteer
|
||||||
|
description: Are you a fluent speaker of this language **and** willing to contribute a translation map?
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Comments
|
||||||
|
description: Any other notes you would like to share
|
@ -53,7 +53,8 @@ django-tables2
|
|||||||
|
|
||||||
# User-defined tags for objects
|
# User-defined tags for objects
|
||||||
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
|
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
|
||||||
django-taggit
|
# TODO: Upgrade to v5.0 for NetBox v3.7 beta
|
||||||
|
django-taggit<5.0
|
||||||
|
|
||||||
# A Django field for representing time zones
|
# A Django field for representing time zones
|
||||||
# https://github.com/mfogel/django-timezone-field/
|
# https://github.com/mfogel/django-timezone-field/
|
||||||
@ -125,10 +126,6 @@ PyYAML
|
|||||||
# https://github.com/psf/requests/blob/main/HISTORY.md
|
# https://github.com/psf/requests/blob/main/HISTORY.md
|
||||||
requests
|
requests
|
||||||
|
|
||||||
# Sentry SDK
|
|
||||||
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
|
|
||||||
sentry-sdk
|
|
||||||
|
|
||||||
# Social authentication framework
|
# Social authentication framework
|
||||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||||
social-auth-core
|
social-auth-core
|
||||||
|
@ -4,27 +4,15 @@
|
|||||||
|
|
||||||
### Enabling Error Reporting
|
### Enabling Error Reporting
|
||||||
|
|
||||||
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis.
|
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to True and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
|
||||||
|
|
||||||
```python
|
|
||||||
SENTRY_ENABLED = True
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using a Custom DSN
|
|
||||||
|
|
||||||
If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://examplePublicKey@o0.ingest.sentry.io/0
|
|
||||||
```
|
|
||||||
|
|
||||||
Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
SENTRY_ENABLED = True
|
SENTRY_ENABLED = True
|
||||||
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Setting `SENTRY_ENABLED` to False will disable the Sentry integration.
|
||||||
|
|
||||||
### Assigning Tags
|
### Assigning Tags
|
||||||
|
|
||||||
You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter:
|
You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter:
|
||||||
|
@ -18,6 +18,9 @@ Default: False
|
|||||||
|
|
||||||
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
|
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
The `sentry-sdk` Python package is required to enable Sentry integration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SENTRY_SAMPLE_RATE
|
## SENTRY_SAMPLE_RATE
|
||||||
|
@ -53,6 +53,10 @@ This store maintains all registered items for plugins, such as navigation menus,
|
|||||||
|
|
||||||
A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.
|
A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.
|
||||||
|
|
||||||
|
### `tables`
|
||||||
|
|
||||||
|
A dictionary mapping table classes to lists of extra columns that have been registered by plugins using the `register_table_column()` utility function. Each column is defined as a tuple of name and column instance.
|
||||||
|
|
||||||
### `views`
|
### `views`
|
||||||
|
|
||||||
A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`.
|
A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`.
|
||||||
|
@ -17,6 +17,7 @@ class MyModelIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('site', 'device', 'status', 'description')
|
||||||
```
|
```
|
||||||
|
|
||||||
A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below.
|
A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below.
|
||||||
|
@ -227,6 +227,17 @@ sudo sh -c "echo 'boto3' >> /opt/netbox/local_requirements.txt"
|
|||||||
!!! info
|
!!! info
|
||||||
These packages were previously required in NetBox v3.5 but now are optional.
|
These packages were previously required in NetBox v3.5 but now are optional.
|
||||||
|
|
||||||
|
### Sentry Integration
|
||||||
|
|
||||||
|
NetBox may be configured to send error reports to [Sentry](../administration/error-reporting.md) for analysis. This integration requires installation of the `sentry-sdk` Python library.
|
||||||
|
|
||||||
|
```no-highlight
|
||||||
|
sudo sh -c "echo 'sentry-sdk' >> /opt/netbox/local_requirements.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
Sentry integration was previously included by default in NetBox v3.6 but is now optional.
|
||||||
|
|
||||||
## Run the Upgrade Script
|
## Run the Upgrade Script
|
||||||
|
|
||||||
Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions:
|
Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions:
|
||||||
|
@ -14,8 +14,11 @@ class MyModelIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('site', 'device', 'status', 'description')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
|
||||||
|
|
||||||
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
|
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
@ -87,3 +87,28 @@ The table column classes listed below are supported for use in plugins. These cl
|
|||||||
options:
|
options:
|
||||||
members:
|
members:
|
||||||
- __init__
|
- __init__
|
||||||
|
|
||||||
|
## Extending Core Tables
|
||||||
|
|
||||||
|
!!! info "This feature was introduced in NetBox v3.7."
|
||||||
|
|
||||||
|
Plugins can register their own custom columns on core tables using the `register_table_column()` utility function. This allows a plugin to attach additional information, such as relationships to its own models, to built-in object lists.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import django_tables2
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from dcim.tables import SiteTable
|
||||||
|
from utilities.tables import register_table_column
|
||||||
|
|
||||||
|
mycol = django_tables2.Column(
|
||||||
|
verbose_name=_('My Column'),
|
||||||
|
accessor=django_tables2.A('description')
|
||||||
|
)
|
||||||
|
|
||||||
|
register_table_column(mycol, 'foo', SiteTable)
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll typically want to define an accessor identifying the desired model field or relationship when defining a custom column. See the [django-tables2 documentation](https://django-tables2.readthedocs.io/) for more information on creating custom columns.
|
||||||
|
|
||||||
|
::: utilities.tables.register_table_column
|
||||||
|
@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"attr": "tags",
|
"attr": "tags.slug",
|
||||||
"value": "exempt",
|
"value": "exempt",
|
||||||
"op": "contains"
|
"op": "contains"
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,36 @@
|
|||||||
# NetBox v3.6
|
# NetBox v3.6
|
||||||
|
|
||||||
## v3.6.5 (FUTURE)
|
## v3.6.6 (FUTURE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.6.5 (2023-11-09)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
|
||||||
|
* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
|
||||||
|
* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
|
||||||
|
* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
|
||||||
|
* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
|
||||||
|
* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
|
||||||
|
* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
|
||||||
|
* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
|
||||||
|
* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
|
||||||
|
* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
|
||||||
|
* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
|
||||||
|
* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
|
||||||
|
* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
|
||||||
|
* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
|
||||||
|
* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
|
||||||
|
* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
|
||||||
|
* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
|
||||||
|
* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
|
||||||
|
* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ class CircuitIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -22,6 +23,7 @@ class CircuitTerminationIndex(SearchIndex):
|
|||||||
('port_speed', 2000),
|
('port_speed', 2000),
|
||||||
('upstream_speed', 2000),
|
('upstream_speed', 2000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('circuit', 'site', 'provider_network', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -32,6 +34,7 @@ class CircuitTypeIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -42,6 +45,7 @@ class ProviderIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
class ProviderAccountIndex(SearchIndex):
|
class ProviderAccountIndex(SearchIndex):
|
||||||
@ -51,6 +55,7 @@ class ProviderAccountIndex(SearchIndex):
|
|||||||
('account', 200),
|
('account', 200),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('provider', 'account', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -62,3 +67,4 @@ class ProviderNetworkIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('provider', 'service_id', 'description')
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.choices import *
|
from core.choices import *
|
||||||
from core.models import *
|
from core.models import *
|
||||||
from extras.forms.mixins import SavedFiltersMixin
|
from extras.forms.mixins import SavedFiltersMixin
|
||||||
from extras.utils import FeatureQuery
|
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from netbox.utils import get_data_backend_choices
|
from netbox.utils import get_data_backend_choices
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
|
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
|
||||||
@ -69,7 +67,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
object_type = ContentTypeChoiceField(
|
object_type = ContentTypeChoiceField(
|
||||||
label=_('Object Type'),
|
label=_('Object Type'),
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()),
|
queryset=ContentType.objects.with_feature('jobs'),
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
status = forms.MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
|
@ -1,11 +1,20 @@
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from extras.models import ConfigRevision
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""Command to clear the entire cache."""
|
"""Command to clear the entire cache."""
|
||||||
help = 'Clears the cache.'
|
help = 'Clears the cache.'
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
|
# Fetch the current config revision from the cache
|
||||||
|
config_version = cache.get('config_version')
|
||||||
|
# Clear the cache
|
||||||
cache.clear()
|
cache.clear()
|
||||||
self.stdout.write('Cache has been cleared.', ending="\n")
|
self.stdout.write('Cache has been cleared.', ending="\n")
|
||||||
|
if config_version:
|
||||||
|
# Activate the current config revision
|
||||||
|
ConfigRevision.objects.get(id=config_version).activate()
|
||||||
|
self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")
|
||||||
|
@ -4,7 +4,6 @@ from django.conf import settings
|
|||||||
import django.core.validators
|
import django.core.validators
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import extras.utils
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -30,7 +29,7 @@ class Migration(migrations.Migration):
|
|||||||
('status', models.CharField(default='pending', max_length=30)),
|
('status', models.CharField(default='pending', max_length=30)),
|
||||||
('data', models.JSONField(blank=True, null=True)),
|
('data', models.JSONField(blank=True, null=True)),
|
||||||
('job_id', models.UUIDField(unique=True)),
|
('job_id', models.UUIDField(unique=True)),
|
||||||
('object_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
|
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
|
||||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
@ -21,6 +21,24 @@ class ContentTypeManager(ContentTypeManager_):
|
|||||||
q |= Q(app_label=app_label, model__in=models)
|
q |= Q(app_label=app_label, model__in=models)
|
||||||
return self.get_queryset().filter(q)
|
return self.get_queryset().filter(q)
|
||||||
|
|
||||||
|
def with_feature(self, feature):
|
||||||
|
"""
|
||||||
|
Return the ContentTypes only for models which are registered as supporting the specified feature. For example,
|
||||||
|
we can find all ContentTypes for models which support webhooks with
|
||||||
|
|
||||||
|
ContentType.objects.with_feature('webhooks')
|
||||||
|
"""
|
||||||
|
if feature not in registry['model_features']:
|
||||||
|
raise KeyError(
|
||||||
|
f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
q = Q()
|
||||||
|
for app_label, models in registry['model_features'][feature].items():
|
||||||
|
q |= Q(app_label=app_label, model__in=models)
|
||||||
|
|
||||||
|
return self.get_queryset().filter(q)
|
||||||
|
|
||||||
|
|
||||||
class ContentType(ContentType_):
|
class ContentType(ContentType_):
|
||||||
"""
|
"""
|
||||||
|
@ -6,7 +6,6 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -368,7 +367,7 @@ class AutoSyncRecord(models.Model):
|
|||||||
related_name='+'
|
related_name='+'
|
||||||
)
|
)
|
||||||
object_type = models.ForeignKey(
|
object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='+'
|
related_name='+'
|
||||||
)
|
)
|
||||||
|
@ -3,7 +3,7 @@ import uuid
|
|||||||
import django_rq
|
import django_rq
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -11,8 +11,8 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from core.choices import JobStatusChoices
|
from core.choices import JobStatusChoices
|
||||||
|
from core.models import ContentType
|
||||||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||||
from extras.utils import FeatureQuery
|
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
@ -28,9 +28,8 @@ class Job(models.Model):
|
|||||||
Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
|
Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
|
||||||
"""
|
"""
|
||||||
object_type = models.ForeignKey(
|
object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
related_name='jobs',
|
related_name='jobs',
|
||||||
limit_choices_to=FeatureQuery('jobs'),
|
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
object_id = models.PositiveBigIntegerField(
|
object_id = models.PositiveBigIntegerField(
|
||||||
@ -123,6 +122,15 @@ class Job(models.Model):
|
|||||||
def get_status_color(self):
|
def get_status_color(self):
|
||||||
return JobStatusChoices.colors.get(self.status)
|
return JobStatusChoices.colors.get(self.status)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Validate the assigned object type
|
||||||
|
if self.object_type not in ContentType.objects.with_feature('jobs'):
|
||||||
|
raise ValidationError(
|
||||||
|
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def duration(self):
|
def duration(self):
|
||||||
if not self.completed:
|
if not self.completed:
|
||||||
|
@ -11,6 +11,7 @@ class DataSourceIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('type', 'status', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
|
@ -19,7 +19,8 @@ class JobTable(NetBoxTable):
|
|||||||
)
|
)
|
||||||
object = tables.Column(
|
object = tables.Column(
|
||||||
verbose_name=_('Object'),
|
verbose_name=_('Object'),
|
||||||
linkify=True
|
linkify=True,
|
||||||
|
orderable=False
|
||||||
)
|
)
|
||||||
status = columns.ChoiceFieldColumn(
|
status = columns.ChoiceFieldColumn(
|
||||||
verbose_name=_('Status'),
|
verbose_name=_('Status'),
|
||||||
|
@ -3,10 +3,8 @@ from django.shortcuts import get_object_or_404
|
|||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.renderers import JSONRenderer
|
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.routers import APIRootView
|
from rest_framework.routers import APIRootView
|
||||||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
|
||||||
from rest_framework.viewsets import ViewSet
|
from rest_framework.viewsets import ViewSet
|
||||||
|
|
||||||
from circuits.models import Circuit
|
from circuits.models import Circuit
|
||||||
@ -14,12 +12,11 @@ from dcim import filtersets
|
|||||||
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from dcim.svg import CableTraceSVG
|
from dcim.svg import CableTraceSVG
|
||||||
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
|
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
|
||||||
from ipam.models import Prefix, VLAN
|
from ipam.models import Prefix, VLAN
|
||||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||||
from netbox.api.metadata import ContentTypeMetadata
|
from netbox.api.metadata import ContentTypeMetadata
|
||||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||||
from netbox.api.renderers import TextRenderer
|
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
|
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
|
||||||
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
|
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
|
||||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||||
@ -390,7 +387,7 @@ class PlatformViewSet(NetBoxModelViewSet):
|
|||||||
class DeviceViewSet(
|
class DeviceViewSet(
|
||||||
SequentialBulkCreatesMixin,
|
SequentialBulkCreatesMixin,
|
||||||
ConfigContextQuerySetMixin,
|
ConfigContextQuerySetMixin,
|
||||||
ConfigTemplateRenderMixin,
|
RenderConfigMixin,
|
||||||
NetBoxModelViewSet
|
NetBoxModelViewSet
|
||||||
):
|
):
|
||||||
queryset = Device.objects.prefetch_related(
|
queryset = Device.objects.prefetch_related(
|
||||||
@ -420,23 +417,6 @@ class DeviceViewSet(
|
|||||||
|
|
||||||
return serializers.DeviceWithConfigContextSerializer
|
return serializers.DeviceWithConfigContextSerializer
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
|
|
||||||
def render_config(self, request, pk):
|
|
||||||
"""
|
|
||||||
Resolve and render the preferred ConfigTemplate for this Device.
|
|
||||||
"""
|
|
||||||
device = self.get_object()
|
|
||||||
configtemplate = device.get_config_template()
|
|
||||||
if not configtemplate:
|
|
||||||
return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# Compile context data
|
|
||||||
context_data = device.get_config_context()
|
|
||||||
context_data.update(request.data)
|
|
||||||
context_data.update({'device': device})
|
|
||||||
|
|
||||||
return self.render_configtemplate(request, configtemplate, context_data)
|
|
||||||
|
|
||||||
|
|
||||||
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
|
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
|
||||||
queryset = VirtualDeviceContext.objects.prefetch_related(
|
queryset = VirtualDeviceContext.objects.prefetch_related(
|
||||||
|
@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
from extras.filtersets import LocalConfigContextFilterSet
|
from extras.filtersets import LocalConfigContextFilterSet
|
||||||
from extras.models import ConfigTemplate
|
from extras.models import ConfigTemplate
|
||||||
|
from ipam.filtersets import PrimaryIPFilterSet
|
||||||
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||||
from netbox.filtersets import (
|
from netbox.filtersets import (
|
||||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||||
@ -818,7 +819,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
|||||||
fields = ['id', 'name', 'slug', 'description']
|
fields = ['id', 'name', 'slug', 'description']
|
||||||
|
|
||||||
|
|
||||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
class DeviceFilterSet(
|
||||||
|
NetBoxModelFilterSet,
|
||||||
|
TenancyFilterSet,
|
||||||
|
ContactModelFilterSet,
|
||||||
|
LocalConfigContextFilterSet,
|
||||||
|
PrimaryIPFilterSet,
|
||||||
|
):
|
||||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='device_type__manufacturer',
|
field_name='device_type__manufacturer',
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
@ -994,16 +1001,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
|||||||
method='_device_bays',
|
method='_device_bays',
|
||||||
label=_('Has device bays'),
|
label=_('Has device bays'),
|
||||||
)
|
)
|
||||||
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
|
|
||||||
field_name='primary_ip4',
|
|
||||||
queryset=IPAddress.objects.all(),
|
|
||||||
label=_('Primary IPv4 (ID)'),
|
|
||||||
)
|
|
||||||
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
|
||||||
field_name='primary_ip6',
|
|
||||||
queryset=IPAddress.objects.all(),
|
|
||||||
label=_('Primary IPv6 (ID)'),
|
|
||||||
)
|
|
||||||
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
|
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='oob_ip',
|
field_name='oob_ip',
|
||||||
queryset=IPAddress.objects.all(),
|
queryset=IPAddress.objects.all(),
|
||||||
@ -1070,7 +1067,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
|||||||
return queryset.exclude(devicebays__isnull=value)
|
return queryset.exclude(devicebays__isnull=value)
|
||||||
|
|
||||||
|
|
||||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
||||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='device',
|
field_name='device',
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
|
@ -443,7 +443,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
platform = DynamicModelChoiceField(
|
platform = DynamicModelChoiceField(
|
||||||
label=_('Platform'),
|
label=_('Platform'),
|
||||||
queryset=Platform.objects.all(),
|
queryset=Platform.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
selector=True
|
||||||
)
|
)
|
||||||
cluster = DynamicModelChoiceField(
|
cluster = DynamicModelChoiceField(
|
||||||
label=_('Cluster'),
|
label=_('Cluster'),
|
||||||
|
@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
|||||||
)
|
)
|
||||||
self.fields['rear_port'].choices = choices
|
self.fields['rear_port'].choices = choices
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||||
|
# positions
|
||||||
|
frontport_count = len(self.cleaned_data['name'])
|
||||||
|
rearport_count = len(self.cleaned_data['rear_port'])
|
||||||
|
if frontport_count != rearport_count:
|
||||||
|
raise forms.ValidationError({
|
||||||
|
'rear_port': _(
|
||||||
|
"The number of front port templates to be created ({frontport_count}) must match the selected "
|
||||||
|
"number of rear port positions ({rearport_count})."
|
||||||
|
).format(
|
||||||
|
frontport_count=frontport_count,
|
||||||
|
rearport_count=rearport_count
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
def get_iterative_data(self, iteration):
|
def get_iterative_data(self, iteration):
|
||||||
|
|
||||||
# Assign rear port and position from selected set
|
# Assign rear port and position from selected set
|
||||||
@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
|||||||
)
|
)
|
||||||
self.fields['rear_port'].choices = choices
|
self.fields['rear_port'].choices = choices
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
||||||
|
frontport_count = len(self.cleaned_data['name'])
|
||||||
|
rearport_count = len(self.cleaned_data['rear_port'])
|
||||||
|
if frontport_count != rearport_count:
|
||||||
|
raise forms.ValidationError({
|
||||||
|
'rear_port': _(
|
||||||
|
"The number of front ports to be created ({frontport_count}) must match the selected number of "
|
||||||
|
"rear port positions ({rearport_count})."
|
||||||
|
).format(
|
||||||
|
frontport_count=frontport_count,
|
||||||
|
rearport_count=rearport_count
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
def get_iterative_data(self, iteration):
|
def get_iterative_data(self, iteration):
|
||||||
|
|
||||||
# Assign rear port and position from selected set
|
# Assign rear port and position from selected set
|
||||||
|
@ -2,7 +2,6 @@ import itertools
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
@ -10,12 +9,12 @@ from django.dispatch import Signal
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import ContentType
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.fields import PathField
|
from dcim.fields import PathField
|
||||||
from dcim.utils import decompile_path_node, object_to_path_node
|
from dcim.utils import decompile_path_node, object_to_path_node
|
||||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||||
|
|
||||||
from utilities.fields import ColorField
|
from utilities.fields import ColorField
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from utilities.utils import to_meters
|
from utilities.utils import to_meters
|
||||||
@ -180,6 +179,17 @@ class Cable(PrimaryModel):
|
|||||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||||
|
|
||||||
|
if a_type == b_type:
|
||||||
|
# can't directly use self.a_terminations here as possible they
|
||||||
|
# don't have pk yet
|
||||||
|
a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
|
||||||
|
b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
|
||||||
|
|
||||||
|
if (a_pks & b_pks):
|
||||||
|
raise ValidationError(
|
||||||
|
_("A and B terminations cannot connect to the same object.")
|
||||||
|
)
|
||||||
|
|
||||||
# Run clean() on any new CableTerminations
|
# Run clean() on any new CableTerminations
|
||||||
for termination in self.a_terminations:
|
for termination in self.a_terminations:
|
||||||
CableTermination(cable=self, cable_end='A', termination=termination).clean()
|
CableTermination(cable=self, cable_end='A', termination=termination).clean()
|
||||||
@ -247,7 +257,7 @@ class CableTermination(ChangeLoggedModel):
|
|||||||
verbose_name=_('end')
|
verbose_name=_('end')
|
||||||
)
|
)
|
||||||
termination_type = models.ForeignKey(
|
termination_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='+'
|
related_name='+'
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -709,7 +708,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
|
|||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
component_type = models.ForeignKey(
|
component_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
|
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='+',
|
related_name='+',
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -1181,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
|
|||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
component_type = models.ForeignKey(
|
component_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
limit_choices_to=MODULAR_COMPONENT_MODELS,
|
limit_choices_to=MODULAR_COMPONENT_MODELS,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='+',
|
related_name='+',
|
||||||
|
@ -10,6 +10,7 @@ class CableIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('type', 'status', 'tenant', 'label', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -21,6 +22,7 @@ class ConsolePortIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('speed', 2000),
|
('speed', 2000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -32,6 +34,7 @@ class ConsoleServerPortIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('speed', 2000),
|
('speed', 2000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -44,6 +47,9 @@ class DeviceIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = (
|
||||||
|
'site', 'location', 'rack', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'description',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -54,6 +60,7 @@ class DeviceBayIndex(SearchIndex):
|
|||||||
('label', 200),
|
('label', 200),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -64,6 +71,7 @@ class DeviceRoleIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -75,6 +83,7 @@ class DeviceTypeIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('manufacturer', 'part_number', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -85,6 +94,7 @@ class FrontPortIndex(SearchIndex):
|
|||||||
('label', 200),
|
('label', 200),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -99,6 +109,7 @@ class InterfaceIndex(SearchIndex):
|
|||||||
('mtu', 2000),
|
('mtu', 2000),
|
||||||
('speed', 2000),
|
('speed', 2000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -112,6 +123,7 @@ class InventoryItemIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('part_id', 2000),
|
('part_id', 2000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -122,6 +134,7 @@ class LocationIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('site', 'status', 'tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -132,6 +145,7 @@ class ManufacturerIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -143,6 +157,7 @@ class ModuleIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -153,6 +168,7 @@ class ModuleBayIndex(SearchIndex):
|
|||||||
('label', 200),
|
('label', 200),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'position', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -164,6 +180,7 @@ class ModuleTypeIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('manufacturer', 'model', 'part_number', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -174,6 +191,7 @@ class PlatformIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('manufacturer', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -184,6 +202,7 @@ class PowerFeedIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('power_panel', 'rack', 'status', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -194,6 +213,7 @@ class PowerOutletIndex(SearchIndex):
|
|||||||
('label', 200),
|
('label', 200),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -204,6 +224,7 @@ class PowerPanelIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('site', 'location', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -216,6 +237,7 @@ class PowerPortIndex(SearchIndex):
|
|||||||
('maximum_draw', 2000),
|
('maximum_draw', 2000),
|
||||||
('allocated_draw', 2000),
|
('allocated_draw', 2000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -229,6 +251,7 @@ class RackIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('site', 'location', 'facility_id', 'tenant', 'status', 'role', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -238,6 +261,7 @@ class RackReservationIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('rack', 'tenant', 'user', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -248,6 +272,7 @@ class RackRoleIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -258,6 +283,7 @@ class RearPortIndex(SearchIndex):
|
|||||||
('label', 200),
|
('label', 200),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'label', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -268,6 +294,7 @@ class RegionIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('parent', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -282,6 +309,7 @@ class SiteIndex(SearchIndex):
|
|||||||
('shipping_address', 2000),
|
('shipping_address', 2000),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('region', 'group', 'status', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -292,6 +320,7 @@ class SiteGroupIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('parent', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -303,6 +332,7 @@ class VirtualChassisIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('master', 'domain', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -314,3 +344,4 @@ class VirtualDeviceContextIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'status', 'identifier', 'description')
|
||||||
|
@ -466,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
|||||||
'args': [Accessor('device_id')],
|
'args': [Accessor('device_id')],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
maximum_draw = tables.Column(
|
||||||
|
verbose_name=_('Maximum draw (W)')
|
||||||
|
)
|
||||||
|
allocated_draw = tables.Column(
|
||||||
|
verbose_name=_('Allocated draw (W)')
|
||||||
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:powerport_list'
|
url_name='dcim:powerport_list'
|
||||||
)
|
)
|
||||||
@ -625,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
|||||||
verbose_name=_('VRF'),
|
verbose_name=_('VRF'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
inventory_items = tables.ManyToManyColumn(
|
||||||
|
linkify_item=True,
|
||||||
|
verbose_name=_('Inventory Items'),
|
||||||
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:interface_list'
|
url_name='dcim:interface_list'
|
||||||
)
|
)
|
||||||
@ -636,7 +646,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
|||||||
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
||||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
|
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
|
||||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||||
|
|
||||||
@ -933,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable):
|
|||||||
discovered = columns.BooleanColumn(
|
discovered = columns.BooleanColumn(
|
||||||
verbose_name=_('Discovered'),
|
verbose_name=_('Discovered'),
|
||||||
)
|
)
|
||||||
|
parent = tables.Column(
|
||||||
|
linkify=True,
|
||||||
|
verbose_name=_('Parent'),
|
||||||
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:inventoryitem_list'
|
url_name='dcim:inventoryitem_list'
|
||||||
)
|
)
|
||||||
@ -941,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = models.InventoryItem
|
model = models.InventoryItem
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||||
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
|
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
|
@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
|||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name=_('Tenant')
|
verbose_name=_('Tenant')
|
||||||
)
|
)
|
||||||
|
site = tables.Column(
|
||||||
|
accessor='rack__site',
|
||||||
|
linkify=True,
|
||||||
|
verbose_name=_('Site'),
|
||||||
|
)
|
||||||
comments = columns.MarkdownColumn(
|
comments = columns.MarkdownColumn(
|
||||||
verbose_name=_('Comments'),
|
verbose_name=_('Comments'),
|
||||||
)
|
)
|
||||||
@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = PowerFeed
|
model = PowerFeed
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage',
|
||||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant',
|
'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
|
||||||
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||||
|
@ -6,6 +6,7 @@ from rest_framework import status
|
|||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
|
from extras.models import ConfigTemplate
|
||||||
from ipam.models import ASN, RIR, VLAN, VRF
|
from ipam.models import ASN, RIR, VLAN, VRF
|
||||||
from netbox.api.serializers import GenericObjectSerializer
|
from netbox.api.serializers import GenericObjectSerializer
|
||||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
|
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
|
||||||
@ -1265,6 +1266,22 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
|||||||
|
|
||||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_render_config(self):
|
||||||
|
configtemplate = ConfigTemplate.objects.create(
|
||||||
|
name='Config Template 1',
|
||||||
|
template_code='Config for device {{ device.name }}'
|
||||||
|
)
|
||||||
|
|
||||||
|
device = Device.objects.first()
|
||||||
|
device.config_template = configtemplate
|
||||||
|
device.save()
|
||||||
|
|
||||||
|
self.add_permissions('dcim.add_device')
|
||||||
|
url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/'
|
||||||
|
response = self.client.post(url, {}, format='json', **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['content'], f'Config for device {device.name}')
|
||||||
|
|
||||||
|
|
||||||
class ModuleTest(APIViewTestCases.APIViewTestCase):
|
class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = Module
|
model = Module
|
||||||
|
@ -4712,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
addresses = (
|
addresses = (
|
||||||
IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
|
IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
|
||||||
IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
|
IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
|
||||||
|
IPAddress(assigned_object=None, address='10.1.1.3/24'),
|
||||||
|
IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'),
|
||||||
|
IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
|
||||||
|
IPAddress(assigned_object=None, address='2001:db8::3/64'),
|
||||||
)
|
)
|
||||||
IPAddress.objects.bulk_create(addresses)
|
IPAddress.objects.bulk_create(addresses)
|
||||||
|
|
||||||
vdcs[0].primary_ip4 = addresses[0]
|
vdcs[0].primary_ip4 = addresses[0]
|
||||||
|
vdcs[0].primary_ip6 = addresses[3]
|
||||||
vdcs[0].save()
|
vdcs[0].save()
|
||||||
vdcs[1].primary_ip4 = addresses[1]
|
vdcs[1].primary_ip4 = addresses[1]
|
||||||
|
vdcs[1].primary_ip6 = addresses[4]
|
||||||
vdcs[1].save()
|
vdcs[1].save()
|
||||||
|
|
||||||
def test_device(self):
|
def test_device(self):
|
||||||
@ -4738,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
params = {'has_primary_ip': False}
|
params = {'has_primary_ip': False}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
def test_primary_ip4(self):
|
||||||
|
addresses = IPAddress.objects.filter(address__family=4)
|
||||||
|
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||||
|
|
||||||
|
def test_primary_ip6(self):
|
||||||
|
addresses = IPAddress.objects.filter(address__family=6)
|
||||||
|
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||||
|
@ -2960,6 +2960,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
|
|||||||
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(InventoryItem, 'children')
|
||||||
|
class InventoryItemChildrenView(generic.ObjectChildrenView):
|
||||||
|
queryset = InventoryItem.objects.all()
|
||||||
|
child_model = InventoryItem
|
||||||
|
table = tables.InventoryItemTable
|
||||||
|
filterset = filtersets.InventoryItemFilterSet
|
||||||
|
template_name = 'generic/object_children.html'
|
||||||
|
tab = ViewTab(
|
||||||
|
label=_('Children'),
|
||||||
|
badge=lambda obj: obj.child_items.count(),
|
||||||
|
permission='dcim.view_inventoryitem',
|
||||||
|
hide_if_empty=True,
|
||||||
|
weight=5000
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_children(self, request, parent):
|
||||||
|
return parent.child_items.restrict(request.user, 'view')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Inventory item roles
|
# Inventory item roles
|
||||||
#
|
#
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
from jinja2.exceptions import TemplateError
|
from jinja2.exceptions import TemplateError
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.renderers import JSONRenderer
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
from netbox.api.renderers import TextRenderer
|
||||||
from .nested_serializers import NestedConfigTemplateSerializer
|
from .nested_serializers import NestedConfigTemplateSerializer
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextQuerySetMixin',
|
'ConfigContextQuerySetMixin',
|
||||||
|
'ConfigTemplateRenderMixin',
|
||||||
|
'RenderConfigMixin',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -31,7 +37,9 @@ class ConfigContextQuerySetMixin:
|
|||||||
|
|
||||||
|
|
||||||
class ConfigTemplateRenderMixin:
|
class ConfigTemplateRenderMixin:
|
||||||
|
"""
|
||||||
|
Provides a method to return a rendered ConfigTemplate as REST API data.
|
||||||
|
"""
|
||||||
def render_configtemplate(self, request, configtemplate, context):
|
def render_configtemplate(self, request, configtemplate, context):
|
||||||
try:
|
try:
|
||||||
output = configtemplate.render(context=context)
|
output = configtemplate.render(context=context)
|
||||||
@ -50,3 +58,28 @@ class ConfigTemplateRenderMixin:
|
|||||||
'configtemplate': template_serializer.data,
|
'configtemplate': template_serializer.data,
|
||||||
'content': output
|
'content': output
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class RenderConfigMixin(ConfigTemplateRenderMixin):
|
||||||
|
"""
|
||||||
|
Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned.
|
||||||
|
"""
|
||||||
|
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
|
||||||
|
def render_config(self, request, pk):
|
||||||
|
"""
|
||||||
|
Resolve and render the preferred ConfigTemplate for this Device.
|
||||||
|
"""
|
||||||
|
instance = self.get_object()
|
||||||
|
object_type = instance._meta.model_name
|
||||||
|
configtemplate = instance.get_config_template()
|
||||||
|
if not configtemplate:
|
||||||
|
return Response({
|
||||||
|
'error': f'No config template found for this {object_type}.'
|
||||||
|
}, status=HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Compile context data
|
||||||
|
context_data = instance.get_config_context()
|
||||||
|
context_data.update(request.data)
|
||||||
|
context_data.update({object_type: instance})
|
||||||
|
|
||||||
|
return self.render_configtemplate(request, configtemplate, context_data)
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from core.api.serializers import JobSerializer
|
from core.api.serializers import JobSerializer
|
||||||
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
|
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
|
||||||
|
from core.models import ContentType
|
||||||
from dcim.api.nested_serializers import (
|
from dcim.api.nested_serializers import (
|
||||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
||||||
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||||
@ -14,7 +14,6 @@ from drf_spectacular.utils import extend_schema_field
|
|||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
|
||||||
from netbox.api.exceptions import SerializerNotFound
|
from netbox.api.exceptions import SerializerNotFound
|
||||||
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||||
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
|
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
|
||||||
@ -64,7 +63,7 @@ __all__ = (
|
|||||||
class WebhookSerializer(NetBoxModelSerializer):
|
class WebhookSerializer(NetBoxModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail')
|
||||||
content_types = ContentTypeField(
|
content_types = ContentTypeField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
|
queryset=ContentType.objects.with_feature('webhooks'),
|
||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -85,7 +84,7 @@ class WebhookSerializer(NetBoxModelSerializer):
|
|||||||
class CustomFieldSerializer(ValidatedModelSerializer):
|
class CustomFieldSerializer(ValidatedModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
|
||||||
content_types = ContentTypeField(
|
content_types = ContentTypeField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
queryset=ContentType.objects.with_feature('custom_fields'),
|
||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
type = ChoiceField(choices=CustomFieldTypeChoices)
|
type = ChoiceField(choices=CustomFieldTypeChoices)
|
||||||
@ -151,7 +150,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
|
|||||||
class CustomLinkSerializer(ValidatedModelSerializer):
|
class CustomLinkSerializer(ValidatedModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail')
|
||||||
content_types = ContentTypeField(
|
content_types = ContentTypeField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
queryset=ContentType.objects.with_feature('custom_links'),
|
||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -170,7 +169,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
|
|||||||
class ExportTemplateSerializer(ValidatedModelSerializer):
|
class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
|
||||||
content_types = ContentTypeField(
|
content_types = ContentTypeField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
queryset=ContentType.objects.with_feature('export_templates'),
|
||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
data_source = NestedDataSourceSerializer(
|
data_source = NestedDataSourceSerializer(
|
||||||
@ -215,7 +214,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
|
|||||||
class BookmarkSerializer(ValidatedModelSerializer):
|
class BookmarkSerializer(ValidatedModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail')
|
||||||
object_type = ContentTypeField(
|
object_type = ContentTypeField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()),
|
queryset=ContentType.objects.with_feature('bookmarks'),
|
||||||
)
|
)
|
||||||
object = serializers.SerializerMethodField(read_only=True)
|
object = serializers.SerializerMethodField(read_only=True)
|
||||||
user = NestedUserSerializer()
|
user = NestedUserSerializer()
|
||||||
@ -239,7 +238,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
|
|||||||
class TagSerializer(ValidatedModelSerializer):
|
class TagSerializer(ValidatedModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
||||||
object_types = ContentTypeField(
|
object_types = ContentTypeField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
|
queryset=ContentType.objects.with_feature('tags'),
|
||||||
many=True,
|
many=True,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
@ -32,13 +32,20 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_content_type_labels():
|
def get_object_type_choices():
|
||||||
return [
|
return [
|
||||||
(content_type_identifier(ct), content_type_name(ct))
|
(content_type_identifier(ct), content_type_name(ct))
|
||||||
for ct in ContentType.objects.public().order_by('app_label', 'model')
|
for ct in ContentType.objects.public().order_by('app_label', 'model')
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_bookmarks_object_type_choices():
|
||||||
|
return [
|
||||||
|
(content_type_identifier(ct), content_type_name(ct))
|
||||||
|
for ct in ContentType.objects.with_feature('bookmarks').order_by('app_label', 'model')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_models_from_content_types(content_types):
|
def get_models_from_content_types(content_types):
|
||||||
"""
|
"""
|
||||||
Return a list of models corresponding to the given content types, identified by natural key.
|
Return a list of models corresponding to the given content types, identified by natural key.
|
||||||
@ -158,7 +165,7 @@ class ObjectCountsWidget(DashboardWidget):
|
|||||||
|
|
||||||
class ConfigForm(WidgetConfigForm):
|
class ConfigForm(WidgetConfigForm):
|
||||||
models = forms.MultipleChoiceField(
|
models = forms.MultipleChoiceField(
|
||||||
choices=get_content_type_labels
|
choices=get_object_type_choices
|
||||||
)
|
)
|
||||||
filters = forms.JSONField(
|
filters = forms.JSONField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -207,7 +214,7 @@ class ObjectListWidget(DashboardWidget):
|
|||||||
|
|
||||||
class ConfigForm(WidgetConfigForm):
|
class ConfigForm(WidgetConfigForm):
|
||||||
model = forms.ChoiceField(
|
model = forms.ChoiceField(
|
||||||
choices=get_content_type_labels
|
choices=get_object_type_choices
|
||||||
)
|
)
|
||||||
page_size = forms.IntegerField(
|
page_size = forms.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -343,8 +350,7 @@ class BookmarksWidget(DashboardWidget):
|
|||||||
|
|
||||||
class ConfigForm(WidgetConfigForm):
|
class ConfigForm(WidgetConfigForm):
|
||||||
object_types = forms.MultipleChoiceField(
|
object_types = forms.MultipleChoiceField(
|
||||||
# TODO: Restrict the choices by FeatureQuery('bookmarks')
|
choices=get_bookmarks_object_type_choices,
|
||||||
choices=get_content_type_labels,
|
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
order_by = forms.ChoiceField(
|
order_by = forms.ChoiceField(
|
||||||
|
@ -6,7 +6,6 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from core.models import ContentType
|
from core.models import ContentType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
|
||||||
from netbox.forms import NetBoxModelImportForm
|
from netbox.forms import NetBoxModelImportForm
|
||||||
from utilities.forms import CSVModelForm
|
from utilities.forms import CSVModelForm
|
||||||
from utilities.forms.fields import (
|
from utilities.forms.fields import (
|
||||||
@ -29,8 +28,7 @@ __all__ = (
|
|||||||
class CustomFieldImportForm(CSVModelForm):
|
class CustomFieldImportForm(CSVModelForm):
|
||||||
content_types = CSVMultipleContentTypeField(
|
content_types = CSVMultipleContentTypeField(
|
||||||
label=_('Content types'),
|
label=_('Content types'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('custom_fields'),
|
||||||
limit_choices_to=FeatureQuery('custom_fields'),
|
|
||||||
help_text=_("One or more assigned object types")
|
help_text=_("One or more assigned object types")
|
||||||
)
|
)
|
||||||
type = CSVChoiceField(
|
type = CSVChoiceField(
|
||||||
@ -88,8 +86,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
|||||||
class CustomLinkImportForm(CSVModelForm):
|
class CustomLinkImportForm(CSVModelForm):
|
||||||
content_types = CSVMultipleContentTypeField(
|
content_types = CSVMultipleContentTypeField(
|
||||||
label=_('Content types'),
|
label=_('Content types'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('custom_links'),
|
||||||
limit_choices_to=FeatureQuery('custom_links'),
|
|
||||||
help_text=_("One or more assigned object types")
|
help_text=_("One or more assigned object types")
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -104,8 +101,7 @@ class CustomLinkImportForm(CSVModelForm):
|
|||||||
class ExportTemplateImportForm(CSVModelForm):
|
class ExportTemplateImportForm(CSVModelForm):
|
||||||
content_types = CSVMultipleContentTypeField(
|
content_types = CSVMultipleContentTypeField(
|
||||||
label=_('Content types'),
|
label=_('Content types'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('export_templates'),
|
||||||
limit_choices_to=FeatureQuery('export_templates'),
|
|
||||||
help_text=_("One or more assigned object types")
|
help_text=_("One or more assigned object types")
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -142,8 +138,7 @@ class SavedFilterImportForm(CSVModelForm):
|
|||||||
class WebhookImportForm(NetBoxModelImportForm):
|
class WebhookImportForm(NetBoxModelImportForm):
|
||||||
content_types = CSVMultipleContentTypeField(
|
content_types = CSVMultipleContentTypeField(
|
||||||
label=_('Content types'),
|
label=_('Content types'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('webhooks'),
|
||||||
limit_choices_to=FeatureQuery('webhooks'),
|
|
||||||
help_text=_("One or more assigned object types")
|
help_text=_("One or more assigned object types")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -6,7 +6,6 @@ from core.models import ContentType, DataFile, DataSource
|
|||||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
|
||||||
from netbox.forms.base import NetBoxModelFilterSetForm
|
from netbox.forms.base import NetBoxModelFilterSetForm
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||||
@ -44,7 +43,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
content_type_id = ContentTypeMultipleChoiceField(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
queryset=ContentType.objects.with_feature('custom_fields'),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Object type')
|
label=_('Object type')
|
||||||
)
|
)
|
||||||
@ -108,7 +107,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
label=_('Content types'),
|
label=_('Content types'),
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
|
queryset=ContentType.objects.with_feature('custom_links'),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
@ -151,7 +150,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
content_type_id = ContentTypeMultipleChoiceField(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
queryset=ContentType.objects.with_feature('export_templates'),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Content types')
|
label=_('Content types')
|
||||||
)
|
)
|
||||||
@ -179,7 +178,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
content_type_id = ContentTypeChoiceField(
|
content_type_id = ContentTypeChoiceField(
|
||||||
label=_('Content type'),
|
label=_('Content type'),
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()),
|
queryset=ContentType.objects.with_feature('image_attachments'),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
name = forms.CharField(
|
name = forms.CharField(
|
||||||
@ -228,7 +227,7 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
|
|||||||
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
|
(_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')),
|
||||||
)
|
)
|
||||||
content_type_id = ContentTypeMultipleChoiceField(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
|
queryset=ContentType.objects.with_feature('webhooks'),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Object type')
|
label=_('Object type')
|
||||||
)
|
)
|
||||||
@ -284,12 +283,12 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
|
|||||||
class TagFilterForm(SavedFiltersMixin, FilterForm):
|
class TagFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
model = Tag
|
model = Tag
|
||||||
content_type_id = ContentTypeMultipleChoiceField(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
|
queryset=ContentType.objects.with_feature('tags'),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Tagged object type')
|
label=_('Tagged object type')
|
||||||
)
|
)
|
||||||
for_object_type_id = ContentTypeChoiceField(
|
for_object_type_id = ContentTypeChoiceField(
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
|
queryset=ContentType.objects.with_feature('tags'),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Allowed object type')
|
label=_('Allowed object type')
|
||||||
)
|
)
|
||||||
|
@ -10,7 +10,6 @@ from core.models import ContentType
|
|||||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
|
||||||
from netbox.config import get_config, PARAMS
|
from netbox.config import get_config, PARAMS
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
@ -43,8 +42,7 @@ __all__ = (
|
|||||||
class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
label=_('Content types'),
|
label=_('Content types'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('custom_fields')
|
||||||
limit_choices_to=FeatureQuery('custom_fields'),
|
|
||||||
)
|
)
|
||||||
object_type = ContentTypeChoiceField(
|
object_type = ContentTypeChoiceField(
|
||||||
label=_('Object type'),
|
label=_('Object type'),
|
||||||
@ -114,8 +112,7 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
|||||||
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
label=_('Content types'),
|
label=_('Content types'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('custom_links')
|
||||||
limit_choices_to=FeatureQuery('custom_links')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@ -142,8 +139,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
|||||||
class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
|
class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
label=_('Content types'),
|
label=_('Content types'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('export_templates')
|
||||||
limit_choices_to=FeatureQuery('export_templates')
|
|
||||||
)
|
)
|
||||||
template_code = forms.CharField(
|
template_code = forms.CharField(
|
||||||
label=_('Template code'),
|
label=_('Template code'),
|
||||||
@ -210,8 +206,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
|
|||||||
class BookmarkForm(BootstrapMixin, forms.ModelForm):
|
class BookmarkForm(BootstrapMixin, forms.ModelForm):
|
||||||
object_type = ContentTypeChoiceField(
|
object_type = ContentTypeChoiceField(
|
||||||
label=_('Object type'),
|
label=_('Object type'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('bookmarks')
|
||||||
limit_choices_to=FeatureQuery('bookmarks').get_query()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -222,8 +217,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm):
|
|||||||
class WebhookForm(NetBoxModelForm):
|
class WebhookForm(NetBoxModelForm):
|
||||||
content_types = ContentTypeMultipleChoiceField(
|
content_types = ContentTypeMultipleChoiceField(
|
||||||
label=_('Content types'),
|
label=_('Content types'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('webhooks')
|
||||||
limit_choices_to=FeatureQuery('webhooks')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@ -257,8 +251,7 @@ class TagForm(BootstrapMixin, forms.ModelForm):
|
|||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
object_types = ContentTypeMultipleChoiceField(
|
object_types = ContentTypeMultipleChoiceField(
|
||||||
label=_('Object types'),
|
label=_('Object types'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('tags'),
|
||||||
limit_choices_to=FeatureQuery('tags'),
|
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -88,7 +88,7 @@ class Migration(migrations.Migration):
|
|||||||
('secret', models.CharField(blank=True, max_length=255)),
|
('secret', models.CharField(blank=True, max_length=255)),
|
||||||
('ssl_verification', models.BooleanField(default=True)),
|
('ssl_verification', models.BooleanField(default=True)),
|
||||||
('ca_file_path', models.CharField(blank=True, max_length=4096, null=True)),
|
('ca_file_path', models.CharField(blank=True, max_length=4096, null=True)),
|
||||||
('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('webhooks'), related_name='webhooks', to='contenttypes.ContentType')),
|
('content_types', models.ManyToManyField(related_name='webhooks', to='contenttypes.ContentType')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ('name',),
|
'ordering': ('name',),
|
||||||
@ -151,7 +151,7 @@ class Migration(migrations.Migration):
|
|||||||
('status', models.CharField(default='pending', max_length=30)),
|
('status', models.CharField(default='pending', max_length=30)),
|
||||||
('data', models.JSONField(blank=True, null=True)),
|
('data', models.JSONField(blank=True, null=True)),
|
||||||
('job_id', models.UUIDField(unique=True)),
|
('job_id', models.UUIDField(unique=True)),
|
||||||
('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')),
|
('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')),
|
||||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
@ -184,7 +184,7 @@ class Migration(migrations.Migration):
|
|||||||
('mime_type', models.CharField(blank=True, max_length=50)),
|
('mime_type', models.CharField(blank=True, max_length=50)),
|
||||||
('file_extension', models.CharField(blank=True, max_length=15)),
|
('file_extension', models.CharField(blank=True, max_length=15)),
|
||||||
('as_attachment', models.BooleanField(default=True)),
|
('as_attachment', models.BooleanField(default=True)),
|
||||||
('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['content_type', 'name'],
|
'ordering': ['content_type', 'name'],
|
||||||
@ -201,7 +201,7 @@ class Migration(migrations.Migration):
|
|||||||
('group_name', models.CharField(blank=True, max_length=50)),
|
('group_name', models.CharField(blank=True, max_length=50)),
|
||||||
('button_class', models.CharField(default='default', max_length=30)),
|
('button_class', models.CharField(default='default', max_length=30)),
|
||||||
('new_window', models.BooleanField(default=False)),
|
('new_window', models.BooleanField(default=False)),
|
||||||
('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['group_name', 'weight', 'name'],
|
'ordering': ['group_name', 'weight', 'name'],
|
||||||
@ -223,7 +223,7 @@ class Migration(migrations.Migration):
|
|||||||
('validation_maximum', models.PositiveIntegerField(blank=True, null=True)),
|
('validation_maximum', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])),
|
('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])),
|
||||||
('choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)),
|
('choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)),
|
||||||
('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType')),
|
('content_types', models.ManyToManyField(related_name='custom_fields', to='contenttypes.ContentType')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['weight', 'name'],
|
'ordering': ['weight', 'name'],
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import extras.utils
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -13,7 +12,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='tag',
|
model_name='tag',
|
||||||
name='object_types',
|
name='object_types',
|
||||||
field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'),
|
field=models.ManyToManyField(blank=True, related_name='+', to='contenttypes.contenttype'),
|
||||||
),
|
),
|
||||||
migrations.RenameIndex(
|
migrations.RenameIndex(
|
||||||
model_name='taggeditem',
|
model_name='taggeditem',
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import ContentType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from ..querysets import ObjectChangeQuerySet
|
from ..querysets import ObjectChangeQuerySet
|
||||||
|
|
||||||
@ -48,7 +49,7 @@ class ObjectChange(models.Model):
|
|||||||
choices=ObjectChangeActionChoices
|
choices=ObjectChangeActionChoices
|
||||||
)
|
)
|
||||||
changed_object_type = models.ForeignKey(
|
changed_object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='+'
|
related_name='+'
|
||||||
)
|
)
|
||||||
@ -58,7 +59,7 @@ class ObjectChange(models.Model):
|
|||||||
fk_field='changed_object_id'
|
fk_field='changed_object_id'
|
||||||
)
|
)
|
||||||
related_object_type = models.ForeignKey(
|
related_object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='+',
|
related_name='+',
|
||||||
blank=True,
|
blank=True,
|
||||||
@ -104,6 +105,17 @@ class ObjectChange(models.Model):
|
|||||||
self.user_name
|
self.user_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Validate the assigned object type
|
||||||
|
if self.changed_object_type not in ContentType.objects.with_feature('change_logging'):
|
||||||
|
raise ValidationError(
|
||||||
|
_("Change logging is not supported for this object type ({type}).").format(
|
||||||
|
type=self.changed_object_type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
# Record the user's name and the object's representation as static strings
|
# Record the user's name and the object's representation as static strings
|
||||||
|
@ -5,18 +5,16 @@ from datetime import datetime, date
|
|||||||
import django_filters
|
import django_filters
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.validators import RegexValidator, ValidationError
|
from django.core.validators import RegexValidator, ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import escape
|
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import ContentType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.data import CHOICE_SETS
|
from extras.data import CHOICE_SETS
|
||||||
from extras.utils import FeatureQuery
|
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
||||||
from netbox.search import FieldTypes
|
from netbox.search import FieldTypes
|
||||||
@ -60,9 +58,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
|||||||
|
|
||||||
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
content_types = models.ManyToManyField(
|
content_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
related_name='custom_fields',
|
related_name='custom_fields',
|
||||||
limit_choices_to=FeatureQuery('custom_fields'),
|
|
||||||
help_text=_('The object(s) to which this field applies.')
|
help_text=_('The object(s) to which this field applies.')
|
||||||
)
|
)
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
@ -73,7 +70,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
help_text=_('The type of data this custom field holds')
|
help_text=_('The type of data this custom field holds')
|
||||||
)
|
)
|
||||||
object_type = models.ForeignKey(
|
object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
|
@ -3,7 +3,6 @@ import urllib.parse
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -14,10 +13,11 @@ from django.utils.formats import date_format
|
|||||||
from django.utils.translation import gettext, gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
from rest_framework.utils.encoders import JSONEncoder
|
from rest_framework.utils.encoders import JSONEncoder
|
||||||
|
|
||||||
|
from core.models import ContentType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.conditions import ConditionSet
|
from extras.conditions import ConditionSet
|
||||||
from extras.constants import *
|
from extras.constants import *
|
||||||
from extras.utils import FeatureQuery, image_upload
|
from extras.utils import image_upload
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import (
|
from netbox.models.features import (
|
||||||
@ -45,10 +45,9 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
|
|||||||
Each Webhook can be limited to firing only on certain actions or certain object types.
|
Each Webhook can be limited to firing only on certain actions or certain object types.
|
||||||
"""
|
"""
|
||||||
content_types = models.ManyToManyField(
|
content_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
related_name='webhooks',
|
related_name='webhooks',
|
||||||
verbose_name=_('object types'),
|
verbose_name=_('object types'),
|
||||||
limit_choices_to=FeatureQuery('webhooks'),
|
|
||||||
help_text=_("The object(s) to which this Webhook applies.")
|
help_text=_("The object(s) to which this Webhook applies.")
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
@ -235,7 +234,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
code to be rendered with an object as context.
|
code to be rendered with an object as context.
|
||||||
"""
|
"""
|
||||||
content_types = models.ManyToManyField(
|
content_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
related_name='custom_links',
|
related_name='custom_links',
|
||||||
help_text=_('The object type(s) to which this link applies.')
|
help_text=_('The object type(s) to which this link applies.')
|
||||||
)
|
)
|
||||||
@ -331,7 +330,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
|
|
||||||
class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
content_types = models.ManyToManyField(
|
content_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
related_name='export_templates',
|
related_name='export_templates',
|
||||||
help_text=_('The object type(s) to which this template applies.')
|
help_text=_('The object type(s) to which this template applies.')
|
||||||
)
|
)
|
||||||
@ -440,7 +439,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
A set of predefined keyword parameters that can be reused to filter for specific objects.
|
A set of predefined keyword parameters that can be reused to filter for specific objects.
|
||||||
"""
|
"""
|
||||||
content_types = models.ManyToManyField(
|
content_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
related_name='saved_filters',
|
related_name='saved_filters',
|
||||||
help_text=_('The object type(s) to which this filter applies.')
|
help_text=_('The object type(s) to which this filter applies.')
|
||||||
)
|
)
|
||||||
@ -520,7 +519,7 @@ class ImageAttachment(ChangeLoggedModel):
|
|||||||
An uploaded image which is associated with an object.
|
An uploaded image which is associated with an object.
|
||||||
"""
|
"""
|
||||||
content_type = models.ForeignKey(
|
content_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.CASCADE
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
object_id = models.PositiveBigIntegerField()
|
object_id = models.PositiveBigIntegerField()
|
||||||
@ -560,6 +559,15 @@ class ImageAttachment(ChangeLoggedModel):
|
|||||||
filename = self.image.name.rsplit('/', 1)[-1]
|
filename = self.image.name.rsplit('/', 1)[-1]
|
||||||
return filename.split('_', 2)[2]
|
return filename.split('_', 2)[2]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Validate the assigned object type
|
||||||
|
if self.content_type not in ContentType.objects.with_feature('image_attachments'):
|
||||||
|
raise ValidationError(
|
||||||
|
_("Image attachments cannot be assigned to this object type ({type}).").format(type=self.content_type)
|
||||||
|
)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
|
|
||||||
_name = self.image.name
|
_name = self.image.name
|
||||||
@ -605,7 +613,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
|
|||||||
might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded.
|
might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded.
|
||||||
"""
|
"""
|
||||||
assigned_object_type = models.ForeignKey(
|
assigned_object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.CASCADE
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
assigned_object_id = models.PositiveBigIntegerField()
|
assigned_object_id = models.PositiveBigIntegerField()
|
||||||
@ -644,9 +652,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Prevent the creation of journal entries on unsupported models
|
# Validate the assigned object type
|
||||||
permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query())
|
if self.assigned_object_type not in ContentType.objects.with_feature('journaling'):
|
||||||
if self.assigned_object_type not in permitted_types:
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type)
|
_("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type)
|
||||||
)
|
)
|
||||||
@ -664,7 +671,7 @@ class Bookmark(models.Model):
|
|||||||
auto_now_add=True
|
auto_now_add=True
|
||||||
)
|
)
|
||||||
object_type = models.ForeignKey(
|
object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.PROTECT
|
on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
object_id = models.PositiveBigIntegerField()
|
object_id = models.PositiveBigIntegerField()
|
||||||
@ -695,6 +702,15 @@ class Bookmark(models.Model):
|
|||||||
return str(self.object)
|
return str(self.object)
|
||||||
return super().__str__()
|
return super().__str__()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Validate the assigned object type
|
||||||
|
if self.object_type not in ContentType.objects.with_feature('bookmarks'):
|
||||||
|
raise ValidationError(
|
||||||
|
_("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ConfigRevision(models.Model):
|
class ConfigRevision(models.Model):
|
||||||
"""
|
"""
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from netbox.search.utils import get_indexer
|
||||||
|
from netbox.registry import registry
|
||||||
from utilities.fields import RestrictedGenericForeignKey
|
from utilities.fields import RestrictedGenericForeignKey
|
||||||
|
from utilities.utils import content_type_identifier
|
||||||
from ..fields import CachedValueField
|
from ..fields import CachedValueField
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -24,7 +26,7 @@ class CachedValue(models.Model):
|
|||||||
editable=False
|
editable=False
|
||||||
)
|
)
|
||||||
object_type = models.ForeignKey(
|
object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='+'
|
related_name='+'
|
||||||
)
|
)
|
||||||
@ -58,3 +60,19 @@ class CachedValue(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_attrs(self):
|
||||||
|
"""
|
||||||
|
Render any display attributes associated with this search result.
|
||||||
|
"""
|
||||||
|
indexer = get_indexer(self.object_type)
|
||||||
|
attrs = {}
|
||||||
|
for attr in indexer.display_attrs:
|
||||||
|
name = self.object._meta.get_field(attr).verbose_name
|
||||||
|
if value := getattr(self.object, attr):
|
||||||
|
if display_func := getattr(self.object, f'get_{attr}_display', None):
|
||||||
|
attrs[name] = display_func()
|
||||||
|
else:
|
||||||
|
attrs[name] = value
|
||||||
|
return attrs
|
||||||
|
@ -2,7 +2,6 @@ import logging
|
|||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -71,7 +70,7 @@ class StagedChange(ChangeLoggedModel):
|
|||||||
choices=ChangeActionChoices
|
choices=ChangeActionChoices
|
||||||
)
|
)
|
||||||
object_type = models.ForeignKey(
|
object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='+'
|
related_name='+'
|
||||||
)
|
)
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from taggit.models import TagBase, GenericTaggedItemBase
|
from taggit.models import TagBase, GenericTaggedItemBase
|
||||||
|
|
||||||
from extras.utils import FeatureQuery
|
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
||||||
from utilities.choices import ColorChoices
|
from utilities.choices import ColorChoices
|
||||||
@ -37,9 +34,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
|
|||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
object_types = models.ManyToManyField(
|
object_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
related_name='+',
|
related_name='+',
|
||||||
limit_choices_to=FeatureQuery('tags'),
|
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=_("The object type(s) to which this this tag can be applied.")
|
help_text=_("The object type(s) to which this this tag can be applied.")
|
||||||
)
|
)
|
||||||
|
@ -457,7 +457,7 @@ class ConfigContextTestCase(
|
|||||||
'platforms': [],
|
'platforms': [],
|
||||||
'tenant_groups': [],
|
'tenant_groups': [],
|
||||||
'tenants': [],
|
'tenants': [],
|
||||||
'device_types': [devicetype.id,],
|
'device_types': [devicetype.id],
|
||||||
'tags': [],
|
'tags': [],
|
||||||
'data': '{"foo": 123}',
|
'data': '{"foo": 123}',
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
from django.db.models import Q
|
|
||||||
from django.utils.deconstruct import deconstructible
|
|
||||||
from taggit.managers import _TaggableManager
|
from taggit.managers import _TaggableManager
|
||||||
|
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
@ -31,29 +29,6 @@ def image_upload(instance, filename):
|
|||||||
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
|
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
|
||||||
|
|
||||||
|
|
||||||
@deconstructible
|
|
||||||
class FeatureQuery:
|
|
||||||
"""
|
|
||||||
Helper class that delays evaluation of the registry contents for the functionality store
|
|
||||||
until it has been populated.
|
|
||||||
"""
|
|
||||||
def __init__(self, feature):
|
|
||||||
self.feature = feature
|
|
||||||
|
|
||||||
def __call__(self):
|
|
||||||
return self.get_query()
|
|
||||||
|
|
||||||
def get_query(self):
|
|
||||||
"""
|
|
||||||
Given an extras feature, return a Q object for content type lookup
|
|
||||||
"""
|
|
||||||
query = Q()
|
|
||||||
for app_label, models in registry['model_features'][self.feature].items():
|
|
||||||
query |= Q(app_label=app_label, model__in=models)
|
|
||||||
|
|
||||||
return query
|
|
||||||
|
|
||||||
|
|
||||||
def register_features(model, features):
|
def register_features(model, features):
|
||||||
"""
|
"""
|
||||||
Register model features in the application registry.
|
Register model features in the application registry.
|
||||||
|
@ -29,6 +29,7 @@ __all__ = (
|
|||||||
'L2VPNFilterSet',
|
'L2VPNFilterSet',
|
||||||
'L2VPNTerminationFilterSet',
|
'L2VPNTerminationFilterSet',
|
||||||
'PrefixFilterSet',
|
'PrefixFilterSet',
|
||||||
|
'PrimaryIPFilterSet',
|
||||||
'RIRFilterSet',
|
'RIRFilterSet',
|
||||||
'RoleFilterSet',
|
'RoleFilterSet',
|
||||||
'RouteTargetFilterSet',
|
'RouteTargetFilterSet',
|
||||||
@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
)
|
)
|
||||||
mask_length = MultiValueNumberFilter(
|
mask_length = MultiValueNumberFilter(
|
||||||
field_name='prefix',
|
field_name='prefix',
|
||||||
lookup_expr='net_mask_length'
|
lookup_expr='net_mask_length',
|
||||||
|
label=_('Mask length')
|
||||||
)
|
)
|
||||||
mask_length__gte = django_filters.NumberFilter(
|
mask_length__gte = django_filters.NumberFilter(
|
||||||
field_name='prefix',
|
field_name='prefix',
|
||||||
@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
method='filter_address',
|
method='filter_address',
|
||||||
label=_('Address'),
|
label=_('Address'),
|
||||||
)
|
)
|
||||||
mask_length = django_filters.NumberFilter(
|
mask_length = MultiValueNumberFilter(
|
||||||
method='filter_mask_length',
|
field_name='address',
|
||||||
label=_('Mask length'),
|
lookup_expr='net_mask_length',
|
||||||
|
label=_('Mask length')
|
||||||
|
)
|
||||||
|
mask_length__gte = django_filters.NumberFilter(
|
||||||
|
field_name='address',
|
||||||
|
lookup_expr='net_mask_length__gte'
|
||||||
|
)
|
||||||
|
mask_length__lte = django_filters.NumberFilter(
|
||||||
|
field_name='address',
|
||||||
|
lookup_expr='net_mask_length__lte'
|
||||||
)
|
)
|
||||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
except ValidationError:
|
except ValidationError:
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
def filter_mask_length(self, queryset, name, value):
|
|
||||||
if not value:
|
|
||||||
return queryset
|
|
||||||
return queryset.filter(address__net_mask_length=value)
|
|
||||||
|
|
||||||
@extend_schema_field(OpenApiTypes.STR)
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||||
if vrf is None:
|
if vrf is None:
|
||||||
@ -1227,3 +1233,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class PrimaryIPFilterSet(django_filters.FilterSet):
|
||||||
|
"""
|
||||||
|
An inheritable FilterSet for models which support primary IP assignment.
|
||||||
|
"""
|
||||||
|
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='primary_ip4',
|
||||||
|
queryset=IPAddress.objects.all(),
|
||||||
|
label=_('Primary IPv4 (ID)'),
|
||||||
|
)
|
||||||
|
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='primary_ip6',
|
||||||
|
queryset=IPAddress.objects.all(),
|
||||||
|
label=_('Primary IPv6 (ID)'),
|
||||||
|
)
|
||||||
|
@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm):
|
|||||||
choices=ServiceProtocolChoices,
|
choices=ServiceProtocolChoices,
|
||||||
help_text=_('IP protocol')
|
help_text=_('IP protocol')
|
||||||
)
|
)
|
||||||
|
ipaddresses = CSVModelMultipleChoiceField(
|
||||||
|
queryset=IPAddress.objects.all(),
|
||||||
|
required=False,
|
||||||
|
to_field_name='address',
|
||||||
|
help_text=_('IP Address'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Service
|
model = Service
|
||||||
fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags')
|
fields = (
|
||||||
|
'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_ipaddresses(self):
|
||||||
|
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
|
||||||
|
for ip_address in self.cleaned_data['ipaddresses']:
|
||||||
|
if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_("{ip} is not assigned to this device/VM.").format(ip=ip_address)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.cleaned_data['ipaddresses']
|
||||||
|
|
||||||
|
|
||||||
class L2VPNImportForm(NetBoxModelImportForm):
|
class L2VPNImportForm(NetBoxModelImportForm):
|
||||||
|
@ -523,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
|||||||
|
|
||||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||||
model = Service
|
model = Service
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
|
(_('Attributes'), ('protocol', 'port')),
|
||||||
|
(_('Assignment'), ('device_id', 'virtual_machine_id')),
|
||||||
|
)
|
||||||
|
device_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Device.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Device'),
|
||||||
|
)
|
||||||
|
virtual_machine_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=VirtualMachine.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Virtual Machine'),
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
|
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'FHRPGroup',
|
'FHRPGroup',
|
||||||
@ -78,7 +77,7 @@ class FHRPGroup(PrimaryModel):
|
|||||||
|
|
||||||
class FHRPGroupAssignment(ChangeLoggedModel):
|
class FHRPGroupAssignment(ChangeLoggedModel):
|
||||||
interface_type = models.ForeignKey(
|
interface_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.CASCADE
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
interface_id = models.PositiveBigIntegerField()
|
interface_id = models.PositiveBigIntegerField()
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import netaddr
|
import netaddr
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
@ -9,6 +8,7 @@ from django.urls import reverse
|
|||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import ContentType
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
from ipam.fields import IPNetworkField, IPAddressField
|
from ipam.fields import IPNetworkField, IPAddressField
|
||||||
@ -740,7 +740,7 @@ class IPAddress(PrimaryModel):
|
|||||||
help_text=_('The functional role of this IP')
|
help_text=_('The functional role of this IP')
|
||||||
)
|
)
|
||||||
assigned_object_type = models.ForeignKey(
|
assigned_object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
|
limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='+',
|
related_name='+',
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import ContentType
|
||||||
from ipam.choices import L2VPNTypeChoices
|
from ipam.choices import L2VPNTypeChoices
|
||||||
from ipam.constants import L2VPN_ASSIGNMENT_MODELS
|
from ipam.constants import L2VPN_ASSIGNMENT_MODELS
|
||||||
from netbox.models import NetBoxModel, PrimaryModel
|
from netbox.models import NetBoxModel, PrimaryModel
|
||||||
@ -86,7 +86,7 @@ class L2VPNTermination(NetBoxModel):
|
|||||||
related_name='terminations'
|
related_name='terminations'
|
||||||
)
|
)
|
||||||
assigned_object_type = models.ForeignKey(
|
assigned_object_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
|
limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='+'
|
related_name='+'
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -32,7 +31,7 @@ class VLANGroup(OrganizationalModel):
|
|||||||
max_length=100
|
max_length=100
|
||||||
)
|
)
|
||||||
scope_type = models.ForeignKey(
|
scope_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
|
limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
|
||||||
blank=True,
|
blank=True,
|
||||||
|
@ -11,6 +11,7 @@ class AggregateIndex(SearchIndex):
|
|||||||
('date_added', 2000),
|
('date_added', 2000),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('rir', 'tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -20,6 +21,7 @@ class ASNIndex(SearchIndex):
|
|||||||
('asn', 100),
|
('asn', 100),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('rir', 'tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -28,6 +30,7 @@ class ASNRangeIndex(SearchIndex):
|
|||||||
fields = (
|
fields = (
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('rir', 'tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -39,6 +42,7 @@ class FHRPGroupIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('protocol', 'auth_type', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -50,6 +54,7 @@ class IPAddressIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -61,6 +66,7 @@ class IPRangeIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -72,6 +78,7 @@ class L2VPNIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('type', 'identifier', 'tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -82,6 +89,7 @@ class PrefixIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -92,6 +100,7 @@ class RIRIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -102,6 +111,7 @@ class RoleIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -112,6 +122,7 @@ class RouteTargetIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -122,6 +133,7 @@ class ServiceIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('device', 'virtual_machine', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -132,6 +144,7 @@ class ServiceTemplateIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -143,6 +156,7 @@ class VLANIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('site', 'group', 'tenant', 'status', 'role', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -154,6 +168,7 @@ class VLANGroupIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('max_vid', 2000),
|
('max_vid', 2000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -165,3 +180,4 @@ class VRFIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('rd', 'tenant', 'description')
|
||||||
|
@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_mask_length(self):
|
def test_mask_length(self):
|
||||||
params = {'mask_length': ['24']}
|
params = {'mask_length': [24]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
params = {'mask_length__gte': 32}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||||
|
params = {'mask_length__lte': 24}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||||
|
|
||||||
def test_vrf(self):
|
def test_vrf(self):
|
||||||
vrfs = VRF.objects.all()[:2]
|
vrfs = VRF.objects.all()[:2]
|
||||||
@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_mask_length(self):
|
def test_mask_length(self):
|
||||||
params = {'mask_length': '24'}
|
params = {'mask_length': [24]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||||
|
params = {'mask_length__gte': 64}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||||
|
params = {'mask_length__lte': 25}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||||
|
|
||||||
def test_vrf(self):
|
def test_vrf(self):
|
||||||
vrfs = VRF.objects.all()[:2]
|
vrfs = VRF.objects.all()[:2]
|
||||||
|
@ -4,6 +4,7 @@ from django.test import override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from netaddr import IPNetwork
|
from netaddr import IPNetwork
|
||||||
|
|
||||||
|
from dcim.constants import InterfaceTypeChoices
|
||||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
|
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.models import *
|
from ipam.models import *
|
||||||
@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
|
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
|
||||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||||
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
|
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
|
||||||
|
interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
||||||
|
|
||||||
services = (
|
services = (
|
||||||
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
|
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
|
||||||
@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
)
|
)
|
||||||
Service.objects.bulk_create(services)
|
Service.objects.bulk_create(services)
|
||||||
|
|
||||||
|
ip_addresses = (
|
||||||
|
IPAddress(assigned_object=interface, address='192.0.2.1/24'),
|
||||||
|
IPAddress(assigned_object=interface, address='192.0.2.2/24'),
|
||||||
|
)
|
||||||
|
IPAddress.objects.bulk_create(ip_addresses)
|
||||||
|
|
||||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||||
|
|
||||||
cls.form_data = {
|
cls.form_data = {
|
||||||
@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"device,name,protocol,ports,description",
|
"device,name,protocol,ports,ipaddresses,description",
|
||||||
"Device 1,Service 1,tcp,1,First service",
|
"Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
|
||||||
"Device 1,Service 2,tcp,2,Second service",
|
"Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
|
||||||
"Device 1,Service 3,udp,3,Third service",
|
"Device 1,Service 3,udp,3,,Third service",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
|
|||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('ASNs'),
|
label=_('ASNs'),
|
||||||
badge=lambda x: x.get_child_asns().count(),
|
badge=lambda x: x.get_child_asns().count(),
|
||||||
permission='ipam.view_asns',
|
permission='ipam.view_asn',
|
||||||
weight=500
|
weight=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@ from collections import defaultdict
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.validators import ValidationError
|
from django.core.validators import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.signals import class_prepared
|
from django.db.models.signals import class_prepared
|
||||||
@ -13,6 +12,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
from core.choices import JobStatusChoices
|
from core.choices import JobStatusChoices
|
||||||
|
from core.models import ContentType
|
||||||
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
||||||
from extras.utils import is_taggable, register_features
|
from extras.utils import is_taggable, register_features
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
|
@ -28,6 +28,7 @@ registry = Registry({
|
|||||||
'models': collections.defaultdict(set),
|
'models': collections.defaultdict(set),
|
||||||
'plugins': dict(),
|
'plugins': dict(),
|
||||||
'search': dict(),
|
'search': dict(),
|
||||||
|
'tables': collections.defaultdict(dict),
|
||||||
'views': collections.defaultdict(dict),
|
'views': collections.defaultdict(dict),
|
||||||
'widgets': dict(),
|
'widgets': dict(),
|
||||||
})
|
})
|
||||||
|
@ -33,10 +33,12 @@ class SearchIndex:
|
|||||||
category: The label of the group under which this indexer is categorized (for form field display). If none,
|
category: The label of the group under which this indexer is categorized (for form field display). If none,
|
||||||
the name of the model's app will be used.
|
the name of the model's app will be used.
|
||||||
fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each.
|
fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each.
|
||||||
|
display_attrs: An iterable of additional object attributes to include when displaying search results.
|
||||||
"""
|
"""
|
||||||
model = None
|
model = None
|
||||||
category = None
|
category = None
|
||||||
fields = ()
|
fields = ()
|
||||||
|
display_attrs = ()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_field_type(instance, field_name):
|
def get_field_type(instance, field_name):
|
||||||
|
@ -3,7 +3,8 @@ from collections import defaultdict
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.db.models import F, Window, Q
|
from django.db.models import F, Window, Q, prefetch_related_objects
|
||||||
|
from django.db.models.fields.related import ForeignKey
|
||||||
from django.db.models.functions import window
|
from django.db.models.functions import window
|
||||||
from django.db.models.signals import post_delete, post_save
|
from django.db.models.signals import post_delete, post_save
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
@ -13,7 +14,7 @@ from netaddr.core import AddrFormatError
|
|||||||
from extras.models import CachedValue, CustomField
|
from extras.models import CachedValue, CustomField
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
from utilities.querysets import RestrictedPrefetch
|
from utilities.querysets import RestrictedPrefetch
|
||||||
from utilities.utils import title
|
from utilities.utils import content_type_identifier, title
|
||||||
from . import FieldTypes, LookupTypes, get_indexer
|
from . import FieldTypes, LookupTypes, get_indexer
|
||||||
|
|
||||||
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
|
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
|
||||||
@ -103,17 +104,17 @@ class CachedValueSearchBackend(SearchBackend):
|
|||||||
|
|
||||||
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
||||||
|
|
||||||
|
# Build the filter used to find relevant CachedValue records
|
||||||
query_filter = Q(**{f'value__{lookup}': value})
|
query_filter = Q(**{f'value__{lookup}': value})
|
||||||
|
|
||||||
if object_types:
|
if object_types:
|
||||||
|
# Limit results by object type
|
||||||
query_filter &= Q(object_type__in=object_types)
|
query_filter &= Q(object_type__in=object_types)
|
||||||
|
|
||||||
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
||||||
# Partial string matches are valid only on string values
|
# "Starts/ends with" matches are valid only on string values
|
||||||
query_filter &= Q(type=FieldTypes.STRING)
|
query_filter &= Q(type=FieldTypes.STRING)
|
||||||
|
elif lookup == LookupTypes.PARTIAL:
|
||||||
if lookup == LookupTypes.PARTIAL:
|
|
||||||
try:
|
try:
|
||||||
|
# If the value looks like an IP address, add an extra match for CIDR values
|
||||||
address = str(netaddr.IPNetwork(value.strip()).cidr)
|
address = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||||
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
|
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
|
||||||
except (AddrFormatError, ValueError):
|
except (AddrFormatError, ValueError):
|
||||||
@ -129,6 +130,12 @@ class CachedValueSearchBackend(SearchBackend):
|
|||||||
)
|
)
|
||||||
)[:MAX_RESULTS]
|
)[:MAX_RESULTS]
|
||||||
|
|
||||||
|
# Gather all ContentTypes present in the search results (used for prefetching related
|
||||||
|
# objects). This must be done before generating the final results list, which returns
|
||||||
|
# a RawQuerySet.
|
||||||
|
content_type_ids = set(queryset.values_list('object_type', flat=True))
|
||||||
|
content_types = ContentType.objects.filter(pk__in=content_type_ids)
|
||||||
|
|
||||||
# Construct a Prefetch to pre-fetch only those related objects for which the
|
# Construct a Prefetch to pre-fetch only those related objects for which the
|
||||||
# user has permission to view.
|
# user has permission to view.
|
||||||
if user:
|
if user:
|
||||||
@ -144,12 +151,34 @@ class CachedValueSearchBackend(SearchBackend):
|
|||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Iterate through each ContentType represented in the search results and prefetch any
|
||||||
|
# related objects necessary to render the prescribed display attributes (display_attrs).
|
||||||
|
for ct in content_types:
|
||||||
|
model = ct.model_class()
|
||||||
|
indexer = registry['search'].get(content_type_identifier(ct))
|
||||||
|
if not (display_attrs := getattr(indexer, 'display_attrs', None)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add ForeignKey fields to prefetch list
|
||||||
|
prefetch_fields = []
|
||||||
|
for attr in display_attrs:
|
||||||
|
field = model._meta.get_field(attr)
|
||||||
|
if type(field) is ForeignKey:
|
||||||
|
prefetch_fields.append(f'object__{attr}')
|
||||||
|
|
||||||
|
# Compile a list of all CachedValues referencing this object type, and prefetch
|
||||||
|
# any related objects
|
||||||
|
if prefetch_fields:
|
||||||
|
objects = [r for r in results if r.object_type == ct]
|
||||||
|
prefetch_related_objects(objects, *prefetch_fields)
|
||||||
|
|
||||||
# Omit any results pertaining to an object the user does not have permission to view
|
# Omit any results pertaining to an object the user does not have permission to view
|
||||||
ret = []
|
ret = []
|
||||||
for r in results:
|
for r in results:
|
||||||
if r.object is not None:
|
if r.object is not None:
|
||||||
r.name = str(r.object)
|
r.name = str(r.object)
|
||||||
ret.append(r)
|
ret.append(r)
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def cache(self, instances, indexer=None, remove_existing=True):
|
def cache(self, instances, indexer=None, remove_existing=True):
|
||||||
|
14
netbox/netbox/search/utils.py
Normal file
14
netbox/netbox/search/utils.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from netbox.registry import registry
|
||||||
|
from utilities.utils import content_type_identifier
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'get_indexer',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_indexer(content_type):
|
||||||
|
"""
|
||||||
|
Return the registered search indexer for the given ContentType.
|
||||||
|
"""
|
||||||
|
ct_identifier = content_type_identifier(content_type)
|
||||||
|
return registry['search'].get(ct_identifier)
|
@ -9,12 +9,14 @@ import warnings
|
|||||||
from urllib.parse import urlencode, urlsplit
|
from urllib.parse import urlencode, urlsplit
|
||||||
|
|
||||||
import django
|
import django
|
||||||
import sentry_sdk
|
|
||||||
from django.contrib.messages import constants as messages
|
from django.contrib.messages import constants as messages
|
||||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.utils.encoding import force_str
|
from django.utils.encoding import force_str
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
try:
|
||||||
|
import sentry_sdk
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
from netbox.config import PARAMS
|
from netbox.config import PARAMS
|
||||||
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||||
@ -25,7 +27,7 @@ from netbox.plugins import PluginConfig
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '3.6.5-dev'
|
VERSION = '3.6.6-dev'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
@ -39,8 +41,6 @@ if sys.version_info < (3, 8):
|
|||||||
f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})"
|
f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})"
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485'
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Configuration import
|
# Configuration import
|
||||||
#
|
#
|
||||||
@ -158,7 +158,7 @@ RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
|
|||||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||||
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
|
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
|
||||||
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
|
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
|
||||||
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
|
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None)
|
||||||
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
||||||
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
|
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
|
||||||
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
|
SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0)
|
||||||
@ -502,6 +502,9 @@ AUTH_EXEMPT_PATHS = (
|
|||||||
MAINTENANCE_EXEMPT_PATHS = (
|
MAINTENANCE_EXEMPT_PATHS = (
|
||||||
f'/{BASE_PATH}admin/',
|
f'/{BASE_PATH}admin/',
|
||||||
f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration
|
f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration
|
||||||
|
LOGIN_URL,
|
||||||
|
LOGIN_REDIRECT_URL,
|
||||||
|
LOGOUT_REDIRECT_URL
|
||||||
)
|
)
|
||||||
|
|
||||||
SERIALIZATION_MODULES = {
|
SERIALIZATION_MODULES = {
|
||||||
@ -514,12 +517,12 @@ SERIALIZATION_MODULES = {
|
|||||||
#
|
#
|
||||||
|
|
||||||
if SENTRY_ENABLED:
|
if SENTRY_ENABLED:
|
||||||
|
try:
|
||||||
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
raise ImproperlyConfigured("SENTRY_ENABLED is True but the sentry-sdk package is not installed.")
|
||||||
if not SENTRY_DSN:
|
if not SENTRY_DSN:
|
||||||
raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.")
|
raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.")
|
||||||
# If using the default DSN, force sampling rates
|
|
||||||
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
|
|
||||||
SENTRY_SAMPLE_RATE = 1.0
|
|
||||||
SENTRY_TRACES_SAMPLE_RATE = 0
|
|
||||||
# Initialize the SDK
|
# Initialize the SDK
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=SENTRY_DSN,
|
dsn=SENTRY_DSN,
|
||||||
@ -534,9 +537,6 @@ if SENTRY_ENABLED:
|
|||||||
# Assign any configured tags
|
# Assign any configured tags
|
||||||
for k, v in SENTRY_TAGS.items():
|
for k, v in SENTRY_TAGS.items():
|
||||||
sentry_sdk.set_tag(k, v)
|
sentry_sdk.set_tag(k, v)
|
||||||
# If using the default DSN, append a unique deployment ID tag for error correlation
|
|
||||||
if SENTRY_DSN == DEFAULT_SENTRY_DSN:
|
|
||||||
sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
@ -12,9 +14,11 @@ from django_tables2.data import TableQuerysetData
|
|||||||
|
|
||||||
from extras.models import CustomField, CustomLink
|
from extras.models import CustomField, CustomLink
|
||||||
from extras.choices import CustomFieldVisibilityChoices
|
from extras.choices import CustomFieldVisibilityChoices
|
||||||
|
from netbox.registry import registry
|
||||||
from netbox.tables import columns
|
from netbox.tables import columns
|
||||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||||
from utilities.utils import get_viewname, highlight_string, title
|
from utilities.utils import get_viewname, highlight_string, title
|
||||||
|
from .template_code import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BaseTable',
|
'BaseTable',
|
||||||
@ -119,7 +123,7 @@ class BaseTable(tables.Table):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def available_columns(self):
|
def available_columns(self):
|
||||||
return self._get_columns(visible=False)
|
return sorted(self._get_columns(visible=False))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def selected_columns(self):
|
def selected_columns(self):
|
||||||
@ -190,12 +194,17 @@ class NetBoxTable(BaseTable):
|
|||||||
if extra_columns is None:
|
if extra_columns is None:
|
||||||
extra_columns = []
|
extra_columns = []
|
||||||
|
|
||||||
|
if registered_columns := registry['tables'].get(self.__class__):
|
||||||
|
extra_columns.extend([
|
||||||
|
# Create a copy to avoid modifying the original Column
|
||||||
|
(name, deepcopy(column)) for name, column in registered_columns.items()
|
||||||
|
])
|
||||||
|
|
||||||
# Add custom field & custom link columns
|
# Add custom field & custom link columns
|
||||||
content_type = ContentType.objects.get_for_model(self._meta.model)
|
content_type = ContentType.objects.get_for_model(self._meta.model)
|
||||||
custom_fields = CustomField.objects.filter(
|
custom_fields = CustomField.objects.filter(
|
||||||
content_types=content_type
|
content_types=content_type
|
||||||
).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN)
|
).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN)
|
||||||
|
|
||||||
extra_columns.extend([
|
extra_columns.extend([
|
||||||
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
|
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
|
||||||
])
|
])
|
||||||
@ -236,6 +245,10 @@ class SearchTable(tables.Table):
|
|||||||
value = tables.Column(
|
value = tables.Column(
|
||||||
verbose_name=_('Value'),
|
verbose_name=_('Value'),
|
||||||
)
|
)
|
||||||
|
attrs = columns.TemplateColumn(
|
||||||
|
template_code=SEARCH_RESULT_ATTRS,
|
||||||
|
verbose_name=_('Attributes')
|
||||||
|
)
|
||||||
|
|
||||||
trim_length = 30
|
trim_length = 30
|
||||||
|
|
||||||
|
18
netbox/netbox/tables/template_code.py
Normal file
18
netbox/netbox/tables/template_code.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
SEARCH_RESULT_ATTRS = """
|
||||||
|
{% for name, value in record.display_attrs.items %}
|
||||||
|
<span class="badge bg-secondary"
|
||||||
|
{% if value|length > 40 %} data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ value }}"{% endif %}
|
||||||
|
>
|
||||||
|
{{ name|bettertitle }}:
|
||||||
|
{% with url=value.get_absolute_url %}
|
||||||
|
{% if url %}<a href="url">{% endif %}
|
||||||
|
{% if value|length > 40 %}
|
||||||
|
{{ value|truncatechars:"40" }}
|
||||||
|
{% else %}
|
||||||
|
{{ value }}
|
||||||
|
{% endif %}
|
||||||
|
{% if url %}</a>{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
"""
|
11
netbox/netbox/tests/dummy_plugin/tables.py
Normal file
11
netbox/netbox/tests/dummy_plugin/tables.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import django_tables2 as tables
|
||||||
|
|
||||||
|
from dcim.tables import SiteTable
|
||||||
|
from utilities.tables import register_table_column
|
||||||
|
|
||||||
|
mycol = tables.Column(
|
||||||
|
verbose_name='My column',
|
||||||
|
accessor=tables.A('description')
|
||||||
|
)
|
||||||
|
|
||||||
|
register_table_column(mycol, 'foo', SiteTable)
|
@ -4,6 +4,8 @@ from django.views.generic import View
|
|||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from utilities.views import register_model_view
|
from utilities.views import register_model_view
|
||||||
from .models import DummyModel
|
from .models import DummyModel
|
||||||
|
# Trigger registration of custom column
|
||||||
|
from .tables import mycol
|
||||||
|
|
||||||
|
|
||||||
class DummyModelsView(View):
|
class DummyModelsView(View):
|
||||||
|
@ -97,6 +97,16 @@ class PluginTest(TestCase):
|
|||||||
|
|
||||||
self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
|
self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site'])
|
||||||
|
|
||||||
|
def test_registered_columns(self):
|
||||||
|
"""
|
||||||
|
Check that a plugin can register a custom column on a core model table.
|
||||||
|
"""
|
||||||
|
from dcim.models import Site
|
||||||
|
from dcim.tables import SiteTable
|
||||||
|
|
||||||
|
table = SiteTable(Site.objects.all())
|
||||||
|
self.assertIn('foo', table.columns.names())
|
||||||
|
|
||||||
def test_user_preferences(self):
|
def test_user_preferences(self):
|
||||||
"""
|
"""
|
||||||
Check that plugin UserPreferences are registered.
|
Check that plugin UserPreferences are registered.
|
||||||
|
@ -9,7 +9,6 @@ from django.template.exceptions import TemplateDoesNotExist
|
|||||||
from django.views.decorators.csrf import requires_csrf_token
|
from django.views.decorators.csrf import requires_csrf_token
|
||||||
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from sentry_sdk import capture_message
|
|
||||||
|
|
||||||
from netbox.plugins.utils import get_installed_plugins
|
from netbox.plugins.utils import get_installed_plugins
|
||||||
|
|
||||||
@ -34,7 +33,9 @@ def handler_404(request, exception):
|
|||||||
"""
|
"""
|
||||||
Wrap Django's default 404 handler to enable Sentry reporting.
|
Wrap Django's default 404 handler to enable Sentry reporting.
|
||||||
"""
|
"""
|
||||||
capture_message("Page not found", level="error")
|
if settings.SENTRY_ENABLED:
|
||||||
|
from sentry_sdk import capture_message
|
||||||
|
capture_message("Page not found", level="error")
|
||||||
|
|
||||||
return page_not_found(request, exception)
|
return page_not_found(request, exception)
|
||||||
|
|
||||||
|
@ -25,4 +25,11 @@
|
|||||||
{% render_field form.priority %}
|
{% render_field form.priority %}
|
||||||
{% render_field form.tags %}
|
{% render_field form.tags %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group mb-5">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<h5 class="offset-sm-3">{% trans "Custom Fields" %}</h5>
|
||||||
|
</div>
|
||||||
|
{% render_custom_fields form %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -105,7 +105,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
|
|||||||
model = ContactAssignment
|
model = ContactAssignment
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags',
|
'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags',
|
||||||
'created', 'last_updated',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
@extend_schema_field(OpenApiTypes.OBJECT)
|
@extend_schema_field(OpenApiTypes.OBJECT)
|
||||||
|
@ -3,11 +3,10 @@ from django.db.models import Q
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from extras.filters import TagFilter
|
from extras.filters import TagFilter
|
||||||
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
|
||||||
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
|
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ContactAssignmentFilterSet',
|
'ContactAssignmentFilterSet',
|
||||||
'ContactFilterSet',
|
'ContactFilterSet',
|
||||||
@ -81,7 +80,7 @@ class ContactFilterSet(NetBoxModelFilterSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
|
class ContactAssignmentFilterSet(NetBoxModelFilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
method='search',
|
method='search',
|
||||||
label=_('Search'),
|
label=_('Search'),
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from extras.utils import FeatureQuery
|
from core.models import ContentType
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.choices import *
|
from tenancy.choices import *
|
||||||
from tenancy.models import *
|
from tenancy.models import *
|
||||||
@ -87,8 +86,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm):
|
|||||||
(_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')),
|
(_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')),
|
||||||
)
|
)
|
||||||
content_type_id = ContentTypeMultipleChoiceField(
|
content_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.with_feature('contacts'),
|
||||||
limit_choices_to=FeatureQuery('contacts'),
|
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Object type')
|
label=_('Object type')
|
||||||
)
|
)
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from extras.forms.mixins import TagsMixin
|
|
||||||
from extras.models import Tag
|
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from tenancy.models import *
|
from tenancy.models import *
|
||||||
from utilities.forms.mixins import BootstrapMixin
|
from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
|
||||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ContactAssignmentForm',
|
'ContactAssignmentForm',
|
||||||
@ -122,7 +119,7 @@ class ContactForm(NetBoxModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm):
|
class ContactAssignmentForm(NetBoxModelForm):
|
||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
label=_('Group'),
|
label=_('Group'),
|
||||||
queryset=ContactGroup.objects.all(),
|
queryset=ContactGroup.objects.all(),
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import graphene
|
import graphene
|
||||||
|
|
||||||
from extras.graphql.mixins import TagsMixin
|
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
|
||||||
from tenancy import filtersets, models
|
from tenancy import filtersets, models
|
||||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ class ContactGroupType(OrganizationalObjectType):
|
|||||||
filterset_class = filtersets.ContactGroupFilterSet
|
filterset_class = filtersets.ContactGroupFilterSet
|
||||||
|
|
||||||
|
|
||||||
class ContactAssignmentType(TagsMixin, BaseObjectType):
|
class ContactAssignmentType(CustomFieldsMixin, TagsMixin, BaseObjectType):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ContactAssignment
|
model = models.ContactAssignment
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 4.2.6 on 2023-11-06 20:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import utilities.json
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tenancy', '0011_contactassignment_tags'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contactassignment',
|
||||||
|
name='custom_field_data',
|
||||||
|
field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
|
||||||
|
),
|
||||||
|
]
|
@ -1,11 +1,12 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import ContentType
|
||||||
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
|
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
|
||||||
from netbox.models.features import TagsMixin
|
from netbox.models.features import CustomFieldsMixin, TagsMixin
|
||||||
from tenancy.choices import *
|
from tenancy.choices import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -109,9 +110,9 @@ class Contact(PrimaryModel):
|
|||||||
return reverse('tenancy:contact', args=[self.pk])
|
return reverse('tenancy:contact', args=[self.pk])
|
||||||
|
|
||||||
|
|
||||||
class ContactAssignment(ChangeLoggedModel, TagsMixin):
|
class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel):
|
||||||
content_type = models.ForeignKey(
|
content_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
on_delete=models.CASCADE
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
object_id = models.PositiveBigIntegerField()
|
object_id = models.PositiveBigIntegerField()
|
||||||
@ -157,6 +158,15 @@ class ContactAssignment(ChangeLoggedModel, TagsMixin):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('tenancy:contact', args=[self.contact.pk])
|
return reverse('tenancy:contact', args=[self.contact.pk])
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Validate the assigned object type
|
||||||
|
if self.content_type not in ContentType.objects.with_feature('contacts'):
|
||||||
|
raise ValidationError(
|
||||||
|
_("Contacts cannot be assigned to this object type ({type}).").format(type=self.content_type)
|
||||||
|
)
|
||||||
|
|
||||||
def to_objectchange(self, action):
|
def to_objectchange(self, action):
|
||||||
objectchange = super().to_objectchange(action)
|
objectchange = super().to_objectchange(action)
|
||||||
objectchange.related_object = self.object
|
objectchange.related_object = self.object
|
||||||
|
@ -15,6 +15,7 @@ class ContactIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('group', 'title', 'phone', 'email', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -25,6 +26,7 @@ class ContactGroupIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -35,6 +37,7 @@ class ContactRoleIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -46,6 +49,7 @@ class TenantIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('group', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -56,3 +60,4 @@ class TenantGroupIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable):
|
|||||||
verbose_name=_('Role'),
|
verbose_name=_('Role'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
contact_group = tables.Column(
|
||||||
|
accessor=Accessor('contact__group'),
|
||||||
|
verbose_name=_('Group'),
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
contact_title = tables.Column(
|
contact_title = tables.Column(
|
||||||
accessor=Accessor('contact__title'),
|
accessor=Accessor('contact__title'),
|
||||||
verbose_name=_('Contact Title')
|
verbose_name=_('Contact Title')
|
||||||
@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable):
|
|||||||
model = ContactAssignment
|
model = ContactAssignment
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
|
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
|
||||||
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'tags', 'actions'
|
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags',
|
||||||
|
'actions'
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
|
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
|
||||||
|
@ -2,14 +2,9 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from circuits.models import Circuit
|
|
||||||
from dcim.models import Cable, Device, Location, PowerFeed, Rack, RackReservation, Site, VirtualDeviceContext
|
|
||||||
from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF
|
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related, get_related_models
|
||||||
from utilities.views import register_model_view, ViewTab
|
from utilities.views import register_model_view, ViewTab
|
||||||
from virtualization.models import VirtualMachine, Cluster
|
|
||||||
from wireless.models import WirelessLAN, WirelessLink
|
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -132,32 +127,8 @@ class TenantView(generic.ObjectView):
|
|||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = [
|
related_models = [
|
||||||
# DCIM
|
(model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id')
|
||||||
(Site.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
for model, field in get_related_models(Tenant)
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(Location.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(PowerFeed.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
# IPAM
|
|
||||||
(VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(Prefix.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(IPRange.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(ASN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(VLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
# Circuits
|
|
||||||
(Circuit.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
# Virtualization
|
|
||||||
(VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(Cluster.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
# Wireless
|
|
||||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
(WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -387,6 +358,7 @@ class ContactAssignmentListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.ContactAssignmentFilterForm
|
filterset_form = forms.ContactAssignmentFilterForm
|
||||||
table = tables.ContactAssignmentTable
|
table = tables.ContactAssignmentTable
|
||||||
actions = {
|
actions = {
|
||||||
|
'import': {'add'},
|
||||||
'export': {'view'},
|
'export': {'view'},
|
||||||
'bulk_edit': {'change'},
|
'bulk_edit': {'change'},
|
||||||
'bulk_delete': {'delete'},
|
'bulk_delete': {'delete'},
|
||||||
|
@ -3,7 +3,6 @@ import os
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group, GroupManager, User, UserManager
|
from django.contrib.auth.models import Group, GroupManager, User, UserManager
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
@ -15,6 +14,7 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from netaddr import IPNetwork
|
from netaddr import IPNetwork
|
||||||
|
|
||||||
|
from core.models import ContentType
|
||||||
from ipam.fields import IPNetworkField
|
from ipam.fields import IPNetworkField
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
@ -353,7 +353,7 @@ class ObjectPermission(models.Model):
|
|||||||
default=True
|
default=True
|
||||||
)
|
)
|
||||||
object_types = models.ManyToManyField(
|
object_types = models.ManyToManyField(
|
||||||
to=ContentType,
|
to='contenttypes.ContentType',
|
||||||
limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
|
limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES,
|
||||||
related_name='object_permissions'
|
related_name='object_permissions'
|
||||||
)
|
)
|
||||||
|
@ -17,7 +17,7 @@ def get_client_ip(request, additional_headers=()):
|
|||||||
)
|
)
|
||||||
for header in HTTP_HEADERS:
|
for header in HTTP_HEADERS:
|
||||||
if header in request.META:
|
if header in request.META:
|
||||||
client_ip = request.META[header].split(',')[0]
|
client_ip = request.META[header].split(',')[0].partition(':')[0]
|
||||||
try:
|
try:
|
||||||
return IPAddress(client_ip)
|
return IPAddress(client_ip)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
|
from netbox.registry import registry
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'get_table_ordering',
|
'get_table_ordering',
|
||||||
'linkify_phone',
|
'linkify_phone',
|
||||||
|
'register_table_column'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -26,3 +29,19 @@ def linkify_phone(value):
|
|||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return f"tel:{value}"
|
return f"tel:{value}"
|
||||||
|
|
||||||
|
|
||||||
|
def register_table_column(column, name, *tables):
|
||||||
|
"""
|
||||||
|
Register a custom column for use on one or more tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
column: The column instance to register
|
||||||
|
name: The name of the table column
|
||||||
|
tables: One or more table classes
|
||||||
|
"""
|
||||||
|
for table in tables:
|
||||||
|
reg = registry['tables'][table]
|
||||||
|
if name in reg:
|
||||||
|
raise ValueError(f"A column named {name} is already defined for table {table.__name__}")
|
||||||
|
reg[name] = column
|
||||||
|
@ -8,7 +8,7 @@ from itertools import count, groupby
|
|||||||
import bleach
|
import bleach
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
from django.db.models import Count, OuterRef, Subquery
|
from django.db.models import Count, ManyToOneRel, OuterRef, Subquery
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -567,3 +567,20 @@ def local_now():
|
|||||||
Return the current date & time in the system timezone.
|
Return the current date & time in the system timezone.
|
||||||
"""
|
"""
|
||||||
return localtime(timezone.now())
|
return localtime(timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
def get_related_models(model, ordered=True):
|
||||||
|
"""
|
||||||
|
Return a list of all models which have a ForeignKey to the given model and the name of the field. For example,
|
||||||
|
`get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant.
|
||||||
|
"""
|
||||||
|
related_models = [
|
||||||
|
(field.related_model, field.remote_field.name)
|
||||||
|
for field in model._meta.related_objects
|
||||||
|
if type(field) is ManyToOneRel
|
||||||
|
]
|
||||||
|
|
||||||
|
if ordered:
|
||||||
|
return sorted(related_models, key=lambda x: x[0]._meta.verbose_name)
|
||||||
|
|
||||||
|
return related_models
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from rest_framework.routers import APIRootView
|
from rest_framework.routers import APIRootView
|
||||||
|
|
||||||
from dcim.models import Device
|
from dcim.models import Device
|
||||||
from extras.api.mixins import ConfigContextQuerySetMixin
|
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet
|
||||||
from utilities.query_functions import CollateAsChar
|
from utilities.query_functions import CollateAsChar
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
@ -53,9 +53,10 @@ class ClusterViewSet(NetBoxModelViewSet):
|
|||||||
# Virtual machines
|
# Virtual machines
|
||||||
#
|
#
|
||||||
|
|
||||||
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
|
||||||
queryset = VirtualMachine.objects.prefetch_related(
|
queryset = VirtualMachine.objects.prefetch_related(
|
||||||
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags', 'virtualdisks',
|
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template',
|
||||||
|
'tags', 'virtualdisks',
|
||||||
)
|
)
|
||||||
filterset_class = filtersets.VirtualMachineFilterSet
|
filterset_class = filtersets.VirtualMachineFilterSet
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ from dcim.filtersets import CommonInterfaceFilterSet
|
|||||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||||
from extras.filtersets import LocalConfigContextFilterSet
|
from extras.filtersets import LocalConfigContextFilterSet
|
||||||
from extras.models import ConfigTemplate
|
from extras.models import ConfigTemplate
|
||||||
|
from ipam.filtersets import PrimaryIPFilterSet
|
||||||
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||||
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
|
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
|
||||||
@ -115,7 +116,8 @@ class VirtualMachineFilterSet(
|
|||||||
NetBoxModelFilterSet,
|
NetBoxModelFilterSet,
|
||||||
TenancyFilterSet,
|
TenancyFilterSet,
|
||||||
ContactModelFilterSet,
|
ContactModelFilterSet,
|
||||||
LocalConfigContextFilterSet
|
LocalConfigContextFilterSet,
|
||||||
|
PrimaryIPFilterSet,
|
||||||
):
|
):
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=VirtualMachineStatusChoices,
|
choices=VirtualMachineStatusChoices,
|
||||||
|
@ -205,7 +205,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
|||||||
platform = DynamicModelChoiceField(
|
platform = DynamicModelChoiceField(
|
||||||
label=_('Platform'),
|
label=_('Platform'),
|
||||||
queryset=Platform.objects.all(),
|
queryset=Platform.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
selector=True
|
||||||
)
|
)
|
||||||
local_context_data = JSONField(
|
local_context_data = JSONField(
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -10,6 +10,7 @@ class ClusterIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('type', 'group', 'status', 'tenant', 'site', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -20,6 +21,7 @@ class ClusterGroupIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -30,6 +32,7 @@ class ClusterTypeIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -40,6 +43,7 @@ class VirtualMachineIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -51,6 +55,7 @@ class VMInterfaceIndex(SearchIndex):
|
|||||||
('description', 500),
|
('description', 500),
|
||||||
('mtu', 2000),
|
('mtu', 2000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('virtual_machine', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -60,3 +65,4 @@ class VirtualDiskIndex(SearchIndex):
|
|||||||
('name', 100),
|
('name', 100),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('virtual_machine', 'description')
|
||||||
|
@ -3,6 +3,7 @@ from rest_framework import status
|
|||||||
|
|
||||||
from dcim.choices import InterfaceModeChoices
|
from dcim.choices import InterfaceModeChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
|
from extras.models import ConfigTemplate
|
||||||
from ipam.models import VLAN, VRF
|
from ipam.models import VLAN, VRF
|
||||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
|
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
|
||||||
from virtualization.choices import *
|
from virtualization.choices import *
|
||||||
@ -228,6 +229,22 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
|
|||||||
response = self.client.post(url, data, format='json', **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_render_config(self):
|
||||||
|
configtemplate = ConfigTemplate.objects.create(
|
||||||
|
name='Config Template 1',
|
||||||
|
template_code='Config for virtual machine {{ virtualmachine.name }}'
|
||||||
|
)
|
||||||
|
|
||||||
|
vm = VirtualMachine.objects.first()
|
||||||
|
vm.config_template = configtemplate
|
||||||
|
vm.save()
|
||||||
|
|
||||||
|
self.add_permissions('virtualization.add_virtualmachine')
|
||||||
|
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/'
|
||||||
|
response = self.client.post(url, {}, format='json', **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
|
||||||
|
|
||||||
|
|
||||||
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
|
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = VMInterface
|
model = VMInterface
|
||||||
|
@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
ipaddresses = (
|
ipaddresses = (
|
||||||
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
||||||
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
|
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
|
||||||
|
IPAddress(address='192.0.2.3/24', assigned_object=None),
|
||||||
|
IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
|
||||||
|
IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
|
||||||
|
IPAddress(address='2001:db8::3/64', assigned_object=None),
|
||||||
)
|
)
|
||||||
IPAddress.objects.bulk_create(ipaddresses)
|
IPAddress.objects.bulk_create(ipaddresses)
|
||||||
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
|
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
|
||||||
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
|
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
|
||||||
|
|
||||||
def test_name(self):
|
def test_name(self):
|
||||||
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
|
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
|
||||||
@ -412,6 +416,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_primary_ip4(self):
|
||||||
|
addresses = IPAddress.objects.filter(address__family=4)
|
||||||
|
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||||
|
|
||||||
|
def test_primary_ip6(self):
|
||||||
|
addresses = IPAddress.objects.filter(address__family=6)
|
||||||
|
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
queryset = VMInterface.objects.all()
|
queryset = VMInterface.objects.all()
|
||||||
|
@ -11,6 +11,7 @@ class WirelessLANIndex(SearchIndex):
|
|||||||
('auth_psk', 2000),
|
('auth_psk', 2000),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('group', 'status', 'vlan', 'tenant', 'description')
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -21,6 +22,7 @@ class WirelessLANGroupIndex(SearchIndex):
|
|||||||
('slug', 110),
|
('slug', 110),
|
||||||
('description', 500),
|
('description', 500),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
@register_search
|
@register_search
|
||||||
@ -32,3 +34,4 @@ class WirelessLinkIndex(SearchIndex):
|
|||||||
('auth_psk', 2000),
|
('auth_psk', 2000),
|
||||||
('comments', 5000),
|
('comments', 5000),
|
||||||
)
|
)
|
||||||
|
display_attrs = ('status', 'tenant', 'description')
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
bleach==6.1.0
|
bleach==6.1.0
|
||||||
Django==4.2.6
|
Django==4.2.7
|
||||||
django-cors-headers==4.3.0
|
django-cors-headers==4.3.0
|
||||||
django-debug-toolbar==4.2.0
|
django-debug-toolbar==4.2.0
|
||||||
django-filter==23.3
|
django-filter==23.3
|
||||||
@ -21,16 +21,15 @@ graphene-django==3.0.0
|
|||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
Markdown==3.3.7
|
Markdown==3.3.7
|
||||||
mkdocs-material==9.4.6
|
mkdocs-material==9.4.8
|
||||||
mkdocstrings[python-legacy]==0.23.0
|
mkdocstrings[python-legacy]==0.23.0
|
||||||
netaddr==0.9.0
|
netaddr==0.9.0
|
||||||
Pillow==10.1.0
|
Pillow==10.1.0
|
||||||
psycopg[binary,pool]==3.1.12
|
psycopg[binary,pool]==3.1.12
|
||||||
PyYAML==6.0.1
|
PyYAML==6.0.1
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
sentry-sdk==1.32.0
|
|
||||||
social-auth-app-django==5.4.0
|
social-auth-app-django==5.4.0
|
||||||
social-auth-core[openidconnect]==4.4.2
|
social-auth-core[openidconnect]==4.5.0
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.5.0
|
tablib==3.5.0
|
||||||
tzdata==2023.3
|
tzdata==2023.3
|
||||||
|
Loading…
Reference in New Issue
Block a user