Coverage for src / mesh / models / file_helpers.py: 38%

95 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-05-04 12:41 +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: 

17 from mesh.models.roles.base_role import Role 

18 

19DELETE_FILE_PREFIX = "_delete_file_" 

20 

21FILE_NAME_MAX_LENGTH = 1024 

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

23 

24 

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

26 """ 

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

28 """ 

29 if file_path is None: 

30 return "" 

31 return os.path.basename(file_path) 

32 

33 

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

35 """ 

36 Returns whether the FieldFile represents an actual file. 

37 """ 

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

39 

40 

41class MeshFileSystemStorage(FileSystemStorage): 

42 """ 

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

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

45 name for the file to be saved. 

46 """ 

47 

48 def __init__(self, **kwargs): 

49 """ 

50 Overrides the default storage location from MEDIA_ROOT to FILES_DIRECTORY 

51 setting. 

52 """ 

53 kwargs["location"] = app_settings.FILES_DIRECTORY 

54 super().__init__(**kwargs) 

55 

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

57 """ 

58 Built-in get_available_name with our max length constant. 

59 """ 

60 max_length = max_length or FILE_NAME_MAX_LENGTH 

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

62 

63 

64def post_delete_model_file( 

65 instance: Model, request: HttpRequest, role: Role, add_message=True 

66) -> tuple[bool, bool]: 

67 """ 

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

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

70 

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

72 relationship. 

73 

74 Returns: 

75 - `bool` whether a file deletion was requested 

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

77 """ 

78 # Check for specific delete entry in the post request 

79 deletion_requested = False 

80 deletion_performed = False 

81 

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

83 

84 matched = False 

85 match = None 

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

87 match = re.match(delete_regex, a) 

88 if match: 

89 matched = True 

90 break 

91 

92 if not matched: 

93 return deletion_requested, deletion_performed 

94 

95 deletion_requested = True 

96 

97 # Additionally split the field details: 

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

99 field_details = match.group("field_details") 

100 field_name = field_details 

101 field_pk = None 

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

103 if detail_match: 

104 field_name = detail_match.group("field_name") 

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

106 

107 # Check that the requested field can be deleted 

108 can_delete_file = getattr( 

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

110 ) 

111 result, error_msg = can_delete_file(field_name) 

112 if result is False: 

113 messages.error(request, error_msg) 

114 return deletion_requested, deletion_performed 

115 

116 # Get the requested entity to delete 

117 model_to_delete: Model | None = None 

118 

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

120 # a ManyToOne relationship. 

121 if field_pk: 

122 try: 

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

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

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

126 if field_name in required_fields: 

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

128 # entities. Otherwise abort. 

129 if len(entities) < 2: 

130 messages.error( 

131 request, 

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

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

134 ) 

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

136 except Exception: 

137 if add_message: 

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

139 

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

141 # or a Model in a OneToOne relationship. 

142 else: 

143 try: 

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

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

146 if field_details in required_fields: 

147 if add_message: 

148 messages.error( 

149 request, 

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

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

152 ) 

153 return deletion_requested, deletion_performed 

154 file = getattr(instance, field_details) 

155 

156 # FileField - Handle directly 

157 if isinstance(file, FieldFile): 

158 if file.field.null is False: 

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

160 return deletion_requested, deletion_performed 

161 file.delete(save=False) 

162 setattr(instance, field_details, None) 

163 instance.save() 

164 deletion_performed = True 

165 

166 # Attached model - Handled later 

167 elif isinstance(file, Model): 

168 model_to_delete = file 

169 

170 except Exception: 

171 if add_message: 

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

173 

174 # Delete the requested Model 

175 if model_to_delete: 

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

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

178 if check_access_right and not check_access_right(role, "delete"): 

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

180 return deletion_requested, deletion_performed 

181 

182 model_to_delete.delete() 

183 deletion_performed = True 

184 

185 if deletion_performed and add_message: 

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

187 

188 return deletion_requested, deletion_performed