Jeremy Stretch преди 1 ден
родител
ревизия
b94136c121
променени са 5 файла, в които са добавени 81 реда и са изтрити 61 реда
  1. 7 7
      netbox/dcim/ui/panels.py
  2. 12 3
      netbox/ipam/ui/attrs.py
  3. 5 3
      netbox/netbox/tests/test_ui.py
  4. 56 47
      netbox/netbox/ui/attrs.py
  5. 1 1
      netbox/templates/ui/attrs/numeric.html

+ 7 - 7
netbox/dcim/ui/panels.py

@@ -74,7 +74,7 @@ class RackReservationPanel(panels.ObjectAttributesPanel):
     unit_count = attrs.TextAttr('unit_count', label=_("Total U's"))
     status = attrs.ChoiceAttr('status')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
-    user = attrs.RelatedObjectAttr('user')
+    user = attrs.RelatedObjectAttr('user', linkify=True)
     description = attrs.TextAttr('description')
 
 
@@ -219,8 +219,8 @@ class PowerPortPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     description = attrs.TextAttr('description')
-    maximum_draw = attrs.TextAttr('maximum_draw')
-    allocated_draw = attrs.TextAttr('allocated_draw')
+    maximum_draw = attrs.TextAttr('maximum_draw', format_string='{}W')
+    allocated_draw = attrs.TextAttr('allocated_draw', format_string='{}W')
 
 
 class PowerOutletPanel(panels.ObjectAttributesPanel):
@@ -243,7 +243,7 @@ class FrontPortPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     color = attrs.ColorAttr('color')
-    positions = attrs.TextAttr('positions')
+    positions = attrs.NumericAttr('positions')
     description = attrs.TextAttr('description')
 
 
@@ -254,7 +254,7 @@ class RearPortPanel(panels.ObjectAttributesPanel):
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     color = attrs.ColorAttr('color')
-    positions = attrs.TextAttr('positions')
+    positions = attrs.NumericAttr('positions')
     description = attrs.TextAttr('description')
 
 
@@ -472,7 +472,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     type = attrs.ChoiceAttr('type')
     speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
     duplex = attrs.ChoiceAttr('duplex')
-    mtu = attrs.TextAttr('mtu', label=_('MTU'))
+    mtu = attrs.NumericAttr('mtu', label=_('MTU'))
     enabled = attrs.BooleanAttr('enabled')
     mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
     description = attrs.TextAttr('description')
@@ -481,7 +481,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
     qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
     untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))
-    tx_power = attrs.TextAttr('tx_power', label=_('Transmit power (dBm)'))
+    tx_power = attrs.TextAttr('tx_power', label=_('Transmit power'), format_string='{} dBm')
     tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
     l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
 

+ 12 - 3
netbox/ipam/ui/attrs.py

@@ -7,6 +7,9 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
     """
     Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes
     the route distinguisher (RD).
+
+    Parameters:
+         show_rd (bool): If true, the VRF's RD will be included. (Default: False)
     """
     template_name = 'ipam/attrs/vrf.html'
 
@@ -14,11 +17,17 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
         super().__init__(*args, **kwargs)
         self.show_rd = show_rd
 
+    def get_context(self, obj, attr, value, context):
+        return {
+            'show_rd': self.show_rd,
+        }
+
     def render(self, obj, context):
+        name = context['name']
         value = self.get_value(obj)
+
         return render_to_string(self.template_name, {
-            **self.get_context(obj, context),
-            'name': context['name'],
+            **self.get_context(obj, name, value, context),
+            'name': name,
             'value': value,
-            'show_rd': self.show_rd,
         })

+ 5 - 3
netbox/netbox/tests/test_ui.py

@@ -76,7 +76,7 @@ class ChoiceAttrTest(TestCase):
             self.termination.get_role_display(),
         )
         self.assertEqual(
-            attr.get_context(self.termination, {}),
+            attr.get_context(self.termination, 'role', attr.get_value(self.termination), {}),
             {'bg_color': self.termination.get_role_color()},
         )
 
@@ -88,7 +88,7 @@ class ChoiceAttrTest(TestCase):
             self.termination.interface.get_type_display(),
         )
         self.assertEqual(
-            attr.get_context(self.termination, {}),
+            attr.get_context(self.termination, 'interface.type', attr.get_value(self.termination), {}),
             {'bg_color': None},
         )
 
