Coverage for src/mesh/model/file_helpers.py: 21%

97 statements  

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

1from __future__ import annotations 

2 

3import os 

4import re 

5from typing import TYPE_CHECKING 

6 

7from django.contrib import messages 

8from django.core.files.storage import FileSystemStorage 

9from django.db.models import Model, QuerySet 

10from django.db.models.fields.files import FieldFile 

11from django.http import HttpRequest 

12from django.utils.translation import gettext_lazy as _ 

13 

14from mesh.app_settings import app_settings 

15 

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

17 from mesh.model.roles.role_handler import RoleHandler 

18 

19 

20DELETE_FILE_PREFIX = "_delete_file_" 

21 

22FILE_NAME_MAX_LENGTH = 1024 

23FILE_DELETE_DEFAULT_ERROR = _("The requested field can not be deleted.") 

24 

25 

26def file_name(file_path: str | None) -> str: 

27 """ 

28 Returns the base name of a file from its path. 

29 """ 

30 if file_path is None: 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true

31 return "" 

32 return os.path.basename(file_path) 

33 

34 

35def file_exists(f: FieldFile) -> bool: 

36 """ 

37 Returns whether the FieldFile represents an actual file. 

38 """ 

39 return bool(f and getattr(f, "url", False)) 

40 

41 

42class MeshFileSystemStorage(FileSystemStorage): 

43 """ 

44 The base location of the files corresponds to the setting FILES_DIRECTORY. 

45 We make use of Django built-in get_available_name to generate an available 

46 name for the file to be saved. 

47 """ 

48 

49 def __init__(self, **kwargs): 

50 """ 

51 Overrides the default storage location from MEDIA_ROOT to FILES_DIRECTORY 

52 setting. 

53 """ 

54 kwargs["location"] = app_settings.FILES_DIRECTORY 

55 super().__init__(**kwargs) 

56 

57 def get_available_name(self, name, max_length=None): 

58 """ 

59 Built-in get_available_name with our max length constant. 

60 """ 

61 max_length = max_length or FILE_NAME_MAX_LENGTH 

62 return super().get_available_name(name, max_length=max_length) 

63 

64 

65def post_delete_model_file( 

66 instance: Model, request: HttpRequest, role_handler: RoleHandler, add_message=True 

67) -> tuple[bool, bool]: 

68 """ 

69 Checks the given POST request for specific input used for file deletion. 

70 Deletes the requested file, if any, if the field is deletable and not required. 

71 

72 This handles direct FileFields and attached models in a ManyToOne or OneToOne 

73 relationship. 

74 

75 Returns: 

76 - `bool` whether a file deletion was requested 

77 - `bool` whether the file or model was successfully deleted or not. 

78 """ 

79 # Check for specific delete entry in the post request 

80 deletion_requested = False 

81 deletion_performed = False 

82 

83 delete_regex = rf"{re.escape(DELETE_FILE_PREFIX)}(?P<field_details>.+)$" 

84 

85 matched = False 

86 match = None 

87 for a in request.POST.keys(): 

88 match = re.match(delete_regex, a) 

89 if match: 

90 matched = True 

91 break 

92 

93 if not matched: 

94 return deletion_requested, deletion_performed 

95 

96 deletion_requested = True 

97 

98 # Additionally split the field details: 

99 # It's either just the field name or the field name with a pk: `{field_name}_{pk}` 

100 field_details = match.group("field_details") # type:ignore 

101 field_name = field_details 

102 field_pk = None 

103 detail_match = re.match(r"(?P<field_name>.+?)_(?P<field_pk>[0-9]+)", field_details) 

104 if detail_match: 

105 field_name = detail_match.group("field_name") 

106 field_pk = int(detail_match.group("field_pk")) 

107 

108 # Check that the requested field can be deleted 

109 can_delete_file = getattr( 

110 instance, "can_delete_file", lambda x: (False, FILE_DELETE_DEFAULT_ERROR) 

111 ) 

112 result, error_msg = can_delete_file(field_name) 

113 if result is False: 

114 messages.error(request, error_msg) 

115 return deletion_requested, deletion_performed 

116 

117 # Get the requested entity to delete 

118 model_to_delete: Model | None = None 

119 

120 # If field_pk is not None, we are requested to delete a related model in 

121 # a ManyToOne relationship. 

122 if field_pk: 

123 try: 

124 # Check if the field is required from the model custom attribute 

125 required_fields = getattr(instance, "file_fields_required", []) 

126 entities: QuerySet[Model] = getattr(instance, field_name).all() 

127 if field_name in required_fields: 

128 # If the field is required, check that there are more than one related 

129 # entities. Otherwise abort. 

130 if len(entities) < 2: 

131 messages.error( 

132 request, 

133 "This field is required. Add another file, save the form, " 

134 "then come back to delete this file.", 

135 ) 

136 model_to_delete = [entity for entity in entities if entity.pk == field_pk][0] 

137 except Exception: 

138 if add_message: 

139 messages.error(request, "Something went wrong.") 

140 

141 # Else, we are requested to delete the file which is either a FileField 

142 # or a Model in a OneToOne relationship. 

143 else: 

144 try: 

145 # Check if the field is required from the model custom attribute 

146 required_fields = getattr(instance, "file_fields_required", []) 

147 if field_details in required_fields: 

148 if add_message: 

149 messages.error( 

150 request, 

151 "This field is required. You can not delete the file. " 

152 "Try replacing it using the file picker.", 

153 ) 

154 return deletion_requested, deletion_performed 

155 file = getattr(instance, field_details) 

156 

157 # FileField - Handle directly 

158 if isinstance(file, FieldFile): 

159 if file.field.null is False: 

160 messages.error(request, "This field is required. You can not delete the file.") 

161 return deletion_requested, deletion_performed 

162 file.delete(save=False) 

163 setattr(instance, field_details, None) 

164 instance.save() 

165 deletion_performed = True 

166 

167 # Attached model - Handled later 

168 elif isinstance(file, Model): 

169 model_to_delete = file 

170 

171 except Exception: 

172 if add_message: 

173 messages.error(request, "Something went wrong.") 

174 

175 # Delete the requested Model 

176 if model_to_delete: 

177 # Check if there's a restriction to delete the model 

178 check_access_right = getattr(model_to_delete, "check_access_right", None) 

179 if check_access_right and not check_access_right(role_handler, "delete"): 

180 messages.error(request, "You don't have the right to delete the requested file.") 

181 return deletion_requested, deletion_performed 

182 

183 model_to_delete.delete() 

184 deletion_performed = True 

185 

186 if deletion_performed and add_message: 

187 messages.success(request, "The file was successfully deleted.") 

188 

189 return deletion_requested, deletion_performed