diff --git a/docs/development/testing.md b/docs/development/testing.md index ca861ea502..1e1290a25f 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -52,6 +52,7 @@ The test suite covers all major plugin functionality. Tests are organized by the | [test_coverage_sync_views3.py](../../netbox_librenms_plugin/tests/test_coverage_sync_views3.py) | Further sync action view coverage—location sync, VLAN assignment edge cases | | [test_coverage_actions.py](../../netbox_librenms_plugin/tests/test_coverage_actions.py) | Import action views—bulk import, device role/cluster/rack update, validation details | | [test_coverage_filters.py](../../netbox_librenms_plugin/tests/test_coverage_filters.py) | Import filter logic—filter form processing and device count helpers | +| [test_init.py](../../netbox_librenms_plugin/tests/test_init.py) | Plugin startup—`_ensure_librenms_id_custom_field` creation, type migration, and multi-DB alias handling | | [test_coverage_tables.py](../../netbox_librenms_plugin/tests/test_coverage_tables.py) | Sync tables—column rendering, row data, interface and cable table helpers | | [test_coverage_utils.py](../../netbox_librenms_plugin/tests/test_coverage_utils.py) | Utility function coverage—name matching, speed conversion, site/platform lookup | | [test_coverage_virtual_chassis.py](../../netbox_librenms_plugin/tests/test_coverage_virtual_chassis.py) | Virtual chassis coverage—VC creation, position conflict handling, member naming | diff --git a/docs/usage_tips/custom_field.md b/docs/usage_tips/custom_field.md index 1474d6826e..c0328734ee 100644 --- a/docs/usage_tips/custom_field.md +++ b/docs/usage_tips/custom_field.md @@ -18,10 +18,10 @@ For the Interface object, the plugin will automatically populate the LibreNMS ID - **Efficient Synchronization:** Enhances the reliability of API lookups. - **Cable creation:** Allows better device identification for the creation of cables between NetBox devices. -## Manual Custom Field Setup (Legacy) +## Manual Custom Field Setup !!! note - This section is only needed if you are running an older version of the plugin that does not auto-create the field, or if you need to recreate it after deletion. + On 0.4.3+, rerun migrations first (`manage.py migrate`). If you need to recreate the field manually on current releases, use the JSON schema below. Pre-0.4.2 releases used an Integer field — do not use Integer for new entries. Follow these steps to create the `librenms_id` custom field in NetBox: diff --git a/netbox_librenms_plugin/__init__.py b/netbox_librenms_plugin/__init__.py index 299ceadd3e..6676229613 100644 --- a/netbox_librenms_plugin/__init__.py +++ b/netbox_librenms_plugin/__init__.py @@ -70,25 +70,28 @@ def _validate_legacy_config(self, plugin_config): def _ensure_librenms_id_custom_field(sender, **kwargs): """ - Auto-create the 'librenms_id' custom field if it doesn't exist. + Auto-create (or migrate) the 'librenms_id' custom field. Runs after migrations via post_migrate signal to ensure tables exist. Uses dispatch_uid to avoid duplicate connections. + + librenms_id stores a per-server JSON mapping {"server_key": device_id}. + Legacy installations may have this field typed as 'integer'; we upgrade it + to 'json' automatically so the UI and API accept the dict format. """ - # Only run once per migrate invocation (post_migrate fires per-app). - # The _executed flag is intentionally never reset: migrations are expected to - # run in short-lived CLI processes (manage.py migrate) where the flag is - # naturally cleared on exit. Long-running processes (e.g. gunicorn workers) - # should not rely on this handler re-executing after startup. - if getattr(_ensure_librenms_id_custom_field, "_executed", False): + # Track per-alias execution so each database alias is bootstrapped exactly once. + db_alias = kwargs.get("using") or "default" + executed_aliases = getattr(_ensure_librenms_id_custom_field, "_executed_aliases", set()) + if db_alias in executed_aliases: return - _ensure_librenms_id_custom_field._executed = True # not reset; see comment above + + import logging try: from django.contrib.contenttypes.models import ContentType from extras.models import CustomField - cf, created = CustomField.objects.get_or_create( + cf, created = CustomField.objects.using(db_alias).get_or_create( name="librenms_id", defaults={ "type": "json", @@ -101,6 +104,15 @@ def _ensure_librenms_id_custom_field(sender, **kwargs): }, ) + # Migrate legacy integer-typed field to JSON so the multi-server + # dict format {"server_key": device_id} is accepted by the UI/API. + if not created and cf.type == "integer": + cf.type = "json" + cf.save(using=db_alias, update_fields=["type"]) + logging.getLogger("netbox_librenms_plugin").info( + "Migrated 'librenms_id' custom field type from integer to json" + ) + # Ensure the field is assigned to the required object types from dcim.models import Device, Interface from virtualization.models import VirtualMachine, VMInterface @@ -109,21 +121,21 @@ def _ensure_librenms_id_custom_field(sender, **kwargs): current_types = set(cf.object_types.values_list("pk", flat=True)) for model in required_models: - ct = ContentType.objects.get_for_model(model) + ct = ContentType.objects.db_manager(db_alias).get_for_model(model) if ct.pk not in current_types: cf.object_types.add(ct) if created: - import logging - logging.getLogger("netbox_librenms_plugin").info( "Auto-created 'librenms_id' custom field for Device, VirtualMachine, Interface, VMInterface" ) + + # Mark this alias as executed after successful completion to allow retry on failure. + executed_aliases.add(db_alias) + _ensure_librenms_id_custom_field._executed_aliases = executed_aliases except Exception as e: # Don't break startup if custom field creation fails (e.g., during initial migration), # but log the error so it's not silently swallowed. - import logging - logging.getLogger("netbox_librenms_plugin").exception("Failed to auto-create 'librenms_id' custom field: %s", e) diff --git a/netbox_librenms_plugin/api/views.py b/netbox_librenms_plugin/api/views.py index 768c67f5fe..a5d440b8fb 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -8,9 +8,12 @@ from netbox.api.viewsets import NetBoxModelViewSet from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import BasePermission, SAFE_METHODS +from rq.exceptions import NoSuchJobError from rq.job import Job as RQJob from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN +from netbox_librenms_plugin.filters import InterfaceTypeMappingFilterSet +from netbox_librenms_plugin.jobs import FilterDevicesJob, ImportDevicesJob from netbox_librenms_plugin.models import InterfaceTypeMapping from .serializers import InterfaceTypeMappingSerializer @@ -22,8 +25,8 @@ class LibreNMSPluginPermission(BasePermission): """ Permission class for LibreNMS plugin API endpoints. - - GET requests require view_librenmssettings - - All other requests require change_librenmssettings + - Safe requests (GET, HEAD, OPTIONS) require netbox_librenms_plugin.view_librenmssettings + - All other requests require netbox_librenms_plugin.change_librenmssettings """ def has_permission(self, request, view): @@ -36,6 +39,7 @@ class InterfaceTypeMappingViewSet(NetBoxModelViewSet): """API viewset for InterfaceTypeMapping CRUD operations.""" permission_classes = [LibreNMSPluginPermission] + filterset_class = InterfaceTypeMappingFilterSet queryset = InterfaceTypeMapping.objects.all() serializer_class = InterfaceTypeMappingSerializer @@ -50,6 +54,8 @@ def sync_job_status(request, job_pk): This is needed because NetBox's worker doesn't always update the database when a job is stopped before it starts processing. + Only allows users to sync their own LibreNMS jobs. + Args: request: Django request job_pk: Primary key of the Job to sync @@ -57,8 +63,9 @@ def sync_job_status(request, job_pk): Returns: JsonResponse with updated status """ + _LIBRENMS_JOB_NAMES = (FilterDevicesJob.Meta.name, ImportDevicesJob.Meta.name) try: - job = Job.objects.get(pk=job_pk) + job = Job.objects.get(pk=job_pk, user=request.user, name__in=_LIBRENMS_JOB_NAMES) except Job.DoesNotExist: return JsonResponse({"error": "Job not found"}, status=404) @@ -74,18 +81,26 @@ def sync_job_status(request, job_pk): if not job.completed: job.completed = timezone.now() job.save(update_fields=["status", "completed"]) - logger.info(f"Synced Job #{job.pk}: DB status updated to failed (RQ: {rq_status})") + logger.info("Synced Job #%s: DB status updated to failed (RQ: %s)", job.pk, rq_status) return JsonResponse({"status": "updated", "db_status": job.status, "rq_status": rq_status}) else: # Job still active in RQ return JsonResponse({"status": "no_change", "db_status": job.status, "rq_status": rq_status}) - except Exception as e: - # Job not in RQ queue - mark as failed - logger.warning(f"Job #{job.pk} not found in RQ: {e}") - if job.status == JobStatusChoices.STATUS_RUNNING: + except NoSuchJobError: + # Job not in RQ queue — mark any non-terminal DB job as failed + logger.warning("Job #%s not found in RQ (NoSuchJobError)", job.pk) + terminal_states = { + JobStatusChoices.STATUS_COMPLETED, + JobStatusChoices.STATUS_FAILED, + JobStatusChoices.STATUS_ERRORED, + } + if job.status not in terminal_states: job.status = JobStatusChoices.STATUS_FAILED if not job.completed: job.completed = timezone.now() job.save(update_fields=["status", "completed"]) return JsonResponse({"status": "updated", "db_status": job.status, "rq_status": "not_found"}) return JsonResponse({"status": "no_change", "db_status": job.status, "rq_status": "not_found"}) + except Exception as e: + logger.exception("Unexpected error fetching RQ job for Job #%s: %s", job.pk, e) + return JsonResponse({"error": "Failed to fetch RQ job status"}, status=500) diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py index a01bc7562f..af51d6e23a 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -4,7 +4,6 @@ import logging from typing import List -from core.choices import JobStatusChoices from django.core.cache import cache from ..import_validation_helpers import apply_role_to_validation, recalculate_validation_status, remove_validation_issue @@ -12,7 +11,7 @@ from ..utils import find_by_librenms_id from .cache import get_cache_metadata_key, get_import_device_cache_key, get_validated_device_cache_key from .device_operations import import_single_device, validate_device_for_import -from .filters import get_librenms_devices_for_import +from .filters import _safe_disabled, get_librenms_devices_for_import from .permissions import check_user_permissions, require_permissions from .virtual_chassis import ( create_virtual_chassis_with_members, @@ -23,27 +22,28 @@ logger = logging.getLogger(__name__) -def _safe_disabled(device: dict) -> int: +def _is_job_cancelled(job) -> bool: """ - Return 1 if the device is disabled, 0 otherwise. + Return True if a background job has been stopped or cancelled. - Handles None, booleans, numeric strings, and common truthy/falsy tokens - (e.g. "true"/"yes"/"on" → 1, "false"/"no"/"off" → 0) without raising. + Checks RQ/Redis state only (reflects stop API calls immediately). + On Redis connectivity issues or a missing RQ job, returns False to avoid + false cancellation. Unexpected exceptions are logged and also return False. """ - val = device.get("disabled", 0) - if isinstance(val, bool): - return int(val) - if isinstance(val, str): - normalized = val.strip().lower() - if normalized in ("1", "true", "yes", "on"): - return 1 - if normalized in ("0", "false", "no", "off", ""): - return 0 + from django_rq import get_queue + from redis.exceptions import RedisError + from rq.exceptions import NoSuchJobError + from rq.job import Job as RQJob + try: - int_val = int(val) - return 1 if int_val else 0 - except (TypeError, ValueError): - return 0 + queue = get_queue("default") + rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) + return rq_job.is_failed or rq_job.is_stopped + except (RedisError, NoSuchJobError): + return False + except Exception: + logger.warning("Unexpected error checking RQ job cancellation state", exc_info=True) + return False def bulk_import_devices_shared( @@ -52,7 +52,6 @@ def bulk_import_devices_shared( sync_options: dict = None, manual_mappings_per_device: dict = None, libre_devices_cache: dict = None, - vc_detection_enabled: bool = False, job=None, user=None, ) -> dict: @@ -70,8 +69,6 @@ def bulk_import_devices_shared( Example: {1179: {'device_role_id': 5}, 1180: {'device_role_id': 3}} libre_devices_cache: Optional dict mapping device_id to pre-fetched device data to avoid redundant API calls. Example: {123: {...device_data...}} - vc_detection_enabled: Whether to enable virtual chassis detection during import. - Should match the flag used during the filter/preview step for consistency. job: Optional JobRunner instance for progress logging and cancellation checks user: User performing the import (for permission checks). If job is provided, user is extracted from job.job.user if not explicitly passed. @@ -101,7 +98,7 @@ def bulk_import_devices_shared( # Check permissions at start of bulk operation — device and VM add perms are # required because any device may be flagged as import_as_vm during validation. - # change_device is needed for VC master/member updates; VMs are only created, not changed. + # change_device is needed for VC master/member updates. required_perms = [ "dcim.add_device", "dcim.change_device", @@ -122,35 +119,13 @@ def bulk_import_devices_shared( for idx, device_id in enumerate(device_ids, start=1): # Check for job cancellation on first iteration and every 5th thereafter. - # Check RQ/Redis state first (reflects stop API immediately); fall back to DB. - if job and (idx == 1 or idx % 5 == 0): - try: - from django_rq import get_queue - from rq.job import Job as RQJob - - queue = get_queue("default") - rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) - if rq_job.is_failed or rq_job.is_stopped: - if job.logger: - job.logger.warning( - f"Import job stopped at device {idx} of {total} (RQ status: {rq_job.get_status()})" - ) - else: - logger.warning(f"Import cancelled at device {idx} of {total}") - _cancelled = True - break - except Exception: - # Fall back to DB check if RQ is unavailable - job.job.refresh_from_db() - job_status = job.job.status - status_value = job_status.value if hasattr(job_status, "value") else job_status - if status_value in (JobStatusChoices.STATUS_FAILED, "failed", "errored"): - if job.logger: - job.logger.warning(f"Import job cancelled at device {idx} of {total}") - else: - logger.warning(f"Import cancelled at device {idx} of {total}") - _cancelled = True - break + if job and (idx == 1 or idx % 5 == 0) and _is_job_cancelled(job): + if job.logger: + job.logger.warning(f"Import job stopped at device {idx} of {total}") + else: + logger.warning(f"Import cancelled at device {idx} of {total}") + _cancelled = True + break try: # Use cached device data if available to avoid redundant API calls @@ -174,10 +149,10 @@ def bulk_import_devices_shared( validation = validate_device_for_import( libre_device, api=api, + include_vc_detection=bool(sync_options and sync_options.get("vc_detection_enabled", False)), use_sysname=use_sysname_opt, strip_domain=strip_domain_opt, server_key=api.server_key, - include_vc_detection=vc_detection_enabled, ) # Build manual mappings from validation + any provided overrides @@ -315,7 +290,6 @@ def bulk_import_devices( sync_options: dict = None, manual_mappings_per_device: dict = None, libre_devices_cache: dict = None, - vc_detection_enabled: bool = False, user=None, ) -> dict: """ @@ -332,7 +306,6 @@ def bulk_import_devices( Example: {1179: {'device_role_id': 5}, 1180: {'device_role_id': 3}} libre_devices_cache: Optional dict mapping device_id to pre-fetched device data to avoid redundant API calls. Example: {123: {...device_data...}} - vc_detection_enabled: Whether to enable virtual chassis detection during import. user: User performing the import (for permission checks) Returns: @@ -354,7 +327,6 @@ def bulk_import_devices( sync_options=sync_options, manual_mappings_per_device=manual_mappings_per_device, libre_devices_cache=libre_devices_cache, - vc_detection_enabled=vc_detection_enabled, job=None, # No job context for synchronous imports user=user, ) @@ -524,6 +496,9 @@ def process_device_filters( # Fetch devices from LibreNMS if job: job.logger.info(f"Fetching devices with filters: {filters}") + if _is_job_cancelled(job): + job.logger.warning("Job was stopped before fetching devices") + return _empty_return(return_cache_status) else: logger.info(f"Fetching devices with filters: {filters}") @@ -547,6 +522,11 @@ def process_device_filters( else: logger.info(f"Found {len(libre_devices)} devices") + # Check for early cancellation before the expensive VC prefetch + if job and _is_job_cancelled(job): + job.logger.warning("Job was stopped before VC pre-fetch") + return _empty_return(return_cache_status) + # Pre-warm VC cache if needed if vc_detection_enabled and libre_devices: device_ids = [d["device_id"] for d in libre_devices] @@ -575,52 +555,17 @@ def process_device_filters( if job: job.logger.info(f"Starting validation of {total} devices") - # Initial check if job was already terminated before we even started - try: - from django_rq import get_queue - from rq.job import Job as RQJob - - queue = get_queue("default") - rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) - - if rq_job.is_failed or rq_job.is_stopped: - job.logger.warning("Job was already stopped before validation started") - return _empty_return(return_cache_status) - except Exception: - # Fall back to DB check if RQ check fails - job.job.refresh_from_db() - if job.job.status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED): - job.logger.warning("Job was stopped before validation started") - return _empty_return(return_cache_status) + if _is_job_cancelled(job): + job.logger.warning("Job was already stopped before validation started") + return _empty_return(return_cache_status) else: logger.info(f"Validating {total} devices") for idx, device in enumerate(libre_devices, 1): - # Check for job termination or client disconnect periodically - if idx % 5 == 0 or idx == 1: # Check more frequently (every 5 devices + first device) - if job: - # Check if job was terminated via stop API - # CRITICAL: Check the RQ job status in Redis, not just the DB model - # NetBox's stop endpoint marks the RQ job as failed in Redis - try: - from django_rq import get_queue - from rq.job import Job as RQJob - - queue = get_queue("default") - rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) - - # Check if RQ job is in a stopped state - if rq_job.is_failed or rq_job.is_stopped: - job.logger.info( - f"Job stopped at device {idx}/{total} (RQ status: {rq_job.get_status()}). Exiting gracefully." - ) - return _empty_return(return_cache_status) - except Exception: - # If we can't check RQ status, fall back to DB status check - job.job.refresh_from_db() - if job.job.status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED): - job.logger.info(f"Job stopped at device {idx}/{total}. Exiting gracefully.") - return _empty_return(return_cache_status) + # Check for job termination periodically + if (idx % 5 == 0 or idx == 1) and job and _is_job_cancelled(job): + job.logger.info(f"Job stopped at device {idx}/{total}. Exiting gracefully.") + return _empty_return(return_cache_status) # Drop any cached validation/meta keys before recomputing device.pop("_validation", None) diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 2b1e5c416e..152414998b 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -519,7 +519,8 @@ def validate_device_for_import( result["site"] = site_match if not site_match["found"]: - result["issues"].append(f"No matching site found for location: '{location}'") + if not result.get("existing_device"): + result["issues"].append(f"No matching site found for location: '{location}'") # Get alternative suggestions if location: all_sites = Site.objects.all()[:10] # Limit for performance @@ -533,9 +534,10 @@ def validate_device_for_import( result["device_type"]["found"] = False result["device_type"]["device_type"] = None result["device_type"]["match_type"] = "ambiguous" - result["issues"].append( - f"Multiple device types match hardware '{hardware}' — resolve the ambiguity in NetBox." - ) + if not result.get("existing_device"): + result["issues"].append( + f"Multiple device types match hardware '{hardware}' — resolve the ambiguity in NetBox." + ) else: # Chassis inventory fallback: when hardware doesn't match, # try the chassis entPhysicalModelName as an additional lookup source @@ -553,7 +555,8 @@ def validate_device_for_import( if not result["device_type"]["found"] and result["device_type"].get("match_type") != "ambiguous": result["device_type"]["found"] = False - result["issues"].append(f"No matching device type found for hardware: '{hardware}'") + if not result.get("existing_device"): + result["issues"].append(f"No matching device type found for hardware: '{hardware}'") # Get some device types for user to choose from all_device_types = DeviceType.objects.all()[:10] result["device_type"]["suggestions"] = [ diff --git a/netbox_librenms_plugin/import_utils/vm_operations.py b/netbox_librenms_plugin/import_utils/vm_operations.py index e1c43cc434..a630b55632 100644 --- a/netbox_librenms_plugin/import_utils/vm_operations.py +++ b/netbox_librenms_plugin/import_utils/vm_operations.py @@ -8,6 +8,7 @@ from virtualization.models import Cluster from ..librenms_api import LibreNMSAPI +from .bulk_import import _is_job_cancelled from .device_operations import _determine_device_name, fetch_device_with_cache, validate_device_for_import from .permissions import require_permissions @@ -152,26 +153,10 @@ def bulk_import_vms( for idx, vm_id in enumerate(vm_ids, start=1): # Check for job cancellation before first VM and every 5 thereafter - if job and (idx == 1 or idx % 5 == 0): - cancelled = False - try: - from django_rq import get_queue - from rq.job import Job as RQJob - - queue = get_queue("default") - rq_job = RQJob.fetch(str(job.job.job_id), connection=queue.connection) - if rq_job.is_failed or rq_job.is_stopped: - cancelled = True - except Exception: - job.job.refresh_from_db() - job_status = job.job.status - status_value = job_status.value if hasattr(job_status, "value") else job_status - if status_value in ("failed", "errored", "stopped"): - cancelled = True - if cancelled: - log.warning(f"Job cancelled at VM {idx} of {len(vm_ids)}") - break - log.info(f"Processing VM {idx} of {len(vm_ids)}") + if job and (idx == 1 or idx % 5 == 0) and _is_job_cancelled(job): + log.warning(f"Job cancelled at VM {idx} of {len(vm_ids)}") + break + log.info(f"Processing VM {idx} of {len(vm_ids)}") try: # Fetch device data (uses cache helper) diff --git a/netbox_librenms_plugin/jobs.py b/netbox_librenms_plugin/jobs.py index d8aa638c73..678a367824 100644 --- a/netbox_librenms_plugin/jobs.py +++ b/netbox_librenms_plugin/jobs.py @@ -166,7 +166,6 @@ def run( vm_imports, server_key=None, sync_options=None, - vc_detection_enabled=False, manual_mappings_per_device=None, libre_devices_cache=None, **kwargs, @@ -178,8 +177,8 @@ def run( device_ids: List of LibreNMS device IDs to import as Devices vm_imports: Dict mapping device_id to cluster/role info for VM imports server_key: Optional LibreNMS server key for multi-server setups - sync_options: Dict with sync_interfaces, sync_cables, sync_ips, use_sysname, strip_domain - vc_detection_enabled: Whether VC detection was enabled during the filter step. + sync_options: Dict with sync_interfaces, sync_cables, sync_ips, + use_sysname, strip_domain, and vc_detection_enabled manual_mappings_per_device: Dict mapping device_id to manual_mappings dict libre_devices_cache: Optional dict mapping device_id to pre-fetched device data **kwargs: Additional job parameters @@ -214,7 +213,6 @@ def run( sync_options=sync_options, manual_mappings_per_device=manual_mappings_per_device, libre_devices_cache=libre_devices_cache, - vc_detection_enabled=vc_detection_enabled, job=self, # Pass job context for logging and cancellation user=self.job.user, # Pass user for permission checks ) diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_confirm.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_confirm.html index da8ba2ef65..349080eb71 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_confirm.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_confirm.html @@ -132,6 +132,7 @@