[ADD] web_unsplash: Find free high-resolution images from Unsplash

Move module from enterprise to community

Work of @dep-odoo and @rdeodoo
This commit is contained in:
Romain Derie
2018-08-13 20:38:38 +02:00
committed by Jeremy Kersten
parent 0b608109ee
commit 732702d5e2
18 changed files with 672 additions and 0 deletions
+5
View File
@@ -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
+15
View File
@@ -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,
}
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+95
View File
@@ -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()
+5
View File
@@ -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
@@ -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')
+12
View File
@@ -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')
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,22 @@
<section class="oe_container">
<div class="oe_row oe_spaced">
<h2 class="oe_slogan" style="color:#875A7B;">Free high-resolution image library</h2>
<h3 class="oe_slogan">An Unsplash search bar is added to the image library modal...</h3>
<div class="oe_span12">
<div class="oe_demo oe_picture oe_screenshot">
<img src="unsplash_interface_bar.png">
</div>
</div>
</div>
</section>
<section class="oe_container">
<div class="oe_row oe_spaced">
<h3 class="oe_slogan">...to explore <a href="https://unsplash.com/">Unsplash.com</a> and find images to use in Odoo.</h3>
<div class="oe_span12">
<div class="oe_demo oe_picture oe_screenshot">
<img src="unsplash_interface.png">
</div>
</div>
</div>
</section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -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');
},
});
});
@@ -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;
});
@@ -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;
}
}
}
}
}
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-extend="web_editor.dialog.image">
<t t-jquery=".card-body .form-inline" t-operation="append">
<div class="ml8">
<span class="text-muted">— or —</span>
<input type="text" class="form-control unsplash_search mr8" placeholder="Search from Unsplash" style="width: 250px;"/>
</div>
</t>
<t t-jquery=".existing-attachments" t-operation="after">
<div class="unsplash_img_container"></div>
</t>
</t>
<t t-name="web_unsplash.dialog.image.content">
<div class="row mt16" t-as="row" t-foreach="rows">
<div class="col-sm-2 o_unsplash_img_cell" t-as="img" t-foreach="row">
<div class="o_image o_webimage" t-att-data-imgid="img.id" t-att-data-url="img.urls.regular" t-att-data-download-url="img.links.download_location">
<img class="img-fluid" t-att-src="img.urls.thumb" />
</div>
<div class="author_name text-center"><a t-att-href="img.user.links.html" target="_blank"><t t-esc="img.user.name"/></a></div>
</div>
</div>
<div class="row mt16" t-if="rows.length == 0">
<div class="text-center text-muted not-found">
<i class="fa fa-camera mr8"/> Photos not found
</div>
</div>
</t>
<t t-name="web_unsplash.dialog.error.content">
<div class="row mt16 text-muted unsplash_error">
<t t-if="key_not_found">
<div class="text-center">
<p class="title">
Access key is not set
</p>
<div class="details">
<a href="https://unsplash.com/oauth/applications" target="_blank"><i class="fa fa-arrow-right"/> Generate an access key</a>
<div>
<div class="form-group btn-group access_key_box">
<input type="text" class="form-control float-left" id="accessKeyInput" placeholder="Paste your access key here"/>
<button type="button" class="btn btn-primary access_key">Apply</button>
</div>
</div>
</div>
</div>
</t>
<t t-elif="status == 403">
<div class="text-center">
<p class="title">
Search is temporary unavailable
</p>
<div class="details">
The max number of searches is exceeded. Please retry in an hour or extend to a better account.
</div>
</div>
</t>
<t t-elif="status == 401">
<div class="text-center">
<p class="title">
Unauthorized Key
</p>
<div class="details">
Please check your unsplash api key.
<div>
<div class="form-group btn-group mt16 access_key_box">
<input type="text" class="form-control float-left" id="accessKeyInput" placeholder="Paste your access key here"/>
<button type="button" class="btn btn-primary access_key">Add</button>
</div>
</div>
</div>
</div>
</t>
<t t-else="">
<div class="text-center">
<p class="title">
Something went wrong
</p>
<div class="details">
Please check your internet connection or contact administrator.
</div>
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.web.unsplash</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<div id="web_unsplash_settings" position="inside">
<div class="content-group" attrs="{'invisible': [('module_web_unsplash', '=', False)]}">
<label for="unsplash_access_key" class="o_light_label"/>
<field name="unsplash_access_key"/>
</div>
<div>
<a href="https://unsplash.com/oauth/applications" class="oe_link" target="_blank">
<i class="fa fa-arrow-right"/> Generate an Access Key
</a>
</div>
</div>
</field>
</record>
</odoo>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="web_unsplash_assets" inherit_id="web_editor.assets_editor" name="Unsplash Images">
<xpath expr="." position="inside">
<link rel="stylesheet" type="text/scss" href="/web_unsplash/static/src/scss/unsplash.scss"/>
<script type="text/javascript" src="/web_unsplash/static/src/js/unsplashapi.js"></script>
<script type="text/javascript" src="/web_unsplash/static/src/js/unsplash_image_widget.js"></script>
</xpath>
</template>
</odoo>