mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-08 00:28:16 -06:00
Merge branch 'develop' into 16670-nested-serializer
This commit is contained in:
commit
c992a2f946
@ -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.
|
||||||
|
@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
## v4.0.9 (FUTURE)
|
## v4.0.9 (FUTURE)
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
* [#16176](https://github.com/netbox-community/netbox/issues/16176) - Restore ability to select multiple terminating devices when connecting a cable
|
||||||
|
* [#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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v4.0.8 (2024-07-26)
|
## v4.0.8 (2024-07-26)
|
||||||
|
@ -109,7 +109,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.)
|
||||||
@ -159,7 +159,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))
|
||||||
@ -234,7 +234,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)
|
||||||
@ -249,7 +249,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', {
|
||||||
|
@ -76,7 +76,10 @@ class DataSourceSyncView(BaseObjectView):
|
|||||||
datasource = get_object_or_404(self.queryset, pk=pk)
|
datasource = get_object_or_404(self.queryset, pk=pk)
|
||||||
job = datasource.enqueue_sync_job(request)
|
job = datasource.enqueue_sync_job(request)
|
||||||
|
|
||||||
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())
|
||||||
|
|
||||||
|
|
||||||
@ -235,7 +238,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())
|
||||||
|
|
||||||
@ -379,9 +382,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'))
|
||||||
|
|
||||||
@ -394,13 +397,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]))
|
||||||
|
|
||||||
|
|
||||||
@ -412,7 +415,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)
|
||||||
@ -435,7 +438,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]))
|
||||||
|
|
||||||
|
|
||||||
@ -452,11 +455,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]))
|
||||||
|
|
||||||
|
@ -886,6 +886,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'
|
||||||
|
|
||||||
@ -1057,6 +1058,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,
|
||||||
]
|
]
|
||||||
|
@ -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',
|
||||||
|
@ -2059,7 +2059,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 {
|
||||||
@ -2823,7 +2823,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)
|
||||||
@ -2858,7 +2864,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)
|
||||||
@ -3426,7 +3438,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:
|
||||||
@ -3471,7 +3483,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():
|
||||||
@ -3483,7 +3498,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))
|
||||||
|
@ -352,13 +352,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())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def serialize(self, value):
|
def serialize(self, value):
|
||||||
|
@ -221,6 +221,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,
|
||||||
@ -269,9 +282,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
|
||||||
|
@ -106,7 +106,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')
|
||||||
@ -668,7 +674,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 +754,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 +852,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 +872,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 +920,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)
|
||||||
|
|
||||||
|
@ -202,11 +202,14 @@ class ObjectSyncDataView(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())
|
||||||
|
|
||||||
@ -228,7 +231,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
@ -44,3 +44,7 @@ table a {
|
|||||||
[data-bs-theme=dark] ::selection {
|
[data-bs-theme=dark] ::selection {
|
||||||
background-color: rgba(var(--tblr-primary-rgb),.48)
|
background-color: rgba(var(--tblr-primary-rgb),.48)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
padding: unset;
|
||||||
|
}
|
||||||
|
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>]
|
||||||
|
@ -271,8 +271,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())
|
||||||
|
|
||||||
@ -305,8 +306,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())
|
||||||
|
|
||||||
@ -444,7 +446,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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user