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
« prev ^ index » next coverage.py v7.13.1, created at 2026-05-04 12:41 +0000
1from __future__ import annotations
3import os
4import re
5from typing import TYPE_CHECKING
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 _
14from mesh.app_settings import app_settings
16if TYPE_CHECKING:
17 from mesh.models.roles.base_role import Role
19DELETE_FILE_PREFIX = "_delete_file_"
21FILE_NAME_MAX_LENGTH = 1024
22FILE_DELETE_DEFAULT_ERROR = _("The requested field can not be deleted.")
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)
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))
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 """
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)
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)
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.
71 This handles direct FileFields and attached models in a ManyToOne or OneToOne
72 relationship.
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
82 delete_regex = rf"{re.escape(DELETE_FILE_PREFIX)}(?P<field_details>.+)$"
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
92 if not matched:
93 return deletion_requested, deletion_performed
95 deletion_requested = True
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"))
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
116 # Get the requested entity to delete
117 model_to_delete: Model | None = None
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.")
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)
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
166 # Attached model - Handled later
167 elif isinstance(file, Model):
168 model_to_delete = file
170 except Exception:
171 if add_message:
172 messages.error(request, "Something went wrong.")
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
182 model_to_delete.delete()
183 deletion_performed = True
185 if deletion_performed and add_message:
186 messages.success(request, "The file was successfully deleted.")
188 return deletion_requested, deletion_performed