@@ -100,7 +100,9 @@ class ChoiceAttrTest(TestCase):
             self.termination.virtual_circuit.get_status_display(),
         )
         self.assertEqual(
-            attr.get_context(self.termination, {}),
+            attr.get_context(
+                self.termination, 'virtual_circuit.status', attr.get_value(self.termination), {}
+            ),
             {'bg_color': self.termination.virtual_circuit.get_status_color()},
         )
 

+ 56 - 47
netbox/netbox/ui/attrs.py

@@ -29,10 +29,26 @@ PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
 
 IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync')
 
+
 #
-# Attributes
+# Mixins
 #
 
+class MapURLMixin:
+    _map_url = None
+
+    @property
+    def map_url(self):
+        if self._map_url is True:
+            return get_config().MAPS_URL
+        if self._map_url:
+            return self._map_url
+        return None
+
+
+#
+# Attributes
+#
 
 class ObjectAttribute:
     """
@@ -64,17 +80,20 @@ class ObjectAttribute:
         """
         return resolve_attr_path(obj, self.accessor)
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         """
         Return any additional template context used to render the attribute value.
 
         Parameters:
             obj (object): The object for which the attribute is being rendered
+            attr (str): The name of the attribute being rendered
+            value: The value of the attribute on the object
             context (dict): The root template context
         """
         return {}
 
     def render(self, obj, context):
+        name = context['name']
         value = self.get_value(obj)
 
         # If the value is empty, render a placeholder
@@ -82,8 +101,8 @@ class ObjectAttribute:
             return self.placeholder
 
         return render_to_string(self.template_name, {
-            **self.get_context(obj, context),
-            'name': context['name'],
+            **self.get_context(obj, name, value, context),
+            'name': name,
             'value': value,
         })
 
@@ -112,7 +131,7 @@ class TextAttr(ObjectAttribute):
             return self.format_string.format(value)
         return value
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
             'style': self.style,
             'copy_button': self.copy_button,
