utils.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import importlib
  2. import types
  3. from pathlib import Path
  4. from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation
  5. from django.core.files.storage import Storage, default_storage
  6. from django.core.files.utils import validate_file_name
  7. from django.db import models
  8. from taggit.managers import _TaggableManager
  9. from netbox.context import current_request
  10. from .constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
  11. from .validators import CustomValidator
  12. __all__ = (
  13. 'SharedObjectViewMixin',
  14. 'filename_from_model',
  15. 'image_upload',
  16. 'is_report',
  17. 'is_script',
  18. 'is_taggable',
  19. 'run_validators',
  20. 'validate_script_content',
  21. )
  22. class SharedObjectViewMixin:
  23. def get_queryset(self, request):
  24. """
  25. Return only shared objects, or those owned by the current user, unless this is a superuser.
  26. """
  27. return super().get_queryset(request).restrict_to_shared(request.user)
  28. def filename_from_model(model: models.Model) -> str:
  29. """Standardizes how we generate filenames from model class for exports"""
  30. base = model._meta.verbose_name_plural.lower().replace(' ', '_')
  31. return f'netbox_{base}'
  32. def filename_from_object(context: dict) -> str:
  33. """Standardizes how we generate filenames from model class for exports"""
  34. if 'device' in context:
  35. base = f"{context['device'].name or 'config'}"
  36. elif 'virtualmachine' in context:
  37. base = f"{context['virtualmachine'].name or 'config'}"
  38. else:
  39. base = 'config'
  40. return base
  41. def is_taggable(obj):
  42. """
  43. Return True if the instance can have Tags assigned to it; False otherwise.
  44. """
  45. if hasattr(obj, 'tags'):
  46. if issubclass(obj.tags.__class__, _TaggableManager):
  47. return True
  48. return False
  49. def _build_image_attachment_path(instance, filename, *, storage=default_storage):
  50. """
  51. Build a deterministic relative path for an image attachment.
  52. - Normalizes browser paths (e.g., C:\\fake_path\\photo.jpg)
  53. - Uses the instance.name if provided (sanitized to a *basename*, no ext)
  54. - Prefixes with a machine-friendly identifier
  55. """
  56. upload_dir = 'image-attachments'
  57. default_filename = 'unnamed'
  58. allowed_img_extensions = IMAGE_ATTACHMENT_IMAGE_FORMATS.keys()
  59. # Normalize Windows paths and create a Path object.
  60. normalized_filename = str(filename).replace('\\', '/')
  61. file_path = Path(normalized_filename)
  62. # Extract the extension from the uploaded file.
  63. ext = file_path.suffix.lower().lstrip('.')
  64. # Use the instance-provided name if available; otherwise use the file stem.
  65. # Rely on Django's get_valid_filename to perform sanitization.
  66. stem = (instance.name or file_path.stem).strip()
  67. try:
  68. safe_stem = storage.get_valid_name(stem)
  69. except SuspiciousFileOperation:
  70. safe_stem = default_filename
  71. # Append the uploaded extension only if it's an allowed image type
  72. final_name = f'{safe_stem}.{ext}' if ext in allowed_img_extensions else safe_stem
  73. # Create a machine-friendly prefix from the instance
  74. prefix = f'{instance.object_type.model}_{instance.object_id}'
  75. name_with_path = f'{upload_dir}/{prefix}_{final_name}'
  76. # Validate the generated relative path (blocks absolute/traversal)
  77. validate_file_name(name_with_path, allow_relative_path=True)
  78. return name_with_path
  79. def image_upload(instance, filename):
  80. """
  81. Return a relative upload path for an image attachment, applying Django's
  82. usual suffix-on-collision behavior regardless of storage backend.
  83. """
  84. field = instance.image.field
  85. name_with_path = _build_image_attachment_path(instance, filename, storage=field.storage)
  86. # Intentionally call Django's base Storage implementation here. Some
  87. # backends override get_available_name() to reuse the incoming name
  88. # unchanged, but we want Django's normal suffix-on-collision behavior
  89. # while still dispatching exists() / get_alternative_name() to the
  90. # configured storage instance.
  91. return Storage.get_available_name(field.storage, name_with_path, max_length=field.max_length)
  92. def is_script(obj):
  93. """
  94. Returns True if the object is a Script or Report.
  95. """
  96. from .reports import Report
  97. from .scripts import Script
  98. try:
  99. return (issubclass(obj, Report) and obj != Report) or (issubclass(obj, Script) and obj != Script)
  100. except TypeError:
  101. return False
  102. def validate_script_content(content, filename):
  103. """
  104. Validate that the given content can be loaded as a Python module by compiling
  105. and executing it. Raises an exception if the script cannot be loaded.
  106. """
  107. code = compile(content, filename, 'exec')
  108. module_name = Path(filename).stem
  109. module = types.ModuleType(module_name)
  110. exec(code, module.__dict__)
  111. def is_report(obj):
  112. """
  113. Returns True if the given object is a Report.
  114. """
  115. from .reports import Report
  116. try:
  117. return issubclass(obj, Report) and obj != Report
  118. except TypeError:
  119. return False
  120. def run_validators(instance, validators):
  121. """
  122. Run the provided iterable of CustomValidators for the instance.
  123. """
  124. request = current_request.get()
  125. for validator in validators:
  126. # Loading a validator class by a dotted path
  127. if type(validator) is str:
  128. module, cls = validator.rsplit('.', 1)
  129. validator = getattr(importlib.import_module(module), cls)()
  130. # Constructing a new instance on the fly from a ruleset
  131. elif type(validator) is dict:
  132. validator = CustomValidator(validator)
  133. elif not issubclass(validator.__class__, CustomValidator):
  134. raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}")
  135. validator(instance, request)