Coverage for src/mesh/models/file_models.py: 86%

101 statements  

« prev     ^ index     » next       coverage.py v7.7.0, created at 2025-04-28 07:45 +0000

1from __future__ import annotations 

2 

3import os 

4from collections.abc import Callable 

5from typing import TYPE_CHECKING 

6 

7from django.core.exceptions import ValidationError 

8from django.core.files.uploadedfile import UploadedFile 

9from django.db import models 

10from django.urls import reverse_lazy 

11from ptf.url_utils import encode_for_url 

12 

13from mesh.model.file_helpers import ( 

14 FILE_DELETE_DEFAULT_ERROR, 

15 FILE_NAME_MAX_LENGTH, 

16 MeshFileSystemStorage, 

17 file_name, 

18) 

19 

20from .base_models import BaseChangeTrackingModel 

21 

22if TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true

23 from mesh.model.roles.role_handler import RoleHandler 

24 

25 

26MEGABYTE_VALUE = 1048576 

27 

28 

29class BaseModelWithFiles(BaseChangeTrackingModel): 

30 """ 

31 Model template for a model with attached files using the below BaseFileWrapperModel 

32 model. 

33 """ 

34 

35 # List of the required file fields. We need to mention them somewhere because 

36 # some "required" file fields can have null=True for other purpose 

37 file_fields_required = [] 

38 # List of file fields or related file models that can be deleted by an user. 

39 file_fields_deletable = [] 

40 

41 class Meta: 

42 abstract = True 

43 

44 def can_delete_file(self, file_field: str) -> tuple[bool, str]: 

45 """ 

46 Check function that returns whether the provided file field is deletable. 

47 Basic implementation only checks if the field is in the deletable fields list. 

48 

49 Returns: 

50 - result: bool Whether the field can be deleted 

51 - error_message: str An explaining error message when `result=False` 

52 """ 

53 return file_field in self.file_fields_deletable, FILE_DELETE_DEFAULT_ERROR 

54 

55 

56def get_upload_path_from_model(instance: models.Model, filename: str) -> str: 

57 """ 

58 Calls the `get_upload_path` method of the instance's model. 

59 """ 

60 model_class = instance.__class__ 

61 upload_path_func = getattr(model_class, "get_upload_path", None) 

62 if not upload_path_func or not callable(upload_path_func): 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true

63 raise Exception( 

64 f"Error: model {model_class} does not have a callable `get_upload_path` method." 

65 ) 

66 return upload_path_func(instance, filename) 

67 

68 

69class BaseFileWrapperModel(BaseChangeTrackingModel): 

70 """ 

71 Base model class providing a wrapper around a single file field. 

72 This should be used instead of creating direct FileField on the models. 

73 """ 

74 

75 # Allowed file extensions 

76 file_extensions = [".pdf", ".docx"] 

77 # Max length for file name - Must be synced with the `name` field and 

78 # the file name + the prefix path should be less than FILE_NAME_MAX_LENGTH 

79 # 256 characters should be okay for any file. 

80 file_name_max_length = 256 

81 # File's max size (in bytes) 

82 file_max_size = 10 * MEGABYTE_VALUE 

83 

84 file = models.FileField( 

85 max_length=FILE_NAME_MAX_LENGTH, 

86 upload_to=get_upload_path_from_model, 

87 storage=MeshFileSystemStorage, 

88 # validators=[file_validators], No validators - They are handled by forms 

89 blank=False, 

90 null=False, 

91 ) 

92 # Stores the original file name. The file might be stored with a different name 

93 # on the server. Note that you must update this field when changing the `file` 

94 # field manually (it doesn't happen, usually a new Wrapper instance is created). 

95 name = models.CharField(max_length=256, blank=True, null=False) 

96 

97 # The foreign key when the file is linked to another entity 

98 # (Should always be the case) 

99 attached_to: None | models.OneToOneField | models.ForeignKey = None 

100 

101 class Meta: 

102 abstract = True 

103 

104 @classmethod 

105 def get_help_text(cls) -> dict: 

106 """ """ 

107 descriptions = [] 

108 if cls.file_extensions: 108 ↛ 110line 108 didn't jump to line 110 because the condition on line 108 was always true

109 descriptions.append(f"Allowed files are {', '.join(cls.file_extensions)}.") 

110 descriptions.append(f"Maximum size: {cls.file_max_size / MEGABYTE_VALUE:.2f}Mb") 

111 return {"type": "list", "texts": descriptions} 

112 

113 @staticmethod 

114 def get_upload_path(instance: BaseFileWrapperModel, filename: str) -> str: 

115 """ 

116 Customizable hook to get the file upload path from the model instance. 

117 

118 Note: We can't use @abstractmethod decorator here because we can't use 

119 ABCMeta as the metaclass 

120 """ 

