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

94 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-22 07:20 +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: 

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 and len(entities) < 2: 

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

128 # entities. Otherwise abort. 

129 messages.error( 

130 request, 

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

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

133 ) 

134 model_to_delete = next(entity for entity in entities if entity.pk == field_pk) 

135 except Exception: 

136 if add_message: 

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

138 

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

140 # or a Model in a OneToOne relationship. 

141 else: 

142 try: 

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

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

145 if field_details in required_fields: 

146 if add_message: 

147 messages.error( 

148 request, 

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

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

151 ) 

152 return deletion_requested, deletion_performed 

153 file = getattr(instance, field_details) 

154 

155 # FileField - Handle directly 

156 if isinstance(file, FieldFile): 

157 if file.field.null is False: 

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

159 return deletion_requested, deletion_performed 

160 file.delete(save=False) 

161 setattr(instance, field_details, None) 

162 instance.save() 

163 deletion_performed = True 

164 

165 # Attached model - Handled later 

166 elif isinstance(file, Model): 

167 model_to_delete = file 

168 

169 except Exception: 

170 if add_message: 

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

172 

173 # Delete the requested Model 

174 if model_to_delete: 

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

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

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

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

179 return deletion_requested, deletion_performed 

180 

181 model_to_delete.delete() 

182 deletion_performed = True 

183 

184 if deletion_performed and add_message: 

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

186 

187 return deletion_requested, deletion_performed