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.
@@ -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()))