diff --git a/attachment_action/README.rst b/attachment_action/README.rst new file mode 100644 index 00000000..a4b442e0 --- /dev/null +++ b/attachment_action/README.rst @@ -0,0 +1,52 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: https://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +====================== +Actions on attachments +====================== + +This module was written to provide a container for other modules to add buttons for actions on attachments. + +Usage +===== + +This module does nothing useful on its own. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Holger Brunn + +Do not contact contributors directly about help with questions or problems concerning this addon, but use the `community mailing list `_ or the `appropriate specialized mailinglist `_ for help, and the bug tracker linked in `Bug Tracker`_ above for technical issues. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/attachment_action/__init__.py b/attachment_action/__init__.py new file mode 100644 index 00000000..8c501711 --- /dev/null +++ b/attachment_action/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). diff --git a/attachment_action/__openerp__.py b/attachment_action/__openerp__.py new file mode 100644 index 00000000..2ab7d384 --- /dev/null +++ b/attachment_action/__openerp__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Actions on attachments", + "version": "8.0.1.0.0", + "author": "Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Hidden/Dependency", + "summary": "Enable/disable actions on attachments", + "depends": [ + 'web', + 'knowledge', + 'document', + ], + "data": [ + 'views/templates.xml', + ], + "qweb": [ + 'static/src/xml/attachment_action.xml', + ], +} diff --git a/attachment_action/static/description/icon.png b/attachment_action/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/attachment_action/static/description/icon.png differ diff --git a/attachment_action/static/src/css/attachment_action.css b/attachment_action/static/src/css/attachment_action.css new file mode 100644 index 00000000..3caa01a0 --- /dev/null +++ b/attachment_action/static/src/css/attachment_action.css @@ -0,0 +1,37 @@ +.openerp .oe_sidebar .oe_dropdown_menu li span.oe_sidebar_attachment_action .oe_sidebar_delete_item +{ + position: static; +} +.openerp .oe_sidebar .oe_dropdown_menu li span.oe_sidebar_attachment_action +{ + display: none; + position: absolute; + right: 0; + top: 0; + left: 50%; + text-align: right; + padding: 0px 6px; +} +.openerp .oe_sidebar .oe_dropdown_menu li span.oe_sidebar_attachment_action > * +{ + display: inline-block; + padding: 2px; + color: #8786b7; + font-weight: bold; + text-shadow: 0 1px 1px white; + margin-right: 1px; +} +.openerp .oe_sidebar .oe_dropdown_menu li span.oe_sidebar_attachment_action > *:hover +{ + text-decoration: none; + color: white; + background: #8786b7; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4); + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + border-radius: 2px +} +.openerp .oe_sidebar .oe_dropdown_menu li:hover span.oe_sidebar_attachment_action +{ + display: block; +} diff --git a/attachment_action/static/src/js/attachment_action.js b/attachment_action/static/src/js/attachment_action.js new file mode 100644 index 00000000..fb8bdfa2 --- /dev/null +++ b/attachment_action/static/src/js/attachment_action.js @@ -0,0 +1,32 @@ +//-*- coding: utf-8 -*- +//Copyright 2018 Therp BV +//License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +openerp.attachment_action = function(instance) +{ + instance.web.Sidebar.include({ + _extra_sidebar_attachment_fields: [], + on_attachments_loaded: function(attachments) + { + var self = this, _super = this._super; + return new instance.web.Model('ir.attachment') + .query(this._extra_sidebar_attachment_fields) + .filter([['id', 'in', _.pluck(attachments, 'id')]]) + .all() + .then(function(extra_data) + { + _.each(attachments, function(attachment) + { + _.extend(attachment, _.find( + extra_data, function(x) + { + return x.id == attachment.id + } + )); + }); + _super.apply(self, [attachments]); + return attachments; + }); + }, + }); +}; diff --git a/attachment_action/static/src/xml/attachment_action.xml b/attachment_action/static/src/xml/attachment_action.xml new file mode 100644 index 00000000..b66df4ea --- /dev/null +++ b/attachment_action/static/src/xml/attachment_action.xml @@ -0,0 +1,11 @@ + + + + + + + + jQuery(this).appendTo(this.siblings('.oe_sidebar_attachment_action')); + + + diff --git a/attachment_action/views/templates.xml b/attachment_action/views/templates.xml new file mode 100644 index 00000000..665d8cf7 --- /dev/null +++ b/attachment_action/views/templates.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/attachment_edit/__openerp__.py b/attachment_edit/__openerp__.py index cd5e14ae..9c8901a5 100644 --- a/attachment_edit/__openerp__.py +++ b/attachment_edit/__openerp__.py @@ -9,7 +9,7 @@ "category": "Knowledge Management", "summary": "Edit attachments after upload", "depends": [ - 'web', + 'attachment_action', ], "data": [ "views/ir_attachment.xml", diff --git a/attachment_edit/static/src/css/attachment_edit.css b/attachment_edit/static/src/css/attachment_edit.css deleted file mode 100644 index 3b25edd3..00000000 --- a/attachment_edit/static/src/css/attachment_edit.css +++ /dev/null @@ -1,22 +0,0 @@ -.openerp .oe_sidebar .oe_dropdown_menu li .oe-sidebar-attachment-edit -{ - position: absolute; - right: 32px; - top: 0px; - display: none; -} -.openerp .oe_sidebar .oe_dropdown_menu li:hover .oe-sidebar-attachment-edit -{ - display: inherit; -} -.openerp .oe_sidebar .oe_dropdown_menu li .oe-sidebar-attachment-edit:hover -{ - text-decoration: none; - color: white; - background: #8786b7; - text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4); - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - border-radius: 2px; -} - diff --git a/attachment_edit/static/src/js/attachment_edit.js b/attachment_edit/static/src/js/attachment_edit.js index 65234fd2..d25d270b 100644 --- a/attachment_edit/static/src/js/attachment_edit.js +++ b/attachment_edit/static/src/js/attachment_edit.js @@ -25,10 +25,13 @@ openerp.attachment_edit = function(instance) { on_attachments_loaded: function(attachments) { - var result = this._super.apply(this, arguments); - this.$el.find('.oe-sidebar-attachment-edit') - .click(this.on_attachment_edit); - return result; + var self = this; + return jQuery.when(this._super.apply(this, arguments)) + .then(function() + { + self.$el.find('.oe-sidebar-attachment-edit') + .click(self.on_attachment_edit); + }); }, on_attachment_edit: function(e) { diff --git a/attachment_edit/views/templates.xml b/attachment_edit/views/templates.xml index e4da3b4b..f51deabe 100644 --- a/attachment_edit/views/templates.xml +++ b/attachment_edit/views/templates.xml @@ -4,7 +4,6 @@ diff --git a/attachment_lock/README.rst b/attachment_lock/README.rst new file mode 100644 index 00000000..5b9b7a23 --- /dev/null +++ b/attachment_lock/README.rst @@ -0,0 +1,72 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: https://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +================== +Attachment locking +================== + +This module was written to allow users to lock attachments for external editing. + +Configuration +============= + +To configure this module, you need to: + +#. add users to the group `Attachment Locking` + +Usage +===== + +To use this module, you need to: + +#. go to some document with an attachment +#. click the lock or unlock button + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/118/8.0 + +Known issues / Roadmap +====================== + +* support database locks +* support some kind of push api on locks + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Holger Brunn + +Do not contact contributors directly about help with questions or problems concerning this addon, but use the `community mailing list `_ or the `appropriate specialized mailinglist `_ for help, and the bug tracker linked in `Bug Tracker`_ above for technical issues. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/attachment_lock/__init__.py b/attachment_lock/__init__.py new file mode 100644 index 00000000..24223449 --- /dev/null +++ b/attachment_lock/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models diff --git a/attachment_lock/__openerp__.py b/attachment_lock/__openerp__.py new file mode 100644 index 00000000..8c09b815 --- /dev/null +++ b/attachment_lock/__openerp__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Attachment locking", + "version": "8.0.1.0.0", + "author": "Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Knowledge Management", + "summary": "Support for locks on attachments for external applications", + "depends": [ + 'attachment_action', + ], + "data": [ + "security/res_groups.xml", + "security/ir_rule.xml", + 'security/ir.model.access.csv', + "views/ir_attachment_lock.xml", + "data/ir_cron.xml", + 'views/templates.xml', + ], + "qweb": [ + 'static/src/xml/attachment_lock.xml', + ], +} diff --git a/attachment_lock/data/ir_cron.xml b/attachment_lock/data/ir_cron.xml new file mode 100644 index 00000000..d6a852b4 --- /dev/null +++ b/attachment_lock/data/ir_cron.xml @@ -0,0 +1,13 @@ + + + + + Cleanup attachment locks + 1 + hours + -1 + ir.attachment.lock + _cleanup_cron + + + diff --git a/attachment_lock/models/__init__.py b/attachment_lock/models/__init__.py new file mode 100644 index 00000000..88ca98d4 --- /dev/null +++ b/attachment_lock/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import ir_attachment_lock +from . import ir_attachment diff --git a/attachment_lock/models/ir_attachment.py b/attachment_lock/models/ir_attachment.py new file mode 100644 index 00000000..00c0ab7d --- /dev/null +++ b/attachment_lock/models/ir_attachment.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# © 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from openerp import _, api, fields, models +from openerp.exceptions import AccessError, ValidationError + + +class IrAttachment(models.Model): + _inherit = 'ir.attachment' + + lock_ids = fields.One2many( + 'ir.attachment.lock', 'attachment_id', 'Locks', + ) + locked = fields.Boolean(compute='_compute_locked') + can_lock = fields.Boolean(compute='_compute_locked') + + @api.multi + @api.depends( + 'lock_ids.create_uid', 'lock_ids.shared_user_ids', + 'lock_ids.lock_type', + ) + def _compute_locked(self): + for this in self: + this.locked = bool(this.lock_ids) + this.can_lock = bool( + this.mapped('lock_ids.create_uid') & this.env.user or + 'shared' in this.mapped('lock_ids.lock_type') + ) or not this.locked and self.check_access_rights('write', False) + + @api.constrains('datas', 'datas_fname') + def _constrain_datas(self): + for this in self: + if not this.lock_ids: + continue + if not this.can_lock: + raise ValidationError(_('Attachment is locked')) + + @api.multi + def lock(self, lock_type='exclusive', valid_until=None, data=None): + data = data or {} + if valid_until: + data['valid_until'] = valid_until + data['lock_type'] = lock_type + for this in self: + if not this.can_lock: + raise AccessError(_('Unable to obtain lock')) + if this.lock_ids: + this.lock_ids.filtered( + lambda x: x.lock_type == lock_type + ).sudo().write(dict(data, shared_user_ids=[(4, self.env.uid)])) + else: + this.write({'lock_ids': [(0, 0, data)]}) + + @api.multi + def unlock(self): + return self.mapped('lock_ids').filtered( + lambda x: x.create_uid & self.env.user or + x.lock_type == 'shared' and x.shared_user_ids & self.env.user + ).unlink() diff --git a/attachment_lock/models/ir_attachment_lock.py b/attachment_lock/models/ir_attachment_lock.py new file mode 100644 index 00000000..f5cba252 --- /dev/null +++ b/attachment_lock/models/ir_attachment_lock.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# © 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from datetime import datetime, timedelta +from openerp import api, fields, models + + +class IrAttachmentLock(models.Model): + _name = 'ir.attachment.lock' + _description = 'Attachment lock' + + create_uid = fields.Many2one( + 'res.users', 'User', required=True, default=lambda self: self.env.user, + ) + attachment_id = fields.Many2one( + 'ir.attachment', 'Attachment', required=True, index=True, + ) + lock_type = fields.Selection( + [('exclusive', 'Exclusive'), ('shared', 'Shared')], + string='Type', required=True, default='shared', + ) + application = fields.Selection( + [('manual', 'Manual lock')], required=True, default='manual', + ) + valid_until = fields.Datetime( + required=True, default=fields.Datetime.to_string( + datetime.now() + timedelta(hours=1), + ), + ) + shared_user_ids = fields.Many2many('res.users') + + _sql_constraints = [ + ('unique_attachment_id', 'unique(attachment_id)', 'Lock exists!'), + ] + + @api.model + def _cleanup_cron(self): + self.search([ + ('valid_until', '<', fields.Datetime.to_string(datetime.now())), + ]).unlink() diff --git a/attachment_lock/security/ir.model.access.csv b/attachment_lock/security/ir.model.access.csv new file mode 100644 index 00000000..0e809bd7 --- /dev/null +++ b/attachment_lock/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_ir_attachment_lock_all,access_ir_attachment_lock,model_ir_attachment_lock,,1,0,0,0 +access_ir_attachment_lock,access_ir_attachment_lock,model_ir_attachment_lock,group_attachment_lock,1,1,1,1 diff --git a/attachment_lock/security/ir_rule.xml b/attachment_lock/security/ir_rule.xml new file mode 100644 index 00000000..0bda09ce --- /dev/null +++ b/attachment_lock/security/ir_rule.xml @@ -0,0 +1,17 @@ + + + + + Attachment locks + + + + ['|', ('create_uid', '=', user.id), '&', ('lock_type', '=', 'shared'), ('shared_user_ids', 'in', user.ids)] + + + + + + + + diff --git a/attachment_lock/security/res_groups.xml b/attachment_lock/security/res_groups.xml new file mode 100644 index 00000000..fcceec37 --- /dev/null +++ b/attachment_lock/security/res_groups.xml @@ -0,0 +1,10 @@ + + + + + Attachment locking + + + + + diff --git a/attachment_lock/static/description/icon.png b/attachment_lock/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/attachment_lock/static/description/icon.png differ diff --git a/attachment_lock/static/src/css/attachment_lock.css b/attachment_lock/static/src/css/attachment_lock.css new file mode 100644 index 00000000..e69de29b diff --git a/attachment_lock/static/src/js/attachment_lock.js b/attachment_lock/static/src/js/attachment_lock.js new file mode 100644 index 00000000..e61d2576 --- /dev/null +++ b/attachment_lock/static/src/js/attachment_lock.js @@ -0,0 +1,78 @@ +//-*- coding: utf-8 -*- +//Copyright 2018 Therp BV +//License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +openerp.attachment_lock = function(instance) +{ + instance.web.Sidebar.include({ + init: function() + { + this._super.apply(this, arguments); + this._extra_sidebar_attachment_fields.push('locked'); + this._extra_sidebar_attachment_fields.push('can_lock'); + }, + on_attachments_loaded: function() + { + var self = this; + return jQuery.when(this._super.apply(this, arguments)) + .then(function() + { + self.$('.oe_sidebar_attachment_lock').click( + self.on_attachment_lock + ); + self.$('.oe_sidebar_attachment_locked').click( + self.on_attachment_locked + ); + self.$('.oe_sidebar_attachment_unlock').click( + self.on_attachment_unlock + ); + }); + }, + on_attachment_lock: function(e) + { + var self = this; + e.stopPropagation(); + return new instance.web.Model('ir.attachment') + .call('lock', [[jQuery(e.currentTarget).data('id')]]) + .then(function() + { + return self.do_attachement_update(self.dataset, self.model_id); + }); + }, + on_attachment_locked: function(e) + { + var self = this; + e.stopPropagation(); + return new instance.web.Model('ir.attachment.lock') + .query(['create_uid', 'valid_until']) + .filter([['attachment_id', '=', jQuery(e.currentTarget).data('id')]]) + .first() + .then(function(lock) + { + new instance.web.Dialog( + this, { + title: instance.web._t('Locked'), + }, + $('
').text( + _.str.sprintf( + instance.web._t('By %s until %s'), + lock.create_uid[1], + instance.web.format_value(lock.valid_until, {type: 'datetime'}) + ) + ) + ).open(); + }); + }, + on_attachment_unlock: function(e) + { + var self = this; + e.stopPropagation(); + return new instance.web.Model('ir.attachment') + .call('unlock', [[jQuery(e.currentTarget).data('id')]]) + .then(function() + { + return self.do_attachement_update(self.dataset, self.model_id); + }); + }, + }); +}; diff --git a/attachment_lock/static/src/xml/attachment_lock.xml b/attachment_lock/static/src/xml/attachment_lock.xml new file mode 100644 index 00000000..d81855a0 --- /dev/null +++ b/attachment_lock/static/src/xml/attachment_lock.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/attachment_lock/tests/__init__.py b/attachment_lock/tests/__init__.py new file mode 100644 index 00000000..079f877e --- /dev/null +++ b/attachment_lock/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import test_attachment_lock diff --git a/attachment_lock/tests/test_attachment_lock.py b/attachment_lock/tests/test_attachment_lock.py new file mode 100644 index 00000000..77de470a --- /dev/null +++ b/attachment_lock/tests/test_attachment_lock.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from base64 import b64encode +from openerp.tests.common import TransactionCase +from openerp.exceptions import AccessError, ValidationError + + +class TestAttachmentLock(TransactionCase): + def test_attachment_lock(self): + demo = self.env.ref('base.user_demo') + testattachment = self.env['ir.attachment'].create({ + 'name': 'testattachment', + 'datas': b64encode('hello world'), + 'datas_fname': 'test.txt', + }) + self.assertTrue(testattachment.can_lock) + self.assertFalse(testattachment.locked) + testattachment.lock() + self.assertTrue(testattachment.can_lock) + self.assertTrue(testattachment.locked) + with self.assertRaises(ValidationError): + testattachment.sudo(demo).write({ + 'datas': b64encode('hello world2'), + }) + with self.assertRaises(AccessError): + testattachment.sudo(demo).lock() + demo.write({'groups_id': [ + (4, self.env.ref('attachment_lock.group_attachment_lock').id), + ]}) + with self.assertRaises(AccessError): + testattachment.sudo(demo).lock() + testattachment.unlock() + self.assertTrue(testattachment.sudo(demo).can_lock) + testattachment.sudo(demo).lock() + self.assertTrue(testattachment.sudo(demo).can_lock) + self.assertTrue(testattachment.sudo(demo).locked) diff --git a/attachment_lock/views/ir_attachment_lock.xml b/attachment_lock/views/ir_attachment_lock.xml new file mode 100644 index 00000000..6e753710 --- /dev/null +++ b/attachment_lock/views/ir_attachment_lock.xml @@ -0,0 +1,42 @@ + + + + + ir.attachment.lock + + + + + + + + + + + ir.attachment.lock + +
+ + + + + + + +
+
+
+ + +
+
diff --git a/attachment_lock/views/templates.xml b/attachment_lock/views/templates.xml new file mode 100644 index 00000000..f0614dce --- /dev/null +++ b/attachment_lock/views/templates.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/attachment_preview/__openerp__.py b/attachment_preview/__openerp__.py index 62eda094..56f7fbe4 100644 --- a/attachment_preview/__openerp__.py +++ b/attachment_preview/__openerp__.py @@ -27,7 +27,7 @@ 'summary': 'Preview attachments supported by Viewer.js', "category": "Knowledge Management", "depends": [ - 'web', + 'attachment_action', ], "data": [ "view/attachment_preview.xml", diff --git a/attachment_preview/static/src/css/attachment_preview.css b/attachment_preview/static/src/css/attachment_preview.css index 90f25f6e..887fa42f 100644 --- a/attachment_preview/static/src/css/attachment_preview.css +++ b/attachment_preview/static/src/css/attachment_preview.css @@ -1,24 +1,3 @@ -.openerp .oe_sidebar .oe_dropdown_menu li .oe-sidebar-attachment-preview -{ - position: absolute; - right: 18px; - top: 0px; - display: none; -} -.openerp .oe_sidebar .oe_dropdown_menu li:hover .oe-sidebar-attachment-preview -{ - display: inherit; -} -.openerp .oe_sidebar .oe_dropdown_menu li .oe-sidebar-attachment-preview:hover -{ - text-decoration: none; - color: white; - background: #8786b7; - text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4); - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - border-radius: 2px; -} .openerp .oe-binary-preview { cursor: pointer; diff --git a/attachment_preview/static/src/js/attachment_preview.js b/attachment_preview/static/src/js/attachment_preview.js index 92f17676..b7f30121 100644 --- a/attachment_preview/static/src/js/attachment_preview.js +++ b/attachment_preview/static/src/js/attachment_preview.js @@ -47,11 +47,14 @@ openerp.attachment_preview = function(instance) { on_attachments_loaded: function(attachments) { - var result = this._super.apply(this, arguments); - this.$el.find('.oe-sidebar-attachment-preview') - .click(this.on_attachment_preview); - this.update_preview_buttons(); - return result; + var self = this; + return jQuery.when(this._super.apply(this, arguments)) + .then(function() + { + self.$el.find('.oe-sidebar-attachment-preview') + .click(self.on_attachment_preview); + self.update_preview_buttons(); + }); }, on_attachment_preview: function(e) { diff --git a/document_wopi/README.rst b/document_wopi/README.rst new file mode 100644 index 00000000..abcf08c9 --- /dev/null +++ b/document_wopi/README.rst @@ -0,0 +1,80 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +==== +WOPI +==== + +This module was written to implement the `WOPI protocol `_ in order to allow online collaborative editing and viewing of office files. This works with `LibreOffice online `_/`Collabora online `_ and probably also with Office 365. + +Installation +============ + +To install this module, you need to: + +#. have a WOPI client available. For testing, use one of the `Collabora `_ or `Libreoffice `_ docker images. The first works out of the box, but is rate limited, the second needs manual interaction, but is unmodified. The `Nextcloud documentation `_ contains some information on how to get this running +#. have your instance accessible on port 443 via your WOPI client and should have a valid SSL certificate +#. use a dbfilter if necessary to have unauthenticated requests ending up in the database you want + +Configuration +============= + +To configure this module, you need to: + +#. fill in your WOPI client URL in the configuration wizard + +Usage +===== + +To use this module, you need to: + +#. on any supported attachment, click the edit button + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/118/8.0 + +Known issues / Roadmap +====================== + +* implement quasi permanent public shares a la google docs +* whoever want to get their hands dirty with Microsoft should find our how to integrate this with Office 365 + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Holger Brunn + +Do not contact contributors directly about help with questions or problems concerning this addon, but use the `community mailing list `_ or the `appropriate specialized mailinglist `_ for help, and the bug tracker linked in `Bug Tracker`_ above for technical issues. + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/document_wopi/__init__.py b/document_wopi/__init__.py new file mode 100644 index 00000000..c897fc16 --- /dev/null +++ b/document_wopi/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import models +from . import controllers diff --git a/document_wopi/__openerp__.py b/document_wopi/__openerp__.py new file mode 100644 index 00000000..0cc25b16 --- /dev/null +++ b/document_wopi/__openerp__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "WOPI", + "version": "8.0.1.0.0", + "author": "Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Knowledge Management", + "summary": "Allow online collaborative editing of your documents", + "depends": [ + 'knowledge', + 'attachment_action', + 'attachment_lock', + ], + "demo": [ + "demo/ir_config_parameter.xml", + ], + "data": [ + "security/ir_rule.xml", + "data/ir_actions_todo.xml", + "views/knowledge_config_settings.xml", + 'views/templates.xml', + 'security/ir.model.access.csv', + ], + "qweb": [ + "static/src/xml/document_wopi.xml", + ], + "images": [ + "images/screenshot.png", + ], +} diff --git a/document_wopi/controllers/__init__.py b/document_wopi/controllers/__init__.py new file mode 100644 index 00000000..1b21ddd3 --- /dev/null +++ b/document_wopi/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import main diff --git a/document_wopi/controllers/main.py b/document_wopi/controllers/main.py new file mode 100644 index 00000000..7116d829 --- /dev/null +++ b/document_wopi/controllers/main.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import base64 +import json +import time +import logging +from openerp import http, fields +from openerp.http import request +_logger = logging.getLogger(__name__) + + +class WopiEndpoints(http.Controller): + @http.route( + '/wopi/files/', type='http', + auth='public', + ) + def files(self, attachment, access_token, **kwargs): + token = self._verify(access_token) + if not token: + _logger.error(request.httprequest.headers) + return request.not_found() + attachment = token.attachment_id + _logger.debug(request.httprequest.headers) + return json.dumps({ + 'BaseFileName': attachment.name, + 'OwnerId': attachment.create_uid.id, + 'Size': attachment.file_size, + 'UserId': token.user_id.id, + 'Version': str(self._timestamp(attachment)), + 'UserFriendlyName': token.user_id.display_name, + 'UserCanWrite': attachment.check_access_rights('write', False), + 'SupportsUpdate': True, + 'SupportsGetLock': True, + 'SupportsLocks': True, + 'LastModifiedTime': fields.Datetime.from_string( + attachment.write_date + ).isoformat(), + 'UserCanNotWriteRelative': True, + 'DisablePrint': False, + 'DisableExport': False, + 'DisableCopy': False, + 'DisableInactiveMessages': False, + 'HidePrintOption': False, + 'HideSaveOption': False, + 'HideExportOption': False, + 'EnableOwnerTermination': False, + 'UserExtraInfo': False, + 'WatermarkText': '', + 'PostMessageOrigin': False, + }) + + @http.route( + '/wopi/files//contents', + type='http', auth='public', + ) + def files_contents(self, attachment, access_token, **kwargs): + token = self._verify(access_token) + attachment = token.attachment_id + if not token: + _logger.error(request.httprequest.headers) + return request.not_found() + _logger.debug(request.httprequest.headers) + if 'X-WOPI-Override' in request.httprequest.headers: + name = '_files_contents_%s' % ( + request.httprequest.headers['X-WOPI-Override'] + ) + if hasattr(self, name): + return getattr(self, name)(attachment, token, **kwargs) + return request.not_found() + return request.make_response( + base64.b64decode(attachment.datas), + [('X-WOPI-ItemVersion', str(self._timestamp(attachment)))], + ) + + def _files_contents_PUT(self, attachment, token, **kwargs): + if not attachment.can_lock: + return request.not_found() + if not attachment.locked: + attachment.lock() + attachment.write({'datas': base64.b64encode(request.httprequest.data)}) + return request.make_response( + json.dumps({ + 'LastModifiedTime': fields.Datetime.from_string( + attachment.create_date + ).isoformat(), + }), + [ + ('X-WOPI-Lock', str(attachment.lock_ids.ids)), + ('X-WOPI-ItemVersion', str(self._timestamp(attachment))), + ], + ) + + def _verify(self, access_token): + return request.env['document.wopi.access.token'].sudo()._verify_token( + access_token + ) + + def _timestamp(self, attachment): + return int(time.mktime( + fields.Datetime.from_string(attachment.write_date).timetuple() + )) diff --git a/document_wopi/data/ir_actions_todo.xml b/document_wopi/data/ir_actions_todo.xml new file mode 100644 index 00000000..2e5ad68d --- /dev/null +++ b/document_wopi/data/ir_actions_todo.xml @@ -0,0 +1,10 @@ + + + + + Configure document_wopi + + once + + + diff --git a/document_wopi/demo/ir_config_parameter.xml b/document_wopi/demo/ir_config_parameter.xml new file mode 100644 index 00000000..f320b3f2 --- /dev/null +++ b/document_wopi/demo/ir_config_parameter.xml @@ -0,0 +1,9 @@ + + + + + document_wopi.client + https://docs.therp.nl + + + diff --git a/document_wopi/images/screenshot.png b/document_wopi/images/screenshot.png new file mode 100644 index 00000000..2c0be5b7 Binary files /dev/null and b/document_wopi/images/screenshot.png differ diff --git a/document_wopi/models/__init__.py b/document_wopi/models/__init__.py new file mode 100644 index 00000000..7f2c31a3 --- /dev/null +++ b/document_wopi/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import knowledge_config_settings +from . import document_wopi +from . import document_wopi_access_token diff --git a/document_wopi/models/document_wopi.py b/document_wopi/models/document_wopi.py new file mode 100644 index 00000000..7d66ad39 --- /dev/null +++ b/document_wopi/models/document_wopi.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import requests +import urlparse +from openerp import api, models, tools +from lxml import etree + + +class DocumentWopi(models.AbstractModel): + _name = 'document.wopi' + _description = 'Implement the WOPI protocol' + + @api.model + def _get_url(self, path): + client = self.env['ir.config_parameter'].get_param( + 'document_wopi.client' + ) + assert client, 'You need to define your WOPI client in the settings' + return urlparse.urljoin(client, path) + + @api.model + def _call(self, path, query=None, headers=None): + return requests.get( + self._get_url(path), params=query, headers=headers + ) + + @api.model + def get_access_token(self, attachment_id): + """Return an access token to pass to the WOPI client. Make sure the + user in question can access the resource""" + self.env['ir.attachment'].browse(attachment_id).read([]) + return self.env['document.wopi.access.token']._get_token( + self.env['ir.attachment'].browse(attachment_id) + ).id + + @tools.ormcache() + @api.model + def _discovery(self): + response = self.env['document.wopi']._call('hosting/discovery') + xml = etree.fromstring(response.text) + result = {} + for action in xml.xpath('//action'): + ext = action.attrib['ext'] + name = action.attrib['name'] + result[(ext, name)] = dict(action.attrib) + return result diff --git a/document_wopi/models/document_wopi_access_token.py b/document_wopi/models/document_wopi_access_token.py new file mode 100644 index 00000000..05317b1d --- /dev/null +++ b/document_wopi/models/document_wopi_access_token.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import time +import mimetypes +import os.path +import uuid +import urllib +import urlparse +from datetime import datetime, timedelta +from openerp import api, fields, models +from openerp.exceptions import AccessDenied + + +class DocumentWopiAccessToken(models.Model): + _name = 'document.wopi.access.token' + _description = 'Access token for WOPI requests' + + user_id = fields.Many2one( + 'res.users', 'User', required=True, ondelete='cascade', + ) + attachment_id = fields.Many2one( + 'ir.attachment', 'Attachment', required=True, ondelete='cascade', + ) + valid_until = fields.Datetime() + token = fields.Char(required=True) + token_ttl = fields.Integer(compute='_compute_token_ttl') + action_url = fields.Char(compute='_compute_action_url') + + @api.model + def _get_token(self, attachment): + """Create or return a token ID for some attachment for the current + user. This implies creating a shared lock for the attachment""" + result = self.search([ + ('user_id', '=', self.env.user.id), + ('attachment_id', '=', attachment.id), + '|', + ('valid_until', '=', False), + ('valid_until', '>=', fields.Datetime.now()), + ], limit=1) + valid_until = fields.Datetime.to_string( + datetime.now() + + timedelta(self.env['ir.config_parameter'].get_param( + 'document_wopi.token_validity', 1800, + )) + ) + if result: + result.write({'valid_until': valid_until}) + else: + result = self.create({ + 'user_id': self.env.user.id, + 'attachment_id': attachment.id, + 'valid_until': valid_until, + # TODO: is this random enough or do we need some secret + # generator? + 'token': str(uuid.uuid4()), + }) + result.attachment_id.lock(lock_type='shared', valid_until=valid_until) + return result + + @api.multi + def _compute_token_ttl(self): + for this in self: + if not this.valid_until: + continue + this.token_ttl = int(time.mktime( + fields.Datetime.from_string(this.valid_until).timetuple() + )) + + @api.multi + def _compute_action_url(self): + wopi = self.env['document.wopi'] + discovery = wopi._discovery() + for this in self: + dummy, ext = os.path.splitext(this.attachment_id.datas_fname) + if not ext: + ext = mimetypes.guess_extension(this.attachment_id.file_type) + if not ext: + continue + ext = ext[1:] + action = discovery.get( + (ext, 'edit'), discovery.get((ext, 'view')), + ) + if not action: + continue + + this.action_url = wopi._get_url( + action.get('urlsrc', '') + ) + '?' + urllib.urlencode({ + 'WOPISrc': urlparse.urljoin( + self.env['ir.config_parameter'] + .get_param('web.base.url'), + '/wopi/files/%d' % this.attachment_id.id, + ) + }) + + @api.model + def _verify_token(self, token): + """Be sure a token is valid; return a falsy value for invalid tokens""" + access_token = self.search([('token', '=', token)]) + if not access_token: + return self.browse([]) + try: + access_token.attachment_id.sudo(access_token.user_id).read([]) + except AccessDenied: + return self.browse([]) + if access_token.valid_until < fields.Datetime.now(): + return self.browse([]) + return access_token.sudo(access_token.user_id) diff --git a/document_wopi/models/knowledge_config_settings.py b/document_wopi/models/knowledge_config_settings.py new file mode 100644 index 00000000..19d64e1d --- /dev/null +++ b/document_wopi/models/knowledge_config_settings.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from openerp import _, api, fields, models +from openerp.exceptions import Warning as UserError + + +class KnowledgeConfigSettings(models.TransientModel): + _inherit = 'knowledge.config.settings' + + document_wopi_client = fields.Char( + 'WOPI client', required=True, + default=lambda self: self._default_document_wopi_client() + ) + + @api.multi + def set_document_wopi_client(self): + self.ensure_one() + self.env['ir.config_parameter'].set_param( + 'document_wopi.client', self.document_wopi_client + ) + self.env['document.wopi']._discovery.clear_cache( + self.env['document.wopi'] + ) + response = self.env['document.wopi']._discovery() + if not response: + raise UserError( + _("%s doesn't seem to support WOPI discovery") % + self.document_wopi_client + ) + + @api.model + def _default_document_wopi_client(self): + return self.env['ir.config_parameter'].get_param( + 'document_wopi.client' + ) diff --git a/document_wopi/security/ir.model.access.csv b/document_wopi/security/ir.model.access.csv new file mode 100644 index 00000000..02498f9c --- /dev/null +++ b/document_wopi/security/ir.model.access.csv @@ -0,0 +1,2 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_document_wopi_access_token,access_document_wopi_access_token,model_document_wopi_access_token,base.group_document_user,1,1,1,1 diff --git a/document_wopi/security/ir_rule.xml b/document_wopi/security/ir_rule.xml new file mode 100644 index 00000000..e77dff5d --- /dev/null +++ b/document_wopi/security/ir_rule.xml @@ -0,0 +1,10 @@ + + + + + Users can only work on their own access tokens + [('user_id', '=', user.id)] + + + + diff --git a/document_wopi/static/description/icon.png b/document_wopi/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/document_wopi/static/description/icon.png differ diff --git a/document_wopi/static/src/css/document_wopi.css b/document_wopi/static/src/css/document_wopi.css new file mode 100644 index 00000000..9e1e7787 --- /dev/null +++ b/document_wopi/static/src/css/document_wopi.css @@ -0,0 +1,9 @@ +#document_wopi_iframe +{ + position: absolute; + width: 100%; + height: 100%; + padding: 0px; + margin: 0px; + border: none; +} diff --git a/document_wopi/static/src/js/document_wopi.js b/document_wopi/static/src/js/document_wopi.js new file mode 100644 index 00000000..f8f99183 --- /dev/null +++ b/document_wopi/static/src/js/document_wopi.js @@ -0,0 +1,89 @@ +//-*- coding: utf-8 -*- +//© 2017 Therp BV +//License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +openerp.document_wopi = function(instance) +{ + var _t = instance.web._t; + instance.web.Sidebar.include( + { + on_attachments_loaded: function(attachments) + { + var self = this; + return jQuery.when(this._super.apply(this, arguments)) + .then(function() + { + self.$el.find('.oe-sidebar-document-wopi') + .click(self.on_document_wopi); + }); + }, + on_document_wopi: function(e) + { + var self = this, + $target = jQuery(e.currentTarget), + attachment_id = $target.data('id'); + e.preventDefault(); + e.stopPropagation(); + return new instance.web.Model('document.wopi').call( + 'get_access_token', [attachment_id] + ).then(function(access_token_id) + { + return self.document_wopi_frame(access_token_id); + }); + }, + document_wopi_frame: function(access_token_id) + { + this.do_action({ + type: 'ir.actions.client', + name: 'document_wopi', + tag: 'document_wopi', + params: { + access_token_id: access_token_id, + } + }); + }, + }); + instance.document_wopi.OfficeWidget = instance.web.Widget.extend({ + 'template': 'DocumentWopiOfficeWidget', + init: function(parent, options) + { + this.params = options.params; + return this._super.apply(this, arguments); + }, + start: function() + { + return this._super.apply(this, arguments) + .then(this.proxy('get_token_values')) + .then(this.proxy('call_wopi_client')); + }, + get_token_values: function() + { + var self = this; + return new instance.web.Model('document.wopi.access.token').query( + ['token', 'token_ttl', 'attachment_id', 'action_url'] + ) + .filter([['id', '=', this.params.access_token_id]]) + .first() + .then(function(data) + { + self.params = _.extend(self.params, data); + }); + }, + call_wopi_client: function() + { + if(!this.params.action_url) + { + throw instance.web._t('Unsupported document!'); + } + this.$('form').attr('action', this.params.action_url); + this.$('form input[name="access_token"]').val(this.params.token); + this.$('form input[name="access_token_ttl"]').val( + this.params.token_ttl + ); + this.$('form').submit(); + }, + }); + instance.web.client_actions.add( + 'document_wopi', 'instance.document_wopi.OfficeWidget' + ); +}; diff --git a/document_wopi/static/src/xml/document_wopi.xml b/document_wopi/static/src/xml/document_wopi.xml new file mode 100644 index 00000000..7e4e0163 --- /dev/null +++ b/document_wopi/static/src/xml/document_wopi.xml @@ -0,0 +1,17 @@ + + + + + + + + + +
+
+ + +
+