diff --git a/addons/web_unsplash/__init__.py b/addons/web_unsplash/__init__.py new file mode 100644 index 00000000000..7d34c7c054a --- /dev/null +++ b/addons/web_unsplash/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/addons/web_unsplash/__manifest__.py b/addons/web_unsplash/__manifest__.py new file mode 100644 index 00000000000..3ad4b1334b3 --- /dev/null +++ b/addons/web_unsplash/__manifest__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'Unsplash Image Library', + 'category': 'Web', + 'summary': 'Find free high-resolution images from Unsplash', + 'version': '1.0', + 'description': """Explore the free high-resolution image library of Unsplash.com and find images to use in Odoo. An Unsplash search bar is added to the image library modal.""", + 'depends': ['base_setup', 'web_editor'], + 'data': [ + 'views/res_config_settings_view.xml', + 'views/web_unsplash_templates.xml', + ], + 'auto_install': True, +} diff --git a/addons/web_unsplash/controllers/__init__.py b/addons/web_unsplash/controllers/__init__.py new file mode 100644 index 00000000000..7fc0cd7cb93 --- /dev/null +++ b/addons/web_unsplash/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import main diff --git a/addons/web_unsplash/controllers/main.py b/addons/web_unsplash/controllers/main.py new file mode 100644 index 00000000000..0c318192bdc --- /dev/null +++ b/addons/web_unsplash/controllers/main.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 +import io +import logging +import requests +import werkzeug.utils + +from PIL import Image +from odoo import http, tools +from odoo.http import request + +logger = logging.getLogger(__name__) + + +class Web_Unsplash(http.Controller): + + # ------------------------------------------------------ + # add unsplash image url + # ------------------------------------------------------ + @http.route('/web_unsplash/attachment/add', type='json', auth='user', methods=['POST']) + def save_unsplash_url(self, unsplashurls=None, **kwargs): + """ + unsplashurls = { + image_id1: image_url1, + image_id2: image_url2, + ..... + } + """ + if not unsplashurls: + return [] + + uploads = [] + Attachments = request.env['ir.attachment'] + + res_model = kwargs.get('res_model', 'ir.ui.view') + if res_model != 'ir.ui.view' and kwargs.get('res_id'): + res_id = int(kwargs['res_id']) + else: + res_id = None + + for key in unsplashurls: + url = unsplashurls[key].get('url') + try: + req = requests.get(url) + if req.status_code != requests.codes.ok: + continue + + # get mime-type of image url because unsplash url dosn't contains mime-types in url + mimetype = req.headers.get('Content-Type') + datas = req.content + except requests.exceptions.ConnectionError as e: + logger.exception("Connection Error: " + str(e)) + continue + except requests.exceptions.Timeout as e: + logger.exception("Timeout: " + str(e)) + continue + + image = Image.open(io.BytesIO(datas)) + image_format = image.format + + # optimized image before save + if image_format and image_format in ('PNG', 'JPEG'): + datas = tools.image_save_for_web(image) + + name = key + # append image extension in name + if image_format: + name += '.' + image_format.lower() + + attachment = Attachments.create({ + 'name': name, + 'datas_fname': name, + 'mimetype': mimetype, + 'datas': base64.b64encode(datas), + 'public': res_model == 'ir.ui.view', + 'res_id': res_id, + 'res_model': res_model, + }) + attachment.generate_access_token() + uploads.extend(attachment.read(['name', 'mimetype', 'checksum', 'res_id', 'res_model', 'access_token'])) + + return uploads + + @http.route("/web_unsplash/get_client_id", type='json', auth="user") + def get_unsplash_client_id(self, **post): + if request.env.user._has_unsplash_key_rights(): + return request.env['ir.config_parameter'].sudo().get_param('unsplash.access_key') + raise werkzeug.exceptions.NotFound() + + @http.route("/web_unsplash/set_client_id", type='json', auth="user") + def set_unsplash_client_id(self, key, **post): + if request.env.user._has_unsplash_key_rights(): + return request.env['ir.config_parameter'].sudo().set_param('unsplash.access_key', key) + raise werkzeug.exceptions.NotFound() diff --git a/addons/web_unsplash/models/__init__.py b/addons/web_unsplash/models/__init__.py new file mode 100644 index 00000000000..63a963dae32 --- /dev/null +++ b/addons/web_unsplash/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_config_settings +from . import res_users diff --git a/addons/web_unsplash/models/res_config_settings.py b/addons/web_unsplash/models/res_config_settings.py new file mode 100644 index 00000000000..2eca06a5f41 --- /dev/null +++ b/addons/web_unsplash/models/res_config_settings.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + unsplash_access_key = fields.Char("Access Key", config_parameter='unsplash.access_key') diff --git a/addons/web_unsplash/models/res_users.py b/addons/web_unsplash/models/res_users.py new file mode 100644 index 00000000000..9400c06b075 --- /dev/null +++ b/addons/web_unsplash/models/res_users.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import api, models + + +class ResUsers(models.Model): + _inherit = 'res.users' + + @api.multi + def _has_unsplash_key_rights(self): + self.ensure_one() + return self.has_group('base.group_erp_manager') diff --git a/addons/web_unsplash/static/description/icon.png b/addons/web_unsplash/static/description/icon.png new file mode 100644 index 00000000000..49ab112cb6b Binary files /dev/null and b/addons/web_unsplash/static/description/icon.png differ diff --git a/addons/web_unsplash/static/description/icon2.png b/addons/web_unsplash/static/description/icon2.png new file mode 100644 index 00000000000..62615241f3f Binary files /dev/null and b/addons/web_unsplash/static/description/icon2.png differ diff --git a/addons/web_unsplash/static/description/index.html b/addons/web_unsplash/static/description/index.html new file mode 100644 index 00000000000..f8c1d62dfa8 --- /dev/null +++ b/addons/web_unsplash/static/description/index.html @@ -0,0 +1,22 @@ +
+
+

