diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py
index 3c3dd4061..b4e2a61ee 100644
--- a/netbox/netbox/ui/attrs.py
+++ b/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.
diff --git a/netbox/templates/ui/attrs/datetime.html b/netbox/templates/ui/attrs/datetime.html
new file mode 100644
index 000000000..b5b968d4e
--- /dev/null
+++ b/netbox/templates/ui/attrs/datetime.html
@@ -0,0 +1 @@
+{% load helpers %}{% if spec == 'date' %}{{ value|isodate }}{% else %}{{ value|isodatetime:spec }}{% endif %}
diff --git a/netbox/templates/users/attrs/full_name.html b/netbox/templates/users/attrs/full_name.html
new file mode 100644
index 000000000..3bb006313
--- /dev/null
+++ b/netbox/templates/users/attrs/full_name.html
@@ -0,0 +1 @@
+{% load helpers %}{{ object.get_full_name|placeholder }}
diff --git a/netbox/templates/users/group.html b/netbox/templates/users/group.html
index 9d4561c65..511d04559 100644
--- a/netbox/templates/users/group.html
+++ b/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 %}
-
-
-
-
-
-
- | {% trans "Name" %} |
- {{ object.name }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
-
-
-
-
-
-
- {% for user in object.users.all %}
-
{{ user }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
-
- {% for perm in object.object_permissions.all %}
-
{{ perm }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
-
- {% for owner in object.owners.all %}
-
{{ owner }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
-{% endblock %}
diff --git a/netbox/templates/users/objectpermission.html b/netbox/templates/users/objectpermission.html
index 23f5ef866..b7218557d 100644
--- a/netbox/templates/users/objectpermission.html
+++ b/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 %}
-
-
-
-
-
-
- | {% trans "Name" %} |
- {{ object.name }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
- | {% trans "Enabled" %} |
- {% checkmark object.enabled %} |
-
-
-
-
-
-
-
- | {% trans "View" %} |
- {% checkmark object.can_view %} |
-
-
- | {% trans "Add" %} |
- {% checkmark object.can_add %} |
-
-
- | {% trans "Change" %} |
- {% checkmark object.can_change %} |
-
-
- | {% trans "Delete" %} |
- {% checkmark object.can_delete %} |
-
-
-
-
-
-
- {% if object.constraints %}
-
{{ object.constraints|json }}
- {% else %}
-
None
- {% endif %}
-
-
-
-
-
-
-
- {% for user in object.object_types.all %}
- - {{ user }}
- {% endfor %}
-
-
-
-
-
- {% for user in object.users.all %}
-
{{ user }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
-
- {% for group in object.groups.all %}
-
{{ group }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
-{% endblock %}
diff --git a/netbox/templates/users/owner.html b/netbox/templates/users/owner.html
index 3e9d1c125..d592ea276 100644
--- a/netbox/templates/users/owner.html
+++ b/netbox/templates/users/owner.html
@@ -11,50 +11,3 @@
{% endblock %}
{% block subtitle %}{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Name" %} |
- {{ object.name }} |
-
-
- | {% trans "Group" %} |
- {{ object.group|linkify|placeholder }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
-
-
-
-
- {% for group in object.user_groups.all %}
-
{{ group }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
-
- {% for user in object.users.all %}
-
{{ user }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
- {% include 'inc/panels/related_objects.html' with filter_name='owner_id' %}
-
-
-{% endblock %}
diff --git a/netbox/templates/users/ownergroup.html b/netbox/templates/users/ownergroup.html
index c45da792a..511d04559 100644
--- a/netbox/templates/users/ownergroup.html
+++ b/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 %}
-
- {% trans "Add Owner" %}
-
- {% endif %}
-{% endblock extra_controls %}
-
-{% block content %}
-
-
-
-
-
-
- | {% trans "Name" %} |
- {{ object.name }} |
-
-
- | {% trans "Description" %} |
- {{ object.description|placeholder }} |
-
-
-
-
-
-
-
-
- {% for owner in object.members.all %}
-
{{ owner }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
-{% endblock %}
diff --git a/netbox/templates/users/panels/object_types.html b/netbox/templates/users/panels/object_types.html
new file mode 100644
index 000000000..838563cf6
--- /dev/null
+++ b/netbox/templates/users/panels/object_types.html
@@ -0,0 +1,11 @@
+{% load i18n %}
+
+
+
+ {% for object_type in object.object_types.all %}
+ - {{ object_type }}
+ {% empty %}
+ - {% trans "None" %}
+ {% endfor %}
+
+
diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html
index efcc743f5..511d04559 100644
--- a/netbox/templates/users/user.html
+++ b/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 %}
-
-
-
-
-
-
- | {% trans "Username" %} |
- {{ object.username }} |
-
-
- | {% trans "Full Name" %} |
- {{ object.get_full_name|placeholder }} |
-
-
- | {% trans "Email" %} |
- {{ object.email|placeholder }} |
-
-
- | {% trans "Account Created" %} |
- {{ object.date_joined|isodate }} |
-
-
- | {% trans "Last Login" %} |
- {{ object.last_login|isodatetime:"minutes"|placeholder }} |
-
-
- | {% trans "Active" %} |
- {% checkmark object.is_active %} |
-
-
- | {% trans "Superuser" %} |
- {% checkmark object.is_superuser %} |
-
-
-
-
-
-
-
-
- {% for group in object.groups.all %}
-
{{ group }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
-
- {% for perm in object.object_permissions.all %}
-
{{ perm }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
-
- {% for owner in object.owners.all %}
-
{{ owner }}
- {% empty %}
-
{% trans "None" %}
- {% endfor %}
-
-
-
-
- {% if perms.core.view_objectchange %}
-
-
- {% include 'users/inc/user_activity.html' with user=object table=changelog_table %}
-
-
- {% endif %}
-{% endblock %}
diff --git a/netbox/users/ui/panels.py b/netbox/users/ui/panels.py
index 92a4dd46a..9ee27f4a8 100644
--- a/netbox/users/ui/panels.py
+++ b/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')
diff --git a/netbox/users/views.py b/netbox/users/views.py
index 44f311615..59ebe518f 100644
--- a/netbox/users/views.py
+++ b/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 {