Repository: nautobot/nautobot

Issue:
500 crash when editing/updating IPAM Services with invalid ports 
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Python version: all
* Nautobot version: 1.0.0b1-b3
* I tested both locally and on demo.nautobot.com

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Have a Service (e.g. `http`)
2. Edit it
3. Populate an invalid value in the **Ports** field, such as `-80`

<!-- What did you expect to happen? -->
### Expected Behavior

Expecting a 400 error that is captured by the UI and emits invalid value error message for that field

<!-- What happened instead? -->
### Observed Behavior

500 error

Traceback
```python
Internal Server Error: /ipam/services/ebfa8fe9-5d4c-4bb0-b71c-7f2b8bb453ea/edit/
Traceback (most recent call last):
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/sentry_sdk/integrations/django/views.py", line 67, in sentry_wrapped_callback
    return callback(request, *args, **kwargs)
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/Users/jathan/sandbox/src/nautobot/nautobot/core/views/generic.py", line 266, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/Users/jathan/sandbox/src/nautobot/nautobot/utilities/views.py", line 94, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/Users/jathan/sandbox/src/nautobot/nautobot/core/views/generic.py", line 293, in post
    if form.is_valid():
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/forms/forms.py", line 177, in is_valid
    return self.is_bound and not self.errors
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/forms/forms.py", line 172, in errors
    self.full_clean()
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/forms/forms.py", line 374, in full_clean
    self._clean_fields()
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/forms/forms.py", line 392, in _clean_fields
    value = field.clean(value)
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/contrib/postgres/forms/array.py", line 31, in clean
    value = super().clean(value)
  File "/Users/jathan/Library/Caches/pypoetry/virtualenvs/nautobot-b2ttWva6-py3.8/lib/python3.8/site-packages/django/forms/fields.py", line 149, in clean
    value = self.to_python(value)
  File "/Users/jathan/sandbox/src/nautobot/nautobot/utilities/forms/widgets.py", line 124, in to_python
    value = ",".join([str(n) for n in parse_numeric_range(value)])
  File "/Users/jathan/sandbox/src/nautobot/nautobot/utilities/forms/utils.py", line 33, in parse_numeric_range
    begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
ValueError: invalid literal for int() with base 10: ''
```


Discussion:
- Related to: #276 because of `NumericArrayField` in form backed by `JSONArrayField` in model
- Related to: #265 because it's the same model object

Resolving patch:
diff --git a/nautobot/utilities/forms/fields.py b/nautobot/utilities/forms/fields.py
--- a/nautobot/utilities/forms/fields.py
+++ b/nautobot/utilities/forms/fields.py
@@ -8,10 +8,10 @@
 from django.apps import apps
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
-from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
+from django.contrib.postgres.forms import SimpleArrayField
 from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist, ValidationError
 from django.db.models import Count
-from django.forms import BoundField
+from django.forms.fields import BoundField, JSONField as _JSONField, InvalidJSONInput
 from django.urls import reverse
 
 from nautobot.extras.utils import FeatureQuery
@@ -19,7 +19,7 @@
 from nautobot.utilities.validators import EnhancedURLValidator
 from . import widgets
 from .constants import *
-from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
+from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, parse_numeric_range
 
 __all__ = (
     "CommentField",
@@ -36,6 +36,7 @@
     "JSONField",
     "JSONArrayFormField",
     "MultipleContentTypeField",
+    "NumericArrayField",
     "LaxURLField",
     "SlugField",
     "TagFilterField",
@@ -596,3 +597,14 @@ def has_changed(self, initial, data):
         if initial in self.empty_values and value in self.empty_values:
             return False
         return super().has_changed(initial, data)
+
+
+class NumericArrayField(SimpleArrayField):
+    """Basic array field that takes comma-separated or hyphenated ranges."""
+
+    def to_python(self, value):
+        try:
+            value = ",".join([str(n) for n in parse_numeric_range(value)])
+        except ValueError as error:
+            raise ValidationError(error)
+        return super().to_python(value)
diff --git a/nautobot/utilities/forms/widgets.py b/nautobot/utilities/forms/widgets.py
--- a/nautobot/utilities/forms/widgets.py
+++ b/nautobot/utilities/forms/widgets.py
@@ -3,11 +3,10 @@
 
 from django import forms
 from django.conf import settings
-from django.contrib.postgres.forms import SimpleArrayField
 from django.urls import get_script_prefix
 
 from nautobot.utilities.choices import ColorChoices
-from .utils import add_blank_choice, parse_numeric_range
+from .utils import add_blank_choice
 
 __all__ = (
     "APISelect",
@@ -17,7 +16,6 @@
     "ContentTypeSelect",
     "DatePicker",
     "DateTimePicker",
-    "NumericArrayField",
     "SelectWithDisabled",
     "SelectWithPK",
     "SlugWidget",
@@ -33,8 +31,6 @@ class SmallTextarea(forms.Textarea):
     Subclass used for rendering a smaller textarea element.
     """
 
-    pass
-
 
 class SlugWidget(forms.TextInput):
     """
@@ -119,12 +115,6 @@ class ContentTypeSelect(StaticSelect2):
     option_template_name = "widgets/select_contenttype.html"
 
 
-class NumericArrayField(SimpleArrayField):
-    def to_python(self, value):
-        value = ",".join([str(n) for n in parse_numeric_range(value)])
-        return super().to_python(value)
-
-
 class APISelect(SelectWithDisabled):
     """
     A select widget populated via an API call


Test patch:
diff --git a/nautobot/utilities/tests/test_forms.py b/nautobot/utilities/tests/test_forms.py
--- a/nautobot/utilities/tests/test_forms.py
+++ b/nautobot/utilities/tests/test_forms.py
@@ -1,8 +1,8 @@
 from django import forms
 from django.test import TestCase
 
-from nautobot.ipam.forms import IPAddressCSVForm
-from nautobot.utilities.forms.fields import CSVDataField
+from nautobot.ipam.forms import IPAddressCSVForm, ServiceForm
+from nautobot.utilities.forms.fields import CSVDataField, NumericArrayField
 from nautobot.utilities.forms.utils import (
     expand_alphanumeric_pattern,
     expand_ipaddress_pattern,
@@ -405,3 +405,27 @@ def test_clean_to_field_on_non_object(self):
         """
         with self.assertRaises(forms.ValidationError):
             self.field.clean(input)
+
+
+class NumericArrayFieldTest(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.field = ServiceForm().fields["ports"]
+
+    def test_valid_input(self):
+        # Mapping of input => expected
+        tests = {
+            "80,443-444": [80, 443, 444],
+            "1024-1028,31337": [1024, 1025, 1026, 1027, 1028, 31337],
+        }
+        for test, expected in tests.items():
+            self.assertEqual(self.field.clean(test), expected)
+
+    def test_invalid_input(self):
+        tests = [
+            "pizza",
+            "-41",
+        ]
+        for test in tests:
+            with self.assertRaises(forms.ValidationError):
+                self.field.clean(test)