Repository: netbox-community/netbox

Issue:
Add a management command to manually recalculate naturalized values (see #3799)
### Environment
* Python version: 3.6.9
* NetBox version: 2.7.4

### Proposed Functionality
#3799 implemented "naturalized" ordering fields for certain models. These fields are populated by calling a `naturalize()` function on their target fields (e.g. `name`) to produce a string value suitable for alphanumeric sorting. This FR proposes the addition of a management command that will enable a user to recalculate naturalized values on-demand.

```
python manage.py renaturalize [app_label[.ModelName] [app_label[.ModelName] ...]]
```

### Use Case
Although in theory this command should never be needed, it would be convenient to have on hand in situations where a bug or data import failure result in partially or erroneously normalized values.

### Database Changes
None

### External Dependencies
None


Discussion:

Resolving patch:
diff --git a/netbox/extras/management/commands/renaturalize.py b/netbox/extras/management/commands/renaturalize.py
new file mode 100644
--- /dev/null
+++ b/netbox/extras/management/commands/renaturalize.py
@@ -0,0 +1,119 @@
+from django.apps import apps
+from django.core.management.base import BaseCommand, CommandError
+
+from utilities.fields import NaturalOrderingField
+
+
+class Command(BaseCommand):
+    help = "Recalculate natural ordering values for the specified models"
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            'args', metavar='app_label.ModelName', nargs='*',
+            help='One or more specific models (each prefixed with its app_label) to renaturalize',
+        )
+
+    def _get_models(self, names):
+        """
+        Compile a list of models to be renaturalized. If no names are specified, all models which have one or more
+        NaturalOrderingFields will be included.
+        """
+        models = []
+
+        if names:
+            # Collect all NaturalOrderingFields present on the specified models
+            for name in names:
+                try:
+                    app_label, model_name = name.split('.')
+                except ValueError:
+                    raise CommandError(
+                        "Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name)
+                    )
+                try:
+                    app_config = apps.get_app_config(app_label)
+                except LookupError as e:
+                    raise CommandError(str(e))
+                try:
+                    model = app_config.get_model(model_name)
+                except LookupError:
+                    raise CommandError("Unknown model: {}.{}".format(app_label, model_name))
+                fields = [
+                    field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
+                ]
+                if not fields:
+                    raise CommandError(
+                        "Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name)
+                    )
+                models.append(
+                    (model, fields)
+                )
+
+        else:
+            # Find *all* models with NaturalOrderingFields
+            for app_config in apps.get_app_configs():
+                for model in app_config.models.values():
+                    fields = [
+                        field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
+                    ]
+                    if fields:
+                        models.append(
+                            (model, fields)
+                        )
+
+        return models
+
+    def handle(self, *args, **options):
+
+        models = self._get_models(args)
+
+        if options['verbosity']:
+            self.stdout.write("Renaturalizing {} models.".format(len(models)))
+
+        for model, fields in models:
+            for field in fields:
+                target_field = field.target_field
+                naturalize = field.naturalize_function
+                count = 0
+
+                # Print the model and field name
+                if options['verbosity']:
+                    self.stdout.write(
+                        "{}.{} ({})... ".format(model._meta.label, field.target_field, field.name),
+                        ending='\n' if options['verbosity'] >= 2 else ''
+                    )
+                    self.stdout.flush()
+
+                # Find all unique values for the field
+                queryset = model.objects.values_list(target_field, flat=True).order_by(target_field).distinct()
+                for value in queryset:
+                    naturalized_value = naturalize(value)
+
+                    # Skip any naturalized values that don't differ from their original form
+                    if value == naturalized_value:
+                        if options['verbosity'] >= 3:
+                            self.stdout.write(self.style.WARNING(
+                                "  {} == {} (skipped)".format(value, naturalized_value)
+                            ))
+                        continue
+
+                    if options['verbosity'] >= 2:
+                        self.stdout.write("  {} -> {}".format(value, naturalized_value), ending='')
+                        self.stdout.flush()
+
+                    # Update each unique field value in bulk
+                    changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
+
+                    if options['verbosity'] >= 2:
+                        self.stdout.write(" ({})".format(changed))
+                    count += changed
+
+                # Print the total count of alterations for the field
+                if options['verbosity'] >= 2:
+                    self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format(
+                        count, model._meta.verbose_name_plural, queryset.count()
+                    )))
+                elif options['verbosity']:
+                    self.stdout.write(self.style.SUCCESS(str(count)))
+
+        if options['verbosity']:
+            self.stdout.write(self.style.SUCCESS("Done."))


Test patch: