diff --git a/addons/website_twitter/__init__.py b/addons/website_twitter/__init__.py new file mode 100644 index 00000000000..396c76fe87a --- /dev/null +++ b/addons/website_twitter/__init__.py @@ -0,0 +1,2 @@ +import models +import controllers diff --git a/addons/website_twitter/__openerp__.py b/addons/website_twitter/__openerp__.py new file mode 100644 index 00000000000..96e17ff9356 --- /dev/null +++ b/addons/website_twitter/__openerp__.py @@ -0,0 +1,25 @@ +{ + 'name': 'Twitter Roller', + 'category': 'Website', + 'summary': 'Add twitter scroller snippet in website builder', + 'version': '1.0', + 'description': """ +Display best tweets +======================== + + """, + 'author': 'OpenERP SA', + 'depends': ['website'], + 'data': [ + 'security/ir.model.access.csv', + 'data/twitter_data.xml', + 'views/twitter_view.xml', + 'views/twitter_snippet.xml' + ], + 'demo': [], + 'qweb': [], + 'js': [], + 'css': [], + 'installable': True, + 'application': True, +} diff --git a/addons/website_twitter/controllers/__init__.py b/addons/website_twitter/controllers/__init__.py new file mode 100644 index 00000000000..8ee9bae18d9 --- /dev/null +++ b/addons/website_twitter/controllers/__init__.py @@ -0,0 +1 @@ +import main diff --git a/addons/website_twitter/controllers/main.py b/addons/website_twitter/controllers/main.py new file mode 100644 index 00000000000..afd636a26c4 --- /dev/null +++ b/addons/website_twitter/controllers/main.py @@ -0,0 +1,32 @@ +from openerp.addons.web import http +from openerp.addons.web.http import request +from openerp.tools.translate import _ + +import json + +class Twitter(http.Controller): + @http.route(['/twitter_reload'], type='json', auth="user", website=True) + def twitter_reload(self): + return request.website.fetch_favorite_tweets() + + @http.route(['/get_favorites'], type='json', auth="public", website=True) + def get_tweets(self, limit=20): + key = request.website.twitter_api_key + secret = request.website.twitter_api_secret + screen_name = request.website.twitter_screen_name + if not key or not secret: + return {"error": _("Please set the Twitter API Key and Secret in the Website Settings.")} + if not screen_name: + return {"error": _("Please set a Twitter screen name to load favorites from, " + "in the Website Settings (it does not have to be yours)")} + twitter_tweets = request.registry['website.twitter.tweet'] + tweets = twitter_tweets.search_read( + request.cr, request.uid, + [('website_id','=', request.website.id), + ('screen_name','=', screen_name)], + ['tweet'], limit=int(limit), order="tweet_id desc", context=request.context) + if len(tweets) < 12: + return {"error": _("Twitter user @%(username)s has less than 12 favorite tweets. " + "Please add more or choose a different screen name.") % \ + {'username': screen_name}} + return [json.loads(tweet['tweet']) for tweet in tweets] diff --git a/addons/website_twitter/data/twitter_data.xml b/addons/website_twitter/data/twitter_data.xml new file mode 100644 index 00000000000..b7ca4281504 --- /dev/null +++ b/addons/website_twitter/data/twitter_data.xml @@ -0,0 +1,16 @@ + + + + + Fetch new Twitter favorites + 2 + hours + -1 + + website + _refresh_favorite_tweets + () + + + + diff --git a/addons/website_twitter/models/__init__.py b/addons/website_twitter/models/__init__.py new file mode 100644 index 00000000000..8c34d4f968e --- /dev/null +++ b/addons/website_twitter/models/__init__.py @@ -0,0 +1,3 @@ +import twitter +import twitter_config + diff --git a/addons/website_twitter/models/twitter.py b/addons/website_twitter/models/twitter.py new file mode 100644 index 00000000000..7c1c417040b --- /dev/null +++ b/addons/website_twitter/models/twitter.py @@ -0,0 +1,115 @@ +from urllib2 import urlopen, Request, HTTPError + +import base64 +import json +import logging +import werkzeug + +from openerp.osv import fields, osv + +API_ENDPOINT = 'https://api.twitter.com' +API_VERSION = '1.1' +REQUEST_TOKEN_URL = '%s/oauth2/token' % API_ENDPOINT +REQUEST_FAVORITE_LIST_URL = '%s/%s/favorites/list.json' % (API_ENDPOINT, API_VERSION) +URLOPEN_TIMEOUT = 10 + +_logger = logging.getLogger(__name__) + +class TwitterClient(osv.osv): + _inherit = "website" + + _columns = { + 'twitter_api_key': fields.char('Twitter API key', help="Twitter API Key"), + 'twitter_api_secret': fields.char('Twitter API secret', help="Twitter API Secret"), + 'twitter_screen_name': fields.char('Get favorites from this screen name'), + } + + def _request(self, website, url, params=None): + """Send an authenticated request to the Twitter API.""" + access_token = self._get_access_token(website) + if params: + params = werkzeug.url_encode(params) + url = url + '?' + params + try: + request = Request(url) + request.add_header('Authorization', 'Bearer %s' % access_token) + return json.load(urlopen(request, timeout=URLOPEN_TIMEOUT)) + except HTTPError, e: + _logger.debug("Twitter API request failed with code: %r, msg: %r, content: %r", + e.code, e.msg, e.fp.read()) + raise + + def _refresh_favorite_tweets(self, cr, uid, context=None): + ''' called by cron job ''' + website = self.pool['website'] + ids = self.pool['website'].search(cr, uid, [('twitter_api_key', '!=', False), + ('twitter_api_secret', '!=', False), + ('twitter_screen_name', '!=', False)], + context=context) + _logger.debug("Refreshing tweets for website IDs: %r", ids) + website.fetch_favorite_tweets(cr, uid, ids, context=context) + + def fetch_favorite_tweets(self, cr, uid, ids, context=None): + website_tweets = self.pool['website.twitter.tweet'] + tweet_ids = [] + for website in self.browse(cr, uid, ids, context=context): + if not all((website.twitter_api_key, website.twitter_api_secret, + website.twitter_screen_name)): + _logger.debug("Skip fetching favorite tweets for unconfigured website %s", + website) + continue + params = {'screen_name': website.twitter_screen_name} + last_tweet = website_tweets.search_read( + cr, uid, [('website_id', '=', website.id), + ('screen_name', '=', website.twitter_screen_name)], + ['tweet_id'], + limit=1, order='tweet_id desc', context=context) + if last_tweet: + params['since_id'] = int(last_tweet[0]['tweet_id']) + _logger.debug("Fetching favorite tweets using params %r", params) + response = self._request(website, REQUEST_FAVORITE_LIST_URL, params=params) + for tweet_dict in response: + tweet_id = tweet_dict['id'] # unsigned 64-bit snowflake ID + tweet_ids = website_tweets.search(cr, uid, [('tweet_id', '=', tweet_id)]) + if not tweet_ids: + new_tweet = website_tweets.create( + cr, uid, + { + 'website_id': website.id, + 'tweet': json.dumps(tweet_dict), + 'tweet_id': tweet_id, # stored in NUMERIC PG field + 'screen_name': website.twitter_screen_name, + }, + context=context) + _logger.debug("Found new favorite: %r, %r", tweet_id, tweet_dict) + tweet_ids.append(new_tweet) + return tweet_ids + + def _get_access_token(self, website): + """Obtain a bearer token.""" + bearer_token_cred = '%s:%s' % (website.twitter_api_key, website.twitter_api_secret) + encoded_cred = base64.b64encode(bearer_token_cred) + request = Request(REQUEST_TOKEN_URL) + request.add_header('Content-Type', + 'application/x-www-form-urlencoded;charset=UTF-8') + request.add_header('Authorization', + 'Basic %s' % encoded_cred) + request.add_data('grant_type=client_credentials') + data = json.load(urlopen(request, timeout=URLOPEN_TIMEOUT)) + access_token = data['access_token'] + return access_token + +class WebsiteTwitterTweet(osv.osv): + _name = "website.twitter.tweet" + _description = "Twitter Tweets" + _columns = { + 'website_id': fields.many2one('website', string="Website"), + 'screen_name': fields.char("Screen Name"), + 'tweet': fields.text('Tweets'), + + # Twitter IDs are 64-bit unsigned ints, so we need to store them in + # unlimited precision NUMERIC columns, which can be done with a + # float field. Used digits=(0,0) to indicate unlimited. + # Using VARCHAR would work too but would have sorting problems. + 'tweet_id': fields.float("Tweet ID", digits=(0,0)), # Twitter + } diff --git a/addons/website_twitter/models/twitter_config.py b/addons/website_twitter/models/twitter_config.py new file mode 100644 index 00000000000..3330a5b0424 --- /dev/null +++ b/addons/website_twitter/models/twitter_config.py @@ -0,0 +1,42 @@ +import logging + +from openerp.osv import fields, osv +from openerp.tools.translate import _ + +_logger = logging.getLogger(__name__) + +class twitter_config_settings(osv.osv_memory): + _inherit = 'website.config.settings' + + _columns = { + 'twitter_api_key': fields.related( + 'website_id', 'twitter_api_key', type="char", + string='Twitter API Key', + help="Twitter API key you can get it from https://apps.twitter.com/app/new"), + 'twitter_api_secret': fields.related( + 'website_id', 'twitter_api_secret', type="char", + string='Twitter API secret', + help="Twitter API secret you can get it from https://apps.twitter.com/app/new"), + 'twitter_tutorial': fields.dummy( + type="boolean", string="Show me how to obtain the Twitter API Key and Secret"), + 'twitter_screen_name': fields.related( + 'website_id', 'twitter_screen_name', + type="char", string='Get favorites from this screen name', + help="Screen Name of the Twitter Account from which you want to load favorites." + "It does not have to match the API Key/Secret."), + } + + def _check_twitter_authorization(self, cr, uid, config_id, context=None): + website_obj = self.pool['website'] + website_config = self.browse(cr, uid, config_id, context=context) + try: + website_obj.fetch_favorite_tweets(cr, uid, [website_config.website_id.id], context=context) + except Exception: + _logger.warning('Failed to verify twitter API authorization', exc_info=True) + raise osv.except_osv(_('Twitter authorization error!'), _('Please double-check your Twitter API Key and Secret')) + + def create(self, cr, uid, vals, context=None): + res_id = super(twitter_config_settings, self).create(cr, uid, vals, context=context) + if vals.get('twitter_api_key') and vals.get('twitter_api_secret'): + self._check_twitter_authorization(cr, uid, res_id, context=context) + return res_id \ No newline at end of file diff --git a/addons/website_twitter/security/ir.model.access.csv b/addons/website_twitter/security/ir.model.access.csv new file mode 100644 index 00000000000..eb88063e9c5 --- /dev/null +++ b/addons/website_twitter/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_website_twitter_tweet_public,access of twitter snippet,website_twitter.model_website_twitter_tweet,,1,0,0,0 diff --git a/addons/website_twitter/static/src/css/Makefile b/addons/website_twitter/static/src/css/Makefile new file mode 100644 index 00000000000..21d0e6cdc39 --- /dev/null +++ b/addons/website_twitter/static/src/css/Makefile @@ -0,0 +1,3 @@ +sass: + sass -t expanded --compass --unix-newlines --watch website.twitter.sass:website.twitter.css + diff --git a/addons/website_twitter/static/src/css/website.twitter.css b/addons/website_twitter/static/src/css/website.twitter.css new file mode 100644 index 00000000000..b44bf800bd7 --- /dev/null +++ b/addons/website_twitter/static/src/css/website.twitter.css @@ -0,0 +1,110 @@ +.wrap-row { + position: relative; + overflow: hidden; + height: 310px; +} +.wrap-row .twitter-row { + position: absolute; + width: 100%; + height: auto; +} +.wrap-row .twitter-row div.scrollWrapper { + position: relative; + overflow: hidden; + width: 100%; + height: 100%; +} +.wrap-row .twitter-row div.scrollableArea { + position: relative; + width: auto; + height: 100%; +} +.wrap-row .twitter-row div .tweet { + border: 1px solid #cccccc; + max-width: 500px; + width: 500px; + font-size: 0.8em; + padding-top: 12px; + padding-right: 10px; + padding-bottom: 12px; + padding-left: 10px; + float: left; + display: block; + margin: 6px; + max-height: 90px; + height: 90px; + opacity: 0.6; +} +.wrap-row .twitter-row div .tweet h4, .wrap-row .twitter-row div .tweet p { + padding: 0; + margin: 0; +} +.wrap-row .twitter-row div .tweet .left { + display: block; + float: left; + width: 80px; +} +.wrap-row .twitter-row div .tweet .left img { + width: 65px; + height: auto; + float: left; + display: block; + margin: 0px 5px 0px -5px; +} +.wrap-row .twitter-row div .tweet .right { + display: block; + float: left; + width: 470px; +} +.wrap-row .twitter-row div .tweet .right .top { + height: 20px; +} +.wrap-row .twitter-row div .tweet h4 { + font-size: 14px; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: bold; + color: black; + float: left; + display: block; + position: relative; + margin-left: 70px; + margin-top: -65px; +} +.wrap-row .twitter-row div .tweet h4 span { + color: #cccccc; + font-weight: bold; + font-size: 14px; +} +.wrap-row .twitter-row div .tweet p { + line-height: 1.5em; + float: left; + position: relative; + display: block; +} +.wrap-row .twitter-row div .tweet .date { + float: right; + line-height: 0.5em; + margin-top: -60px; + margin-right: -10px; +} +.wrap-row .twitter-row div .tweet .right .bottom p { + margin-top: -65px; + margin-left: 70px; + font-size: 12px; + word-break: break-word; +} +.wrap-row .twitter-row div .tweet:hover { + -webkit-box-shadow: 0.5px 0.5px 0.5px 1px #428bca; + -moz-box-shadow: 0.5px 0.5px 0.5px 1px #428bca; + box-shadow: 0.5px 0.5px 0.5px 1px #428bca; + cursor: pointer; + opacity: 1; +} + +@media screen and (max-width: 580px) { + .wrap-row { + position: relative; + overflow: hidden; + height: 100px; + } +} diff --git a/addons/website_twitter/static/src/css/website.twitter.sass b/addons/website_twitter/static/src/css/website.twitter.sass new file mode 100644 index 00000000000..9eca59eb6ce --- /dev/null +++ b/addons/website_twitter/static/src/css/website.twitter.sass @@ -0,0 +1,94 @@ +.wrap-row + position: relative + overflow: hidden + height: 310px + .twitter-row + position: absolute + width: 100% + height: auto + div + &.scrollWrapper + position: relative + overflow: hidden + width: 100% + height: 100% + &.scrollableArea + position: relative + width: auto + height: 100% + .tweet + border: 1px solid #ccc + max-width: 500px + width: 500px + font-size: 0.8em + padding-top: 12px + padding-right: 10px + padding-bottom: 12px + padding-left: 10px + float: left + display: block + margin: 6px + max-height: 90px + height: 90px + opacity: 0.6 + h4, p + padding: 0 + margin: 0 + .left + display: block + float: left + width: 80px + img + width: 65px + height: auto + float: left + display: block + margin: 0px 5px 0px -5px + .right + display: block + float: left + width: 470px + .top + height: 20px + h4 + font-size: 14px + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif + font-weight: bold + color: #000 + float: left + display: block + position: relative + margin-left: 70px + margin-top: -65px + span + color: #ccc + font-weight: bold + font-size: 14px + p + line-height: 1.5em + float: left + position: relative + display: block + &.date + float: right + line-height: 0.5em + margin-top: -60px + margin-right: -10px + .right .bottom p + margin-top: -65px + margin-left: 70px + font-size: 12px + word-break: break-word + &:hover + -webkit-box-shadow: 0.5px 0.5px 0.5px 1px #428BCA + -moz-box-shadow: 0.5px 0.5px 0.5px 1px #428BCA + box-shadow: 0.5px 0.5px 0.5px 1px #428BCA + cursor: pointer + opacity: 1 + + +@media screen and (max-width: 580px) + .wrap-row + position: relative + overflow: hidden + height: 100px diff --git a/addons/website_twitter/static/src/img/api_key.png b/addons/website_twitter/static/src/img/api_key.png new file mode 100644 index 00000000000..8f53f9649fa Binary files /dev/null and b/addons/website_twitter/static/src/img/api_key.png differ diff --git a/addons/website_twitter/static/src/img/loadtweet.gif b/addons/website_twitter/static/src/img/loadtweet.gif new file mode 100644 index 00000000000..f8a0a6179bd Binary files /dev/null and b/addons/website_twitter/static/src/img/loadtweet.gif differ diff --git a/addons/website_twitter/static/src/img/twitter_scroll.png b/addons/website_twitter/static/src/img/twitter_scroll.png new file mode 100644 index 00000000000..b8d602d519a Binary files /dev/null and b/addons/website_twitter/static/src/img/twitter_scroll.png differ diff --git a/addons/website_twitter/static/src/js/website.twitter.animation.js b/addons/website_twitter/static/src/js/website.twitter.animation.js new file mode 100644 index 00000000000..bb19fd5e66a --- /dev/null +++ b/addons/website_twitter/static/src/js/website.twitter.animation.js @@ -0,0 +1,144 @@ +(function () { + 'use strict'; + var website = openerp.website, + qweb = openerp.qweb; + + qweb.add_template('/website_twitter/static/src/xml/website.twitter.xml'); + if (!website.snippet) website.snippet = {}; + website.snippet.animationRegistry.twitter = website.snippet.Animation.extend({ + selector: ".twitter", + start: function () { + var self = this; + var timeline = this.$target.find(".twitter_timeline"); + + this.$target.on('click', '.twitter_timeline .tweet', function($event) { + if ($event.target.tagName.toLowerCase() !== "a") { + var url = $($event.currentTarget).data('url'); + if (url) { + window.open(url, '_blank'); + } + else { + debugger; + } + } + }); + $("
").appendTo(timeline); + openerp.jsonRpc('/get_favorites','call', {}).then(function(data) { + self.$target.find(".twitter_timeline").empty(); + if (data.error) { + self.error(data); + } + else { + self.render(data); + self.setupMouseEvents(); + } + }); + }, + stop: function() { + $(this).find('.scrollWrapper').each(function(index, el){ + self.stop_scrolling($(el)); + }); + this.clearMouseEvents(); + }, + error: function(data){ + var $error = $(qweb.render("website.Twitter.Error", {'data': data})); + $error.appendTo(this.$target.find(".twitter_timeline")); + }, + parse_tweet: function (tweet) { + var create_link = function (url, text) { + var c = $("", { + text: text, + href: url, + target: "_blank" + }); + return c.prop("outerHTML"); + }; + return tweet.text.replace(/[A-Za-z]+:\/\/[A-Za-z0-9-_]+\.[A-Za-z0-9-_:%&~\?\/.=]+/g, + function (url) { return create_link(url, url) }) + .replace(/[@]+[A-Za-z0-9_]+/g, + function (screen_name) { return create_link("http://twitter.com/" + screen_name.replace("@",""), screen_name); }) + .replace(/[#]+[A-Za-z0-9_]+/g, + function (hashtag) { return create_link("http://twitter.com/search?q="+hashtag.replace("#",""), hashtag); }); + }, + parse_date: function(tweet) { + var d = new Date(tweet.created_at); + return d.toDateString(); + }, + setupMouseEvents: function() { + var self = this; + this.$scroller.mouseenter(function() { + $(this).find('.scrollWrapper').each(function(index, el){ + self.stop_scrolling($(el)); + }); + }).mouseleave(function() { + $(this).find('.scrollWrapper').each(function(index, el){ + self.start_scrolling($(el)); + }); + }); + }, + clearMouseEvents: function() { + if (this.$scroller) { + this.$scroller.off('mouseenter') + .off('mouseleave'); + } + }, + render: function(data){ + var self = this; + var timeline = this.$target.find(".twitter_timeline"); + var tweets = []; + $.each(data, function (e, tweet) { + tweet.created_at = self.parse_date(tweet); + tweet.text = self.parse_tweet(tweet); + tweets.push(qweb.render("website.Twitter.Tweet", {'tweet': tweet})); + }); + + var f = Math.floor(tweets.length / 3); + var tweet_slice = [tweets.slice(0, f).join(" "), tweets.slice(f, f * 2).join(" "), tweets.slice(f * 2, tweets.length).join(" ")]; + + this.$scroller = $(qweb.render("website.Twitter.Scroller")); + this.$scroller.appendTo(this.$target.find(".twitter_timeline")); + this.$scroller.find("div[id^='scroller']").each(function(index, element){ + var scrollWrapper = $('
'); + var scrollableArea = $('
'); + scrollWrapper.append(scrollableArea) + .data('scrollableArea', scrollableArea); + scrollableArea.append(tweet_slice[index]); + $(element).append(scrollWrapper); + scrollableArea.width(self.get_wrapper_width(scrollableArea)); + scrollWrapper.scrollLeft(index*180); + self.start_scrolling(scrollWrapper); + }); + }, + get_wrapper_width: function(wrapper){ + var total_width = 0; + wrapper.children().each(function(){ + total_width += $(this).outerWidth(true); + }); + return total_width; + }, + start_scrolling: function(wrapper){ + var self = this; + wrapper.data("getNextElementWidth", true); + wrapper.data("autoScrollingInterval", setInterval(function () { + wrapper.scrollLeft(wrapper.scrollLeft() + 1); + self.swap_right(wrapper); + }, 20)); + }, + stop_scrolling: function(wrapper){ + clearInterval(wrapper.data('autoScrollingInterval')); + }, + swap_right: function(wrapper){ + if (wrapper.data("getNextElementWidth")) { + wrapper.data("swapAt", wrapper.data("scrollableArea").children(":first").outerWidth(true)); + wrapper.data("getNextElementWidth", false); + } + if (wrapper.data("swapAt") <= wrapper.scrollLeft()){ + var swap_el = wrapper.data("scrollableArea").children(":first").detach(); + wrapper.data("scrollableArea").append(swap_el); + wrapper.scrollLeft(wrapper.scrollLeft() - swap_el.outerWidth(true)); + wrapper.data("getNextElementWidth", true); + } + }, + }); + +})(); diff --git a/addons/website_twitter/static/src/js/website.twitter.editor.js b/addons/website_twitter/static/src/js/website.twitter.editor.js new file mode 100644 index 00000000000..69a1896695a --- /dev/null +++ b/addons/website_twitter/static/src/js/website.twitter.editor.js @@ -0,0 +1,51 @@ +(function () { + 'use strict'; + var website = openerp.website, + qweb = openerp.qweb; + + website.snippet.options["twitter"] = website.snippet.options.marginAndResize.extend({ + start: function(){ + this._super(); + this.make_hover_config(); + this.$target.find('.lnk_configure').click(function(e){ + window.location = e.target.href; + }); + if (this.$target.data("snippet-view")) { + this.$target.data("snippet-view").stop(); + } + }, + twitter_reload: function(){ + openerp.jsonRpc('/twitter_reload','call', {}); + }, + make_hover_config: function(){ + var self = this; + var $configuration = $(qweb.render("website.Twitter.Reload")).hide().appendTo(document.body).click(function (e) { + e.preventDefault(); + e.stopPropagation(); + self.twitter_reload(); + }); + this.$target.on('mouseover', '', function () { + var $selected = $(this); + var position = $selected.offset(); + $configuration.show().offset({ + top: $selected.outerHeight() / 2 + + position.top + - $configuration.outerHeight() / 2, + left: $selected.outerWidth() / 2 + + position.left + - $configuration.outerWidth() / 2, + }) + }).on('mouseleave', '', function (e) { + var current = document.elementFromPoint(e.clientX, e.clientY); + if (current === $configuration[0]) { + return; + } + $configuration.hide(); + }); + }, + clean_for_save: function () { + this.$target.find(".twitter_timeline").empty(); + }, + }); + +})(); diff --git a/addons/website_twitter/static/src/xml/website.twitter.xml b/addons/website_twitter/static/src/xml/website.twitter.xml new file mode 100644 index 00000000000..25c6121e166 --- /dev/null +++ b/addons/website_twitter/static/src/xml/website.twitter.xml @@ -0,0 +1,45 @@ + + + + +
+
+ +
+
+
+

+ + + + +

+ +
+

+
+
+ + +
+ +
+
+ + + + +
+ + + Twitter Configuration + +
+
+ diff --git a/addons/website_twitter/views/twitter_snippet.xml b/addons/website_twitter/views/twitter_snippet.xml new file mode 100644 index 00000000000..bc039d71c61 --- /dev/null +++ b/addons/website_twitter/views/twitter_snippet.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/addons/website_twitter/views/twitter_view.xml b/addons/website_twitter/views/twitter_view.xml new file mode 100644 index 00000000000..8600ceca039 --- /dev/null +++ b/addons/website_twitter/views/twitter_view.xml @@ -0,0 +1,56 @@ + + + + + Twitter settings + website.config.settings + + + + + + + +