121 raise NotImplementedError 

122 

123 @classmethod 

124 def reverse_file_path(cls, file_path: str) -> BaseFileWrapperModel | None: 

125 """ 

126 Returns the model instance associated to the given file path if the path 

127 corresponds to a file of the current model. 

128 

129 Note: We can't use @abstractmethod decorator here because we can't use 

130 ABCMeta as the metaclass 

131 """ 

132 raise NotImplementedError 

133 

134 @staticmethod 

135 def instance_valid_file(instance: BaseFileWrapperModel): 

136 return instance and instance.file and getattr(instance.file, "url", False) 

137 

138 @classmethod 

139 def run_file_validators(cls, value: UploadedFile): 

140 for validator in cls.file_validators(): 

141 validator(value) 

142 

143 @classmethod 

144 def file_validators(cls) -> list[Callable]: 

145 """ 

146 Returns all the model's file validators. 

147 Default is the 3 base validators: file_extension, file_name_length and 

148 file_size. 

149 """ 

150 return [ 

151 cls.validate_file_extension, 

152 cls.validate_file_name_length, 

153 cls.validate_file_size, 

154 ] 

155 

156 @classmethod 

157 def validate_file_extension(cls, value: UploadedFile): 

158 """ 

159 Validate the file extension wrt. the class requirements. 

160 All file types are allowed if `file_extensions` is empty. 

161 """ 

162 if not cls.file_extensions: 

163 return 

164 extension = os.path.splitext(value.name)[1] 

165 if extension.lower() not in cls.file_extensions: 

166 raise ValidationError( 

167 f"Unsupported file extension: {extension.lower()}. " 

168 f"Valid file extension(s) are: {', '.join(cls.file_extensions)}" 

169 ) 

170 

171 @classmethod 

172 def validate_file_name_length(cls, value: UploadedFile): 

173 name = file_name(value.name) 

174 max_length = min(cls.file_name_max_length, FILE_NAME_MAX_LENGTH) 

175 if len(name) > max_length: 

176 raise ValidationError( 

177 f"The provided file name is too long: {name}. " 

178 f"The maximum allowed length is {max_length} characters." 

179 ) 

180 

181 @classmethod 

182 def validate_file_size(cls, value: UploadedFile): 

183 if value.size > cls.file_max_size: 

184 raise ValidationError( 

185 f"The provided file is too large: {value.size / (MEGABYTE_VALUE):.2f} MB. " 

186 f"Maximum allowed file size is {cls.file_max_size / (MEGABYTE_VALUE):.2f} MB." 

187 ) 

188 

189 def save(self, *args, **kwargs) -> None: 

190 """ 

191 Deletes the old file from the filesystem if the database insertion/update 

192 succeeds. 

193 

194 TODO: This is not robust with rollbacks. In case this is called during 

195 an encapsulating transaction that gets rolled back, this might create 

196 an inconsistent status : the file is effectively deleted but the database entry 

197 still points to the deleted file as it was rolled back... 

198 """ 

199 # Set the file name now if it does not exist - It might be changed later by the storage 

200 self.name = self.name or file_name(self.file.name) 

201 

202 original_path = None 

203 # Check if there's already a instance of this model with the current pk. 

204 # Store the old file path for potential deletion. 

205 if self.pk: 

206 try: 

207 original_model = self.__class__.objects.get(pk=self.pk) 

208 original_path = original_model.file.path 

209 except self.__class__.DoesNotExist: 

210 pass 

211 

212 super().save(*args, **kwargs) 

213 

214 # If the path changed, delete the old file. 

215 if ( 

216 original_path is not None 

217 and self.file.path != original_path 

218 and os.path.exists(original_path) 

219 ): 

220 os.remove(original_path) 

221 

222 def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: 

223 """ 

224 Deletes the file from the filesystem if the database deletion succeeds. 

225 """ 

226 file_to_delete = self.file 

227 

228 result = super().delete(*args, **kwargs) 

229 

230 if file_to_delete: 230 ↛ 233line 230 didn't jump to line 233 because the condition on line 230 was always true

231 file_to_delete.delete(save=False) 

232 

233 return result 

234 

235 def check_access_right(self, role_handler: RoleHandler, right_code: str) -> bool: 

236 """ 

237 Customizable hook to check the given access right. 

238 Only used when serving a file. 

239 Default is False. 

240 """ 

241 return False 

242 

243 def get_file_url(self) -> str: 

244 """ 

245 Returns the URL to the model's file. 

246 """ 

247 if not self.file.url: 

248 return "" 

249 file_identifier = encode_for_url(self.file.name) 

250 return reverse_lazy( 

251 "mesh:serve_protected_file", kwargs={"file_identifier": file_identifier} 

252 )