Coverage for utilities/permissions.py: 46%

69 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from dataclasses import dataclass 

2 

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 _ 

7 

8from netbox.registry import registry 

9from users.constants import CONSTRAINT_TOKEN_USER, RESERVED_ACTIONS 

10 

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) 

19 

20 

21@dataclass 

22class ModelAction: 

23 """ 

24 Represents a custom permission action for a model. 

25 

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 = '' 

32 

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.") 

38 

39 def __hash__(self): 

40 return hash(self.name) 

41 

42 def __eq__(self, other): 

43 if isinstance(other, ModelAction): 

44 return self.name == other.name 

45 return self.name == other 

46 

47 

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. 

52 

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) 

62 

63 

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

67 

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 

73 

74 return f'{model._meta.app_label}.{action}_{model._meta.model_name}' 

75 

76 

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"). 

81 

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 ) 

91 

92 return app_label, action, model_name 

93 

94 

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"). 

99 

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)) 

108 

109 return object_type, action 

110 

111 

112def permission_is_exempt(name): 

113 """ 

114 Determine whether a specified permission is exempt from evaluation. 

115 

116 :param name: Permission name in the format <app_label>.<action>_<model> 

117 """ 

118 app_label, action, model_name = resolve_permission(name) 

119 

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 

129 

130 return False 

131 

132 

133def qs_filter_from_constraints(constraints, tokens=None): 

134 """ 

135 Construct a Q filter object from an iterable of ObjectPermission constraints. 

136 

137 Args: 

138 tokens: A dictionary mapping string tokens to be replaced with a value. 

139 """ 

140 if tokens is None: 

141 tokens = {} 

142 

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 

147 

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) 

152 

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

160 

161 return params