diff --git a/docs/plugins/development/user-interface.md b/docs/plugins/development/user-interface.md new file mode 100644 index 000000000..a918eb185 --- /dev/null +++ b/docs/plugins/development/user-interface.md @@ -0,0 +1,14 @@ +# User Interface + +## Light & Dark Mode + +The NetBox user interface supports toggling between light and dark versions of the theme. If needed, a plugin can determine the currently active color theme by inspecting `window.localStorage['netbox-color-mode']`, which will indicate either `light` or `dark`. + +Additionally, when the color scheme is toggled by the user, a custom event `netbox.colorModeChanged` indicating the new scheme is dispatched. A plugin can listen for this event if needed to react to the change: + +```typescript +window.addEventListener('netbox.colorModeChanged', e => { + const customEvent = e as CustomEvent; + console.log('New color mode:', customEvent.detail.netboxColorMode); +}); +``` diff --git a/mkdocs.yml b/mkdocs.yml index 8f98fe38c..d427d3d24 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -145,6 +145,7 @@ nav: - Event Types: 'plugins/development/event-types.md' - Data Backends: 'plugins/development/data-backends.md' - Webhooks: 'plugins/development/webhooks.md' + - User Interface: 'plugins/development/user-interface.md' - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Jobs: 'plugins/development/background-jobs.md' diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 0377ffbb1..8a6bf6a1d 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -18,8 +18,10 @@ from rq.exceptions import InvalidJobOperation from core.choices import JobStatusChoices from core.dataclasses import JobLogEntry +from core.events import JOB_COMPLETED, JOB_ERRORED, JOB_FAILED from core.models import ObjectType from core.signals import job_end, job_start +from extras.models import Notification from netbox.models.features import has_feature from utilities.json import JobLogDecoder from utilities.querysets import RestrictedQuerySet @@ -145,6 +147,13 @@ class Job(models.Model): def get_status_color(self): return JobStatusChoices.colors.get(self.status) + def get_event_type(self): + return { + JobStatusChoices.STATUS_COMPLETED: JOB_COMPLETED, + JobStatusChoices.STATUS_FAILED: JOB_FAILED, + JobStatusChoices.STATUS_ERRORED: JOB_ERRORED, + }.get(self.status) + def clean(self): super().clean() @@ -216,6 +225,14 @@ class Job(models.Model): self.completed = timezone.now() self.save() + # Notify the user (if any) of completion + if self.user: + Notification( + user=self.user, + object=self, + event_type=self.get_event_type(), + ).save() + # Send signal job_end.send(self) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index d55d7fc48..2af89bc9a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -340,11 +340,12 @@ class TableConfigTable(NetBoxTable): class BookmarkTable(NetBoxTable): object_type = columns.ContentTypeColumn( - verbose_name=_('Object Types'), + verbose_name=_('Object Type'), ) object = tables.Column( verbose_name=_('Object'), - linkify=True + linkify=True, + orderable=False ) actions = columns.ActionsColumn( actions=('delete',) diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index 72743eaf4..559619ac0 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -8,10 +8,8 @@ from django_pglocks import advisory_lock from rq.timeouts import JobTimeoutException from core.choices import JobStatusChoices -from core.events import JOB_COMPLETED, JOB_FAILED from core.exceptions import JobFailed from core.models import Job, ObjectType -from extras.models import Notification from netbox.constants import ADVISORY_LOCK_KEYS from netbox.registry import registry from utilities.request import apply_request_processors @@ -194,23 +192,11 @@ class AsyncViewJob(JobRunner): def run(self, view_cls, request, **kwargs): view = view_cls.as_view() + request.job = self # Apply all registered request processors (e.g. event_tracking) with apply_request_processors(request): - data = view(request) + view(request) - self.job.data = { - 'log': data.log, - 'errors': data.errors, - } - - # Notify the user - notification = Notification( - user=request.user, - object=self.job, - event_type=JOB_COMPLETED if not data.errors else JOB_FAILED, - ) - notification.save() - - if data.errors: + if self.job.error: raise JobFailed() diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index fec4c1ec7..aa77507de 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -17,18 +17,19 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from mptt.models import MPTTModel +from core.exceptions import JobFailed from core.models import ObjectType from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation +from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.export import TableExport from utilities.forms import BulkDeleteForm, BulkRenameForm, restrict_form_fields from utilities.forms.bulk_import import BulkImportForm from utilities.htmx import htmx_partial -from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job +from utilities.jobs import is_background_request, process_request_as_job from utilities.permissions import get_permission_for_model from utilities.query import reapply_model_ordering from utilities.request import safe_for_redirect @@ -357,7 +358,18 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): return {**required_fields, **optional_fields} - def _save_object(self, import_form, model_form, request): + def _compile_form_errors(self, errors, index, prefix=None): + error_messages = [] + for field_name, errors in errors.items(): + prefix = f'{prefix}.' if prefix else '' + if field_name == '__all__': + field_name = '' + for err in errors: + error_messages.append(f"Record {index} {prefix}{field_name}: {err}") + return error_messages + + def _save_object(self, model_form, request): + _action = 'Updated' if model_form.instance.pk else 'Created' # Save the primary object obj = self.save_object(model_form, request) @@ -383,20 +395,18 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): related_obj_pks.append(related_obj.pk) else: # Replicate errors on the related object form to the import form for display and abort - for subfield_name, errors in f.errors.items(): - for err in errors: - if subfield_name == '__all__': - err_msg = f"{field_name}[{i}]: {err}" - else: - err_msg = f"{field_name}[{i}] {subfield_name}: {err}" - import_form.add_error(None, err_msg) - raise AbortTransaction() + raise ValidationError( + self._compile_form_errors(f.errors, index=i, prefix=f'{field_name}[{i}]') + ) # Enforce object-level permissions on related objects model = related_object_form.Meta.model if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): raise ObjectDoesNotExist + if is_background_request(request): + request.job.logger.info(f'{_action} {obj}') + return obj def save_object(self, object_form, request): @@ -471,18 +481,13 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): restrict_form_fields(model_form, request.user) if model_form.is_valid(): - obj = self._save_object(form, model_form, request) + obj = self._save_object(model_form, request) saved_objects.append(obj) else: - # Replicate model form errors for display - for field, errors in model_form.errors.items(): - for err in errors: - if field == '__all__': - form.add_error(None, f'Record {i}: {err}') - else: - form.add_error(None, f'Record {i} {field}: {err}') - - raise ValidationError("") + # Raise model form errors + raise ValidationError( + self._compile_form_errors(model_form.errors, index=i) + ) return saved_objects @@ -509,7 +514,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): if form.is_valid(): logger.debug("Import form validation was successful") redirect_url = reverse(get_viewname(model, action='list')) - new_objects = [] # If indicated, defer this request to a background job & redirect the user if form.cleaned_data['background_job']: @@ -529,33 +533,32 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): if self.queryset.filter(pk__in=[obj.pk for obj in new_objects]).count() != len(new_objects): raise PermissionsViolation - except (AbortTransaction, ValidationError): - clear_events.send(sender=self) - - except (AbortRequest, PermissionsViolation) as e: - logger.debug(e.message) - form.add_error(None, e.message) - clear_events.send(sender=self) - - # If this request was executed via a background job, return the raw data for logging - if is_background_request(request): - return AsyncJobData( - log=[ - _('Created {object}').format(object=str(obj)) - for obj in new_objects - ], - errors=form.errors - ) - - if new_objects: - msg = _("Imported {count} {object_type}").format( + msg = _('Imported {count} {object_type}').format( count=len(new_objects), object_type=model._meta.verbose_name_plural ) logger.info(msg) + + # Handle background job + if is_background_request(request): + request.job.logger.info(msg) + return + messages.success(request, msg) return redirect(f"{redirect_url}?modified_by_request={request.id}") + except (AbortRequest, PermissionsViolation, ValidationError) as e: + err_messages = e.messages if type(e) is ValidationError else [e.message] + for msg in err_messages: + logger.debug(msg) + form.add_error(None, msg) + if is_background_request(request): + request.job.logger.error(msg) + request.job.logger.warning("Bulk import aborted") + clear_events.send(sender=self) + if is_background_request(request): + raise JobFailed + else: logger.debug("Form validation failed") @@ -670,6 +673,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): self.post_save_operations(form, obj) + if is_background_request(request): + request.job.logger.info(f"Updated {obj}") + # Rebuild the tree for MPTT models if issubclass(self.queryset.model, MPTTModel): self.queryset.model.objects.rebuild() @@ -733,31 +739,30 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): if object_count != len(updated_objects): raise PermissionsViolation - # If this request was executed via a background job, return the raw data for logging + msg = _('Updated {count} {object_type}').format( + count=len(updated_objects), + object_type=model._meta.verbose_name_plural, + ) + logger.info(msg) + + # Handle background job if is_background_request(request): - return AsyncJobData( - log=[ - _('Updated {object}').format(object=str(obj)) - for obj in updated_objects - ], - errors=form.errors - ) - - if updated_objects: - msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}' - logger.info(msg) - messages.success(self.request, msg) + request.job.logger.info(msg) + return + messages.success(self.request, msg) return redirect(self.get_return_url(request)) - except ValidationError as e: - messages.error(self.request, ", ".join(e.messages)) - clear_events.send(sender=self) - - except (AbortRequest, PermissionsViolation) as e: - logger.debug(e.message) - form.add_error(None, e.message) + except (AbortRequest, PermissionsViolation, ValidationError) as e: + err_messages = e.messages if type(e) is ValidationError else [e.message] + for msg in err_messages: + logger.debug(msg) + form.add_error(None, msg) + if is_background_request(request): + request.job.logger.error(msg) clear_events.send(sender=self) + if is_background_request(request): + raise JobFailed else: logger.debug("Form validation failed") @@ -945,32 +950,38 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): # Delete the object obj.delete() + if is_background_request(request): + request.job.logger.info(f"Deleted {obj}") + + msg = _('Deleted {count} {object_type}').format( + count=deleted_count, + object_type=model._meta.verbose_name_plural + ) + logger.info(msg) + + # Handle background job + if is_background_request(request): + request.job.logger.info(msg) + return + + messages.success(request, msg) + except (ProtectedError, RestrictedError) as e: - logger.info(f"Caught {type(e)} while attempting to delete objects") + logger.warning(f"Caught {type(e)} while attempting to delete objects") + if is_background_request(request): + request.job.logger.error( + _("Deletion failed due to the presence of one or more dependent objects.") + ) + raise JobFailed handle_protectederror(queryset, request, e) - return redirect(self.get_return_url(request)) except AbortRequest as e: logger.debug(e.message) + if is_background_request(request): + request.job.logger.error(e.message) + raise JobFailed messages.error(request, mark_safe(e.message)) - return redirect(self.get_return_url(request)) - # If this request was executed via a background job, return the raw data for logging - if is_background_request(request): - return AsyncJobData( - log=[ - _('Deleted {object}').format(object=str(obj)) - for obj in queryset - ], - errors=form.errors - ) - - msg = _("Deleted {count} {object_type}").format( - count=deleted_count, - object_type=model._meta.verbose_name_plural - ) - logger.info(msg) - messages.success(request, msg) return redirect(self.get_return_url(request)) else: diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 9e2d6b383..ed5971014 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0ba7a62d1..295f03aca 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/colorMode.ts b/netbox/project-static/src/colorMode.ts index 453617740..1d05c955d 100644 --- a/netbox/project-static/src/colorMode.ts +++ b/netbox/project-static/src/colorMode.ts @@ -43,6 +43,11 @@ function updateElements(targetMode: ColorMode): void { export function setColorMode(mode: ColorMode): void { storeColorMode(mode); updateElements(mode); + window.dispatchEvent( + new CustomEvent('netbox.colorModeChanged', { + detail: { netboxColorMode: mode }, + }), + ); } /** diff --git a/netbox/project-static/src/global.d.ts b/netbox/project-static/src/global.d.ts index d7244339a..5b75be1ca 100644 --- a/netbox/project-static/src/global.d.ts +++ b/netbox/project-static/src/global.d.ts @@ -79,3 +79,6 @@ type FormControls = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; type ColorMode = 'light' | 'dark'; type ColorModePreference = ColorMode | 'none'; +type ColorModeData = { + netboxColorMode: ColorMode; +}; diff --git a/netbox/templates/account/bookmarks.html b/netbox/templates/account/bookmarks.html index f7aa9bf57..2e3ff5691 100644 --- a/netbox/templates/account/bookmarks.html +++ b/netbox/templates/account/bookmarks.html @@ -24,9 +24,7 @@ {# Form buttons #}
- {% if 'bulk_delete' in actions %} - {% bulk_delete_button model query_params=request.GET %} - {% endif %} + {% bulk_delete_button model query_params=request.GET %}
{% endblock %} diff --git a/netbox/templates/account/notifications.html b/netbox/templates/account/notifications.html index 5a471ef25..46feb80b0 100644 --- a/netbox/templates/account/notifications.html +++ b/netbox/templates/account/notifications.html @@ -24,9 +24,7 @@ {# Form buttons #}
- {% if 'bulk_delete' in actions %} - {% bulk_delete_button model query_params=request.GET %} - {% endif %} + {% bulk_delete_button model query_params=request.GET %}
{% endblock %} diff --git a/netbox/templates/account/subscriptions.html b/netbox/templates/account/subscriptions.html index d97053d63..0ae1060cc 100644 --- a/netbox/templates/account/subscriptions.html +++ b/netbox/templates/account/subscriptions.html @@ -24,9 +24,7 @@ {# Form buttons #}
- {% if 'bulk_delete' in actions %} - {% bulk_delete_button model query_params=request.GET %} - {% endif %} + {% bulk_delete_button model query_params=request.GET %}
{% endblock %} diff --git a/netbox/utilities/jobs.py b/netbox/utilities/jobs.py index 50b2dbc0c..8682c8239 100644 --- a/netbox/utilities/jobs.py +++ b/netbox/utilities/jobs.py @@ -1,6 +1,3 @@ -from dataclasses import dataclass -from typing import List - from django.contrib import messages from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -9,23 +6,16 @@ from netbox.jobs import AsyncViewJob from utilities.request import copy_safe_request __all__ = ( - 'AsyncJobData', 'is_background_request', 'process_request_as_job', ) -@dataclass -class AsyncJobData: - log: List[str] - errors: List[str] - - def is_background_request(request): """ Return True if the request is being processed as a background job. """ - return getattr(request, '_background', False) + return hasattr(request, 'job') def process_request_as_job(view, request, name=None): @@ -39,7 +29,6 @@ def process_request_as_job(view, request, name=None): # Create a serializable copy of the original request request_copy = copy_safe_request(request) - request_copy._background = True # Enqueue a job to perform the work in the background job = AsyncViewJob.enqueue(