Skip to content

Commit

Permalink
[Feature Branch] Implemented SavedView model (#5639)
Browse files Browse the repository at this point in the history
* first commit

* model changes

* various changes to make update current view working

* create new view complete

* change savedview to a changelogged model

* hacking table_config workflow together

* material design icons update

* Last UI changes

* added changelogs

* address feedback from standup and PR

* more refactoring

* added SavedViewMixin and refactored models based on that

* fix unittests

* ruff fix

* unittests

* unittests

* address most PR feedback

* unittests

* address PR feedback

* refactor table_config, pagination_count, filter_params and sort_order into one field config

* address review feedback

* use urllib.parse.urlencode

* address review feedback

* revert changes from users: to user:

* revert minor change

* user vs users

* address PR feedback

* address PR feedback

* Make 'saved_view=...' query param imply the correct filtering, sorting, config, and pagination values (#5682)

* Make 'saved_view=...' query param imply the correct filtering, sorting, config, and pagination values

* Fix update behavior and create behavior when given an existing instance

* Test fix

* [Tests and Doc] Implemented SavedView Model (#5678)

* added factories and some documentation

* added api and view tests

* filter test and ruff fix

* include savedview.md in nav

* delete print statement

* user factory modifications

* fix user unittest

* delete comment

* address PR feedback

* add additional tests

* factory changes

* delete output.txt

* added saved view clear view

* added all_filters_removed non_filter_params

* added tooltip title to asterik

* added user guide for creating and updating saved view

* added refereneces to user guide

* completed user guide

* refactor create saved view

* address some pr feedback

* refactored SavedViewModal

* attempt to fix unittests

* fix screenshots

* specify is_saved_view_model=False on ComponentTemplateModel

* added clear_view_modal

* fixed saved view edit url

* address pr feedback on user guide

* fix all unittests

* markdownlint

* fix unittest

* ruff

* fix factory and tests

* refactored SavedViewClearView to custom action on SavedViewUIViewSet

* address PR feedback

* ruff fix

* pylint and unittest fixes

* fix unittest

* address pr feedabck

* refactor and remove is_saved_view_model=False on StaticGroup

* ruff fix

* unittests

* Enforce users:change_savedview instead of users:clear_savedview

* pylint

* fix clear_view_modal bug

* address PR feedback

* add json blob on saved_view_modal

* gray out json blob on saved_view_modal

* move get_table_class_string_from_view_name() to nautobot.core.utils.lookup

* update migration file

* include missing savedview doc

* refactor StaticGroupFactory

* unittests

---------

Co-authored-by: Glenn Matthews <glenn.matthews@networktocode.com>
  • Loading branch information
HanlinMiao and glennmatthews committed May 18, 2024
1 parent 0753ae4 commit b9f5b38
Show file tree
Hide file tree
Showing 102 changed files with 1,501 additions and 34,301 deletions.
1 change: 1 addition & 0 deletions changes/1758.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implemented SavedView model.
1 change: 1 addition & 0 deletions changes/1758.dependencies
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Updated `materialdesignicons` to version 7.4.47.
4 changes: 4 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ plugins:
"models/ipam/vrf.md": "user-guide/core-data-model/ipam/vrf.md"
"models/tenancy/tenant.md": "user-guide/core-data-model/tenancy/tenant.md"
"models/tenancy/tenantgroup.md": "user-guide/core-data-model/tenancy/tenantgroup.md"
"models/users/savedview.md": "user-guide/platform-functionality/savedview.md"
"models/virtualization/cluster.md": "user-guide/core-data-model/virtualization/cluster.md"
"models/virtualization/clustergroup.md": "user-guide/core-data-model/virtualization/clustergroup.md"
"models/virtualization/clustertype.md": "user-guide/core-data-model/virtualization/clustertype.md"
Expand All @@ -286,6 +287,7 @@ plugins:
"user-guides/graphql.md": "user-guide/feature-guides/graphql.md"
"user-guides/ip-address-merge-tool.md": "user-guide/feature-guides/ip-address-merge-tool.md"
"user-guides/relationships.md": "user-guide/feature-guides/relationships.md"
"user-guides/saved-views.md": "user-guide/feature-guides/saved-views.md"
"user-guides/s3-django-storage.md": "user-guide/administration/guides/s3-django-storage.md"

# Post 2.x redirects
Expand Down Expand Up @@ -365,6 +367,7 @@ nav:
- GraphQL: "user-guide/feature-guides/graphql.md"
- IP Address Merge Tool: "user-guide/feature-guides/ip-address-merge-tool.md"
- Relationships: "user-guide/feature-guides/relationships.md"
- Saved Views: "user-guide/feature-guides/saved-views.md"
- Software Image Files and Versions: "user-guide/feature-guides/software-image-files-and-versions.md"
- Core Data Model:
- Overview:
Expand Down Expand Up @@ -467,6 +470,7 @@ nav:
- Authentication: "user-guide/platform-functionality/rest-api/authentication.md"
- UI Endpoints: "user-guide/platform-functionality/rest-api/ui-related-endpoints.md"
- Roles: "user-guide/platform-functionality/role.md"
- Saved Views: user-guide/platform-functionality/savedview.md
- Secrets: "user-guide/platform-functionality/secret.md"
- Static Groups:
- "user-guide/platform-functionality/staticgroup.md"
Expand Down
2 changes: 2 additions & 0 deletions nautobot/apps/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from nautobot.extras.utils import extras_features
from nautobot.ipam.fields import VarbinaryIPField
from nautobot.ipam.models import get_default_namespace, get_default_namespace_pk
from nautobot.users.models import SavedViewMixin

__all__ = (
"array_to_string",
Expand Down Expand Up @@ -96,6 +97,7 @@
"PrimaryModel",
"RelationshipModel",
"RestrictedQuerySet",
"SavedViewMixin",
"serialize_object_v2",
"serialize_object",
"slugify_dashes_to_underscores",
Expand Down
10 changes: 9 additions & 1 deletion nautobot/core/jobs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,15 @@ def run(self, *, content_type, query_string="", export_format="csv", export_temp
# such that they never are even seen here.
query_params = QueryDict(query_string)
self.logger.debug("Parsed query_params: `%s`", query_params.dict())
default_non_filter_params = ("export", "page", "per_page", "sort")
default_non_filter_params = (
"all_filters_removed",
"export",
"page",
"per_page",
"saved_view",
"sort",
"table_changes_pending",
)
filter_params = get_filterable_params_from_filter_params(
query_params, default_non_filter_params, filterset_class()
)
Expand Down
7 changes: 7 additions & 0 deletions nautobot/core/management/commands/generate_test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ def _generate_factory_data(self, seed, db_name):
VRFFactory,
)
from nautobot.tenancy.factory import TenantFactory, TenantGroupFactory
from nautobot.users.factory import SavedViewFactory, UserFactory
except ImportError as err:
raise CommandError('Unable to load data factories. Is the "factory-boy" package installed?') from err

Expand All @@ -117,6 +118,10 @@ def _generate_factory_data(self, seed, db_name):
TagFactory.create_batch(5, content_types=TaggableClassesQuery().as_queryset(), using=db_name)
# ...and some tags that apply to a random subset of content-types
TagFactory.create_batch(15, using=db_name)
self.stdout.write("Creating Users...")
UserFactory.create_batch(3, using=db_name)
self.stdout.write("Creating SavedViews...")
SavedViewFactory.create_batch(10, using=db_name)
self.stdout.write("Creating Contacts...")
ContactFactory.create_batch(20, using=db_name)
self.stdout.write("Creating Teams...")
Expand Down Expand Up @@ -262,6 +267,7 @@ def _generate_factory_data(self, seed, db_name):
RIRFactory,
RoleFactory,
RouteTargetFactory,
SavedViewFactory,
SoftwareImageFileFactory,
SoftwareVersionFactory,
StaticGroupAssociationFactory,
Expand All @@ -271,6 +277,7 @@ def _generate_factory_data(self, seed, db_name):
TeamFactory,
TenantFactory,
TenantGroupFactory,
UserFactory,
VLANFactory,
VLANGroupFactory,
VRFFactory,
Expand Down
1 change: 1 addition & 0 deletions nautobot/core/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class BaseModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, unique=True, editable=False)

objects = BaseManager.from_queryset(RestrictedQuerySet)()
is_saved_view_model = False # SavedViewMixin overrides this to default True
is_contact_associable_model = False # ContactMixin overrides this to default True
is_static_group_associable_model = False # StaticGroupMixin overrides this to default True

Expand Down
3 changes: 3 additions & 0 deletions nautobot/core/models/generics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from nautobot.extras.models.customfields import CustomFieldModel
from nautobot.extras.models.mixins import ContactMixin, DynamicGroupMixin, NotesMixin, StaticGroupMixin
from nautobot.extras.models.relationships import RelationshipModel
from nautobot.users.models import SavedViewMixin

logger = logging.getLogger(__name__)

Expand All @@ -17,6 +18,7 @@ class OrganizationalModel(
DynamicGroupMixin,
NotesMixin,
RelationshipModel,
SavedViewMixin,
StaticGroupMixin,
BaseModel,
):
Expand All @@ -41,6 +43,7 @@ class PrimaryModel(
DynamicGroupMixin,
NotesMixin,
RelationshipModel,
SavedViewMixin,
StaticGroupMixin,
BaseModel,
):
Expand Down
2 changes: 2 additions & 0 deletions nautobot/core/models/name_color_content_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from nautobot.extras.models.customfields import CustomFieldModel
from nautobot.extras.models.mixins import ContactMixin, DynamicGroupMixin, NotesMixin, StaticGroupMixin
from nautobot.extras.models.relationships import RelationshipModel
from nautobot.users.models import SavedViewMixin


class ContentTypeRelatedQuerySet(RestrictedQuerySet):
Expand Down Expand Up @@ -45,6 +46,7 @@ class NameColorContentTypesModel(
DynamicGroupMixin,
NotesMixin,
RelationshipModel,
SavedViewMixin,
StaticGroupMixin,
BaseModel,
):
Expand Down
27 changes: 19 additions & 8 deletions nautobot/core/models/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,10 @@ def serialize_object_v2(obj):
return data


def find_models_with_matching_fields(app_models, field_names, field_attributes=None, additional_constraints=None):
def find_models_with_matching_fields(app_models, field_names=None, field_attributes=None, additional_constraints=None):
"""
Find all models that have fields with the specified names, and return them grouped by app.
Find all models that have fields with the specified names and satisfy the additional constraints,
and return them grouped by app.
Args:
app_models (list[BaseModel]): A list of model classes to search through.
Expand All @@ -164,20 +165,30 @@ def find_models_with_matching_fields(app_models, field_names, field_attributes=N
(dict): A dictionary where the keys are app labels and the values are sets of model names.
"""
registry_items = {}
field_names = field_names or []
field_attributes = field_attributes or {}
additional_constraints = additional_constraints or {}
for model_class in app_models:
app_label, model_name = model_class._meta.label_lower.split(".")
valid_model = True
for field_name in field_names:
try:
field = model_class._meta.get_field(field_name)
if all((getattr(field, item, None) == value for item, value in field_attributes.items())) and all(
getattr(model_class, additional_field, None) == additional_value
for additional_field, additional_value in additional_constraints.items()
):
registry_items.setdefault(app_label, set()).add(model_name)
if not all((getattr(field, item, None) == value for item, value in field_attributes.items())):
valid_model = False
break
except FieldDoesNotExist:
pass
valid_model = False
break
if valid_model:
if not all(
getattr(model_class, additional_field, None) == additional_value
for additional_field, additional_value in additional_constraints.items()
):
valid_model = False
if valid_model:
registry_items.setdefault(app_label, set()).add(model_name)

registry_items = {key: sorted(value) for key, value in registry_items.items()}
return registry_items

Expand Down
43 changes: 31 additions & 12 deletions nautobot/core/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,16 @@ class Meta:
"class": "table table-hover table-headings",
}

def __init__(self, *args, user=None, hide_hierarchy_ui=False, **kwargs):
def __init__(
self,
*args,
table_changes_pending=False,
saved_view=None,
user=None,
hide_hierarchy_ui=False,
order_by=None,
**kwargs,
):
# Add custom field columns
model = self._meta.model

Expand Down Expand Up @@ -72,8 +81,11 @@ def __init__(self, *args, user=None, hide_hierarchy_ui=False, **kwargs):
)
# symmetric relationships are already handled above in the source_type case

if order_by is None and saved_view is not None:
order_by = saved_view.config.get("sort_order", None)

# Init table
super().__init__(*args, **kwargs)
super().__init__(*args, order_by=order_by, **kwargs)
self.hide_hierarchy_ui = hide_hierarchy_ui

# Set default empty_text if none was provided
Expand All @@ -89,18 +101,25 @@ def __init__(self, *args, user=None, hide_hierarchy_ui=False, **kwargs):
# Hide the column if it is non-default *and* not manually specified as an extra column
self.columns.hide(column.name)

# Apply custom column ordering for user
# Apply custom column ordering for SavedView if it is available
# Takes precedence before user config
columns = []
pk = self.base_columns.pop("pk", None)
actions = self.base_columns.pop("actions", None)
if user is not None and not isinstance(user, AnonymousUser):
columns = user.get_config(f"tables.{self.__class__.__name__}.columns")
if columns:
for name, column in self.base_columns.items():
if name in columns:
self.columns.show(name)
else:
self.columns.hide(name)
self.sequence = [c for c in columns if c in self.base_columns]
if saved_view is not None and not table_changes_pending:
view_table_config = saved_view.config.get("table_config", {}).get(f"{self.__class__.__name__}", None)
if view_table_config is not None:
columns = view_table_config.get("columns", [])
else:
if user is not None and not isinstance(user, AnonymousUser):
columns = user.get_config(f"tables.{self.__class__.__name__}.columns")
if columns:
for name, column in self.base_columns.items():
if name in columns:
self.columns.show(name)
else:
self.columns.hide(name)
self.sequence = [c for c in columns if c in self.base_columns]

# Always include PK and actions columns, if defined on the table, as first and last columns respectively
if pk:
Expand Down
74 changes: 73 additions & 1 deletion nautobot/core/templates/generic/object_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,61 @@
{% if filter_form or dynamic_filter_form %}
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#FilterForm_modal" title="Add filters" id="id__filterbtn"><i class="mdi mdi-filter"></i> Filter</button>
{% endif %}
{% if model.is_saved_view_model %}
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-view-compact-outline" aria-hidden="true"></span> Saved Views <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% if perms.users.view_savedview %}
{% for saved_view in saved_views %}
<li>
<a href="{{ saved_view.get_absolute_url }}">
{% if saved_view == current_saved_view %}
<i class="mdi mdi-view-compact-outline text-bold" aria-hidden="true"></i>
<strong>
{{saved_view.name}}
</strong>
{% else %}
<i class="mdi mdi-view-compact-outline text-muted" aria-hidden="true"></i>
{{saved_view.name}}
{% endif %}
</a>
</li>
{% endfor %}
{% if saved_views %}
<li role="separator" class="divider"></li>
{% endif %}
{% endif %}
{% if current_saved_view %}
{% if perms.users.change_savedview %}
<li>
<a href="{% url 'user:savedview_edit' pk=request.GET.saved_view %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}">
<i class="mdi mdi-content-save-outline text-muted" aria-hidden="true"></i>
<span>Update Current View</span>
</a>
</li>
{% endif %}
{% endif %}
{% if perms.users.add_savedview %}
<li>
<a href="" data-toggle="modal" data-target="#saved_view_modal" title="Save Current View As">
<i class="mdi mdi-content-save-plus-outline text-muted" aria-hidden="true"></i>
<span>Save As New View</span>
</a>
</li>
{% endif %}
{% if current_saved_view and perms.users.change_savedview %}
<li>
<a href="" data-toggle="modal" data-target="#clear_view_modal" title="Confirm Clear View">
<i class="mdi mdi-arrow-u-right-bottom text-danger" aria-hidden="true"></i>
<span class="text-danger">Clear View</span>
</a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
{% if permissions.add and 'add' in action_buttons %}
{% add_button content_type.model_class|validated_viewname:"add" %}
{% endif %}
Expand All @@ -61,7 +116,18 @@
{% endblock export_button %}
{% endif %}
</div>
<h1>{% block title %}{{ title }}{% endblock %}</h1>
<h1>{% block title %}
{{ title }}
{% if current_saved_view %}
{% if new_changes_not_applied %}
<i title="Pending changes not saved">{{ current_saved_view.name }}</i>
{% else %}
{{ current_saved_view.name }}
{% endif %}
{% endif %}
{% endblock %}
</h1>
{% block header_extra %}{% endblock %}
{% if filter_params %}
<div class="filters-applied">
Expand Down Expand Up @@ -181,7 +247,13 @@ <h1>{% block title %}{{ title }}{% endblock %}</h1>
</div>
{% if table %}{% table_config_form table table_name="ObjectTable" %}{% endif %}
{% filter_form_modal filter_form dynamic_filter_form model_plural_name=title %}
{% if model.is_saved_view_model %}
{% saved_view_modal request.GET.urlencode list_url model request %}
{% endif %}
{% static_group_assignment_modal request=request content_type=content_type %}
{% if current_saved_view %}
{% clear_view_modal current_saved_view.pk %}
{% endif %}
{% endblock %}

{% block javascript %}
Expand Down
4 changes: 2 additions & 2 deletions nautobot/core/templates/inc/media.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
href="{% static 'bootstrap-3.4.1-dist/css/bootstrap.min.css' %}"
onerror="window.location='{% url 'media_failure' %}?filename=bootstrap-3.4.1-dist/css/bootstrap.min.css'">
<link rel="stylesheet"
href="{% static 'materialdesignicons-6.5.95/css/materialdesignicons.min.css' %}"
onerror="window.location='{% url 'media_failure' %}?filename=materialdesignicons-6.5.95/css/materialdesignicons.min.css'">
href="{% static 'materialdesignicons-7.4.47/css/materialdesignicons.min.css' %}"
onerror="window.location='{% url 'media_failure' %}?filename=materialdesignicons-7.4.47/css/materialdesignicons.min.css'">
<link rel="stylesheet"
href="{% static 'jquery-ui-1.13.2/jquery-ui.min.css' %}"
onerror="window.location='{% url 'media_failure' %}?filename=jquery-ui-1.13.2/jquery-ui.min.css'">
Expand Down
4 changes: 2 additions & 2 deletions nautobot/core/templates/inc/search_panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
<button type="submit" class="btn btn-primary">
<span class="mdi mdi-magnify" aria-hidden="true"></span> Apply
</button>
<a href="." class="btn btn-default">
<button type="reset" class="btn btn-default">
<span class="mdi mdi-close-thick" aria-hidden="true"></span> Clear
</a>
</button>
</div>
</form>

0 comments on commit b9f5b38

Please sign in to comment.