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

senaite.core.z3cform.widgets.multiupload.storage   A

Complexity

Total Complexity 23

Size/Duplication

Total Lines 272
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 23
eloc 107
dl 0
loc 272
rs 10
c 0
b 0
f 0

11 Methods

Rating   Name   Duplication   Size   Complexity  
A UploadRecord.__init__() 0 13 1
A ITemporaryUploadStorage.cleanup() 0 2 1
A TemporaryUploadStorage.retrieve() 0 28 3
A ITemporaryUploadStorage.store() 0 2 1
A ITemporaryUploadStorage.remove() 0 2 1
A TemporaryUploadStorage.__init__() 0 6 1
B TemporaryUploadStorage.cleanup() 0 35 6
A ITemporaryUploadStorage.retrieve() 0 2 1
A TemporaryUploadStorage.remove() 0 19 3
A TemporaryUploadStorage._get_container() 0 13 2
A TemporaryUploadStorage.store() 0 41 2

1 Function

Rating   Name   Duplication   Size   Complexity  
A get_storage() 0 9 1
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 time
22
23
from bika.lims import api
24
from BTrees.OOBTree import OOBTree
25
from persistent import Persistent
26
from senaite.core import logger
27
from zope.annotation.interfaces import IAnnotations
28
from zope.interface import Interface
29
from zope.interface import implementer
30
31
__doc__ = """
32
Temporary upload storage for multiupload widget
33
34
This module provides a cluster-safe storage mechanism for temporarily
35
storing uploaded files before they are converted to actual File/Image
36
objects. Unlike SESSION storage, this storage is properly shared across
37
all ZEO clients via ZODB.
38
"""
39
40
# Storage key in portal annotations
41
STORAGE_KEY = "senaite.core.multiupload.storage"
42
43
# Default expiration time for uploads (2 hours)
44
DEFAULT_EXPIRATION_SECONDS = 7200
45
46
47
class ITemporaryUploadStorage(Interface):
48
    """Interface for temporary upload storage"""
49
50
    def store(upload_id, filename, content_type, data):
51
        """Store uploaded file data
52
53
        :param upload_id: Unique identifier for the upload
54
        :param filename: Name of the uploaded file
55
        :param content_type: MIME type of the file
56
        :param data: Binary file data
57
        :returns: True if stored successfully
58
        """
59
60
    def retrieve(upload_id):
61
        """Retrieve uploaded file data
62
63
        :param upload_id: Unique identifier for the upload
64
        :returns: Dictionary with file data or None if not found
65
        """
66
67
    def remove(upload_id):
68
        """Remove uploaded file data
69
70
        :param upload_id: Unique identifier for the upload
71
        :returns: True if removed successfully
72
        """
73
74
    def cleanup(max_age_seconds=DEFAULT_EXPIRATION_SECONDS):
75
        """Remove uploads older than specified age
76
77
        :param max_age_seconds: Maximum age in seconds
78
        :returns: Number of uploads removed
79
        """
80
81
82
@implementer(ITemporaryUploadStorage)
83
class TemporaryUploadStorage(object):
84
    """Cluster-safe temporary storage for uploaded files
85
86
    This implementation uses portal annotations to store uploads in a
87
    shared ZODB container that is accessible to all ZEO clients.
88
89
    Each upload is stored with a unique UUID key, preventing conflicts
90
    between concurrent uploads from different users.
91
    """
92
93
    def __init__(self, context=None):
94
        """Initialize the storage
95
96
        :param context: Context object (usually portal)
97
        """
98
        self.context = context or api.get_portal()
99
100
    def _get_container(self):
101
        """Get or create the storage container
102
103
        Uses OOBTree which has built-in ZODB conflict resolution for
104
        concurrent writes of different keys.
105
106
        :returns: OOBTree container for uploads
107
        """
108
        annotations = IAnnotations(self.context)
109
        if STORAGE_KEY not in annotations:
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable STORAGE_KEY does not seem to be defined.
Loading history...
110
            container = OOBTree()
111
            annotations[STORAGE_KEY] = container
112
        return annotations[STORAGE_KEY]
113
114
    def store(self, upload_id, filename, content_type, data):
