-
-
Notifications
You must be signed in to change notification settings - Fork 163
/
fields.py
447 lines (395 loc) · 16.1 KB
/
fields.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
# Copyright 2023 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
# pylint: disable=method-required-super
import base64
import itertools
import mimetypes
import os.path
from io import BytesIO, IOBase
from odoo import fields
from odoo.tools.mimetypes import guess_mimetype
from odoo.addons.fs_attachment.models.ir_attachment import IrAttachment
class FSFileValue:
def __init__(
self,
attachment: IrAttachment = None,
name: str = None,
value: bytes | IOBase = None,
) -> None:
"""
This class holds the information related to FSFile field. It can be
used to assign a value to a FSFile field. In such a case, you can pass
the name and the file content as parameters.
When
:param attachment: the attachment to use to store the file.
:param name: the name of the file. If not provided, the name will be
taken from the attachment or the io.IOBase.
:param value: the content of the file. It can be bytes or an io.IOBase.
"""
self._is_new: bool = attachment is None
self._buffer: IOBase = None
self._attachment: IrAttachment = attachment
if name and attachment:
raise ValueError("Cannot set name and attachment at the same time")
if value:
if isinstance(value, IOBase):
self._buffer = value
if not hasattr(value, "name"):
if name:
self._buffer.name = name
else:
raise ValueError(
"name must be set when value is an io.IOBase "
"and is not provided by the io.IOBase"
)
elif isinstance(value, bytes):
self._buffer = BytesIO(value)
if not name:
raise ValueError("name must be set when value is bytes")
self._buffer.name = name
else:
raise ValueError("value must be bytes or io.BytesIO")
elif name:
self._buffer = BytesIO(b"")
self._buffer.name = name
@property
def write_buffer(self) -> BytesIO:
if self._buffer is None:
name = self._attachment.name if self._attachment else None
self._buffer = BytesIO()
self._buffer.name = name
return self._buffer
@property
def name(self) -> str | None:
name = (
self._attachment.name
if self._attachment
else self._buffer.name
if self._buffer
else None
)
if name:
return os.path.basename(name)
return None
@name.setter
def name(self, value: str) -> None:
# the name should only be updatable while the file is not yet stored
# TODO, we could also allow to update the name of the file and rename
# the file in the external file system
if self._is_new:
self.write_buffer.name = value
else:
raise ValueError(
"The name of the file can only be updated while the file is not "
"yet stored"
)
@property
def is_new(self) -> bool:
return self._is_new
@property
def mimetype(self) -> str | None:
"""Return the mimetype of the file.
If an attachment is set, the mimetype is taken from the attachment.
If no attachment is set, the mimetype is guessed from the name of the
file.
If no name is set or if the mimetype cannot be guessed from the name,
the mimetype is guessed from the content of the file.
"""
mimetype = None
if self._attachment:
mimetype = self._attachment.mimetype
elif self.name:
mimetype = mimetypes.guess_type(self.name)[0]
# at last, try to guess the mimetype from the content
return mimetype or guess_mimetype(self.getvalue())
@property
def size(self) -> int:
if self._attachment:
return self._attachment.file_size
# check if the object supports len
try:
return len(self._buffer)
except TypeError: # pylint: disable=except-pass
# the object does not support len
pass
# if we are on a BytesIO, we can get the size from the buffer
if isinstance(self._buffer, BytesIO):
return self._buffer.getbuffer().nbytes
# we cannot get the size
return 0
@property
def url(self) -> str | None:
return self._attachment.fs_url or None if self._attachment else None
@property
def internal_url(self) -> str | None:
return self._attachment.internal_url or None if self._attachment else None
@property
def url_path(self) -> str | None:
return self._attachment.fs_url_path or None if self._attachment else None
@property
def attachment(self) -> IrAttachment | None:
return self._attachment
@attachment.setter
def attachment(self, value: IrAttachment) -> None:
self._attachment = value
self._buffer = None
@property
def extension(self) -> str | None:
# get extension from mimetype
ext = os.path.splitext(self.name)[1]
if not ext:
ext = mimetypes.guess_extension(self.mimetype)
ext = ext and ext[1:]
return ext
@property
def read_buffer(self) -> BytesIO:
if self._buffer is None:
content = b""
name = None
if self._attachment:
content = self._attachment.raw or b""
name = self._attachment.name
self._buffer = BytesIO(content)
self._buffer.name = name
return self._buffer
def getvalue(self) -> bytes:
buffer = self.read_buffer
current_pos = buffer.tell()
buffer.seek(0)
value = buffer.read()
buffer.seek(current_pos)
return value
def open(
self,
mode="rb",
block_size=None,
cache_options=None,
compression=None,
new_version=True,
**kwargs
) -> IOBase:
"""
Return a file-like object that can be used to read and write the file content.
See the documentation of open() into the ir.attachment model from the
fs_attachment module for more information.
"""
if not self._attachment:
raise ValueError("Cannot open a file that is not stored")
return self._attachment.open(
mode=mode,
block_size=block_size,
cache_options=cache_options,
compression=compression,
new_version=new_version,
**kwargs,
)
class FSFile(fields.Binary):
"""
This field is a binary field that stores the file content in an external
filesystem storage referenced by a storage code.
A major difference with the standard Odoo binary field is that the value
is not encoded in base64 but is a bytes object.
Moreover, the field is designed to always return an instance of
:class:`FSFileValue` when reading the value. This class is a file-like
object that can be used to read the file content and to get information
about the file (filename, mimetype, url, ...).
To update the value of the field, the following values are accepted:
- a bytes object (e.g. ``b"..."``)
- a dict with the following keys:
- ``filename``: the filename of the file
- ``content``: the content of the file encoded in base64
- a FSFileValue instance
- a file-like object (e.g. an instance of :class:`io.BytesIO`)
When the value is provided is a bytes object the filename is set to the
name of the field. You can override this behavior by providing specifying
a fs_filename key in the context. For example:
.. code-block:: python
record.with_context(fs_filename='my_file.txt').write({
'field': b'...',
})
The same applies when the value is provided as a file-like object but the
filename is set to the name of the file-like object or not a property of
the file-like object. (e.g. ``io.BytesIO(b'...')``).
When the value is converted to the read format, it's always an instance of
dict with the following keys:
- ``filename``: the filename of the file
- ``mimetype``: the mimetype of the file
- ``size``: the size of the file
- ``url``: the url to access the file
"""
type = "fs_file"
attachment: bool = True
def __init__(self, *args, **kwargs):
kwargs["attachment"] = True
super().__init__(*args, **kwargs)
def read(self, records):
domain = [
("res_model", "=", records._name),
("res_field", "=", self.name),
("res_id", "in", records.ids),
]
data = {
att.res_id: self._convert_attachment_to_cache(att)
for att in records.env["ir.attachment"].sudo().search(domain)
}
records.env.cache.insert_missing(records, self, map(data.get, records._ids))
def create(self, record_values):
if not record_values:
return
env = record_values[0][0].env
with env.norecompute():
for record, value in record_values:
if value:
cache_value = self.convert_to_cache(value, record)
attachment = self._create_attachment(record, cache_value)
cache_value = self._convert_attachment_to_cache(attachment)
record.env.cache.update(
record,
self,
[cache_value],
dirty=False,
)
def _create_attachment(self, record, cache_value: FSFileValue):
ir_attachment = (
record.env["ir.attachment"]
.sudo()
.with_context(
binary_field_real_user=record.env.user,
)
)
create_value = self._prepare_attachment_create_values(record, cache_value)
return ir_attachment.create(create_value)
def _prepare_attachment_create_values(self, record, cache_value: FSFileValue):
return {
"name": cache_value.name,
"raw": cache_value.getvalue(),
"res_model": record._name,
"res_field": self.name,
"res_id": record.id,
"type": "binary",
}
def write(self, records, value):
# the code is copied from the standard Odoo Binary field
# with the following changes:
# - the value is not encoded in base64 and we therefore write on
# ir.attachment.raw instead of ir.attachment.datas
# discard recomputation of self on records
records.env.remove_to_compute(self, records)
# update the cache, and discard the records that are not modified
cache = records.env.cache
cache_value = self.convert_to_cache(value, records)
records = cache.get_records_different_from(records, self, cache_value)
if not records:
return records
if self.store:
# determine records that are known to be not null
not_null = cache.get_records_different_from(records, self, None)
if self.store:
# Be sure to invalidate the cache for the modified records since
# the value of the field has changed and the new value will be linked
# to the attachment record used to store the file in the storage.
cache.remove(records, self)
else:
# if the field is not stored and a value is set, we need to
# set the value in the cache since the value (the case for computed
# fields)
cache.update(records, self, itertools.repeat(cache_value))
# retrieve the attachments that store the values, and adapt them
if self.store and any(records._ids):
real_records = records.filtered("id")
atts = (
records.env["ir.attachment"]
.sudo()
.with_context(
binary_field_real_user=records.env.user,
)
)
if not_null:
atts = atts.search(
[
("res_model", "=", self.model_name),
("res_field", "=", self.name),
("res_id", "in", real_records.ids),
]
)
if value:
filename = cache_value.name
content = cache_value.getvalue()
# update the existing attachments
atts.write({"raw": content, "name": filename})
atts_records = records.browse(atts.mapped("res_id"))
# set new value in the cache since we have the reference to the
# attachment record and a new access to the field will nomore
# require to load the attachment record
for record in atts_records:
new_cache_value = self._convert_attachment_to_cache(
atts.filtered(lambda att: att.res_id == record.id)
)
cache.update(record, self, [new_cache_value], dirty=False)
# create the missing attachments
missing = real_records - atts_records
if missing:
created = atts.browse()
for record in missing:
created |= self._create_attachment(record, cache_value)
for att in created:
record = records.browse(att.res_id)
new_cache_value = self._convert_attachment_to_cache(att)
record.env.cache.update(
record, self, [new_cache_value], dirty=False
)
else:
atts.unlink()
return records
def _convert_attachment_to_cache(self, attachment: IrAttachment) -> FSFileValue:
return FSFileValue(attachment=attachment)
def _get_filename(self, record):
return record.env.context.get("fs_filename", self.name)
def convert_to_cache(self, value, record, validate=True):
if value is None or value is False:
return None
if isinstance(value, FSFileValue):
return value
if isinstance(value, dict):
if "content" not in value and value.get("url"):
# we come from an onchange
# The id is the third element of the url
att_id = value["url"].split("/")[3]
attachment = record.env["ir.attachment"].sudo().browse(int(att_id))
return self._convert_attachment_to_cache(attachment)
return FSFileValue(
name=value["filename"], value=base64.b64decode(value["content"])
)
if isinstance(value, IOBase):
name = getattr(value, "name", None)
if name is None:
name = self._get_filename(record)
return FSFileValue(name=name, value=value)
if isinstance(value, bytes):
return FSFileValue(
name=self._get_filename(record), value=base64.b64decode(value)
)
raise ValueError(
"Invalid value for %s: %r\n"
"Should be base64 encoded bytes or a file-like object" % (self, value)
)
def convert_to_write(self, value, record):
return self.convert_to_cache(value, record)
def convert_to_read(self, value, record, use_name_get=True):
if value is None or value is False:
return None
if isinstance(value, FSFileValue):
res = {
"filename": value.name,
"size": value.size,
"mimetype": value.mimetype,
}
if value.attachment:
res["url"] = value.internal_url
else:
res["content"] = base64.b64encode(value.getvalue()).decode("ascii")
return res
raise ValueError(
"Invalid value for %s: %r\n"
"Should be base64 encoded bytes or a file-like object" % (self, value)
)