mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
8b91fb8d2d
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -26,7 +26,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: v4.0.8
|
placeholder: v4.0.9
|
||||||
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: v4.0.8
|
placeholder: v4.0.9
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -58,6 +58,8 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
|
|||||||
|
|
||||||
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the feature you have in mind has already been proposed. If you happen to find an open feature request that matches your idea, click "add a reaction" in the top right corner of the issue and add a thumbs up ( :thumbsup: ). This ensures that the issue has a better chance of receiving attention. Also feel free to add a comment with any additional justification for the feature.
|
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the feature you have in mind has already been proposed. If you happen to find an open feature request that matches your idea, click "add a reaction" in the top right corner of the issue and add a thumbs up ( :thumbsup: ). This ensures that the issue has a better chance of receiving attention. Also feel free to add a comment with any additional justification for the feature.
|
||||||
|
|
||||||
|
* Please don't submit duplicate issues! Sometimes we reject feature requests, for various reasons. Even if you disagree with those reasons, please **do not** submit a duplicate feature request. It is very disrepectful of the maintainers' time, and you may be barred from opening future issues.
|
||||||
|
|
||||||
* If you have a rough idea that's not quite ready for formal submission yet, start a [GitHub discussion](https://github.com/netbox-community/netbox/discussions) instead. This is a great way to test the viability and narrow down the scope of a new feature prior to submitting a formal proposal, and can serve to generate interest in your idea from other community members.
|
* If you have a rough idea that's not quite ready for formal submission yet, start a [GitHub discussion](https://github.com/netbox-community/netbox/discussions) instead. This is a great way to test the viability and narrow down the scope of a new feature prior to submitting a formal proposal, and can serve to generate interest in your idea from other community members.
|
||||||
|
|
||||||
* Once you're ready, submit a feature request [using this template](https://github.com/netbox-community/netbox/issues/new?label=type%3A+feature&template=feature_request.yaml). Be sure to provide sufficient context and detail to convey exactly what you're proposing and why. The stronger your use case, the better chance your proposal has of being accepted.
|
* Once you're ready, submit a feature request [using this template](https://github.com/netbox-community/netbox/issues/new?label=type%3A+feature&template=feature_request.yaml). Be sure to provide sufficient context and detail to convey exactly what you're proposing and why. The stronger your use case, the better chance your proposal has of being accepted.
|
||||||
|
@ -377,6 +377,7 @@
|
|||||||
"ieee802.11ad",
|
"ieee802.11ad",
|
||||||
"ieee802.11ax",
|
"ieee802.11ax",
|
||||||
"ieee802.11ay",
|
"ieee802.11ay",
|
||||||
|
"ieee802.11be",
|
||||||
"ieee802.15.1",
|
"ieee802.15.1",
|
||||||
"other-wireless",
|
"other-wireless",
|
||||||
"gsm",
|
"gsm",
|
||||||
|
@ -1,6 +1,27 @@
|
|||||||
# NetBox v4.0
|
# NetBox v4.0
|
||||||
|
|
||||||
## v4.0.9 (FUTURE)
|
## v4.0.10 (FUTURE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.0.9 (2024-08-14)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#16692](https://github.com/netbox-community/netbox/issues/16692) - Enable modifying VLAN assignment while bulk editing prefixes
|
||||||
|
* [#17006](https://github.com/netbox-community/netbox/issues/17006) - Add IEEE 802.11be interface type
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#13459](https://github.com/netbox-community/netbox/issues/13459) - Correct OpenAPI schema type for `TreeNodeMultipleChoiceFilter`
|
||||||
|
* [#16073](https://github.com/netbox-community/netbox/issues/16073) - Respect default values for custom fields during bulk import of objects
|
||||||
|
* [#16176](https://github.com/netbox-community/netbox/issues/16176) - Restore ability to select multiple terminating devices when connecting a cable
|
||||||
|
* [#16871](https://github.com/netbox-community/netbox/issues/16871) - Sanitize device ID query parameter when bulk editing components to prevent exception
|
||||||
|
* [#17038](https://github.com/netbox-community/netbox/issues/17038) - Fix AttributeError exception when attempting to export system status data
|
||||||
|
* [#17064](https://github.com/netbox-community/netbox/issues/17064) - Fix misaligned text within rendered Markdown code blocks
|
||||||
|
* [#17124](https://github.com/netbox-community/netbox/issues/17124) - `BaseTable` should follow reverse one-to-one relationships when prefetching related objects
|
||||||
|
* [#17131](https://github.com/netbox-community/netbox/issues/17131) - Fix exception when creating object-type custom field without selecting related object type
|
||||||
|
* [#17144](https://github.com/netbox-community/netbox/issues/17144) - Avoid showing duplicated pop-up messages
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -111,7 +111,7 @@ class LoginView(View):
|
|||||||
# Authenticate user
|
# Authenticate user
|
||||||
auth_login(request, form.get_user())
|
auth_login(request, form.get_user())
|
||||||
logger.info(f"User {request.user} successfully authenticated")
|
logger.info(f"User {request.user} successfully authenticated")
|
||||||
messages.success(request, f"Logged in as {request.user}.")
|
messages.success(request, _("Logged in as {user}.").format(user=request.user))
|
||||||
|
|
||||||
# Ensure the user has a UserConfig defined. (This should normally be handled by
|
# Ensure the user has a UserConfig defined. (This should normally be handled by
|
||||||
# create_userconfig() on user creation.)
|
# create_userconfig() on user creation.)
|
||||||
@ -161,7 +161,7 @@ class LogoutView(View):
|
|||||||
username = request.user
|
username = request.user
|
||||||
auth_logout(request)
|
auth_logout(request)
|
||||||
logger.info(f"User {username} has logged out")
|
logger.info(f"User {username} has logged out")
|
||||||
messages.info(request, "You have logged out.")
|
messages.info(request, _("You have logged out."))
|
||||||
|
|
||||||
# Delete session key & language cookies (if set) upon logout
|
# Delete session key & language cookies (if set) upon logout
|
||||||
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
|
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
|
||||||
@ -236,7 +236,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
# LDAP users cannot change their password here
|
# LDAP users cannot change their password here
|
||||||
if getattr(request.user, 'ldap_username', None):
|
if getattr(request.user, 'ldap_username', None):
|
||||||
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
|
messages.warning(request, _("LDAP-authenticated user credentials cannot be changed within NetBox."))
|
||||||
return redirect('account:profile')
|
return redirect('account:profile')
|
||||||
|
|
||||||
form = PasswordChangeForm(user=request.user)
|
form = PasswordChangeForm(user=request.user)
|
||||||
@ -251,7 +251,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
update_session_auth_hash(request, form.user)
|
update_session_auth_hash(request, form.user)
|
||||||
messages.success(request, "Your password has been changed successfully.")
|
messages.success(request, _("Your password has been changed successfully."))
|
||||||
return redirect('account:profile')
|
return redirect('account:profile')
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
return render(request, self.template_name, {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from dcim.views import PathTraceView
|
from dcim.views import PathTraceView
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
@ -326,7 +327,9 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
|||||||
|
|
||||||
# Circuit must have at least one termination to swap
|
# Circuit must have at least one termination to swap
|
||||||
if not circuit.termination_a and not circuit.termination_z:
|
if not circuit.termination_a and not circuit.termination_z:
|
||||||
messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
|
messages.error(request, _(
|
||||||
|
"No terminations have been defined for circuit {circuit}."
|
||||||
|
).format(circuit=circuit))
|
||||||
return redirect('circuits:circuit', pk=circuit.pk)
|
return redirect('circuits:circuit', pk=circuit.pk)
|
||||||
|
|
||||||
return render(request, 'circuits/circuit_terminations_swap.html', {
|
return render(request, 'circuits/circuit_terminations_swap.html', {
|
||||||
@ -374,7 +377,7 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
|||||||
circuit.termination_z = None
|
circuit.termination_z = None
|
||||||
circuit.save()
|
circuit.save()
|
||||||
|
|
||||||
messages.success(request, f"Swapped terminations for circuit {circuit}.")
|
messages.success(request, _("Swapped terminations for circuit {circuit}.").format(circuit=circuit))
|
||||||
return redirect('circuits:circuit', pk=circuit.pk)
|
return redirect('circuits:circuit', pk=circuit.pk)
|
||||||
|
|
||||||
return render(request, 'circuits/circuit_terminations_swap.html', {
|
return render(request, 'circuits/circuit_terminations_swap.html', {
|
||||||
|
@ -85,7 +85,10 @@ class DataSourceSyncView(BaseObjectView):
|
|||||||
datasource.status = DataSourceStatusChoices.QUEUED
|
datasource.status = DataSourceStatusChoices.QUEUED
|
||||||
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
|
DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status)
|
||||||
|
|
||||||
messages.success(request, f"Queued job #{job.pk} to sync {datasource}")
|
messages.success(
|
||||||
|
request,
|
||||||
|
_("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)
|
||||||
|
)
|
||||||
return redirect(datasource.get_absolute_url())
|
return redirect(datasource.get_absolute_url())
|
||||||
|
|
||||||
|
|
||||||
@ -313,7 +316,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
|
|||||||
|
|
||||||
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
|
||||||
candidate_config.activate()
|
candidate_config.activate()
|
||||||
messages.success(request, f"Restored configuration revision #{pk}")
|
messages.success(request, _("Restored configuration revision #{id}").format(id=pk))
|
||||||
|
|
||||||
return redirect(candidate_config.get_absolute_url())
|
return redirect(candidate_config.get_absolute_url())
|
||||||
|
|
||||||
@ -457,9 +460,9 @@ class BackgroundTaskDeleteView(BaseRQView):
|
|||||||
# Remove job id from queue and delete the actual job
|
# Remove job id from queue and delete the actual job
|
||||||
queue.connection.lrem(queue.key, 0, job.id)
|
queue.connection.lrem(queue.key, 0, job.id)
|
||||||
job.delete()
|
job.delete()
|
||||||
messages.success(request, f'Deleted job {job_id}')
|
messages.success(request, _('Job {id} has been deleted.').format(id=job_id))
|
||||||
else:
|
else:
|
||||||
messages.error(request, f'Error deleting job: {form.errors[0]}')
|
messages.error(request, _('Error deleting job {id}: {error}').format(id=job_id, error=form.errors[0]))
|
||||||
|
|
||||||
return redirect(reverse('core:background_queue_list'))
|
return redirect(reverse('core:background_queue_list'))
|
||||||
|
|
||||||
@ -472,13 +475,13 @@ class BackgroundTaskRequeueView(BaseRQView):
|
|||||||
try:
|
try:
|
||||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||||
except NoSuchJobError:
|
except NoSuchJobError:
|
||||||
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
|
raise Http404(_("Job {id} not found.").format(id=job_id))
|
||||||
|
|
||||||
queue_index = QUEUES_MAP[job.origin]
|
queue_index = QUEUES_MAP[job.origin]
|
||||||
queue = get_queue_by_index(queue_index)
|
queue = get_queue_by_index(queue_index)
|
||||||
|
|
||||||
requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
|
requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
|
||||||
messages.success(request, f'You have successfully requeued: {job_id}')
|
messages.success(request, _('Job {id} has been re-enqueued.').format(id=job_id))
|
||||||
return redirect(reverse('core:background_task', args=[job_id]))
|
return redirect(reverse('core:background_task', args=[job_id]))
|
||||||
|
|
||||||
|
|
||||||
@ -490,7 +493,7 @@ class BackgroundTaskEnqueueView(BaseRQView):
|
|||||||
try:
|
try:
|
||||||
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
|
||||||
except NoSuchJobError:
|
except NoSuchJobError:
|
||||||
raise Http404(_("Job {job_id} not found").format(job_id=job_id))
|
raise Http404(_("Job {id} not found.").format(id=job_id))
|
||||||
|
|
||||||
queue_index = QUEUES_MAP[job.origin]
|
queue_index = QUEUES_MAP[job.origin]
|
||||||
queue = get_queue_by_index(queue_index)
|
queue = get_queue_by_index(queue_index)
|
||||||
@ -513,7 +516,7 @@ class BackgroundTaskEnqueueView(BaseRQView):
|
|||||||
registry = ScheduledJobRegistry(queue.name, queue.connection)
|
registry = ScheduledJobRegistry(queue.name, queue.connection)
|
||||||
registry.remove(job)
|
registry.remove(job)
|
||||||
|
|
||||||
messages.success(request, f'You have successfully enqueued: {job_id}')
|
messages.success(request, _('Job {id} has been enqueued.').format(id=job_id))
|
||||||
return redirect(reverse('core:background_task', args=[job_id]))
|
return redirect(reverse('core:background_task', args=[job_id]))
|
||||||
|
|
||||||
|
|
||||||
@ -530,11 +533,11 @@ class BackgroundTaskStopView(BaseRQView):
|
|||||||
queue_index = QUEUES_MAP[job.origin]
|
queue_index = QUEUES_MAP[job.origin]
|
||||||
queue = get_queue_by_index(queue_index)
|
queue = get_queue_by_index(queue_index)
|
||||||
|
|
||||||
stopped, _ = stop_jobs(queue, job_id)
|
stopped_jobs = stop_jobs(queue, job_id)[0]
|
||||||
if len(stopped) == 1:
|
if len(stopped_jobs) == 1:
|
||||||
messages.success(request, f'You have successfully stopped {job_id}')
|
messages.success(request, _('Job {id} has been stopped.').format(id=job_id))
|
||||||
else:
|
else:
|
||||||
messages.error(request, f'Failed to stop {job_id}')
|
messages.error(request, _('Failed to stop job {id}').format(id=job_id))
|
||||||
|
|
||||||
return redirect(reverse('core:background_task', args=[job_id]))
|
return redirect(reverse('core:background_task', args=[job_id]))
|
||||||
|
|
||||||
|
@ -916,6 +916,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_80211AD = 'ieee802.11ad'
|
TYPE_80211AD = 'ieee802.11ad'
|
||||||
TYPE_80211AX = 'ieee802.11ax'
|
TYPE_80211AX = 'ieee802.11ax'
|
||||||
TYPE_80211AY = 'ieee802.11ay'
|
TYPE_80211AY = 'ieee802.11ay'
|
||||||
|
TYPE_80211BE = 'ieee802.11be'
|
||||||
TYPE_802151 = 'ieee802.15.1'
|
TYPE_802151 = 'ieee802.15.1'
|
||||||
TYPE_OTHER_WIRELESS = 'other-wireless'
|
TYPE_OTHER_WIRELESS = 'other-wireless'
|
||||||
|
|
||||||
@ -1087,6 +1088,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_80211AD, 'IEEE 802.11ad'),
|
(TYPE_80211AD, 'IEEE 802.11ad'),
|
||||||
(TYPE_80211AX, 'IEEE 802.11ax'),
|
(TYPE_80211AX, 'IEEE 802.11ax'),
|
||||||
(TYPE_80211AY, 'IEEE 802.11ay'),
|
(TYPE_80211AY, 'IEEE 802.11ay'),
|
||||||
|
(TYPE_80211BE, 'IEEE 802.11be'),
|
||||||
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
|
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
|
||||||
(TYPE_OTHER_WIRELESS, 'Other (Wireless)'),
|
(TYPE_OTHER_WIRELESS, 'Other (Wireless)'),
|
||||||
)
|
)
|
||||||
|
@ -49,6 +49,7 @@ WIRELESS_IFACE_TYPES = [
|
|||||||
InterfaceTypeChoices.TYPE_80211AD,
|
InterfaceTypeChoices.TYPE_80211AD,
|
||||||
InterfaceTypeChoices.TYPE_80211AX,
|
InterfaceTypeChoices.TYPE_80211AX,
|
||||||
InterfaceTypeChoices.TYPE_80211AY,
|
InterfaceTypeChoices.TYPE_80211AY,
|
||||||
|
InterfaceTypeChoices.TYPE_80211BE,
|
||||||
InterfaceTypeChoices.TYPE_802151,
|
InterfaceTypeChoices.TYPE_802151,
|
||||||
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
|
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
|
||||||
]
|
]
|
||||||
|
@ -1295,12 +1295,17 @@ class ComponentBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, initial=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
try:
|
||||||
|
self.device_id = int(initial.get('device'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.device_id = None
|
||||||
|
|
||||||
|
super().__init__(*args, initial=initial, **kwargs)
|
||||||
|
|
||||||
# Limit module queryset to Modules which belong to the parent Device
|
# Limit module queryset to Modules which belong to the parent Device
|
||||||
if 'device' in self.initial:
|
if self.device_id:
|
||||||
device = Device.objects.filter(pk=self.initial['device']).first()
|
device = Device.objects.filter(pk=self.device_id).first()
|
||||||
self.fields['module'].queryset = Module.objects.filter(device=device)
|
self.fields['module'].queryset = Module.objects.filter(device=device)
|
||||||
else:
|
else:
|
||||||
self.fields['module'].choices = ()
|
self.fields['module'].choices = ()
|
||||||
@ -1308,8 +1313,8 @@ class ComponentBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
|
|
||||||
class ConsolePortBulkEditForm(
|
class ConsolePortBulkEditForm(
|
||||||
form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
|
ComponentBulkEditForm,
|
||||||
ComponentBulkEditForm
|
form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description'])
|
||||||
):
|
):
|
||||||
mark_connected = forms.NullBooleanField(
|
mark_connected = forms.NullBooleanField(
|
||||||
label=_('Mark connected'),
|
label=_('Mark connected'),
|
||||||
@ -1325,8 +1330,8 @@ class ConsolePortBulkEditForm(
|
|||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortBulkEditForm(
|
class ConsoleServerPortBulkEditForm(
|
||||||
form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
|
ComponentBulkEditForm,
|
||||||
ComponentBulkEditForm
|
form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description'])
|
||||||
):
|
):
|
||||||
mark_connected = forms.NullBooleanField(
|
mark_connected = forms.NullBooleanField(
|
||||||
label=_('Mark connected'),
|
label=_('Mark connected'),
|
||||||
@ -1342,8 +1347,8 @@ class ConsoleServerPortBulkEditForm(
|
|||||||
|
|
||||||
|
|
||||||
class PowerPortBulkEditForm(
|
class PowerPortBulkEditForm(
|
||||||
form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
|
ComponentBulkEditForm,
|
||||||
ComponentBulkEditForm
|
form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description'])
|
||||||
):
|
):
|
||||||
mark_connected = forms.NullBooleanField(
|
mark_connected = forms.NullBooleanField(
|
||||||
label=_('Mark connected'),
|
label=_('Mark connected'),
|
||||||
@ -1360,8 +1365,8 @@ class PowerPortBulkEditForm(
|
|||||||
|
|
||||||
|
|
||||||
class PowerOutletBulkEditForm(
|
class PowerOutletBulkEditForm(
|
||||||
form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
|
ComponentBulkEditForm,
|
||||||
ComponentBulkEditForm
|
form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description'])
|
||||||
):
|
):
|
||||||
mark_connected = forms.NullBooleanField(
|
mark_connected = forms.NullBooleanField(
|
||||||
label=_('Mark connected'),
|
label=_('Mark connected'),
|
||||||
@ -1380,8 +1385,8 @@ class PowerOutletBulkEditForm(
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Limit power_port queryset to PowerPorts which belong to the parent Device
|
# Limit power_port queryset to PowerPorts which belong to the parent Device
|
||||||
if 'device' in self.initial:
|
if self.device_id:
|
||||||
device = Device.objects.filter(pk=self.initial['device']).first()
|
device = Device.objects.filter(pk=self.device_id).first()
|
||||||
self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
|
self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
|
||||||
else:
|
else:
|
||||||
self.fields['power_port'].choices = ()
|
self.fields['power_port'].choices = ()
|
||||||
@ -1389,12 +1394,12 @@ class PowerOutletBulkEditForm(
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceBulkEditForm(
|
class InterfaceBulkEditForm(
|
||||||
|
ComponentBulkEditForm,
|
||||||
form_from_model(Interface, [
|
form_from_model(Interface, [
|
||||||
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
|
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
|
||||||
'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
|
'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
|
||||||
'tx_power', 'wireless_lans'
|
'tx_power', 'wireless_lans'
|
||||||
]),
|
])
|
||||||
ComponentBulkEditForm
|
|
||||||
):
|
):
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
label=_('Enabled'),
|
label=_('Enabled'),
|
||||||
@ -1523,8 +1528,8 @@ class InterfaceBulkEditForm(
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if 'device' in self.initial:
|
if self.device_id:
|
||||||
device = Device.objects.filter(pk=self.initial['device']).first()
|
device = Device.objects.filter(pk=self.device_id).first()
|
||||||
|
|
||||||
# Restrict parent/bridge/LAG interface assignment by device
|
# Restrict parent/bridge/LAG interface assignment by device
|
||||||
self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk)
|
self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk)
|
||||||
@ -1587,8 +1592,8 @@ class InterfaceBulkEditForm(
|
|||||||
|
|
||||||
|
|
||||||
class FrontPortBulkEditForm(
|
class FrontPortBulkEditForm(
|
||||||
form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
|
ComponentBulkEditForm,
|
||||||
ComponentBulkEditForm
|
form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description'])
|
||||||
):
|
):
|
||||||
mark_connected = forms.NullBooleanField(
|
mark_connected = forms.NullBooleanField(
|
||||||
label=_('Mark connected'),
|
label=_('Mark connected'),
|
||||||
@ -1604,8 +1609,8 @@ class FrontPortBulkEditForm(
|
|||||||
|
|
||||||
|
|
||||||
class RearPortBulkEditForm(
|
class RearPortBulkEditForm(
|
||||||
form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
|
ComponentBulkEditForm,
|
||||||
ComponentBulkEditForm
|
form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description'])
|
||||||
):
|
):
|
||||||
mark_connected = forms.NullBooleanField(
|
mark_connected = forms.NullBooleanField(
|
||||||
label=_('Mark connected'),
|
label=_('Mark connected'),
|
||||||
|
@ -19,7 +19,7 @@ def get_cable_form(a_type, b_type):
|
|||||||
# Device component
|
# Device component
|
||||||
if hasattr(term_cls, 'device'):
|
if hasattr(term_cls, 'device'):
|
||||||
|
|
||||||
attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
|
attrs[f'termination_{cable_end}_device'] = DynamicModelMultipleChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
label=_('Device'),
|
label=_('Device'),
|
||||||
required=False,
|
required=False,
|
||||||
@ -33,6 +33,7 @@ def get_cable_form(a_type, b_type):
|
|||||||
label=term_cls._meta.verbose_name.title(),
|
label=term_cls._meta.verbose_name.title(),
|
||||||
context={
|
context={
|
||||||
'disabled': '_occupied',
|
'disabled': '_occupied',
|
||||||
|
'parent': 'device',
|
||||||
},
|
},
|
||||||
query_params={
|
query_params={
|
||||||
'device_id': f'$termination_{cable_end}_device',
|
'device_id': f'$termination_{cable_end}_device',
|
||||||
@ -43,7 +44,7 @@ def get_cable_form(a_type, b_type):
|
|||||||
# PowerFeed
|
# PowerFeed
|
||||||
elif term_cls == PowerFeed:
|
elif term_cls == PowerFeed:
|
||||||
|
|
||||||
attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField(
|
attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelMultipleChoiceField(
|
||||||
queryset=PowerPanel.objects.all(),
|
queryset=PowerPanel.objects.all(),
|
||||||
label=_('Power Panel'),
|
label=_('Power Panel'),
|
||||||
required=False,
|
required=False,
|
||||||
@ -57,6 +58,7 @@ def get_cable_form(a_type, b_type):
|
|||||||
label=_('Power Feed'),
|
label=_('Power Feed'),
|
||||||
context={
|
context={
|
||||||
'disabled': '_occupied',
|
'disabled': '_occupied',
|
||||||
|
'parent': 'powerpanel',
|
||||||
},
|
},
|
||||||
query_params={
|
query_params={
|
||||||
'power_panel_id': f'$termination_{cable_end}_powerpanel',
|
'power_panel_id': f'$termination_{cable_end}_powerpanel',
|
||||||
@ -66,7 +68,7 @@ def get_cable_form(a_type, b_type):
|
|||||||
# CircuitTermination
|
# CircuitTermination
|
||||||
elif term_cls == CircuitTermination:
|
elif term_cls == CircuitTermination:
|
||||||
|
|
||||||
attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
|
attrs[f'termination_{cable_end}_circuit'] = DynamicModelMultipleChoiceField(
|
||||||
queryset=Circuit.objects.all(),
|
queryset=Circuit.objects.all(),
|
||||||
label=_('Circuit'),
|
label=_('Circuit'),
|
||||||
selector=True,
|
selector=True,
|
||||||
@ -79,6 +81,7 @@ def get_cable_form(a_type, b_type):
|
|||||||
label=_('Side'),
|
label=_('Side'),
|
||||||
context={
|
context={
|
||||||
'disabled': '_occupied',
|
'disabled': '_occupied',
|
||||||
|
'parent': 'circuit',
|
||||||
},
|
},
|
||||||
query_params={
|
query_params={
|
||||||
'circuit_id': f'$termination_{cable_end}_circuit',
|
'circuit_id': f'$termination_{cable_end}_circuit',
|
||||||
|
@ -2126,7 +2126,7 @@ class DeviceRenderConfigView(generic.ObjectView):
|
|||||||
try:
|
try:
|
||||||
rendered_config = config_template.render(context=context_data)
|
rendered_config = config_template.render(context=context_data)
|
||||||
except TemplateError as e:
|
except TemplateError as e:
|
||||||
messages.error(request, f"An error occurred while rendering the template: {e}")
|
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e))
|
||||||
rendered_config = traceback.format_exc()
|
rendered_config = traceback.format_exc()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -2890,7 +2890,13 @@ class DeviceBayPopulateView(generic.ObjectEditView):
|
|||||||
device_bay.snapshot()
|
device_bay.snapshot()
|
||||||
device_bay.installed_device = form.cleaned_data['installed_device']
|
device_bay.installed_device = form.cleaned_data['installed_device']
|
||||||
device_bay.save()
|
device_bay.save()
|
||||||
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
|
messages.success(
|
||||||
|
request,
|
||||||
|
_("Installed device {device} in bay {device_bay}.").format(
|
||||||
|
device=device_bay.installed_device,
|
||||||
|
device_bay=device_bay
|
||||||
|
)
|
||||||
|
)
|
||||||
return_url = self.get_return_url(request)
|
return_url = self.get_return_url(request)
|
||||||
|
|
||||||
return redirect(return_url)
|
return redirect(return_url)
|
||||||
@ -2925,7 +2931,13 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
|
|||||||
removed_device = device_bay.installed_device
|
removed_device = device_bay.installed_device
|
||||||
device_bay.installed_device = None
|
device_bay.installed_device = None
|
||||||
device_bay.save()
|
device_bay.save()
|
||||||
messages.success(request, f"{removed_device} has been removed from {device_bay}.")
|
messages.success(
|
||||||
|
request,
|
||||||
|
_("Removed device {device} from bay {device_bay}.").format(
|
||||||
|
device=removed_device,
|
||||||
|
device_bay=device_bay
|
||||||
|
)
|
||||||
|
)
|
||||||
return_url = self.get_return_url(request, device_bay.device)
|
return_url = self.get_return_url(request, device_bay.device)
|
||||||
|
|
||||||
return redirect(return_url)
|
return redirect(return_url)
|
||||||
@ -3493,7 +3505,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
|
|||||||
|
|
||||||
membership_form.save()
|
membership_form.save()
|
||||||
messages.success(request, mark_safe(
|
messages.success(request, mark_safe(
|
||||||
f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
|
_('Added member <a href="{url}">{escape(device)}</a>').format(url=device.get_absolute_url())
|
||||||
))
|
))
|
||||||
|
|
||||||
if '_addanother' in request.POST:
|
if '_addanother' in request.POST:
|
||||||
@ -3538,7 +3550,10 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
|
|||||||
# Protect master device from being removed
|
# Protect master device from being removed
|
||||||
virtual_chassis = VirtualChassis.objects.filter(master=device).first()
|
virtual_chassis = VirtualChassis.objects.filter(master=device).first()
|
||||||
if virtual_chassis is not None:
|
if virtual_chassis is not None:
|
||||||
messages.error(request, f'Unable to remove master device {device} from the virtual chassis.')
|
messages.error(
|
||||||
|
request,
|
||||||
|
_('Unable to remove master device {device} from the virtual chassis.').format(device=device)
|
||||||
|
)
|
||||||
return redirect(device.get_absolute_url())
|
return redirect(device.get_absolute_url())
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@ -3550,7 +3565,10 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
|
|||||||
device.vc_priority = None
|
device.vc_priority = None
|
||||||
device.save()
|
device.save()
|
||||||
|
|
||||||
msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
|
msg = _('Removed {device} from virtual chassis {chassis}').format(
|
||||||
|
device=device,
|
||||||
|
chassis=device.virtual_chassis
|
||||||
|
)
|
||||||
messages.success(request, msg)
|
messages.success(request, msg)
|
||||||
|
|
||||||
return redirect(self.get_return_url(request, device))
|
return redirect(self.get_return_url(request, device))
|
||||||
|
@ -372,13 +372,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
|
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
|
||||||
if not self.related_object_type:
|
if not self.related_object_type:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'object_type': _("Object fields must define an object type.")
|
'related_object_type': _("Object fields must define an object type.")
|
||||||
})
|
})
|
||||||
elif self.related_object_type:
|
elif self.related_object_type:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'object_type': _(
|
'type': _("{type} fields may not define an object type.") .format(type=self.get_type_display())
|
||||||
"{type} fields may not define an object type.")
|
|
||||||
.format(type=self.get_type_display())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Related object filter can be set only for object-type fields, and must contain a dictionary mapping (if set)
|
# Related object filter can be set only for object-type fields, and must contain a dictionary mapping (if set)
|
||||||
|
@ -222,6 +222,19 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
'group_id': '$site_group',
|
'group_id': '$site_group',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
vlan_group = DynamicModelChoiceField(
|
||||||
|
queryset=VLANGroup.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('VLAN Group')
|
||||||
|
)
|
||||||
|
vlan = DynamicModelChoiceField(
|
||||||
|
queryset=VLAN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('VLAN'),
|
||||||
|
query_params={
|
||||||
|
'group_id': '$vlan_group',
|
||||||
|
}
|
||||||
|
)
|
||||||
vrf = DynamicModelChoiceField(
|
vrf = DynamicModelChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -270,9 +283,10 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
FieldSet('tenant', 'status', 'role', 'description'),
|
FieldSet('tenant', 'status', 'role', 'description'),
|
||||||
FieldSet('region', 'site_group', 'site', name=_('Site')),
|
FieldSet('region', 'site_group', 'site', name=_('Site')),
|
||||||
FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
|
FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
|
||||||
|
FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'site', 'vrf', 'tenant', 'role', 'description', 'comments',
|
'site', 'vlan', 'vrf', 'tenant', 'role', 'description', 'comments',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ from django.contrib.auth.models import AnonymousUser
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.core.exceptions import FieldDoesNotExist
|
from django.core.exceptions import FieldDoesNotExist
|
||||||
from django.db.models.fields.related import RelatedField
|
from django.db.models.fields.related import RelatedField
|
||||||
|
from django.db.models.fields.reverse_related import ManyToOneRel
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.urls.exceptions import NoReverseMatch
|
from django.urls.exceptions import NoReverseMatch
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
@ -102,7 +103,7 @@ class BaseTable(tables.Table):
|
|||||||
field = model._meta.get_field(field_name)
|
field = model._meta.get_field(field_name)
|
||||||
except FieldDoesNotExist:
|
except FieldDoesNotExist:
|
||||||
break
|
break
|
||||||
if isinstance(field, RelatedField):
|
if isinstance(field, (RelatedField, ManyToOneRel)):
|
||||||
# Follow ForeignKeys to the related model
|
# Follow ForeignKeys to the related model
|
||||||
prefetch_path.append(field_name)
|
prefetch_path.append(field_name)
|
||||||
model = field.remote_field.model
|
model = field.remote_field.model
|
||||||
|
@ -2,6 +2,7 @@ from django.test import override_settings
|
|||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
|
from extras.models import CustomField
|
||||||
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
|
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||||
from users.models import ObjectPermission
|
from users.models import ObjectPermission
|
||||||
from utilities.testing import ModelViewTestCase, create_tags
|
from utilities.testing import ModelViewTestCase, create_tags
|
||||||
@ -116,3 +117,28 @@ class CSVImportTestCase(ModelViewTestCase):
|
|||||||
# Test POST with permission
|
# Test POST with permission
|
||||||
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
|
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
|
||||||
self.assertEqual(Region.objects.count(), 0)
|
self.assertEqual(Region.objects.count(), 0)
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_custom_field_defaults(self):
|
||||||
|
self.add_permissions('dcim.add_region')
|
||||||
|
csv_data = [
|
||||||
|
'name,slug,description',
|
||||||
|
'Region 1,region-1,abc',
|
||||||
|
]
|
||||||
|
data = {
|
||||||
|
'format': ImportFormatChoices.CSV,
|
||||||
|
'data': self._get_csv_data(csv_data),
|
||||||
|
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||||
|
}
|
||||||
|
|
||||||
|
cf = CustomField.objects.create(
|
||||||
|
name='tcf',
|
||||||
|
type='text',
|
||||||
|
required=False,
|
||||||
|
default='def-cf-text'
|
||||||
|
)
|
||||||
|
cf.object_types.set([ObjectType.objects.get_for_model(self.model)])
|
||||||
|
|
||||||
|
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
|
||||||
|
region = Region.objects.get(slug='region-1')
|
||||||
|
self.assertEqual(region.cf['tcf'], 'def-cf-text')
|
||||||
|
@ -4,6 +4,7 @@ from copy import deepcopy
|
|||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.contenttypes.fields import GenericRel
|
from django.contrib.contenttypes.fields import GenericRel
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
|
||||||
from django.db import transaction, IntegrityError
|
from django.db import transaction, IntegrityError
|
||||||
from django.db.models import ManyToManyField, ProtectedError, RestrictedError
|
from django.db.models import ManyToManyField, ProtectedError, RestrictedError
|
||||||
@ -18,7 +19,8 @@ from django_tables2.export import TableExport
|
|||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from core.signals import clear_events
|
from core.signals import clear_events
|
||||||
from extras.models import ExportTemplate
|
from extras.choices import CustomFieldUIEditableChoices
|
||||||
|
from extras.models import CustomField, ExportTemplate
|
||||||
from utilities.error_handlers import handle_protectederror
|
from utilities.error_handlers import handle_protectederror
|
||||||
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
||||||
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
|
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
|
||||||
@ -106,7 +108,13 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
|||||||
try:
|
try:
|
||||||
return template.render_to_response(self.queryset)
|
return template.render_to_response(self.queryset)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
|
messages.error(
|
||||||
|
request,
|
||||||
|
_("There was an error rendering the selected export template ({template}): {error}").format(
|
||||||
|
template=template.name,
|
||||||
|
error=e
|
||||||
|
)
|
||||||
|
)
|
||||||
# Strip the `export` param and redirect user to the filtered objects list
|
# Strip the `export` param and redirect user to the filtered objects list
|
||||||
query_params = request.GET.copy()
|
query_params = request.GET.copy()
|
||||||
query_params.pop('export')
|
query_params.pop('export')
|
||||||
@ -409,6 +417,17 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
if instance.pk and hasattr(instance, 'snapshot'):
|
if instance.pk and hasattr(instance, 'snapshot'):
|
||||||
instance.snapshot()
|
instance.snapshot()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# For newly created objects, apply any default custom field values
|
||||||
|
custom_fields = CustomField.objects.filter(
|
||||||
|
object_types=ContentType.objects.get_for_model(self.queryset.model),
|
||||||
|
ui_editable=CustomFieldUIEditableChoices.YES
|
||||||
|
)
|
||||||
|
for cf in custom_fields:
|
||||||
|
field_name = f'cf_{cf.name}'
|
||||||
|
if field_name not in record:
|
||||||
|
record[field_name] = cf.default
|
||||||
|
|
||||||
# Instantiate the model form for the object
|
# Instantiate the model form for the object
|
||||||
model_form_kwargs = {
|
model_form_kwargs = {
|
||||||
'data': record,
|
'data': record,
|
||||||
@ -668,7 +687,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
# Retrieve objects being edited
|
# Retrieve objects being edited
|
||||||
table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
|
table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
|
||||||
if not table.rows:
|
if not table.rows:
|
||||||
messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
|
messages.warning(
|
||||||
|
request,
|
||||||
|
_("No {object_type} were selected.").format(object_type=model._meta.verbose_name_plural)
|
||||||
|
)
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
return render(request, self.template_name, {
|
||||||
@ -745,8 +767,13 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
|
if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
|
||||||
raise PermissionsViolation
|
raise PermissionsViolation
|
||||||
|
|
||||||
model_name = self.queryset.model._meta.verbose_name_plural
|
messages.success(
|
||||||
messages.success(request, f"Renamed {len(selected_objects)} {model_name}")
|
request,
|
||||||
|
_("Renamed {count} {object_type}").format(
|
||||||
|
count=len(selected_objects),
|
||||||
|
object_type=self.queryset.model._meta.verbose_name_plural
|
||||||
|
)
|
||||||
|
)
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
except (AbortRequest, PermissionsViolation) as e:
|
except (AbortRequest, PermissionsViolation) as e:
|
||||||
@ -838,7 +865,10 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
messages.error(request, mark_safe(e.message))
|
messages.error(request, mark_safe(e.message))
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
|
msg = _("Deleted {count} {object_type}").format(
|
||||||
|
count=deleted_count,
|
||||||
|
object_type=model._meta.verbose_name_plural
|
||||||
|
)
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
messages.success(request, msg)
|
messages.success(request, msg)
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
@ -855,7 +885,10 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
# Retrieve objects being deleted
|
# Retrieve objects being deleted
|
||||||
table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
|
table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
|
||||||
if not table.rows:
|
if not table.rows:
|
||||||
messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
|
messages.warning(
|
||||||
|
request,
|
||||||
|
_("No {object_type} were selected.").format(object_type=model._meta.verbose_name_plural)
|
||||||
|
)
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
return render(request, self.template_name, {
|
||||||
@ -900,7 +933,10 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
|
|
||||||
selected_objects = self.parent_model.objects.filter(pk__in=pk_list)
|
selected_objects = self.parent_model.objects.filter(pk__in=pk_list)
|
||||||
if not selected_objects:
|
if not selected_objects:
|
||||||
messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
|
messages.warning(
|
||||||
|
request,
|
||||||
|
_("No {object_type} were selected.").format(object_type=self.parent_model._meta.verbose_name_plural)
|
||||||
|
)
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
table = self.table(selected_objects, orderable=False)
|
table = self.table(selected_objects, orderable=False)
|
||||||
|
|
||||||
|
@ -204,11 +204,14 @@ class ObjectSyncDataView(LoginRequiredMixin, View):
|
|||||||
obj = get_object_or_404(qs, **kwargs)
|
obj = get_object_or_404(qs, **kwargs)
|
||||||
|
|
||||||
if not obj.data_file:
|
if not obj.data_file:
|
||||||
messages.error(request, f"Unable to synchronize data: No data file set.")
|
messages.error(request, _("Unable to synchronize data: No data file set."))
|
||||||
return redirect(obj.get_absolute_url())
|
return redirect(obj.get_absolute_url())
|
||||||
|
|
||||||
obj.sync(save=True)
|
obj.sync(save=True)
|
||||||
messages.success(request, f"Synchronized data for {model._meta.verbose_name} {obj}.")
|
messages.success(request, _("Synchronized data for {object_type} {object}.").format(
|
||||||
|
object_type=model._meta.verbose_name,
|
||||||
|
object=obj
|
||||||
|
))
|
||||||
|
|
||||||
return redirect(obj.get_absolute_url())
|
return redirect(obj.get_absolute_url())
|
||||||
|
|
||||||
@ -230,7 +233,9 @@ class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
for obj in selected_objects:
|
for obj in selected_objects:
|
||||||
obj.sync(save=True)
|
obj.sync(save=True)
|
||||||
|
|
||||||
model_name = self.queryset.model._meta.verbose_name_plural
|
messages.success(request, _("Synced {count} {object_type}").format(
|
||||||
messages.success(request, f"Synced {len(selected_objects)} {model_name}")
|
count=len(selected_objects),
|
||||||
|
object_type=self.queryset.model._meta.verbose_name_plural
|
||||||
|
))
|
||||||
|
|
||||||
return redirect(self.get_return_url(request))
|
return redirect(self.get_return_url(request))
|
||||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -10,7 +10,9 @@ export function initMessages(): void {
|
|||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
if (element !== null) {
|
if (element !== null) {
|
||||||
const toast = new Toast(element);
|
const toast = new Toast(element);
|
||||||
|
if (!toast.isShown()) {
|
||||||
toast.show();
|
toast.show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
@ -129,3 +129,7 @@ body[data-bs-theme=dark] {
|
|||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
padding: unset;
|
||||||
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -3,8 +3,8 @@ from django import forms
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django_filters.constants import EMPTY_VALUES
|
from django_filters.constants import EMPTY_VALUES
|
||||||
from drf_spectacular.utils import extend_schema_field
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import extend_schema_field
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ContentTypeFilter',
|
'ContentTypeFilter',
|
||||||
@ -116,6 +116,7 @@ class MultiValueWWNFilter(django_filters.MultipleChoiceFilter):
|
|||||||
field_class = multivalue_field_factory(forms.CharField)
|
field_class = multivalue_field_factory(forms.CharField)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
|
class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
|
||||||
"""
|
"""
|
||||||
Filters for a set of Models, including all descendant models within a Tree. Example: [<Region: R1>,<Region: R2>]
|
Filters for a set of Models, including all descendant models within a Tree. Example: [<Region: R1>,<Region: R2>]
|
||||||
|
@ -272,8 +272,9 @@ class ClusterAddDevicesView(generic.ObjectEditView):
|
|||||||
device.cluster = cluster
|
device.cluster = cluster
|
||||||
device.save()
|
device.save()
|
||||||
|
|
||||||
messages.success(request, "Added {} devices to cluster {}".format(
|
messages.success(request, _("Added {count} devices to cluster {cluster}").format(
|
||||||
len(device_pks), cluster
|
count=len(device_pks),
|
||||||
|
cluster=cluster
|
||||||
))
|
))
|
||||||
return redirect(cluster.get_absolute_url())
|
return redirect(cluster.get_absolute_url())
|
||||||
|
|
||||||
@ -306,8 +307,9 @@ class ClusterRemoveDevicesView(generic.ObjectEditView):
|
|||||||
device.cluster = None
|
device.cluster = None
|
||||||
device.save()
|
device.save()
|
||||||
|
|
||||||
messages.success(request, "Removed {} devices from cluster {}".format(
|
messages.success(request, _("Removed {count} devices from cluster {cluster}").format(
|
||||||
len(device_pks), cluster
|
count=len(device_pks),
|
||||||
|
cluster=cluster
|
||||||
))
|
))
|
||||||
return redirect(cluster.get_absolute_url())
|
return redirect(cluster.get_absolute_url())
|
||||||
|
|
||||||
@ -445,7 +447,7 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
|
|||||||
try:
|
try:
|
||||||
rendered_config = config_template.render(context=context_data)
|
rendered_config = config_template.render(context=context_data)
|
||||||
except TemplateError as e:
|
except TemplateError as e:
|
||||||
messages.error(request, f"An error occurred while rendering the template: {e}")
|
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e))
|
||||||
rendered_config = traceback.format_exc()
|
rendered_config = traceback.format_exc()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
Django==5.0.7
|
Django==5.0.8
|
||||||
django-cors-headers==4.4.0
|
django-cors-headers==4.4.0
|
||||||
django-debug-toolbar==4.4.6
|
django-debug-toolbar==4.4.6
|
||||||
django-filter==24.3
|
django-filter==24.3
|
||||||
django-htmx==1.18.0
|
django-htmx==1.19.0
|
||||||
django-graphiql-debug-toolbar==0.2.0
|
django-graphiql-debug-toolbar==0.2.0
|
||||||
django-mptt==0.16.0
|
django-mptt==0.16.0
|
||||||
django-pglocks==1.0.4
|
django-pglocks==1.0.4
|
||||||
django-prometheus==2.3.1
|
django-prometheus==2.3.1
|
||||||
django-redis==5.4.0
|
django-redis==5.4.0
|
||||||
django-rich==1.9.0
|
django-rich==1.10.0
|
||||||
django-rq==2.10.2
|
django-rq==2.10.2
|
||||||
django-taggit==6.0.0
|
django-taggit==6.0.0
|
||||||
django-tables2==2.7.0
|
django-tables2==2.7.0
|
||||||
@ -17,7 +17,7 @@ djangorestframework==3.15.2
|
|||||||
drf-spectacular==0.27.2
|
drf-spectacular==0.27.2
|
||||||
drf-spectacular-sidecar==2024.7.1
|
drf-spectacular-sidecar==2024.7.1
|
||||||
feedparser==6.0.11
|
feedparser==6.0.11
|
||||||
gunicorn==22.0.0
|
gunicorn==23.0.0
|
||||||
Jinja2==3.1.4
|
Jinja2==3.1.4
|
||||||
Markdown==3.6
|
Markdown==3.6
|
||||||
mkdocs-material==9.5.31
|
mkdocs-material==9.5.31
|
||||||
@ -26,7 +26,7 @@ netaddr==1.3.0
|
|||||||
nh3==0.2.18
|
nh3==0.2.18
|
||||||
Pillow==10.4.0
|
Pillow==10.4.0
|
||||||
psycopg[c,pool]==3.2.1
|
psycopg[c,pool]==3.2.1
|
||||||
PyYAML==6.0.1
|
PyYAML==6.0.2
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
social-auth-app-django==5.4.2
|
social-auth-app-django==5.4.2
|
||||||
social-auth-core==4.5.4
|
social-auth-core==4.5.4
|
||||||
|
Loading…
Reference in New Issue
Block a user