dtcloud里面的附件预览支持的有两种
1、支持图片预览
图片预览:host+model+id+field名字
http://127.0.0.1:8088/web/image?model=hr.employee&id=20&field=avatar_128
效果图 :
2、支持pdf预览
链接:/web/content/%s/%s’ % (attach.id, attach.name)
http://82.156.19.94:8085/web/content/1011/一种3DES加密方法.pdf
dtcloud里面支持文件下载
http://82.156.19.94:8085/web/content/1011?download=true
具体源代码
class Binary(http.Controller):
@http.route(['/web/content',
'/web/content/<string:xmlid>',
'/web/content/<string:xmlid>/<string:filename>',
'/web/content/<int:id>',
'/web/content/<int:id>/<string:filename>',
'/web/content/<string:model>/<int:id>/<string:field>',
'/web/content/<string:model>/<int:id>/<string:field>/<string:filename>'], type='http', auth="public")
def content_common(self, xmlid=None, model='ir.attachment', id=None, field='datas',
filename=None, filename_field='name', unique=None, mimetype=None,
download=None, data=None, token=None, access_token=None, **kw):
return request.env['ir.http']._get_content_common(xmlid=xmlid, model=model, res_id=id, field=field, unique=unique, filename=filename,
filename_field=filename_field, download=download, mimetype=mimetype, access_token=access_token, token=token)
@http.route(['/web/assets/debug/<string:filename>',
'/web/assets/debug/<path:extra>/<string:filename>',
'/web/assets/<int:id>/<string:filename>',
'/web/assets/<int:id>-<string:unique>/<string:filename>',
'/web/assets/<int:id>-<string:unique>/<path:extra>/<string:filename>'], type='http', auth="public")
def content_assets(self, id=None, filename=None, unique=None, extra=None, **kw):
id = id or request.env['ir.attachment'].sudo().search_read(
[('url', '=like', f'/web/assets/%/{extra}/{filename}' if extra else f'/web/assets/%/{filename}')],
fields=['id'], limit=1)[0]['id']
return request.env['ir.http']._get_content_common(xmlid=None, model='ir.attachment', res_id=id, field='datas', unique=unique, filename=filename,
filename_field='name', download=None, mimetype=None, access_token=None, token=None)
@http.route(['/web/image',
'/web/image/<string:xmlid>',
'/web/image/<string:xmlid>/<string:filename>',
'/web/image/<string:xmlid>/<int:width>x<int:height>',
'/web/image/<string:xmlid>/<int:width>x<int:height>/<string:filename>',
'/web/image/<string:model>/<int:id>/<string:field>',
'/web/image/<string:model>/<int:id>/<string:field>/<string:filename>',
'/web/image/<string:model>/<int:id>/<string:field>/<int:width>x<int:height>',
'/web/image/<string:model>/<int:id>/<string:field>/<int:width>x<int:height>/<string:filename>',
'/web/image/<int:id>',
'/web/image/<int:id>/<string:filename>',
'/web/image/<int:id>/<int:width>x<int:height>',
'/web/image/<int:id>/<int:width>x<int:height>/<string:filename>',
'/web/image/<int:id>-<string:unique>',
'/web/image/<int:id>-<string:unique>/<string:filename>',
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>',
'/web/image/<int:id>-<string:unique>/<int:width>x<int:height>/<string:filename>'], type='http', auth="public")
def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas',
filename_field='name', unique=None, filename=None, mimetype=None,
download=None, width=0, height=0, crop=False, access_token=None,
**kwargs):
# other kwargs are ignored on purpose
return request.env['ir.http']._content_image(xmlid=xmlid, model=model, res_id=id, field=field,
filename_field=filename_field, unique=unique, filename=filename, mimetype=mimetype,
download=download, width=width, height=height, crop=crop,
quality=int(kwargs.get('quality', 0)), access_token=access_token)
# backward compatibility
@http.route(['/web/binary/image'], type='http', auth="public")
def content_image_backward_compatibility(self, model, id, field, resize=None, **kw):
width = None
height = None
if resize:
width, height = resize.split(",")
return request.env['ir.http']._content_image(model=model, res_id=id, field=field, width=width, height=height)
@http.route('/web/binary/upload', type='http', auth="user")
@serialize_exception
def upload(self, ufile, callback=None):
# TODO: might be useful to have a configuration flag for max-length file uploads
out = """<script language="javascript" type="text/javascript">
var win = window.top.window;
win.jQuery(win).trigger(%s, %s);
</script>"""
try:
data = ufile.read()
args = [len(data), ufile.filename,
ufile.content_type, pycompat.to_text(base64.b64encode(data))]
except Exception as e:
args = [False, str(e)]
return out % (json.dumps(clean(callback)), json.dumps(args)) if callback else json.dumps(args)
@http.route('/web/binary/upload_attachment', type='http', auth="user")
@serialize_exception
def upload_attachment(self, model, id, ufile, callback=None):
files = request.httprequest.files.getlist('ufile')
Model = request.env['ir.attachment']
out = """<script language="javascript" type="text/javascript">
var win = window.top.window;
win.jQuery(win).trigger(%s, %s);
</script>"""
args = []
for ufile in files:
filename = ufile.filename
if request.httprequest.user_agent.browser == 'safari':
# Safari sends NFD UTF-8 (where é is composed by 'e' and [accent])
# we need to send it the same stuff, otherwise it'll fail
filename = unicodedata.normalize('NFD', ufile.filename)
try:
attachment = Model.create({
'name': filename,
'datas': base64.encodebytes(ufile.read()),
'res_model': model,
'res_id': int(id)
})
attachment._post_add_create()
except Exception:
args.append({'error': _("Something horrible happened")})
_logger.exception("Fail to upload attachment %s" % ufile.filename)
else:
args.append({
'filename': clean(filename),
'mimetype': ufile.content_type,
'id': attachment.id,
'size': attachment.file_size
})
return out % (json.dumps(clean(callback)), json.dumps(args)) if callback else json.dumps(args)
附件与二进制字段
附件
我们可以在设置-技术设置-数据结构-附件中查看所有附件的详细信息,通常会包含附件的名称、类型、MIME类型、文件路径等信息。
默认情况下文件存储在应用服务器的上,用户可以通过在技术参数中新增一个参数ir_attachment.location,并将值设置为db,来告诉odoo将附件的存储位置更改为数据库存储(当然我们是不建议这么做的)。
附件文件的存储路径我们可以通过配置文件中的data_dir参数来指定。data_dir指定的路径下通常有三个文件夹,分别为addons、session和files,其中files就是附件存储的路径。
附件的基础特性
- 附件的类型
默认的附件有两种类型可选:
- url: 以URL形式存储的附件
- binary: 以二进制文件存储的附件
这就是说,文件支持两种方式的上传,一种是直接上传二进制文件,另外一种是直接输入文件的URL地址。直接上传二进制的文件会被存放在前面提到过的存储文件夹中,URL一般是模块自带的静态文件。
相应的,如果附件是url类型,那么可以通过url字段获取附件的URL地址。如果附件是binary类型,则可以通过datas获取附件的二进制内容。
- 多媒体文件类型
如果附件是多媒体类型的文件,那么系统在附件上传以后会自动检测其相应的文件类型,并存储在mimetype字段中。
常见的mimetype列表:
- text/css
- text/plain
- text/html
- image/jpeg
- image/png
- audio/mpeg
- audio/ogg
- audio/*
- video/mp4
- application/*
- application/json
- application/javascript
- application/ecmascript
- application/octet-stream
- …
- 其他附件的信息
- access_token: 用于公开访问的token
- checksum: 校验和/SHA1
- datas_fname: 附件的文件名
- file_size: 附件文件的大小
- public: 是否公开
- res_model: 关联的模型名称
- res_field: 关联的字段名称
- res_id: 关联的记录ID
其中res_field是与二进制字段相关的内容,将在后面介绍。res_model指明附件是在哪个模型下上传的,res_id指明该模型的记录信息,这样就能定位到具体是哪个模型的哪个记录附加的附件。
默认附件存储逻辑
系统默认的存储逻辑是,先查找当前用户的home目录,如果home目录存在,那么就是用用户文件夹,对于不同的操作系统,用户文件夹的定义不同。常见的操作系统及其用户文件夹举例如下: - Mac OS X:~/Library/Application Support/
- Unix: ~/.local/share/
- WinXP(not roaming): C:\Documents and Settings\
- WinXP(roaming): C:\Documents and Settings\
- Win7(not roaming):C:\Users\
- Win7(roaming):C:\Users
对于Unix, 遵循和支持XDG(X Desktop Group),也就意味着默认的路径 ~/.local/share/
附件的访问权限
附件的访问权限分为两种,一种是可以公开访问的,直接通过URL可以读取。另外一种是只能够拥有访问token的人才可以读取。
附件的存储机制
附件在写入硬盘之前,dtcloud会计算文件的sha1值,得到一个校验值。并以校验值的前两位作为索引文件夹,以加快搜索速度。因此如果你去看filestore文件夹下的文件,通常你应该会看到类似下图的结构:
上层文件夹名为sha1计算值的前两位。当我们进入到前面说过的文件夹中,会看到如下的类似的文件:
-rwxrwxrwx 1 kevin kevin 658 Sep 26 2019 01157b1c3257f89fcea410089fdb73c65a697a9e*
-rwxrwxrwx 1 kevin kevin 16865 Sep 26 2019 0142ddc716ead38a9aea64b1c6239c2fa6ad6a77*
-rwxrwxrwx 1 kevin kevin 20344 Nov 22 2019 014fc0f76d4e8de32b10e72e9853c51261138c5f*
这些文件的文件名即我们前面讲到过的二进制内容的sha1值,我们是无法直接从文件名看出这个文件具体是什么内容的。与文件有关的信息都存储在ir_attachment表中。
当dtcloud需要读取某个附件时,它会搜索附件表,找到对应的索引文件,然后去文件所属的文件夹下找到改文件,加载到内存中,以base64编码的方式将二进制文件转换为字节流,然后传给前端。
如果文件是图片等媒体文件,dtcloud会在base64的编码前加载对应的MIME文件头,以方便浏览器正确地加载解析。
同样的,当我们在dtcloud中上传了文件时,dtcloud会把我们的文件以base64编码的方式传给后台,后台然后使用base64解码,得到二进制字节流。然后按照相同的逻辑,计算出文件应该被存储的位置,将文件写入硬盘。
如果要删除文件,dtcloud的处理方式不是直接删除,而是将文件简单地加入到垃圾回收列表(checklist)。等待垃圾回收机制的回收处理。
经过源代码分析,ir.attachment对象的search方法会在domain为空时自动添加一个过滤条件:(‘res_field’,‘=’,False),也就是模型字段生成的附件会被默认过滤掉。若要搜索字段的附件,设置res_field不为空即可。
垃圾回收
附件的垃圾回收机制其实非常简单,即对比数据库ir_attachment表中的附件的文件名和硬盘checklist文件夹中的文件名,如果数据库中存在的加入白名单不进行删除,否则则在硬盘中删除,并清空checklist列表。
至于为什么要加入垃圾回收机制,而不是直接在硬盘上进行删除,是因为如果事务在创建或者删除过程中失败或者并发事务中,会导致文件的差异。而垃圾回收机制在运行过程中会将ir_attachemnt锁表,从而保证了事务的一致性。
附件的共享
有时候我们希望将附件共享给他人,包括且不限于内部用户,门户用户,甚至游客。但出于安全性的考虑,很显然我们不能生成一个公开的链接让所有拥有连接的人进行访问。
dtcloud给出的方案即在附件附加一个Access Token,凡是拥有这个access token的人可以访问,否则就不能访问。
生成Access Token方法即调用附件的generate_access_token方法,该方法会在附件对象上生成一个uuid的随机字符串,只有拥有正确的access token的人才可以正常访问附件。
附件共享可以将URL直接发送给用户,具体的拼接方式如下:
{host}/web/content?id={attachment.id}&download=true&access_token={attachment.generate_access_token()}
其中 host是服务器域名,attachment是附件对象。
二进制字段
我们在第一部分第三章的时候曾经简短地介绍过二进制字段的基本用法,但是我们并没有详细说明二进制字段是如何实现以及它是如何在后端进行存储的。
首先我们先看二进制字段的定义:
class Binary(Field):
type = 'binary'
_slots = {
'prefetch': False, # not prefetched by default
'context_dependent': True, # depends on context (content or size)
'attachment': True, # whether value is stored in attachment
}
可以看出,二进制字段默认被设置了不可预先获取数据,但使用附件存储技术存储二进制字段。
NOTE: 12.0及更早的版本中,默认的attachment的值是False,即在12.0及之前的版本默认不存储在附件中,从13.0版本才开始使用默认的附件存储。
存储机制
二进制字段有一个属性attachment,用来标识是否存储在附件中。默认情况下使用附件的存储技术,对于用户来说可以简单地把二进制字段等同于附件。所不同的是,二进制字段虽然存在了ir_attachement表中,但是它不会在附件中显示出来。
如果不使用附件存储,那么二进制数据将被存储到数据库中,对应到数据库中的字段类型为postgresql中的bytea类型。
@mute_logger('dtcloud.addons.http_routing.models.ir_http', 'dtcloud.http')
def test_01_portal_attachment(self):
"""Test the portal chatter attachment route."""
self.authenticate(None, None)
# Test public user can't create attachment without token of document
res = self.url_open(
url='%s/portal/attachment/add' % self.base_url,
data={
'name': "new attachment",
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'csrf_token': http.WebRequest.csrf_token(self),
},
files=[('file', ('test.txt', b'test', 'plain/text'))],
)
self.assertEqual(res.status_code, 400)
self.assertIn("you do not have the rights", res.text)
# Test public user can create attachment with token
res = self.url_open(
url='%s/portal/attachment/add' % self.base_url,
data={
'name': "new attachment",
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'csrf_token': http.WebRequest.csrf_token(self),
'access_token': self.out_invoice._portal_ensure_token(),
},
files=[('file', ('test.txt', b'test', 'plain/text'))],
)
self.assertEqual(res.status_code, 200)
create_res = json.loads(res.content.decode('utf-8'))
self.assertTrue(self.env['ir.attachment'].sudo().search([('id', '=', create_res['id'])]))
# Test created attachment is private
res_binary = self.url_open('/web/content/%d' % create_res['id'])
self.assertEqual(res_binary.status_code, 404)
# Test created access_token is working
res_binary = self.url_open('/web/content/%d?access_token=%s' % (create_res['id'], create_res['access_token']))
self.assertEqual(res_binary.status_code, 200)
# Test mimetype is neutered as non-admin
res = self.url_open(
url='%s/portal/attachment/add' % self.base_url,
data={
'name': "new attachment",
'res_model': self.out_invoice._name,
'res_id': self.out_invoice.id,
'csrf_token': http.WebRequest.csrf_token(self),
'access_token': self.out_invoice._portal_ensure_token(),
},
files=[('file', ('test.svg', b'<svg></svg>', 'image/svg+xml'))],
)
self.assertEqual(res.status_code, 200)
create_res = json.loads(res.content.decode('utf-8'))
self.assertEqual(create_res['mimetype'], 'text/plain')
res_binary = self.url_open('/web/content/%d?access_token=%s' % (create_res['id'], create_res['access_token']))
self.assertEqual(res_binary.headers['Content-Type'], 'text/plain')
self.assertEqual(res_binary.content, b'<svg></svg>')
res_image = self.url_open('/web/image/%d?access_token=%s' % (create_res['id'], create_res['access_token']))
self.assertEqual(res_image.headers['Content-Type'], 'text/plain')
self.assertEqual(res_image.content, b'<svg></svg>')
# Test attachment can't be removed without valid token
res = self.opener.post(
url='%s/portal/attachment/remove' % self.base_url,
json={
'params': {
'attachment_id': create_res['id'],
'access_token': "wrong",
},
},
)
self.assertEqual(res.status_code, 200)
self.assertTrue(self.env['ir.attachment'].sudo().search([('id', '=', create_res['id'])]))
self.assertIn("you do not have the rights", res.text)
# Test attachment can be removed with token if "pending" state
res = self.opener.post(
url='%s/portal/attachment/remove' % self.base_url,
json={
'params': {
'attachment_id': create_res['id'],
'access_token': create_res['access_token'],
},
},
)
self.assertEqual(res.status_code, 200)
remove_res = json.loads(res.content.decode('utf-8'))['result']
self.assertFalse(self.env['ir.attachment'].sudo().search([('id', '=', create_res['id'])]))
self.assertTrue(remove_res is True)
# Test attachment can't be removed if not "pending" state
attachment = self.env['ir.attachment'].create({
'name': 'an attachment',
'access_token': self.env['ir.attachment']._generate_access_token(),
})
res = self.opener.post(
url='%s/portal/attachment/remove' % self.base_url,
json={
'params': {
'attachment_id': attachment.id,
'access_token': attachment.access_token,
},
},
)
self.assertEqual(res.status_code, 200)
self.assertTrue(self.env['ir.attachment'].sudo().search([('id', '=', attachment.id)]))
self.assertIn("not in a pending state", res.text)
# Test attachment can't be removed if attached to a message
attachment.write({
'res_model': 'mail.compose.message',
'res_id': 0,
})
attachment.flush()
message = self.env['mail.message'].create({
'attachment_ids': [(6, 0, attachment.ids)],
})
res = self.opener.post(
url='%s/portal/attachment/remove' % self.base_url,
json={
'params': {
'attachment_id': attachment.id,
'access_token': attachment.access_token,
},
},
)
self.assertEqual(res.status_code, 200)
self.assertTrue(attachment.exists())
self.assertIn("it is linked to a message", res.text)
message.sudo().unlink()
中亿丰——何双新