|
1
|
|
|
# -*- coding: utf-8 -*- |
|
2
|
|
|
# |
|
3
|
|
|
# This file is part of SENAITE.CORE. |
|
4
|
|
|
# |
|
5
|
|
|
# SENAITE.CORE is free software: you can redistribute it and/or modify it under |
|
6
|
|
|
# the terms of the GNU General Public License as published by the Free Software |
|
7
|
|
|
# Foundation, version 2. |
|
8
|
|
|
# |
|
9
|
|
|
# This program is distributed in the hope that it will be useful, but WITHOUT |
|
10
|
|
|
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
|
11
|
|
|
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more |
|
12
|
|
|
# details. |
|
13
|
|
|
# |
|
14
|
|
|
# You should have received a copy of the GNU General Public License along with |
|
15
|
|
|
# this program; if not, write to the Free Software Foundation, Inc., 51 |
|
16
|
|
|
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. |
|
17
|
|
|
# |
|
18
|
|
|
# Copyright 2018-2025 by it's authors. |
|
19
|
|
|
# Some rights reserved, see README and LICENSE. |
|
20
|
|
|
|
|
21
|
|
|
import json |
|
22
|
|
|
|
|
23
|
|
|
from bika.lims import api |
|
24
|
|
|
from bika.lims import logger |
|
25
|
|
|
from senaite.core.interfaces import IMultiUploadFileCreator |
|
26
|
|
|
from senaite.core.interfaces import IMultiUploadFileRemover |
|
27
|
|
|
from senaite.core.schema.interfaces import IMultiUploadField |
|
28
|
|
|
from senaite.core.z3cform.widgets.multiupload.storage import get_storage |
|
29
|
|
|
from zope.component import getAdapter |
|
30
|
|
|
from zope.component import getMultiAdapter |
|
31
|
|
|
|
|
32
|
|
|
# Request attribute key for storing objects being processed |
|
33
|
|
|
UPLOAD_PROCESSING_KEY = "_multiupload_processing" |
|
34
|
|
|
UPLOAD_DELETING_KEY = "_multiupload_deleting" |
|
35
|
|
|
|
|
36
|
|
|
_marker = object() |
|
37
|
|
|
|
|
38
|
|
|
|
|
39
|
|
|
def on_object_added(obj, event): |
|
40
|
|
|
"""Event handler for when object is added to container |
|
41
|
|
|
|
|
42
|
|
|
(DX: IObjectAddedEvent, AT: IObjectInitializedEvent) |
|
43
|
|
|
|
|
44
|
|
|
We use IObjectAddedEvent instead of IObjectCreatedEvent because |
|
45
|
|
|
IObjectCreatedEvent fires before the object is added to its container. |
|
46
|
|
|
At that stage, the object has no UID, no acquisition chain, and cannot |
|
47
|
|
|
be used as a container to create File/Image child objects. |
|
48
|
|
|
|
|
49
|
|
|
IObjectAddedEvent fires after the object is fully added to the |
|
50
|
|
|
container and has a proper acquisition chain, allowing us to create |
|
51
|
|
|
child objects inside it. |
|
52
|
|
|
""" |
|
53
|
|
|
process_multiupload_fields(obj, event) |
|
54
|
|
|
|
|
55
|
|
|
|
|
56
|
|
|
def on_object_modified(obj, event): |
|
57
|
|
|
"""Event handler for object modification |
|
58
|
|
|
|
|
59
|
|
|
(DX: IObjectModifiedEvent, AT: IObjectEditedEvent) |
|
60
|
|
|
|
|
61
|
|
|
Processes MultiUploadField fields after the object is modified. |
|
62
|
|
|
Also handles deletion of removed File/Image objects. |
|
63
|
|
|
""" |
|
64
|
|
|
|
|
65
|
|
|
# Early check: does object have MultiUploadField fields? |
|
66
|
|
|
fields = get_multiupload_fields(obj) |
|
67
|
|
|
if not fields: |
|
68
|
|
|
return # No multiupload fields, skip entirely |
|
69
|
|
|
|
|
70
|
|
|
# Get request |
|
71
|
|
|
request = api.get_request() |
|
72
|
|
|
if not request: |
|
73
|
|
|
# Use test request if no real request is available (e.g., test setup) |
|
74
|
|
|
request = api.get_test_request() |
|
75
|
|
|
|
|
76
|
|
|
# Check for upload data OR submitted UIDs (for deletion detection) |
|
77
|
|
|
if not has_multiupload_activity(fields, request, obj): |
|
78
|
|
|
return # No multiupload activity |
|
79
|
|
|
|
|
80
|
|
|
# Prevent infinite recursion for deletion handler |
|
81
|
|
|
processing_objs = getattr(request, UPLOAD_DELETING_KEY, set()) |
|
82
|
|
|
|
|
83
|
|
|
obj_uid = api.get_uid(obj) |
|
84
|
|
|
if obj_uid in processing_objs: |
|
85
|
|
|
return |
|
86
|
|
|
|
|
87
|
|
|
logger.info("="*80) |
|
88
|
|
|
logger.info("on_object_modified called for {}".format(api.get_path(obj))) |
|
89
|
|
|
|
|
90
|
|
|
# Mark this object as being processed for deletions |
|
91
|
|
|
processing_objs.add(obj_uid) |
|
92
|
|
|
setattr(request, UPLOAD_DELETING_KEY, processing_objs) |
|
93
|
|
|
|
|
94
|
|
|
try: |
|
95
|
|
|
# Track current values before processing (for deletion detection) |
|
96
|
|
|
current_values = track_current_uids(fields, obj) |
|
97
|
|
|
|
|
98
|
|
|
# Process uploads (create new File/Image objects from UUIDs) |
|
99
|
|
|
process_multiupload_fields(obj, event) |
|
100
|
|
|
|
|
101
|
|
|
# Remove files that were deleted from fields |
|
102
|
|
|
remove_deleted_files(fields, obj, current_values, request) |
|
103
|
|
|
|
|
104
|
|
|
finally: |
|
105
|
|
|
# Always remove from processing set |
|
106
|
|
|
processing_objs.discard(obj_uid) |
|
107
|
|
|
setattr(request, UPLOAD_DELETING_KEY, processing_objs) |
|
108
|
|
|
|
|
109
|
|
|
|
|
110
|
|
|
def process_multiupload_fields(obj, event): |
|
111
|
|
|
"""Create File/Image objects from upload UUIDs |
|
112
|
|
|
|
|
113
|
|
|
This handler processes all MultiUploadField fields on the object and |
|
114
|
|
|
creates File/Image objects for any upload UUIDs that were submitted |
|
115
|
|
|
in the form request. |
|
116
|
|
|
|
|
117
|
|
|
Called for: |
|
118
|
|
|
- IObjectAddedEvent (DX) - after object added to container |
|
119
|
|
|
- IObjectModifiedEvent (DX) - after object modified |
|
120
|
|
|
""" |
|
121
|
|
|
# Prevent infinite recursion: check if we're already processing this object |
|
122
|
|
|
request = api.get_request() |
|
123
|
|
|
if not request: |
|
124
|
|
|
# Use test request if no real request is available (e.g., test setup) |
|
125
|
|
|
request = api.get_test_request() |
|
126
|
|
|
|
|
127
|
|
|
processing_objs = getattr(request, UPLOAD_PROCESSING_KEY, set()) |
|
128
|
|
|
|
|
129
|
|
|
obj_uid = api.get_uid(obj) |
|
130
|
|
|
if obj_uid in processing_objs: |
|
131
|
|
|
# Already processing this object, skip to prevent recursion |
|
132
|
|
|
return |
|
133
|
|
|
|
|
134
|
|
|
# Get all MultiUploadField fields - early return if none |
|
135
|
|
|
fields = get_multiupload_fields(obj) |
|
136
|
|
|
if not fields: |
|
137
|
|
|
return # No fields to process |
|
138
|
|
|
|
|
139
|
|
|
# Check for upload data - early return if none |
|
140
|
|
|
if not has_upload_uuids(fields, request, obj): |
|
141
|
|
|
return # No upload data |
|
142
|
|
|
|
|
143
|
|
|
# Determine form prefix (needed later for processing) |
|
144
|
|
|
form_prefix = "form.widgets." if api.is_dexterity_content(obj) else "" |
|
145
|
|
|
|
|
146
|
|
|
# NOW log (only if we're actually processing) |
|
147
|
|
|
logger.info("="*80) |
|
148
|
|
|
logger.info("process_multiupload_fields called for {}".format( |
|
149
|
|
|
api.get_path(obj))) |
|
150
|
|
|
|
|
151
|
|
|
# Mark this object as being processed |
|
152
|
|
|
processing_objs.add(obj_uid) |
|
153
|
|
|
setattr(request, UPLOAD_PROCESSING_KEY, processing_objs) |
|
154
|
|
|
|
|
155
|
|
|
try: |
|
156
|
|
|
# Get the shared upload storage (cluster-safe) |
|
157
|
|
|
storage = get_storage() |
|
158
|
|
|
|
|
159
|
|
|
# Process each MultiUploadField |
|
160
|
|
|
for name, field in fields.items(): |
|
161
|
|
|
logger.info("Processing MultiUploadField: {}".format(name)) |
|
162
|
|
|
|
|
163
|
|
|
# Get submitted UIDs from request (what user wants to keep) |
|
164
|
|
|
submitted_uids = get_submitted_uids( |
|
165
|
|
|
name, request, prefix=form_prefix, default=[]) |
|
166
|
|
|
|
|
167
|
|
|
logger.info("Field {} submitted UIDs: {}".format( |
|
168
|
|
|
name, submitted_uids)) |
|
169
|
|
|
|
|
170
|
|
|
# Parse upload UUIDs from request (new files to create) |
|
171
|
|
|
upload_uuids = get_upload_uuids(name, request, prefix=form_prefix) |
|
172
|
|
|
|
|
173
|
|
|
logger.info("Field {} upload UUIDs: {}".format( |
|
174
|
|
|
name, upload_uuids)) |
|
175
|
|
|
|
|
176
|
|
|
# Create File/Image objects for each upload UUID |
|
177
|
|
|
created_uids = [] |
|
178
|
|
|
for upload_uuid in upload_uuids: |
|
179
|
|
|
# Retrieve file data from shared storage |
|
180
|
|
|
file_data = storage.retrieve(upload_uuid) |
|
181
|
|
|
if not file_data: |
|
182
|
|
|
logger.warning( |
|
183
|
|
|
"Upload data for UUID {} not found in storage" |
|
184
|
|
|
.format(upload_uuid)) |
|
185
|
|
|
continue |
|
186
|
|
|
|
|
187
|
|
|
# Create file object using adapter |
|
188
|
|
|
uid = create_file_object(obj, field, upload_uuid, file_data) |
|
189
|
|
|
if uid: |
|
190
|
|
|
created_uids.append(uid) |
|
191
|
|
|
# Mark created object as processing |
|
192
|
|
|
processing_objs.add(uid) |
|
193
|
|
|
# Remove from storage after successful creation |
|
194
|
|
|
storage.remove(upload_uuid) |
|
195
|
|
|
|
|
196
|
|
|
# Combine submitted UIDs (existing) with newly created UIDs |
|
197
|
|
|
new_value = submitted_uids + created_uids |
|
198
|
|
|
logger.info("Updating field {} with value: {}".format( |
|
199
|
|
|
name, new_value)) |
|
200
|
|
|
field.set(obj, new_value) |
|
201
|
|
|
|
|
202
|
|
|
finally: |
|
203
|
|
|
# Always remove from processing set, even if an error occurred |
|
204
|
|
|
processing_objs.discard(obj_uid) |
|
205
|
|
|
setattr(request, UPLOAD_PROCESSING_KEY, processing_objs) |
|
206
|
|
|
|
|
207
|
|
|
|
|
208
|
|
|
def get_upload_uuids(field_name, request, prefix=""): |
|
209
|
|
|
"""Parse upload UUIDs from request data |
|
210
|
|
|
|
|
211
|
|
|
:param field_name: Name of the field |
|
212
|
|
|
:param request: The request object |
|
213
|
|
|
:param prefix: Form field prefix |
|
214
|
|
|
""" |
|
215
|
|
|
# Get upload UUIDs from request using <fieldname>.data key |
|
216
|
|
|
data_key = "{}{}.data".format(prefix, field_name) |
|
217
|
|
|
data_value = request.get(data_key, "") |
|
218
|
|
|
|
|
219
|
|
|
if not data_value: |
|
220
|
|
|
return [] |
|
221
|
|
|
|
|
222
|
|
|
try: |
|
223
|
|
|
upload_data = json.loads(data_value) |
|
224
|
|
|
if isinstance(upload_data, list): |
|
225
|
|
|
# Filter out empty values and ensure strings |
|
226
|
|
|
uuids = [api.safe_unicode(uuid) for uuid in upload_data if uuid] |
|
227
|
|
|
logger.info("Parsed {} UUIDs for field {}".format( |
|
228
|
|
|
len(uuids), field_name)) |
|
229
|
|
|
return uuids |
|
230
|
|
|
except (ValueError, TypeError) as e: |
|
231
|
|
|
logger.error("Error parsing upload data for field {}: {}".format( |
|
232
|
|
|
field_name, str(e))) |
|
233
|
|
|
|
|
234
|
|
|
return [] |
|
235
|
|
|
|
|
236
|
|
|
|
|
237
|
|
|
def get_submitted_uids(field_name, request, prefix="", default=_marker): |
|
238
|
|
|
"""Get submitted UIDs from request (what user wants to keep) |
|
239
|
|
|
|
|
240
|
|
|
:param field_name: Name of the field |
|
241
|
|
|
:param request: The request object |
|
242
|
|
|
:param prefix: Form field prefix |
|
243
|
|
|
:returns: List of submitted UIDs |
|
244
|
|
|
""" |
|
245
|
|
|
# Get main field value from request (contains existing UIDs) |
|
246
|
|
|
field_key = "{}{}".format(prefix, field_name) |
|
247
|
|
|
field_value = request.get(field_key, "") |
|
248
|
|
|
|
|
249
|
|
|
if not field_value: |
|
250
|
|
|
return default |
|
251
|
|
|
|
|
252
|
|
|
# Split by newlines and filter UIDs |
|
253
|
|
|
submitted = [] |
|
254
|
|
|
for item in field_value.split("\r\n"): |
|
255
|
|
|
item = item.strip() |
|
256
|
|
|
if item and api.is_uid(item): |
|
257
|
|
|
submitted.append(item) |
|
258
|
|
|
|
|
259
|
|
|
return submitted |
|
260
|
|
|
|
|
261
|
|
|
|
|
262
|
|
|
def create_file_object(obj, field, upload_uuid, file_data): |
|
263
|
|
|
"""Create a File or Image object from upload data |
|
264
|
|
|
|
|
265
|
|
|
:param obj: The container object |
|
266
|
|
|
:param field: The field being processed |
|
267
|
|
|
:param upload_uuid: UUID of the upload |
|
268
|
|
|
:param file_data: Dictionary with file data (data, filename, content_type) |
|
269
|
|
|
:returns: UID of created object or None on error |
|
270
|
|
|
""" |
|
271
|
|
|
try: |
|
272
|
|
|
data = file_data["data"] |
|
273
|
|
|
filename = api.safe_unicode(file_data["filename"]) |
|
274
|
|
|
content_type = file_data["content_type"] |
|
275
|
|
|
|
|
276
|
|
|
# Get the file creator adapter |
|
277
|
|
|
creator = getMultiAdapter( |
|
278
|
|
|
(obj, field), |
|
279
|
|
|
IMultiUploadFileCreator |
|
280
|
|
|
) |
|
281
|
|
|
|
|
282
|
|
|
# Create the File/Image object using the adapter |
|
283
|
|
|
file_obj = creator.create(filename, content_type, data) |
|
284
|
|
|
uid = api.get_uid(file_obj) |
|
285
|
|
|
|
|
286
|
|
|
logger.info("Created object with UID {} for UUID {}".format( |
|
287
|
|
|
uid, upload_uuid)) |
|
288
|
|
|
return uid |
|
289
|
|
|
|
|
290
|
|
|
except Exception as e: |
|
291
|
|
|
import traceback |
|
292
|
|
|
filename = file_data.get("filename", "unknown") |
|
293
|
|
|
logger.error(u"Error creating object {}: {}".format( |
|
294
|
|
|
filename, api.safe_unicode(str(e)))) |
|
295
|
|
|
logger.error(traceback.format_exc()) |
|
296
|
|
|
return None |
|
297
|
|
|
|
|
298
|
|
|
|
|
299
|
|
|
def get_multiupload_fields(obj): |
|
300
|
|
|
"""Get all MultiUploadField fields from object |
|
301
|
|
|
|
|
302
|
|
|
:param obj: The object to get fields from |
|
303
|
|
|
:returns: Dictionary of field name -> field pairs |
|
304
|
|
|
""" |
|
305
|
|
|
fields = api.get_fields(obj) |
|
306
|
|
|
return {name: field for name, field in fields.items() |
|
307
|
|
|
if IMultiUploadField.providedBy(field)} |
|
308
|
|
|
|
|
309
|
|
|
|
|
310
|
|
|
def has_multiupload_activity(fields, request, obj): |
|
311
|
|
|
"""Check if there is any multiupload activity in the request |
|
312
|
|
|
|
|
313
|
|
|
Checks for: |
|
314
|
|
|
- New upload UUIDs in request (field.data parameter) |
|
315
|
|
|
- Field submissions for deletion detection (field parameter) |
|
316
|
|
|
|
|
317
|
|
|
:param fields: Dictionary of field name -> field pairs |
|
318
|
|
|
:param request: The request object |
|
319
|
|
|
:param obj: The object being processed |
|
320
|
|
|
:returns: True if there's multiupload activity, False otherwise |
|
321
|
|
|
""" |
|
322
|
|
|
if not fields: |
|
323
|
|
|
return False |
|
324
|
|
|
|
|
325
|
|
|
# Determine form prefix based on content type |
|
326
|
|
|
form_prefix = "form.widgets." if api.is_dexterity_content(obj) else "" |
|
327
|
|
|
|
|
328
|
|
|
for name in fields.keys(): |
|
329
|
|
|
# Check for new uploads |
|
330
|
|
|
data_key = "{}{}.data".format(form_prefix, name) |
|
331
|
|
|
if request.get(data_key): |
|
332
|
|
|
return True |
|
333
|
|
|
|
|
334
|
|
|
# Check for field submission (deletion scenario) |
|
335
|
|
|
field_key = "{}{}".format(form_prefix, name) |
|
336
|
|
|
if field_key in request.form: |
|
337
|
|
|
return True |
|
338
|
|
|
|
|
339
|
|
|
return False |
|
340
|
|
|
|
|
341
|
|
|
|
|
342
|
|
|
def has_upload_uuids(fields, request, obj): |
|
343
|
|
|
"""Check if there are upload UUIDs in the request |
|
344
|
|
|
|
|
345
|
|
|
Only checks for new upload UUIDs (field.data parameter), |
|
346
|
|
|
not field submissions. |
|
347
|
|
|
|
|
348
|
|
|
:param fields: Dictionary of field name -> field pairs |
|
349
|
|
|
:param request: The request object |
|
350
|
|
|
:param obj: The object being processed |
|
351
|
|
|
:returns: True if there are upload UUIDs, False otherwise |
|
352
|
|
|
""" |
|
353
|
|
|
if not fields: |
|
354
|
|
|
return False |
|
355
|
|
|
|
|
356
|
|
|
# Determine form prefix based on content type |
|
357
|
|
|
form_prefix = "form.widgets." if api.is_dexterity_content(obj) else "" |
|
358
|
|
|
|
|
359
|
|
|
for name in fields.keys(): |
|
360
|
|
|
data_key = "{}{}.data".format(form_prefix, name) |
|
361
|
|
|
if request.get(data_key): |
|
362
|
|
|
return True |
|
363
|
|
|
|
|
364
|
|
|
return False |
|
365
|
|
|
|
|
366
|
|
|
|
|
367
|
|
|
def track_current_uids(fields, obj): |
|
368
|
|
|
"""Track current UIDs before processing |
|
369
|
|
|
|
|
370
|
|
|
:param fields: Dictionary of field name -> field pairs |
|
371
|
|
|
:param obj: The object containing the fields |
|
372
|
|
|
:returns: Dictionary of field name -> list of current UIDs |
|
373
|
|
|
""" |
|
374
|
|
|
current_values = {} |
|
375
|
|
|
for name, field in fields.items(): |
|
376
|
|
|
current_uids = get_current_uids(field, obj) |
|
377
|
|
|
current_values[name] = current_uids |
|
378
|
|
|
logger.info("Field {} current UIDs: {}".format(name, current_uids)) |
|
379
|
|
|
return current_values |
|
380
|
|
|
|
|
381
|
|
|
|
|
382
|
|
|
def get_current_uids(field, obj): |
|
383
|
|
|
"""Get current UIDs stored in field |
|
384
|
|
|
|
|
385
|
|
|
:param field: The field to read |
|
386
|
|
|
:param obj: The object containing the field |
|
387
|
|
|
:returns: List of current UIDs in field |
|
388
|
|
|
""" |
|
389
|
|
|
current_value = field.get(obj) or [] |
|
390
|
|
|
return list(map(api.get_uid, current_value)) |
|
391
|
|
|
|
|
392
|
|
|
|
|
393
|
|
|
def remove_deleted_files(fields, obj, current_values, request): |
|
394
|
|
|
"""Remove files that were deleted from fields |
|
395
|
|
|
|
|
396
|
|
|
Compares current UIDs (what's in the field now) with submitted UIDs |
|
397
|
|
|
(what the user wants to keep) to determine which files to delete. |
|
398
|
|
|
|
|
399
|
|
|
:param fields: Dictionary of field name -> field pairs |
|
400
|
|
|
:param obj: The object containing the fields |
|
401
|
|
|
:param current_values: Dictionary of current UIDs per field |
|
402
|
|
|
:param request: The request object |
|
403
|
|
|
""" |
|
404
|
|
|
form_prefix = "" |
|
405
|
|
|
if api.is_dexterity_content(obj): |
|
406
|
|
|
form_prefix = "form.widgets." |
|
407
|
|
|
|
|
408
|
|
|
for name, field in fields.items(): |
|
409
|
|
|
# Get submitted UIDs (what user wants to keep) |
|
410
|
|
|
submitted_uids = get_submitted_uids(name, request, prefix=form_prefix) |
|
411
|
|
|
if submitted_uids is _marker: |
|
412
|
|
|
# field is missing in request, which means we are not coming from a |
|
413
|
|
|
# form submission, but rather from a programmatic modification |
|
414
|
|
|
continue |
|
415
|
|
|
logger.info("Field {} submitted UIDs: {}".format( |
|
416
|
|
|
name, submitted_uids)) |
|
417
|
|
|
|
|
418
|
|
|
# Get new field value after processing (includes newly created) |
|
419
|
|
|
new_uids = get_current_uids(field, obj) |
|
420
|
|
|
logger.info("Field {} new UIDs: {}".format(name, new_uids)) |
|
421
|
|
|
|
|
422
|
|
|
# Find removed UIDs: what was there before but not submitted |
|
423
|
|
|
current_uids = current_values.get(name, []) |
|
424
|
|
|
removed_uids = set(current_uids) - set(submitted_uids) |
|
425
|
|
|
|
|
426
|
|
|
if removed_uids: |
|
427
|
|
|
logger.info("Field {}: Deleting {} removed file(s): {}".format( |
|
428
|
|
|
name, len(removed_uids), removed_uids)) |
|
429
|
|
|
|
|
430
|
|
|
# Use the remover adapter to delete the files |
|
431
|
|
|
remover = getAdapter(obj, IMultiUploadFileRemover) |
|
432
|
|
|
remover.remove(removed_uids) |
|
433
|
|
|
|