netbox-scope-switcher 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netbox_scope_switcher/__init__.py +33 -0
- netbox_scope_switcher/applicability.py +53 -0
- netbox_scope_switcher/cascade.py +199 -0
- netbox_scope_switcher/config.py +144 -0
- netbox_scope_switcher/middleware.py +87 -0
- netbox_scope_switcher/template_content.py +667 -0
- netbox_scope_switcher/tests/__init__.py +0 -0
- netbox_scope_switcher/tests/test_applicability.py +37 -0
- netbox_scope_switcher/tests/test_cascade.py +88 -0
- netbox_scope_switcher/tests/test_config.py +58 -0
- netbox_scope_switcher/tests/test_middleware.py +115 -0
- netbox_scope_switcher/tests/test_navbar.py +105 -0
- netbox_scope_switcher/tests/test_views.py +106 -0
- netbox_scope_switcher/urls.py +8 -0
- netbox_scope_switcher/views.py +141 -0
- netbox_scope_switcher-0.1.0.dist-info/METADATA +221 -0
- netbox_scope_switcher-0.1.0.dist-info/RECORD +20 -0
- netbox_scope_switcher-0.1.0.dist-info/WHEEL +5 -0
- netbox_scope_switcher-0.1.0.dist-info/licenses/LICENSE +200 -0
- netbox_scope_switcher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from netbox.plugins import PluginConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ScopeSwitcherConfig(PluginConfig):
|
|
5
|
+
name = 'netbox_scope_switcher'
|
|
6
|
+
verbose_name = 'Scope Switcher'
|
|
7
|
+
description = ('Adds a configurable, multi-dimension scope selector to the top '
|
|
8
|
+
'navbar. Selecting values scopes list views by tenant, site, '
|
|
9
|
+
'region, tag, and any other filter you configure.')
|
|
10
|
+
version = '0.1.0'
|
|
11
|
+
author = 'Du10777'
|
|
12
|
+
base_url = 'scope-switcher'
|
|
13
|
+
min_version = '4.4.0'
|
|
14
|
+
# Request-scoped filtering of list views by the selected scope values.
|
|
15
|
+
middleware = ['netbox_scope_switcher.middleware.ScopeSwitcherMiddleware']
|
|
16
|
+
# Defaults to a single tenant scope so the out-of-the-box behaviour matches
|
|
17
|
+
# netbox-tenant-switcher; override `scopes`/`mode` in PLUGINS_CONFIG.
|
|
18
|
+
default_settings = {
|
|
19
|
+
'scopes': [
|
|
20
|
+
{'param': 'tenant_id', 'model': 'tenancy.Tenant'},
|
|
21
|
+
],
|
|
22
|
+
'mode': 'redirect',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def ready(self):
|
|
26
|
+
super().ready()
|
|
27
|
+
# Fail fast at startup with a clear message on a bad configuration,
|
|
28
|
+
# rather than raising a 500 somewhere in the request path.
|
|
29
|
+
from .config import validate_config
|
|
30
|
+
validate_config()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
config = ScopeSwitcherConfig
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Introspection: which configured scope params apply to a given request's view.
|
|
3
|
+
|
|
4
|
+
A scope applies to a page only if the page is an object *list* view whose
|
|
5
|
+
filterset declares a filter named after the scope's ``param``. Filter coverage
|
|
6
|
+
differs a lot across NetBox models, so applicability is computed *per scope*
|
|
7
|
+
(a prefixes list may support ``tenant_id`` and ``tag`` but not ``region_id``).
|
|
8
|
+
"""
|
|
9
|
+
from netbox.views.generic import ObjectListView
|
|
10
|
+
|
|
11
|
+
from .config import get_scopes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _filterset_params(view_class):
|
|
15
|
+
"""Set of filter names a list view's filterset exposes, or None if it has none."""
|
|
16
|
+
filterset = getattr(view_class, 'filterset', None)
|
|
17
|
+
if filterset is None:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
getter = getattr(filterset, 'get_filters', None)
|
|
21
|
+
if callable(getter):
|
|
22
|
+
try:
|
|
23
|
+
return set(getter().keys())
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
params = set()
|
|
28
|
+
for attr in ('base_filters', 'declared_filters'):
|
|
29
|
+
params |= set(getattr(filterset, attr, {}) or {})
|
|
30
|
+
return params or None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_view_params(request):
|
|
34
|
+
"""
|
|
35
|
+
Return the set of filter param names available on the current list view, or
|
|
36
|
+
None if the request does not resolve to an ``ObjectListView``.
|
|
37
|
+
"""
|
|
38
|
+
match = getattr(request, 'resolver_match', None)
|
|
39
|
+
view_func = getattr(match, 'func', None) if match else None
|
|
40
|
+
view_class = getattr(view_func, 'view_class', None) if view_func else None
|
|
41
|
+
if view_class is None or not issubclass(view_class, ObjectListView):
|
|
42
|
+
return None
|
|
43
|
+
return _filterset_params(view_class)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def applicable_params(request, scopes=None):
|
|
47
|
+
"""Configured scope params that apply to the current page (possibly empty)."""
|
|
48
|
+
params = list_view_params(request)
|
|
49
|
+
if not params:
|
|
50
|
+
return set()
|
|
51
|
+
if scopes is None:
|
|
52
|
+
scopes = get_scopes()
|
|
53
|
+
return {s.param for s in scopes if s.param in params}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cascading option filtering.
|
|
3
|
+
|
|
4
|
+
When values are selected in some scopes, the option lists of the *other* scopes
|
|
5
|
+
are narrowed to the objects consistent with that selection -- e.g. picking a
|
|
6
|
+
Tenant limits Regions to the regions where that tenant has sites, and limits
|
|
7
|
+
Sites/Racks to that tenant's, while picking a Region limits Tenants to those
|
|
8
|
+
present in it.
|
|
9
|
+
|
|
10
|
+
Two passes, both reusing NetBox's own filtersets (which encode every relationship
|
|
11
|
+
including tree descendants):
|
|
12
|
+
|
|
13
|
+
- forward: filter the target's own queryset by every other scope its filterset
|
|
14
|
+
supports (the "downward" direction -- Site by Region, Rack by Location, ...).
|
|
15
|
+
- reverse: for the scopes the target's filterset can't express (the "upward"
|
|
16
|
+
direction -- Region by Tenant, Tenant Group by Tenant, ...), pivot through a
|
|
17
|
+
bridge model that has a FK to the target and whose filterset does support the
|
|
18
|
+
source (Site bridges Region<->Tenant; Tenant bridges TenantGroup<->Tenant).
|
|
19
|
+
"""
|
|
20
|
+
import importlib
|
|
21
|
+
|
|
22
|
+
from django.http import QueryDict
|
|
23
|
+
|
|
24
|
+
from .config import get_scopes
|
|
25
|
+
|
|
26
|
+
NULL = 'null'
|
|
27
|
+
_FS_CACHE = {}
|
|
28
|
+
_FILTERS_CACHE = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def filterset_for(model):
|
|
32
|
+
"""Resolve ``<app>.filtersets.<Model>FilterSet`` (cached), or None."""
|
|
33
|
+
label = model._meta.label
|
|
34
|
+
if label not in _FS_CACHE:
|
|
35
|
+
fs = None
|
|
36
|
+
try:
|
|
37
|
+
module = importlib.import_module('{}.filtersets'.format(model._meta.app_label))
|
|
38
|
+
fs = getattr(module, '{}FilterSet'.format(model.__name__), None)
|
|
39
|
+
except Exception:
|
|
40
|
+
fs = None
|
|
41
|
+
_FS_CACHE[label] = fs
|
|
42
|
+
return _FS_CACHE[label]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_filters(fs):
|
|
46
|
+
"""Filterset's filters dict (cached; filtersets are static per class)."""
|
|
47
|
+
if fs is None:
|
|
48
|
+
return {}
|
|
49
|
+
cached = _FILTERS_CACHE.get(fs)
|
|
50
|
+
if cached is None:
|
|
51
|
+
try:
|
|
52
|
+
cached = fs.get_filters()
|
|
53
|
+
except Exception:
|
|
54
|
+
cached = {}
|
|
55
|
+
_FILTERS_CACHE[fs] = cached
|
|
56
|
+
return cached
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _model_of(filt):
|
|
60
|
+
qs = getattr(filt, 'queryset', None)
|
|
61
|
+
return getattr(qs, 'model', None) if qs is not None else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_param(target_filters, source):
|
|
65
|
+
"""The param on the target filterset that filters by the source scope's model."""
|
|
66
|
+
exact = target_filters.get(source.param)
|
|
67
|
+
if exact is not None and _model_of(exact) is source.model:
|
|
68
|
+
return source.param
|
|
69
|
+
candidates = [
|
|
70
|
+
name for name, filt in target_filters.items()
|
|
71
|
+
if _model_of(filt) is source.model and not (name.endswith('__n') or name.endswith('__any'))
|
|
72
|
+
]
|
|
73
|
+
for name in candidates:
|
|
74
|
+
if name.endswith('_id'):
|
|
75
|
+
return name
|
|
76
|
+
return candidates[0] if candidates else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _apply_filterset(fs, qs, param_values):
|
|
80
|
+
if not param_values:
|
|
81
|
+
return qs
|
|
82
|
+
data = QueryDict(mutable=True)
|
|
83
|
+
for param, values in param_values.items():
|
|
84
|
+
data.setlist(param, [str(v) for v in values])
|
|
85
|
+
try:
|
|
86
|
+
return fs(data, queryset=qs).qs
|
|
87
|
+
except Exception:
|
|
88
|
+
return qs
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _restricted(model, user):
|
|
92
|
+
manager = model.objects
|
|
93
|
+
restrict = getattr(manager, 'restrict', None)
|
|
94
|
+
if user is not None and callable(restrict):
|
|
95
|
+
return restrict(user, 'view')
|
|
96
|
+
return manager.all()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _fk_field_to(model, target_model):
|
|
100
|
+
"""Name of a FK/O2O field on ``model`` pointing at ``target_model`` (prefer a
|
|
101
|
+
real relation over a denormalised ``_prefixed`` one), or None."""
|
|
102
|
+
best = None
|
|
103
|
+
for f in model._meta.get_fields():
|
|
104
|
+
if not (getattr(f, 'many_to_one', False) or getattr(f, 'one_to_one', False)):
|
|
105
|
+
continue
|
|
106
|
+
if not getattr(f, 'concrete', False):
|
|
107
|
+
continue
|
|
108
|
+
if getattr(f, 'related_model', None) is target_model:
|
|
109
|
+
name = f.name
|
|
110
|
+
if not name.startswith('_'):
|
|
111
|
+
return name
|
|
112
|
+
best = best or name
|
|
113
|
+
return best
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _forward(scope, qs, selection, scopes):
|
|
117
|
+
fs = filterset_for(scope.model)
|
|
118
|
+
filters = _get_filters(fs)
|
|
119
|
+
applied = set()
|
|
120
|
+
if not filters:
|
|
121
|
+
return qs, applied
|
|
122
|
+
param_values = {}
|
|
123
|
+
for source in scopes:
|
|
124
|
+
if source.param == scope.param:
|
|
125
|
+
continue
|
|
126
|
+
values = selection.get(source.param)
|
|
127
|
+
if not values:
|
|
128
|
+
continue
|
|
129
|
+
param = _resolve_param(filters, source)
|
|
130
|
+
if param:
|
|
131
|
+
param_values[param] = values
|
|
132
|
+
applied.add(source.param)
|
|
133
|
+
if param_values:
|
|
134
|
+
qs = _apply_filterset(fs, qs, param_values)
|
|
135
|
+
return qs, applied
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _reverse(scope, qs, selection, remaining, user, scopes):
|
|
139
|
+
"""Narrow ``qs`` by the ``remaining`` sources via bridge models (FK -> target)."""
|
|
140
|
+
scope_models = {s.model for s in scopes}
|
|
141
|
+
allowed = None
|
|
142
|
+
for bridge in scope_models:
|
|
143
|
+
if bridge is scope.model:
|
|
144
|
+
continue
|
|
145
|
+
fk_name = _fk_field_to(bridge, scope.model)
|
|
146
|
+
if not fk_name:
|
|
147
|
+
continue
|
|
148
|
+
bridge_filters = _get_filters(filterset_for(bridge))
|
|
149
|
+
# The bridge can express a source either via a model-typed filter, or --
|
|
150
|
+
# if the bridge *is* the source's model -- by filtering on identity.
|
|
151
|
+
if not any(s.model is bridge or _resolve_param(bridge_filters, s) for s in remaining):
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
bqs = _restricted(bridge, user)
|
|
155
|
+
param_values = {}
|
|
156
|
+
for source in scopes:
|
|
157
|
+
values = selection.get(source.param)
|
|
158
|
+
if not values:
|
|
159
|
+
continue
|
|
160
|
+
if source.model is bridge:
|
|
161
|
+
field = 'pk' if source.value_field == 'pk' else source.value_field
|
|
162
|
+
concrete = [v for v in values
|
|
163
|
+
if v != NULL and (source.value_field != 'pk' or v.isdigit())]
|
|
164
|
+
if concrete:
|
|
165
|
+
bqs = bqs.filter(**{'{}__in'.format(field): concrete})
|
|
166
|
+
else:
|
|
167
|
+
param = _resolve_param(bridge_filters, source)
|
|
168
|
+
if param:
|
|
169
|
+
param_values[param] = values
|
|
170
|
+
if param_values:
|
|
171
|
+
bqs = _apply_filterset(filterset_for(bridge), bqs, param_values)
|
|
172
|
+
|
|
173
|
+
ids = set(bqs.values_list(fk_name, flat=True))
|
|
174
|
+
ids.discard(None)
|
|
175
|
+
allowed = ids if allowed is None else (allowed | ids)
|
|
176
|
+
|
|
177
|
+
if allowed is not None:
|
|
178
|
+
qs = qs.filter(pk__in=allowed)
|
|
179
|
+
return qs
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def constrained_queryset(scope, qs, selection, user=None, scopes=None):
|
|
183
|
+
"""
|
|
184
|
+
Narrow ``qs`` (already permission-restricted) to objects consistent with the
|
|
185
|
+
values selected in the OTHER scopes. Only ever narrows; returns ``qs``
|
|
186
|
+
unchanged when nothing applies. Pass ``scopes`` to avoid re-resolving them.
|
|
187
|
+
"""
|
|
188
|
+
if not selection:
|
|
189
|
+
return qs
|
|
190
|
+
if scopes is None:
|
|
191
|
+
scopes = get_scopes()
|
|
192
|
+
qs, applied = _forward(scope, qs, selection, scopes)
|
|
193
|
+
remaining = [
|
|
194
|
+
s for s in scopes
|
|
195
|
+
if s.param != scope.param and selection.get(s.param) and s.param not in applied
|
|
196
|
+
]
|
|
197
|
+
if remaining:
|
|
198
|
+
qs = _reverse(scope, qs, selection, remaining, user, scopes)
|
|
199
|
+
return qs
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scope configuration: parse and validate the ``scopes`` setting, resolve each
|
|
3
|
+
scope's model, and expose the resolved list to the rest of the plugin.
|
|
4
|
+
|
|
5
|
+
A *scope* is one filter dimension the switcher can apply -- tenant, site,
|
|
6
|
+
region, tag, ... Each is described by a dict in ``PLUGINS_CONFIG``::
|
|
7
|
+
|
|
8
|
+
{"param": "tenant_id", "model": "tenancy.Tenant", "label": "Tenant",
|
|
9
|
+
"value_field": "pk"}
|
|
10
|
+
|
|
11
|
+
Defaults to a single tenant scope, so out-of-the-box behaviour matches the
|
|
12
|
+
original ``netbox-tenant-switcher`` plugin.
|
|
13
|
+
"""
|
|
14
|
+
from django.apps import apps
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
|
17
|
+
from django.utils.text import capfirst
|
|
18
|
+
|
|
19
|
+
PLUGIN_NAME = 'netbox_scope_switcher'
|
|
20
|
+
|
|
21
|
+
# Out-of-the-box behaviour == netbox-tenant-switcher: scope by tenant only.
|
|
22
|
+
DEFAULT_SCOPES = [
|
|
23
|
+
{'param': 'tenant_id', 'model': 'tenancy.Tenant'},
|
|
24
|
+
]
|
|
25
|
+
DEFAULT_MODE = 'redirect'
|
|
26
|
+
VALID_MODES = ('redirect', 'silent')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Scope:
|
|
30
|
+
"""A single resolved scope dimension."""
|
|
31
|
+
|
|
32
|
+
__slots__ = ('param', 'model', 'label', 'value_field', 'group')
|
|
33
|
+
|
|
34
|
+
def __init__(self, param, model, label=None, value_field='pk', group=None):
|
|
35
|
+
self.param = param
|
|
36
|
+
self.value_field = value_field or 'pk'
|
|
37
|
+
self.group = group or None
|
|
38
|
+
self.model = _resolve_model(param, model)
|
|
39
|
+
if self.value_field != 'pk':
|
|
40
|
+
_check_field(self.model, self.value_field, param)
|
|
41
|
+
# No explicit label -> use NetBox's own (translated) model name, so the
|
|
42
|
+
# section titles follow the active UI language. Resolved per request
|
|
43
|
+
# (get_scopes() rebuilds scopes each call).
|
|
44
|
+
self.label = label or capfirst(self.model._meta.verbose_name)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def order_field(self):
|
|
48
|
+
for f in ('name', self.value_field, 'slug'):
|
|
49
|
+
if _has_field(self.model, f):
|
|
50
|
+
return f
|
|
51
|
+
return 'pk'
|
|
52
|
+
|
|
53
|
+
def display(self, obj):
|
|
54
|
+
return getattr(obj, 'name', None) or str(obj)
|
|
55
|
+
|
|
56
|
+
def value_of(self, obj):
|
|
57
|
+
raw = obj.pk if self.value_field == 'pk' else getattr(obj, self.value_field)
|
|
58
|
+
return str(raw)
|
|
59
|
+
|
|
60
|
+
def queryset(self, user):
|
|
61
|
+
"""Options the user is allowed to see, ordered for display."""
|
|
62
|
+
manager = self.model.objects
|
|
63
|
+
restrict = getattr(manager, 'restrict', None)
|
|
64
|
+
qs = restrict(user, 'view') if callable(restrict) else manager.all()
|
|
65
|
+
return qs.order_by(self.order_field)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _plugin_settings():
|
|
69
|
+
return (getattr(settings, 'PLUGINS_CONFIG', {}) or {}).get(PLUGIN_NAME, {}) or {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_model(param, label):
|
|
73
|
+
try:
|
|
74
|
+
return apps.get_model(label)
|
|
75
|
+
except (ValueError, LookupError) as exc:
|
|
76
|
+
raise ImproperlyConfigured(
|
|
77
|
+
"netbox-scope-switcher: scope '{}' references unknown model '{}' "
|
|
78
|
+
"({}). Use 'app_label.ModelName'.".format(param, label, exc)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _has_field(model, name):
|
|
83
|
+
if name == 'pk':
|
|
84
|
+
return True
|
|
85
|
+
try:
|
|
86
|
+
model._meta.get_field(name)
|
|
87
|
+
return True
|
|
88
|
+
except FieldDoesNotExist:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_field(model, name, param):
|
|
93
|
+
if not _has_field(model, name):
|
|
94
|
+
raise ImproperlyConfigured(
|
|
95
|
+
"netbox-scope-switcher: scope '{}' uses value_field '{}', which does "
|
|
96
|
+
"not exist on {}.".format(param, name, model._meta.label)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_mode():
|
|
101
|
+
mode = _plugin_settings().get('mode', DEFAULT_MODE)
|
|
102
|
+
if mode not in VALID_MODES:
|
|
103
|
+
raise ImproperlyConfigured(
|
|
104
|
+
"netbox-scope-switcher: 'mode' must be one of {}, got {!r}.".format(
|
|
105
|
+
VALID_MODES, mode
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return mode
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_scopes():
|
|
112
|
+
"""Return the resolved list of :class:`Scope` objects for the current settings."""
|
|
113
|
+
raw = _plugin_settings().get('scopes', DEFAULT_SCOPES)
|
|
114
|
+
if not isinstance(raw, (list, tuple)):
|
|
115
|
+
raise ImproperlyConfigured("netbox-scope-switcher: 'scopes' must be a list of dicts.")
|
|
116
|
+
|
|
117
|
+
scopes = []
|
|
118
|
+
seen = set()
|
|
119
|
+
for entry in raw:
|
|
120
|
+
if not isinstance(entry, dict) or 'param' not in entry or 'model' not in entry:
|
|
121
|
+
raise ImproperlyConfigured(
|
|
122
|
+
"netbox-scope-switcher: each scope needs at least 'param' and "
|
|
123
|
+
"'model' keys; got {!r}.".format(entry)
|
|
124
|
+
)
|
|
125
|
+
param = entry['param']
|
|
126
|
+
if param in seen:
|
|
127
|
+
raise ImproperlyConfigured(
|
|
128
|
+
"netbox-scope-switcher: duplicate scope param '{}'.".format(param)
|
|
129
|
+
)
|
|
130
|
+
seen.add(param)
|
|
131
|
+
scopes.append(Scope(
|
|
132
|
+
param=param,
|
|
133
|
+
model=entry['model'],
|
|
134
|
+
label=entry.get('label'),
|
|
135
|
+
value_field=entry.get('value_field', 'pk'),
|
|
136
|
+
group=entry.get('group'),
|
|
137
|
+
))
|
|
138
|
+
return scopes
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def validate_config():
|
|
142
|
+
"""Validate settings at startup; raise ImproperlyConfigured on any problem."""
|
|
143
|
+
get_mode()
|
|
144
|
+
get_scopes()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scope switcher middleware.
|
|
3
|
+
|
|
4
|
+
When one or more scope values are selected (stored in the session), scope object
|
|
5
|
+
*list* views by adding the corresponding filter params to the URL -- but only
|
|
6
|
+
for the scopes whose filter the current list view actually supports, and only
|
|
7
|
+
when the user hasn't already set that param by hand.
|
|
8
|
+
|
|
9
|
+
- ``mode="redirect"`` (default): issue a single 302 to the same URL with the
|
|
10
|
+
params added, so the address bar and NetBox's own filter panel reflect the
|
|
11
|
+
scope, links are shareable, and "Clear filters" behaves predictably.
|
|
12
|
+
- ``mode="silent"``: rewrite ``request.GET`` in place, without redirecting.
|
|
13
|
+
|
|
14
|
+
Combining semantics:
|
|
15
|
+
|
|
16
|
+
- several values within one scope -> OR (repeated param: ``?tenant_id=1&tenant_id=2``)
|
|
17
|
+
- different scopes -> AND (naturally, they are different params)
|
|
18
|
+
- the special ``null`` value -> ``?param=null`` (objects with no value assigned)
|
|
19
|
+
|
|
20
|
+
The middleware only ever *narrows* a result set; NetBox object permissions still
|
|
21
|
+
apply on top.
|
|
22
|
+
"""
|
|
23
|
+
from django.shortcuts import redirect
|
|
24
|
+
|
|
25
|
+
from netbox.views.generic import ObjectListView
|
|
26
|
+
|
|
27
|
+
from .applicability import _filterset_params
|
|
28
|
+
from .config import get_mode, get_scopes
|
|
29
|
+
from .views import SESSION_KEY, get_state
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ScopeSwitcherMiddleware:
|
|
33
|
+
def __init__(self, get_response):
|
|
34
|
+
self.get_response = get_response
|
|
35
|
+
|
|
36
|
+
def __call__(self, request):
|
|
37
|
+
return self.get_response(request)
|
|
38
|
+
|
|
39
|
+
def process_view(self, request, view_func, view_args, view_kwargs):
|
|
40
|
+
if request.method != 'GET':
|
|
41
|
+
return None
|
|
42
|
+
# Never touch API / GraphQL / HTMX (partial) requests.
|
|
43
|
+
if request.headers.get('HX-Request'):
|
|
44
|
+
return None
|
|
45
|
+
path = request.path
|
|
46
|
+
if path.startswith('/api/') or path.startswith('/graphql'):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
session = getattr(request, 'session', None)
|
|
50
|
+
if session is None or SESSION_KEY not in session:
|
|
51
|
+
return None
|
|
52
|
+
scopes = get_scopes()
|
|
53
|
+
state = get_state(request, scopes)
|
|
54
|
+
if not state:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
view_class = getattr(view_func, 'view_class', None)
|
|
58
|
+
if view_class is None or not issubclass(view_class, ObjectListView):
|
|
59
|
+
return None
|
|
60
|
+
available = _filterset_params(view_class)
|
|
61
|
+
if not available:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
to_apply = {}
|
|
65
|
+
for scope in scopes:
|
|
66
|
+
values = state.get(scope.param)
|
|
67
|
+
if not values:
|
|
68
|
+
continue
|
|
69
|
+
# A manually chosen filter always wins (and this also breaks the
|
|
70
|
+
# redirect loop: once we add the param, it is present on reload).
|
|
71
|
+
if scope.param in request.GET:
|
|
72
|
+
continue
|
|
73
|
+
if scope.param not in available:
|
|
74
|
+
continue
|
|
75
|
+
to_apply[scope.param] = values
|
|
76
|
+
|
|
77
|
+
if not to_apply:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
params = request.GET.copy()
|
|
81
|
+
for param, values in to_apply.items():
|
|
82
|
+
params.setlist(param, values)
|
|
83
|
+
|
|
84
|
+
if get_mode() == 'silent':
|
|
85
|
+
request.GET = params
|
|
86
|
+
return None
|
|
87
|
+
return redirect('{}?{}'.format(path, params.urlencode()))
|