@@ -134,7 +153,7 @@ class NumericAttr(ObjectAttribute):
         self.unit_accessor = unit_accessor
         self.copy_button = copy_button
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
         return {
             'unit': unit,
@@ -172,7 +191,7 @@ class ChoiceAttr(ObjectAttribute):
 
         return resolve_attr_path(target, field_name)
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         target, field_name = self._resolve_target(obj)
         if target is None:
             return {'bg_color': None}
@@ -241,7 +260,7 @@ class ImageAttr(ObjectAttribute):
             decoding = 'async'
         self.decoding = decoding
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
             'decoding': self.decoding,
             'load_lazy': self.load_lazy,
@@ -264,8 +283,7 @@ class RelatedObjectAttr(ObjectAttribute):
         self.linkify = linkify
         self.grouped_by = grouped_by
 
-    def get_context(self, obj, context):
-        value = self.get_value(obj)
+    def get_context(self, obj, attr, value, context):
         group = getattr(value, self.grouped_by, None) if self.grouped_by else None
         return {
             'linkify': self.linkify,
@@ -300,14 +318,13 @@ class RelatedObjectListAttr(RelatedObjectAttr):
         self.max_items = max_items
         self.overflow_indicator = overflow_indicator
 
-    def _get_items(self, obj):
+    def _get_items(self, items):
         """
         Retrieve items from the given object using the accessor path.
 
         Returns a tuple of (items, has_more) where items is a list of resolved objects
         and has_more indicates whether additional items exist beyond the max_items limit.
         """
-        items = resolve_attr_path(obj, self.accessor)
         if items is None:
             return [], False
 
@@ -322,8 +339,8 @@ class RelatedObjectListAttr(RelatedObjectAttr):
 
         return items[:self.max_items], has_more
 
-    def get_context(self, obj, context):
-        items, has_more = self._get_items(obj)
+    def get_context(self, obj, attr, value, context):
+        items, has_more = self._get_items(value)
 
         return {
             'linkify': self.linkify,
@@ -338,14 +355,15 @@ class RelatedObjectListAttr(RelatedObjectAttr):
         }
 
     def render(self, obj, context):
-        context = context or {}
-        context_data = self.get_context(obj, context)
+        name = context['name']
+        value = self.get_value(obj)
+        context_data = self.get_context(obj, name, value, context)
 
         if not context_data['items']:
             return self.placeholder
 
         return render_to_string(self.template_name, {
-            'name': context.get('name'),
+            'name': name,
             **context_data,
         })
 
@@ -366,11 +384,13 @@ class NestedObjectAttr(ObjectAttribute):
         self.linkify = linkify
         self.max_depth = max_depth
 
-    def get_context(self, obj, context):
-        value = self.get_value(obj)
-        nodes = value.get_ancestors(include_self=True)
-        if self.max_depth:
-            nodes = list(nodes)[-self.max_depth:]
+    def get_context(self, obj, attr, value, context):
+        if value is not None:
+            nodes = value.get_ancestors(include_self=True)
+            if self.max_depth:
+                nodes = list(nodes)[-self.max_depth:]
+        else:
+            nodes = []
         return {
             'nodes': nodes,
             'linkify': self.linkify,
@@ -394,40 +414,35 @@ class GenericForeignKeyAttr(ObjectAttribute):
         super().__init__(*args, **kwargs)
         self.linkify = linkify
 
-    def get_context(self, obj, context):
-        value = self.get_value(obj)
-        content_type = value._meta.verbose_name
+    def get_context(self, obj, attr, value, context):
+        content_type = value._meta.verbose_name if value is not None else None
         return {
             'content_type': content_type,
             'linkify': self.linkify,
         }
 
 
-class AddressAttr(ObjectAttribute):
+class AddressAttr(MapURLMixin, ObjectAttribute):
     """
     A physical or mailing address.
 
     Parameters:
-         map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL
+         map_url (bool/str): The URL to use when rendering the address. If True, the address will render as a
+            hyperlink using settings.MAPS_URL.
     """
     template_name = 'ui/attrs/address.html'
 
     def __init__(self, *args, map_url=True, **kwargs):
         super().__init__(*args, **kwargs)
-        if map_url is True:
-            self.map_url = get_config().MAPS_URL
-        elif map_url:
-            self.map_url = map_url
-        else:
-            self.map_url = None
+        self._map_url = map_url
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
             'map_url': self.map_url,
         }
 
 
-class GPSCoordinatesAttr(ObjectAttribute):
+class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
     """
     A GPS coordinates pair comprising latitude and longitude values.
 
@@ -440,24 +455,18 @@ class GPSCoordinatesAttr(ObjectAttribute):
     label = _('GPS coordinates')
 
     def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
-        super().__init__(accessor=None, **kwargs)
+        super().__init__(accessor=latitude_attr, **kwargs)
         self.latitude_attr = latitude_attr
         self.longitude_attr = longitude_attr
-        if map_url is True:
-            self.map_url = get_config().MAPS_URL
-        elif map_url:
-            self.map_url = map_url
-        else:
-            self.map_url = None
+        self._map_url = map_url
 
-    def render(self, obj, context=None):
-        context = context or {}
+    def render(self, obj, context):
         latitude = resolve_attr_path(obj, self.latitude_attr)
         longitude = resolve_attr_path(obj, self.longitude_attr)
         if latitude is None or longitude is None:
             return self.placeholder
         return render_to_string(self.template_name, {
-            **context,
+            'name': context['name'],
             'latitude': latitude,
             'longitude': longitude,
             'map_url': self.map_url,
@@ -478,7 +487,7 @@ class DateTimeAttr(ObjectAttribute):
         super().__init__(*args, **kwargs)
         self.spec = spec
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
             'spec': self.spec,
         }
@@ -504,7 +513,7 @@ class TemplatedAttr(ObjectAttribute):
         self.template_name = template_name
         self.context = context or {}
 
-    def get_context(self, obj, context):
+    def get_context(self, obj, attr, value, context):
         return {
             **context,
             **self.context,

+ 1 - 1
netbox/templates/ui/attrs/numeric.html

@@ -1,5 +1,5 @@
 {% load i18n %}
-<span{% if style %} class="{{ style }}"{% endif %}>
+<span>
   <span{% if name %} id="attr_{{ name }}"{% endif %}>{{ value }}</span>
   {% if unit %}
     {{ unit|lower }}