Coverage for utilities/permissions.py: 46%
69 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from dataclasses import dataclass
3from django.apps import apps
4from django.conf import settings
5from django.db.models import Model, Q
6from django.utils.translation import gettext_lazy as _
8from netbox.registry import registry
9from users.constants import CONSTRAINT_TOKEN_USER, RESERVED_ACTIONS
11__all__ = (
12 'ModelAction',
13 'get_permission_for_model',
14 'permission_is_exempt',
15 'qs_filter_from_constraints',
16 'resolve_permission',
17 'resolve_permission_type',
18)
21@dataclass
22class ModelAction:
23 """
24 Represents a custom permission action for a model.
26 Attributes:
27 name: The action identifier (e.g. 'sync', 'render_config')
28 help_text: Optional description displayed in the ObjectPermission form
29 """
30 name: str
31 help_text: str = ''
33 def __post_init__(self):
34 if not self.name: 34 ↛ 35line 34 didn't jump to line 35 because the condition on line 34 was never true
35 raise ValueError("Action name must not be empty.")
36 if self.name in RESERVED_ACTIONS: 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true
37 raise ValueError(f"'{self.name}' is a reserved action and cannot be registered.")
39 def __hash__(self):
40 return hash(self.name)
42 def __eq__(self, other):
43 if isinstance(other, ModelAction):
44 return self.name == other.name
45 return self.name == other
48def register_model_actions(model: type[Model], actions: list[ModelAction | str]):
49 """
50 Register custom permission actions for a model. These actions will appear as
51 checkboxes in the ObjectPermission form when the model is selected.
53 Args:
54 model: The model class to register actions for
55 actions: A list of ModelAction instances or action name strings
56 """
57 label = f'{model._meta.app_label}.{model._meta.model_name}'
58 for action in actions:
59 if isinstance(action, str): 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 action = ModelAction(name=action)
61 registry['model_actions'][label].add(action)
64def get_permission_for_model(model, action):
65 """
66 Resolve the named permission for a given model (or instance) and action (e.g. view or add).
68 :param model: A model or instance
69 :param action: View, add, change, or delete (string)
70 """
71 # Resolve to the "concrete" model (for proxy models)
72 model = model._meta.concrete_model
74 return f'{model._meta.app_label}.{action}_{model._meta.model_name}'
77def resolve_permission(name):
78 """
79 Given a permission name, return the app_label, action, and model_name components. For example, "dcim.view_site"
80 returns ("dcim", "view", "site").
82 :param name: Permission name in the format <app_label>.<action>_<model>
83 """
84 try:
85 app_label, codename = name.split('.')
86 action, model_name = codename.rsplit('_', 1)
87 except ValueError:
88 raise ValueError(
89 _("Invalid permission name: {name}. Must be in the format <app_label>.<action>_<model>").format(name=name)
90 )
92 return app_label, action, model_name
95def resolve_permission_type(name):
96 """
97 Given a permission name, return the relevant ObjectType and action. For example, "dcim.view_site" returns
98 (Site, "view").
100 :param name: Permission name in the format <app_label>.<action>_<model>
101 """
102 from core.models import ObjectType
103 app_label, action, model_name = resolve_permission(name)
104 try:
105 object_type = ObjectType.objects.get_by_natural_key(app_label=app_label, model=model_name)
106 except ObjectType.DoesNotExist:
107 raise ValueError(_("Unknown app_label/model_name for {name}").format(name=name))
109 return object_type, action
112def permission_is_exempt(name):
113 """
114 Determine whether a specified permission is exempt from evaluation.
116 :param name: Permission name in the format <app_label>.<action>_<model>
117 """
118 app_label, action, model_name = resolve_permission(name)
120 if action == 'view': 120 ↛ 130line 120 didn't jump to line 130 because the condition on line 120 was always true
121 if ( 121 ↛ 128line 121 didn't jump to line 128 because the condition on line 121 was never true
122 # All models (excluding those in EXEMPT_EXCLUDE_MODELS) are exempt from view permission enforcement
123 '*' in settings.EXEMPT_VIEW_PERMISSIONS and (app_label, model_name) not in settings.EXEMPT_EXCLUDE_MODELS
124 ) or (
125 # This specific model is exempt from view permission enforcement
126 f'{app_label}.{model_name}' in settings.EXEMPT_VIEW_PERMISSIONS
127 ):
128 return True
130 return False
133def qs_filter_from_constraints(constraints, tokens=None):
134 """
135 Construct a Q filter object from an iterable of ObjectPermission constraints.
137 Args:
138 tokens: A dictionary mapping string tokens to be replaced with a value.
139 """
140 if tokens is None:
141 tokens = {}
143 User = apps.get_model('users.User')
144 for token, value in tokens.items():
145 if token == CONSTRAINT_TOKEN_USER and isinstance(value, User):
146 tokens[token] = value.id
148 def _replace_tokens(value, tokens):
149 if type(value) is list:
150 return list(map(lambda v: tokens.get(v, v), value))
151 return tokens.get(value, value)
153 params = Q()
154 for constraint in constraints:
155 if constraint:
156 params |= Q(**{k: _replace_tokens(v, tokens) for k, v in constraint.items()})
157 else:
158 # Found null constraint; permit model-level access
159 return Q()
161 return params