Passed
Push — 2.x ( 28c280...249d5a )
by Jordi
10:49 queued 04:29
created

senaite.core.subscribers.multiupload   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 433
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 47
eloc 189
dl 0
loc 433
rs 8.64
c 0
b 0
f 0

12 Functions

Rating   Name   Duplication   Size   Complexity  
A on_object_added() 0 15 1
A create_file_object() 0 35 2
A get_submitted_uids() 0 23 5
A get_current_uids() 0 9 1
B remove_deleted_files() 0 40 5
A track_current_uids() 0 13 2
A get_multiupload_fields() 0 9 1
B has_multiupload_activity() 0 30 6
B on_object_modified() 0 52 5
C process_multiupload_fields() 0 96 10
A get_upload_uuids() 0 27 4
A has_upload_uuids() 0 23 5

How to fix   Complexity   

Complexity

Complex classes like senaite.core.subscribers.multiupload often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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