Coverage for extras/utils.py: 35%

80 statements  

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

1import importlib 

2import types 

3from pathlib import Path 

4 

5from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation 

6from django.core.files.storage import Storage, default_storage 

7from django.core.files.utils import validate_file_name 

8from django.db import models 

9from taggit.managers import _TaggableManager 

10 

11from netbox.context import current_request 

12 

13from .constants import IMAGE_ATTACHMENT_IMAGE_FORMATS 

14from .validators import CustomValidator 

15 

16__all__ = ( 

17 'SharedObjectViewMixin', 

18 'filename_from_model', 

19 'image_upload', 

20 'is_report', 

21 'is_script', 

22 'is_taggable', 

23 'run_validators', 

24 'validate_script_content', 

25) 

26 

27 

28class SharedObjectViewMixin: 

29 

30 def get_queryset(self, request): 

31 """ 

32 Return only shared objects, or those owned by the current user, unless this is a superuser. 

33 """ 

34 return super().get_queryset(request).restrict_to_shared(request.user) 

35 

36 

37def filename_from_model(model: models.Model) -> str: 

38 """Standardizes how we generate filenames from model class for exports""" 

39 base = model._meta.verbose_name_plural.lower().replace(' ', '_') 

40 return f'netbox_{base}' 

41 

42 

43def filename_from_object(context: dict) -> str: 

44 """Standardizes how we generate filenames from model class for exports""" 

45 if 'device' in context: 

46 base = f"{context['device'].name or 'config'}" 

47 elif 'virtualmachine' in context: 

48 base = f"{context['virtualmachine'].name or 'config'}" 

49 else: 

50 base = 'config' 

51 return base 

52 

53 

54def is_taggable(obj): 

55 """ 

56 Return True if the instance can have Tags assigned to it; False otherwise. 

57 """ 

58 if hasattr(obj, 'tags'): 

59 if issubclass(obj.tags.__class__, _TaggableManager): 59 ↛ 61line 59 didn't jump to line 61 because the condition on line 59 was always true

60 return True 

61 return False 

62 

63 

64def _build_image_attachment_path(instance, filename, *, storage=default_storage): 

65 """ 

66 Build a deterministic relative path for an image attachment. 

67 

68 - Normalizes browser paths (e.g., C:\\fake_path\\photo.jpg) 

69 - Uses the instance.name if provided (sanitized to a *basename*, no ext) 

70 - Prefixes with a machine-friendly identifier 

71 """ 

72 upload_dir = 'image-attachments' 

73 default_filename = 'unnamed' 

74 allowed_img_extensions = IMAGE_ATTACHMENT_IMAGE_FORMATS.keys() 

75 

76 # Normalize Windows paths and create a Path object. 

77 normalized_filename = str(filename).replace('\\', '/') 

78 file_path = Path(normalized_filename) 

79 

80 # Extract the extension from the uploaded file. 

81 ext = file_path.suffix.lower().lstrip('.') 

82 

83 # Use the instance-provided name if available; otherwise use the file stem. 

84 # Rely on Django's get_valid_filename to perform sanitization. 

85 stem = (instance.name or file_path.stem).strip() 

86 try: 

87 safe_stem = storage.get_valid_name(stem) 

88 except SuspiciousFileOperation: 

89 safe_stem = default_filename 

90 

91 # Append the uploaded extension only if it's an allowed image type 

92 final_name = f'{safe_stem}.{ext}' if ext in allowed_img_extensions else safe_stem 

93 

94 # Create a machine-friendly prefix from the instance 

95 prefix = f'{instance.object_type.model}_{instance.object_id}' 

96 name_with_path = f'{upload_dir}/{prefix}_{final_name}' 

97 

98 # Validate the generated relative path (blocks absolute/traversal) 

99 validate_file_name(name_with_path, allow_relative_path=True) 

100 return name_with_path 

101 

102 

103def image_upload(instance, filename): 

104 """ 

105 Return a relative upload path for an image attachment, applying Django's 

106 usual suffix-on-collision behavior regardless of storage backend. 

107 """ 

108 field = instance.image.field 

109 name_with_path = _build_image_attachment_path(instance, filename, storage=field.storage) 

110 

111 # Intentionally call Django's base Storage implementation here. Some 

112 # backends override get_available_name() to reuse the incoming name 

113 # unchanged, but we want Django's normal suffix-on-collision behavior 

114 # while still dispatching exists() / get_alternative_name() to the 

115 # configured storage instance. 

116 return Storage.get_available_name(field.storage, name_with_path, max_length=field.max_length) 

117 

118 

119def is_script(obj): 

120 """ 

121 Returns True if the object is a Script or Report. 

122 """ 

123 from .reports import Report 

124 from .scripts import Script 

125 try: 

126 return (issubclass(obj, Report) and obj != Report) or (issubclass(obj, Script) and obj != Script) 

127 except TypeError: 

128 return False 

129 

130 

131def validate_script_content(content, filename): 

132 """ 

133 Validate that the given content can be loaded as a Python module by compiling 

134 and executing it. Raises an exception if the script cannot be loaded. 

135 """ 

136 code = compile(content, filename, 'exec') 

137 module_name = Path(filename).stem 

138 module = types.ModuleType(module_name) 

139 exec(code, module.__dict__) 

140 

141 

142def is_report(obj): 

143 """ 

144 Returns True if the given object is a Report. 

145 """ 

146 from .reports import Report 

147 try: 

148 return issubclass(obj, Report) and obj != Report 

149 except TypeError: 

150 return False 

151 

152 

153def run_validators(instance, validators): 

154 """ 

155 Run the provided iterable of CustomValidators for the instance. 

156 """ 

157 request = current_request.get() 

158 for validator in validators: 158 ↛ 161line 158 didn't jump to line 161 because the loop on line 158 never started

159 

160 # Loading a validator class by a dotted path 

161 if type(validator) is str: 

162 module, cls = validator.rsplit('.', 1) 

163 validator = getattr(importlib.import_module(module), cls)() 

164 

165 # Constructing a new instance on the fly from a ruleset 

166 elif type(validator) is dict: 

167 validator = CustomValidator(validator) 

168 

169 elif not issubclass(validator.__class__, CustomValidator): 

170 raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}") 

171 

172 validator(instance, request)