Просмотр исходного кода

#20923: Migrate Users object to declarative layouts (#21568)

This continues the migration of object views in the user app to NetBox v4.5’s declarative layouts.
Replace legacy object view templates with declarative layouts for:
   - Users
   - Groups
   - API Tokens
   - Permissions
   - Owner Groups
   - Owners
Arthur Hanson 1 день назад
Родитель
Сommit
07bb6aa365

+ 21 - 0
netbox/netbox/ui/attrs.py

@@ -10,6 +10,7 @@ __all__ = (
     'BooleanAttr',
     'ChoiceAttr',
     'ColorAttr',
+    'DateTimeAttr',
     'GPSCoordinatesAttr',
     'GenericForeignKeyAttr',
     'ImageAttr',
@@ -367,6 +368,26 @@ class GPSCoordinatesAttr(ObjectAttribute):
         })
 
 
+class DateTimeAttr(ObjectAttribute):
+    """
+    A date or datetime attribute.
+
+    Parameters:
+        spec (str): Controls the rendering format. Use 'date' for date-only rendering,
+                    or 'seconds'/'minutes' for datetime rendering with the given precision.
+    """
+    template_name = 'ui/attrs/datetime.html'
+
+    def __init__(self, *args, spec='seconds', **kwargs):
+        super().__init__(*args, **kwargs)
+        self.spec = spec
+
+    def get_context(self, obj, context):
+        return {
+            'spec': self.spec,
+        }
+
+
 class TimezoneAttr(ObjectAttribute):
     """
     A timezone value. Includes the numeric offset from UTC.

+ 1 - 0
netbox/templates/ui/attrs/datetime.html

@@ -0,0 +1 @@
+{% load helpers %}{% if spec == 'date' %}{{ value|isodate }}{% else %}{{ value|isodatetime:spec }}{% endif %}

+ 1 - 0
netbox/templates/users/attrs/full_name.html

@@ -0,0 +1 @@
+{% load helpers %}{{ object.get_full_name|placeholder }}

+ 0 - 57
netbox/templates/users/group.html

@@ -1,60 +1,3 @@
 {% extends 'generic/object.html' %}
-{% load i18n %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
-
-{% block title %}{% trans "Group" %} {{ object.name }}{% endblock %}
 
 {% block subtitle %}{% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Group" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-    </div>
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Users" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for user in object.users.all %}
-            <a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Permissions" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for perm in object.object_permissions.all %}
-            <a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Owner Membership" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for owner in object.owners.all %}
-            <a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-  </div>
-{% endblock %}

+ 0 - 88
netbox/templates/users/objectpermission.html

@@ -1,93 +1,5 @@
 {% extends 'generic/object.html' %}
 {% load i18n %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
 
 {% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %}
-
 {% block subtitle %}{% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Permission" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Enabled" %}</th>
-            <td>{% checkmark object.enabled %}</td>
-          </tr>
-        </table>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Actions" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "View" %}</th>
-            <td>{% checkmark object.can_view %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Add" %}</th>
-            <td>{% checkmark object.can_add %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Change" %}</th>
-            <td>{% checkmark object.can_change %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Delete" %}</th>
-            <td>{% checkmark object.can_delete %}</td>
-          </tr>
-        </table>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Constraints" %}</h2>
-        <div class="card-body">
-          {% if object.constraints %}
-            <pre>{{ object.constraints|json }}</pre>
-          {% else %}
-            <span class="text-muted">None</span>
-          {% endif %}
-        </div>
-      </div>
-    </div>
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Object Types" %}</h2>
-        <ul class="list-group list-group-flush">
-          {% for user in object.object_types.all %}
-            <li class="list-group-item">{{ user }}</li>
-          {% endfor %}
-        </ul>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Users" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for user in object.users.all %}
-            <a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Groups" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for group in object.groups.all %}
-            <a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-  </div>
-{% endblock %}

+ 0 - 47
netbox/templates/users/owner.html

@@ -11,50 +11,3 @@
 {% endblock %}
 
 {% block subtitle %}{% endblock %}
-
-{% block content %}
-  <div class="row">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Owner" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Group" %}</th>
-            <td>{{ object.group|linkify|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Groups" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for group in object.user_groups.all %}
-            <a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Users" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for user in object.users.all %}
-            <a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-    <div class="col-md-6">
-      {% include 'inc/panels/related_objects.html' with filter_name='owner_id' %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 43
netbox/templates/users/ownergroup.html

@@ -1,46 +1,3 @@
 {% extends 'generic/object.html' %}
-{% load i18n %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
 
 {% block subtitle %}{% endblock %}
-
-{% block extra_controls %}
-  {% if perms.users.add_owner %}
-    <a href="{% url 'users:owner_add' %}?group={{ object.pk }}" class="btn btn-primary">
-      <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Owner" %}
-    </a>
-  {% endif %}
-{% endblock extra_controls %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Group" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-    </div>
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Members" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for owner in object.members.all %}
-            <a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-  </div>
-{% endblock %}

+ 11 - 0
netbox/templates/users/panels/object_types.html

@@ -0,0 +1,11 @@
+{% load i18n %}
+<div class="card">
+  <h2 class="card-header">{% trans "Object Types" %}</h2>
+  <ul class="list-group list-group-flush">
+    {% for object_type in object.object_types.all %}
+      <li class="list-group-item">{{ object_type }}</li>
+    {% empty %}
+      <li class="list-group-item text-muted">{% trans "None" %}</li>
+    {% endfor %}
+  </ul>
+</div>

+ 0 - 82
netbox/templates/users/user.html

@@ -1,85 +1,3 @@
 {% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block title %}{% trans "User" %} {{ object.username }}{% endblock %}
 
 {% block subtitle %}{% endblock %}
-
-{% block content %}
-  <div class="row">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "User" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Username" %}</th>
-            <td>{{ object.username }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Full Name" %}</th>
-            <td>{{ object.get_full_name|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Email" %}</th>
-            <td>{{ object.email|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Account Created" %}</th>
-            <td>{{ object.date_joined|isodate }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Last Login" %}</th>
-            <td>{{ object.last_login|isodatetime:"minutes"|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Active" %}</th>
-            <td>{% checkmark object.is_active %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Superuser" %}</th>
-            <td>{% checkmark object.is_superuser %}</td>
-          </tr>
-        </table>
-      </div>
-    </div>
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Groups" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for group in object.groups.all %}
-            <a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Permissions" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for perm in object.object_permissions.all %}
-            <a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Owner Membership" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for owner in object.owners.all %}
-            <a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-  </div>
-  {% if perms.core.view_objectchange %}
-    <div class="row">
-      <div class="col-md-12">
-        {% include 'users/inc/user_activity.html' with user=object table=changelog_table %}
-      </div>
-    </div>
-  {% endif %}
-{% endblock %}

+ 35 - 0
netbox/users/ui/panels.py

@@ -23,3 +23,38 @@ class TokenExamplePanel(panels.Panel):
     actions = [
         actions.CopyContent('token-example')
     ]
+
+
+class UserPanel(panels.ObjectAttributesPanel):
+    username = attrs.TextAttr('username')
+    full_name = attrs.TemplatedAttr(
+        'get_full_name',
+        label=_('Full name'),
+        template_name='users/attrs/full_name.html',
+    )
+    email = attrs.TextAttr('email')
+    date_joined = attrs.DateTimeAttr('date_joined', label=_('Account created'), spec='date')
+    last_login = attrs.DateTimeAttr('last_login', label=_('Last login'), spec='minutes')
+    is_active = attrs.BooleanAttr('is_active', label=_('Active'))
+    is_superuser = attrs.BooleanAttr('is_superuser', label=_('Superuser'))
+
+
+class ObjectPermissionPanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+    enabled = attrs.BooleanAttr('enabled')
+
+
+class ObjectPermissionActionsPanel(panels.ObjectAttributesPanel):
+    title = _('Actions')
+
+    can_view = attrs.BooleanAttr('can_view', label=_('View'))
+    can_add = attrs.BooleanAttr('can_add', label=_('Add'))
+    can_change = attrs.BooleanAttr('can_change', label=_('Change'))
+    can_delete = attrs.BooleanAttr('can_delete', label=_('Delete'))
+
+
+class OwnerPanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    group = attrs.RelatedObjectAttr('group', linkify=True)
+    description = attrs.TextAttr('description')

+ 105 - 13
netbox/users/views.py

@@ -1,9 +1,18 @@
 from django.db.models import Count
+from django.utils.translation import gettext_lazy as _
 
 from core.models import ObjectChange
 from core.tables import ObjectChangeTable
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
-from netbox.ui import layout
+from netbox.ui import actions, layout
+from netbox.ui.panels import (
+    ContextTablePanel,
+    JSONPanel,
+    ObjectsTablePanel,
+    OrganizationalObjectPanel,
+    RelatedObjectsPanel,
+    TemplatePanel,
+)
 from netbox.views import generic
 from users.ui import panels
 from utilities.query import count_related
@@ -86,7 +95,39 @@ class UserListView(generic.ObjectListView):
 @register_model_view(User)
 class UserView(generic.ObjectView):
     queryset = User.objects.all()
-    template_name = 'users/user.html'
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.UserPanel(),
+        ],
+        right_panels=[
+            ObjectsTablePanel(
+                'users.Group', title=_('Assigned Groups'), filters={'user_id': lambda ctx: ctx['object'].pk}
+            ),
+            ObjectsTablePanel(
+                'users.ObjectPermission',
+                title=_('Assigned Permissions'),
+                filters={'user_id': lambda ctx: ctx['object'].pk},
+            ),
+            ObjectsTablePanel(
+                'users.Owner', title=_('Owner Membership'), filters={'user_id': lambda ctx: ctx['object'].pk}
+            ),
+        ],
+        bottom_panels=[
+            ContextTablePanel(
+                'changelog_table',
+                title=_('Recent Activity'),
+                actions=[
+                    actions.LinkAction(
+                        view_name='core:objectchange_list',
+                        url_params={'user_id': lambda ctx: ctx['object'].pk},
+                        label=_('View All'),
+                        button_icon='arrow-right-thick',
+                        permissions=['core.view_objectchange'],
+                    ),
+                ],
+            ),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(user=instance)[:20]
@@ -154,7 +195,22 @@ class GroupListView(generic.ObjectListView):
 @register_model_view(Group)
 class GroupView(generic.ObjectView):
     queryset = Group.objects.all()
-    template_name = 'users/group.html'
+    layout = layout.SimpleLayout(
+        left_panels=[
+            OrganizationalObjectPanel(),
+        ],
+        right_panels=[
+            ObjectsTablePanel('users.User', filters={'group_id': lambda ctx: ctx['object'].pk}),
+            ObjectsTablePanel(
+                'users.ObjectPermission',
+                title=_('Assigned Permissions'),
+                filters={'group_id': lambda ctx: ctx['object'].pk},
+            ),
+            ObjectsTablePanel(
+                'users.Owner', title=_('Owner Membership'), filters={'user_group_id': lambda ctx: ctx['object'].pk}
+            ),
+        ],
+    )
 
 
 @register_model_view(Group, 'add', detail=False)
@@ -212,7 +268,22 @@ class ObjectPermissionListView(generic.ObjectListView):
 @register_model_view(ObjectPermission)
 class ObjectPermissionView(generic.ObjectView):
     queryset = ObjectPermission.objects.all()
-    template_name = 'users/objectpermission.html'
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ObjectPermissionPanel(),
+            panels.ObjectPermissionActionsPanel(),
+            JSONPanel('constraints', title=_('Constraints')),
+        ],
+        right_panels=[
+            TemplatePanel('users/panels/object_types.html'),
+            ObjectsTablePanel(
+                'users.User', title=_('Assigned Users'), filters={'permission_id': lambda ctx: ctx['object'].pk}
+            ),
+            ObjectsTablePanel(
+                'users.Group', title=_('Assigned Groups'), filters={'permission_id': lambda ctx: ctx['object'].pk}
+            ),
+        ],
+    )
 
 
 @register_model_view(ObjectPermission, 'add', detail=False)
@@ -255,7 +326,7 @@ class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
 @register_model_view(OwnerGroup, 'list', path='', detail=False)
 class OwnerGroupListView(generic.ObjectListView):
     queryset = OwnerGroup.objects.annotate(
-       owner_count=count_related(Owner, 'group')
+        owner_count=count_related(Owner, 'group')
     )
     filterset = filtersets.OwnerGroupFilterSet
     filterset_form = forms.OwnerGroupFilterForm
@@ -263,14 +334,26 @@ class OwnerGroupListView(generic.ObjectListView):
 
 
 @register_model_view(OwnerGroup)
-class OwnerGroupView(GetRelatedModelsMixin, generic.ObjectView):
+class OwnerGroupView(generic.ObjectView):
     queryset = OwnerGroup.objects.all()
-    template_name = 'users/ownergroup.html'
-
-    def get_extra_context(self, request, instance):
-        return {
-            'related_models': self.get_related_models(request, instance),
-        }
+    layout = layout.SimpleLayout(
+        left_panels=[
+            OrganizationalObjectPanel(),
+        ],
+        right_panels=[
+            ObjectsTablePanel(
+                'users.Owner',
+                filters={'group_id': lambda ctx: ctx['object'].pk},
+                title=_('Members'),
+                actions=[
+                    actions.AddObject(
+                        'users.Owner',
+                        url_params={'group': lambda ctx: ctx['object'].pk},
+                    ),
+                ],
+            ),
+        ],
+    )
 
 
 @register_model_view(OwnerGroup, 'add', detail=False)
@@ -326,7 +409,16 @@ class OwnerListView(generic.ObjectListView):
 @register_model_view(Owner)
 class OwnerView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Owner.objects.all()
-    template_name = 'users/owner.html'
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.OwnerPanel(),
+            ObjectsTablePanel('users.Group', filters={'owner_id': lambda ctx: ctx['object'].pk}),
+            ObjectsTablePanel('users.User', filters={'owner_id': lambda ctx: ctx['object'].pk}),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+        ],
+    )
 
     def get_extra_context(self, request, instance):
         return {