Free high-resolution image library

+

An Unsplash search bar is added to the image library modal...

+
+
+ +
+
+
+
+
+
+

...to explore Unsplash.com and find images to use in Odoo.

+
+
+ +
+
+
+
+ diff --git a/addons/web_unsplash/static/description/unsplash_interface.png b/addons/web_unsplash/static/description/unsplash_interface.png new file mode 100644 index 00000000000..841ebb6d75c Binary files /dev/null and b/addons/web_unsplash/static/description/unsplash_interface.png differ diff --git a/addons/web_unsplash/static/description/unsplash_interface_bar.png b/addons/web_unsplash/static/description/unsplash_interface_bar.png new file mode 100644 index 00000000000..db4b161d2d0 Binary files /dev/null and b/addons/web_unsplash/static/description/unsplash_interface_bar.png differ diff --git a/addons/web_unsplash/static/src/js/unsplash_image_widget.js b/addons/web_unsplash/static/src/js/unsplash_image_widget.js new file mode 100644 index 00000000000..7ba5f4961da --- /dev/null +++ b/addons/web_unsplash/static/src/js/unsplash_image_widget.js @@ -0,0 +1,219 @@ +odoo.define('web_unsplash.image_widgets', function (require) { +'use strict'; + +var core = require('web.core'); +var UnsplashAPI = require('unsplash.api'); + +var ImageWidget = require('web_editor.widget').ImageWidget; +var QWeb = core.qweb; + +var unsplashAPI = null; + +ImageWidget.include({ + xmlDependencies: ImageWidget.prototype.xmlDependencies.concat( + ['/web_unsplash/static/src/xml/unsplash_image_widget.xml'] + ), + events: _.extend({}, ImageWidget.prototype.events, { + 'input input.unsplash_search': '_onChangeUnsplashSearch', + 'dblclick .unsplash_img_container [data-imgid]': '_onUnsplashImgDblClick', + 'click .unsplash_img_container [data-imgid]': '_onUnsplashImgClick', + 'click button.access_key': '_onSetAccessKey', + }), + /** + * @override + */ + init: function () { + this._unsplash = { + selectedImages: {}, + isMaxed: false, + query: false, + }; + // TODO This is a `hack` to prevent the UnsplashAPI to be destroyed every time + // the media dialog is closed. + // Indeed, UnsplashAPI has a cache system to recude unsplash call, it is + // then better to keep its state to benefic from it from one media dialog + // call to another. + // Unsplash API will either be (it's still being discussed): + // * a service (ideally coming with an improvement to not auto load the service) + // * initialized in the website_root (trigger_up) + var def = this._super.apply(this, arguments); + if (unsplashAPI === null) { + this.unsplashAPI = new UnsplashAPI(this); + unsplashAPI = this.unsplashAPI; + } else { + this.unsplashAPI = unsplashAPI; + this.unsplashAPI.setParent(this); + } + return def; + }, + destroy: function () { + // TODO See `hack` explained in `init`. This prevent the media dialog destroy + // to destroy unsplashAPI when destroying the children + this.unsplashAPI.setParent(undefined); + this._super.apply(this, arguments); + }, + + // -------------------------------------------------------------------------- + // Public + // -------------------------------------------------------------------------- + + /** + * @override + */ + getControlPanelConfig: function () { + var config = this._super.apply(this, arguments); + if (this._unsplash.query) { + _.extend(config, { + pagerLeftEnabled: this.page > 1, + pagerRightEnabled: !this._unsplash.isMaxed, + }); + } + return config; + }, + /** + * @override + */ + save: function () { + if (!this._unsplash.query) { + return this._super.apply(this, arguments); + } + var self = this; + var args = arguments; + var _super = this._super; + return this._rpc({ + route: '/web_unsplash/attachment/add', + params: { + unsplashurls: self._unsplash.selectedImages, + res_model : self.options.res_model || "ir.ui.view", + res_id: self.options.res_id, + } + }).then(function (images) { + for (var img in self._unsplash.selectedImages) { + self.unsplashAPI.notifyDownload(self._unsplash.selectedImages[img].download_url); + } + + _.each(images, function (image) { + image.src = _.str.sprintf('/web/image/%s', image.id); + image.isDocument = !(/gif|jpe|jpg|png/.test(image.mimetype)); + }); + self.images = images; + return _super.apply(self, args); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Highlights selected image, when an image is clicked and when pager change + * + * @private + */ + _highlightSelectedImages: function () { + var self = this; + if (!this._unsplash.query) { + return this._super.apply(this, arguments); + } + this.$('.o_unsplash_img_cell.o_selected').removeClass("o_selected"); + var $select = this.$('.o_unsplash_img_cell [data-imgid]').filter(function () { + return $(this).data('imgid') in self._unsplash.selectedImages; + }); + $select.closest('.o_unsplash_img_cell').addClass("o_selected"); + return $select; + }, + /** + * @override + */ + _renderImages: function () { + var self = this; + if (!this._unsplash.query) { + return this._super.apply(this, arguments); + } + this.unsplashAPI.getImages(self._unsplash.query, this.IMAGES_PER_PAGE, this.page).then(function (res) { + self._unsplash.isMaxed = res.isMaxed; + var rows = _(res.images).chain() + .groupBy(function (a, index) { return Math.floor(index / self.IMAGES_PER_ROW); }) + .values() + .value(); + + self.$('.unsplash_img_container').html(QWeb.render('web_unsplash.dialog.image.content', { rows: rows })); + self._highlightSelectedImages(); + }).fail(function (err) { + self.$('.unsplash_img_container').html(QWeb.render('web_unsplash.dialog.error.content', err)); + }).always(function () { + self._toggleAttachmentContaines(false); + }); + }, + /** + * @private + */ + _toggleAttachmentContaines: function (hideUnsplash) { + this.$('.existing-attachments').toggleClass('o_hidden', !hideUnsplash); + this.$('.unsplash_img_container').toggleClass('o_hidden', hideUnsplash); + this.trigger_up('update_control_panel'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onSetAccessKey: function () { + var self = this; + var key = this.$('#accessKeyInput').val().trim(); + if (key) { + this._rpc({ + route: '/web_unsplash/set_client_id', + params: { + 'key': key, + }, + }).then(function () { + self.unsplashAPI.clientId = key; + self._renderImages(); + }); + } + }, + /** + * @private + */ + _onChangeUnsplashSearch: _.debounce(function () { + // oldPage saves the original image widget pager. + // Emptying the unsplash search will set the pager to its previous state + this._unsplash.query = this.$('.unsplash_search').val().trim(); + if (this._unsplash.query) { + this.oldPage = this.page; + this.page = 1; + this._renderImages(); + } else { + this.page = this.oldPage || 0; + this._toggleAttachmentContaines(true); + } + }, 1000), + /** + * @private + */ + _onUnsplashImgClick: function (ev) { + var imgid = $(ev.currentTarget).data('imgid'); + var url = $(ev.currentTarget).data('url'); + var download_url = $(ev.currentTarget).data('download-url'); + if (!this.multiImages) { + this._unsplash.selectedImages = {}; + } + if (imgid in this._unsplash.selectedImages) { + delete this._unsplash.selectedImages[imgid]; + } else { + this._unsplash.selectedImages[imgid] = {url: url, download_url: download_url}; + } + this._highlightSelectedImages(); + }, + /** + * @private + */ + _onUnsplashImgDblClick: function (ev) { + this.trigger_up('save_request'); + }, +}); +}); diff --git a/addons/web_unsplash/static/src/js/unsplashapi.js b/addons/web_unsplash/static/src/js/unsplashapi.js new file mode 100644 index 00000000000..0ceb2b8f8a7 --- /dev/null +++ b/addons/web_unsplash/static/src/js/unsplashapi.js @@ -0,0 +1,114 @@ +odoo.define('unsplash.api', function (require) { +'use strict'; + +var Class = require('web.Class'); +var rpc = require('web.rpc'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); + +var UnsplashCore = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @constructor + */ + init: function (parent) { + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + + this._cache = {}; + this.clientId = false; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Gets unsplash images from query string. + * + * @param {String} query search terms + * @param {Integer} pageSize number of image to display per page + * @param {Integer} pageNumber page number to retrieve + */ + getImages: function (query, pageSize, pageNumber) { + var self = this; + var to = pageSize * pageNumber; + var from = to - pageSize; + var cachedData = this._cache[query]; + + if (cachedData && (cachedData.images.length >= to || (cachedData.totalImages !== 0 && cachedData.totalImages < to))) { + return $.when({ images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages }); + } + return this._getAPIKey().then(function (clientID) { + if (!clientID) { + return $.Deferred().reject({ key_not_found: true }); + } + return self._fetchImages(query).then(function (cachedData) { + return { images: cachedData.images.slice(from, to), isMaxed: to > cachedData.totalImages }; + }); + }); + }, + /** + * Notifies Unsplash from an image download. (API requirement) + * + * @param {String} url url of the image to notify + */ + notifyDownload: function (url) { + $.get(url, { client_id: this.clientId }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Checks and retrieves the unsplash API key + * + * @private + */ + _getAPIKey: function () { + var self = this; + if (this.clientId) { + return $.Deferred().resolve(self.clientId); + } + return this._rpc({ + route: '/web_unsplash/get_client_id', + }).then(function (res) { + self.clientId = res; + return res; + }); + }, + /** + * Fetches images from unsplash and stores it in cache + * + * @param {String} query search terms + * @private + */ + _fetchImages: function (query) { + if (!this._cache[query]) { + this._cache[query] = { + images: [], + maxPages: 0, + totalImages: 0, + pageCached: 0 + }; + } + var cachedData = this._cache[query]; + var payload = { + query: query, + page: cachedData.pageCached + 1, + client_id: this.clientId, + per_page: 30, // max size from unsplash API + }; + return $.get('https://api.unsplash.com/search/photos/', payload).then(function (result) { + cachedData.pageCached++; + cachedData.images.push.apply(cachedData.images, result.results); + cachedData.maxPages = result.total_pages; + cachedData.totalImages = result.total; + return cachedData; + }); + }, +}); + +return UnsplashCore; + +}); diff --git a/addons/web_unsplash/static/src/scss/unsplash.scss b/addons/web_unsplash/static/src/scss/unsplash.scss new file mode 100644 index 00000000000..71e97b362b7 --- /dev/null +++ b/addons/web_unsplash/static/src/scss/unsplash.scss @@ -0,0 +1,53 @@ +.unsplash_img_container { + $o-border-image: 2px; + .o_unsplash_img_cell { + .o_image { + cursor: pointer; + transition: opacity 400ms; + + &.o_webimage { + width: 100%; + height: auto; + background-image: none; + + img { + max-height: 150px; + margin: 0 auto; + border: $o-border-image solid transparent; + } + } + + } + &.o_selected .o_image img { + border-color: $o-brand-primary; + box-shadow: 0px 0px 2px 2px $o-brand-secondary; + } + .author_name { + font-size: 11px; + } + } + + .not-found { + font-size: 25px; + padding: 50px 0; + } + + .unsplash_error { + padding: 50px 0; + .title { + font-size: 25px; + text-transform: uppercase; + } + .details { + font-size: 20px; + .access_key_box { + padding: 9px; + background-color: #fcfcfc; + border: 1px solid #ededee; + #accessKeyInput { + width: 300px; + } + } + } + } +} diff --git a/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml new file mode 100644 index 00000000000..3d18b932dd9 --- /dev/null +++ b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml @@ -0,0 +1,89 @@ + + + + + +
+ — or — + +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+
+ Photos not found +
+
+
+ + +
+ +
+

+ Access key is not set +

+
+ Generate an access key +
+
+ + +
+
+
+
+
+ +
+

+ Search is temporary unavailable +

+
+ The max number of searches is exceeded. Please retry in an hour or extend to a better account. +
+
+
+ +
+

+ Unauthorized Key +

+
+ Please check your unsplash api key. +
+
+ + +
+
+
+
+
+ +
+

+ Something went wrong +

+
+ Please check your internet connection or contact administrator. +
+
+
+
+
+ +
diff --git a/addons/web_unsplash/views/res_config_settings_view.xml b/addons/web_unsplash/views/res_config_settings_view.xml new file mode 100644 index 00000000000..1aca1e2d747 --- /dev/null +++ b/addons/web_unsplash/views/res_config_settings_view.xml @@ -0,0 +1,21 @@ + + + + res.config.settings.view.form.inherit.web.unsplash + res.config.settings + + + + + + diff --git a/addons/web_unsplash/views/web_unsplash_templates.xml b/addons/web_unsplash/views/web_unsplash_templates.xml new file mode 100644 index 00000000000..33a95d8baa0 --- /dev/null +++ b/addons/web_unsplash/views/web_unsplash_templates.xml @@ -0,0 +1,10 @@ + + + +