115
        """Store uploaded file data
116
117
        Thread-safe and cluster-safe. Multiple concurrent uploads with
118
        different upload_ids will not conflict.
119
120
        Also triggers automatic cleanup of expired uploads periodically.
121
122
        :param upload_id: Unique identifier for the upload
123
        :param filename: Name of the uploaded file
124
        :param content_type: MIME type of the file
125
        :param data: Binary file data
126
        :returns: True if stored successfully
127
        """
128
        try:
129
            self.cleanup()
130
131
            container = self._get_container()
132
133
            # Create upload record
134
            upload_record = UploadRecord(
135
                upload_id=upload_id,
136
                filename=filename,
137
                content_type=content_type,
138
                data=data
139
            )
140
141
            # Store in container - OOBTree handles concurrent additions
142
            container[upload_id] = upload_record
143
144
            logger.info(
145
                u"Stored upload {} for file {} ({} bytes)".format(
146
                    upload_id, filename, len(data)))
147
148
            return True
149
150
        except Exception as e:
151
            logger.error(
152
                u"Failed to store upload {}: {}".format(
153
                    upload_id, api.safe_unicode(str(e))))
154
            return False
155
156
    def retrieve(self, upload_id):
157
        """Retrieve uploaded file data
158
159
        :param upload_id: Unique identifier for the upload
160
        :returns: Dictionary with file data or None if not found
161
        """
162
        try:
163
            container = self._get_container()
164
            record = container.get(upload_id)
165
166
            if record is None:
167
                logger.warning(
168
                    u"Upload {} not found in storage".format(upload_id))
169
                return None
170
171
            # Return as dictionary
172
            return {
173
                "filename": record.filename,
174
                "content_type": record.content_type,
175
                "data": record.data,
176
                "timestamp": record.timestamp
177
            }
178
179
        except Exception as e:
180
            logger.error(
181
                u"Failed to retrieve upload {}: {}".format(
182
                    upload_id, api.safe_unicode(str(e))))
183
            return None
184
185
    def remove(self, upload_id):
186
        """Remove uploaded file data
187
188
        :param upload_id: Unique identifier for the upload
189
        :returns: True if removed successfully
190
        """
191
        try:
192
            container = self._get_container()
193
            if upload_id in container:
194
                del container[upload_id]
195
                logger.info(u"Removed upload {}".format(upload_id))
196
                return True
197
            return False
198
199
        except Exception as e:
200
            logger.error(
201
                u"Failed to remove upload {}: {}".format(
202
                    upload_id, api.safe_unicode(str(e))))
203
            return False
204
205
    def cleanup(self, max_age_seconds=DEFAULT_EXPIRATION_SECONDS):
206
        """Remove uploads older than specified age
207
208
        This should be called periodically (e.g., via cron job) to
209
        prevent the storage from growing indefinitely.
210
211
        :param max_age_seconds: Maximum age in seconds
212
        :returns: Number of uploads removed
213
        """
214
        try:
215
            container = self._get_container()
216
            cutoff_time = time.time() - max_age_seconds
217
            to_remove = []
218
219
            # Find expired uploads
220
            for upload_id, record in container.items():
221
                if getattr(record, "timestamp", 0) < cutoff_time:
222
                    to_remove.append(upload_id)
223
224
            # Remove expired uploads
225
            for upload_id in to_remove:
226
                del container[upload_id]
227
228
            if to_remove:
229
                logger.info(
230
                    u"Cleaned up {} expired upload(s)".format(
231
                        len(to_remove)))
232
233
            return len(to_remove)
234
235
        except Exception as e:
236
            logger.error(
237
                u"Failed to cleanup uploads: {}".format(
238
                    api.safe_unicode(str(e))))
239
            return 0
240
241
242
class UploadRecord(Persistent):
243
    """Persistent record for a single upload
244
245
    Stores file data and metadata in ZODB.
246
    """
247
248
    def __init__(self, upload_id, filename, content_type, data):
249
        """Initialize upload record
250
251
        :param upload_id: Unique identifier
252
        :param filename: File name
253
        :param content_type: MIME type
254
        :param data: Binary file data
255
        """
256
        self.upload_id = upload_id
257
        self.filename = filename
258
        self.content_type = content_type
259
        self.data = data
260
        self.timestamp = time.time()
261
262
263
def get_storage(context=None):
264
    """Get the temporary upload storage utility
265
266
    Convenience function to get storage instance.
267
268
    :param context: Optional context object
269
    :returns: ITemporaryUploadStorage implementation
270
    """
271
    return TemporaryUploadStorage(context)
272