Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Backport from revision 2, http://logeek-labs.googlecode.com/svn/trunk…

  • Loading branch information...
commit 42ecbe4903e24b716579af8ac9565ce8cafd6729 0 parents
Thibaut Barrère authored
33 README
@@ -0,0 +1,33 @@
+MephistoAmazon
+==============
+
+This plugin is a partial rewrite of Nicholas Faiz's MephistoAmazon.
+Instead of using Ruby/Amazon, it uses the new amazon/ecs (http://www.pluitsolutions.com/projects/amazon-ecs).
+There is one liquid filter and one tag so far: asin_search.
+You can modify how it renders by editing the liquid files in the views directory.
+Use the filter to render content in an article. Use the tag for the layout.
+
+ASIN Search
+-----------
+
+<filter:asin_search>0553214322</filter:asin_search>
+
+or
+
+{% asin_search books 0553214322 %} {% endkeyword_search %}
+
+What is a ASIN? See http://en.wikipedia.org/wiki/Amazon_Standard_Identification_Number
+
+Authors
+=======
+
+Original work by Nicholas Faiz (https://tfw.devguard.com/svn/os/plugins/mephisto_amazon/).
+
+Adaptation to Amazon/ECS by Thibaut Barrère (http://blog.logeek.fr).
+
+The file ecs.rb and everything under the amazon directory is the work of Herryanto Siatono (released under the MIT License).
+
+Dependencies
+------------
+
+This plugin depends on Hpricot.
10 Rakefile
@@ -0,0 +1,10 @@
+require 'rake'
+require 'rake/testtask'
+require 'rake/rdoctask'
+
+desc 'Test the mephisto_amazon plugin.'
+Rake::TestTask.new(:test) do |t|
+ t.libs << 'lib'
+ t.pattern = 'test/**/*_test.rb'
+ t.verbose = true
+end
11 init.rb
@@ -0,0 +1,11 @@
+require 'mephisto_amazon'
+require 'asin_search_macro'
+require 'mephistoamazon/asin_search'
+require 'mephistoamazon/search_delegate'
+require 'mephistoamazon/product_drop'
+
+require File.join(lib_path, 'plugin')
+
+Liquid::Template.register_tag('asin_search', MephistoAmazon::AsinSearch)
+
+FilteredColumn.macros[:asin_search_macro] = AsinSearchMacro
1  install.rb
@@ -0,0 +1 @@
+# Install hook code here
21 lib/amazon/CHANGELOG
@@ -0,0 +1,21 @@
+0.5.3 2007-09-12
+----------------
+* send_request to use default options.
+
+0.5.2 2007-09-08
+----------------
+* Fixed Amazon::Element.get_unescaped error when result returned for given element path is nil
+
+0.5.1 2007-02-08
+----------------
+* Fixed Amazon Japan and France URL error
+* Removed opts.delete(:search_index) from item_lookup, SearchIndex param is allowed
+ when looking for a book with IdType other than the ASIN.
+* Check for defined? RAILS_DEFAULT_LOGGER to avoid exception for non-rails ruby app
+* Added check for LOGGER constant if RAILS_DEFAULT_LOGGER is not defined
+* Added Ecs.configure(&proc) method for easier configuration of default options
+* Added Element#search_and_convert method
+
+0.5.0 2006-09-12
+----------------
+Initial Release
93 lib/amazon/README
@@ -0,0 +1,93 @@
+== amazon-ecs
+
+Generic Amazon E-commerce REST API using Hpricot with configurable
+default options and method call options. Uses Response and
+Element wrapper classes for easy access to REST XML output. It supports ECS 4.0.
+
+It is generic, so you can easily extend <tt>Amazon::Ecs</tt> to support
+other not implemented REST operations; and it is also generic because it just wraps around
+Hpricot element object, instead of providing one-to-one object/attributes to XML elements map.
+
+If in the future, there is a change in REST XML output structure,
+no changes will be required on <tt>amazon-ecs</tt> library,
+instead you just need to change the element path.
+
+Version: 0.5.1
+
+== INSTALLATION
+
+ $ gem install amazon-ecs
+
+== EXAMPLE
+
+ require 'amazon/ecs'
+
+ # set the default options; options will be camelized and converted to REST request parameters.
+ Amazon::Ecs.options = {:aWS_access_key_id => [your developer token]}
+
+ # options provided on method call will merge with the default options
+ res = Amazon::Ecs.item_search('ruby', {:response_group => 'Medium', :sort => 'salesrank'})
+
+ # some common response object methods
+ res.is_valid_request? # return true if request is valid
+ res.has_error? # return true if there is an error
+ res.error # return error message if there is any
+ res.total_pages # return total pages
+ res.total_results # return total results
+ res.item_page # return current page no if :item_page option is provided
+
+ # traverse through each item (Amazon::Element)
+ res.items.each do |item|
+ # retrieve string value using XML path
+ item.get('asin')
+ item.get('itemattributes/title')
+
+ # or return Amazon::Element instance
+ atts = item.search_and_convert('itemattributes')
+ atts.get('title')
+
+ # return first author or a string array of authors
+ atts.get('author') # 'Author 1'
+ atts.get_array('author') # ['Author 1', 'Author 2', ...]
+
+ # return an hash of children text values with the element names as the keys
+ item.get_hash('smallimage') # {:url => ..., :width => ..., :height => ...}
+
+ # note that '/' returns Hpricot::Elements array object, nil if not found
+ reviews = item/'editorialreview'
+
+ # traverse through Hpricot elements
+ reviews.each do |review|
+ # Getting hash value out of Hpricot element
+ Amazon::Element.get_hash(review) # [:source => ..., :content ==> ...]
+
+ # Or to get unescaped HTML values
+ Amazon::Element.get_unescaped(review, 'source')
+ Amazon::Element.get_unescaped(review, 'content')
+
+ # Or this way
+ el = Amazon::Element.new(review)
+ el.get_unescaped('source')
+ el.get_unescaped('content')
+ end
+
+ # returns Amazon::Element instead of string
+ item.search_and_convert('itemattributes').
+ end
+
+Refer to Amazon ECS documentation for more information on Amazon REST request parameters and XML output:
+http://docs.amazonwebservices.com/AWSEcommerceService/2006-09-13/
+
+To get a sample of Amazon REST response XML output, use AWSZone.com scratch pad:
+http://www.awszone.com/scratchpads/aws/ecs.us/index.aws
+
+== LINKS
+
+* http://amazon-ecs.rubyforge.org
+* http://www.pluitsolutions.com/amazon-ecs
+
+== LICENSE
+
+(The MIT License)
+
+Copyright (c) 2006 Herryanto Siatono, Pluit Solutions
301 lib/amazon/ecs.rb
@@ -0,0 +1,301 @@
+#--
+# Copyright (c) 2006 Herryanto Siatono, Pluit Solutions
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+#++
+
+require 'net/http'
+require 'hpricot'
+require 'cgi'
+
+module Amazon
+ class RequestError < StandardError; end
+
+ class Ecs
+ SERVICE_URLS = {:us => 'http://webservices.amazon.com/onca/xml?Service=AWSECommerceService',
+ :uk => 'http://webservices.amazon.co.uk/onca/xml?Service=AWSECommerceService',
+ :ca => 'http://webservices.amazon.ca/onca/xml?Service=AWSECommerceService',
+ :de => 'http://webservices.amazon.de/onca/xml?Service=AWSECommerceService',
+ :jp => 'http://webservices.amazon.co.jp/onca/xml?Service=AWSECommerceService',
+ :fr => 'http://webservices.amazon.fr/onca/xml?Service=AWSECommerceService'
+ }
+
+ @@options = {}
+ @@debug = false
+
+ # Default search options
+ def self.options
+ @@options
+ end
+
+ # Set default search options
+ def self.options=(opts)
+ @@options = opts
+ end
+
+ # Get debug flag.
+ def self.debug
+ @@debug
+ end
+
+ # Set debug flag to true or false.
+ def self.debug=(dbg)
+ @@debug = dbg
+ end
+
+ def self.configure(&proc)
+ raise ArgumentError, "Block is required." unless block_given?
+ yield @@options
+ end
+
+ # Search amazon items with search terms. Default search index option is 'Books'.
+ # For other search type other than keywords, please specify :type => [search type param name].
+ def self.item_search(terms, opts = {})
+ opts[:operation] = 'ItemSearch'
+ opts[:search_index] = opts[:search_index] || 'Books'
+
+ type = opts.delete(:type)
+ if type
+ opts[type.to_sym] = terms
+ else
+ opts[:keywords] = terms
+ end
+
+ self.send_request(opts)
+ end
+
+ # Search an item by ASIN no.
+ def self.item_lookup(item_id, opts = {})
+ opts[:operation] = 'ItemLookup'
+ opts[:item_id] = item_id
+
+ self.send_request(opts)
+ end
+
+ # Generic send request to ECS REST service. You have to specify the :operation parameter.
+ def self.send_request(opts)
+ opts = self.options.merge(opts) if self.options
+ request_url = prepare_url(opts)
+ log "Request URL: #{request_url}"
+
+ res = Net::HTTP.get_response(URI::parse(request_url))
+ unless res.kind_of? Net::HTTPSuccess
+ raise Amazon::RequestError, "HTTP Response: #{res.code} #{res.message}"
+ end
+ Response.new(res.body)
+ end
+
+ # Response object returned after a REST call to Amazon service.
+ class Response
+ # XML input is in string format
+ def initialize(xml)
+ @doc = Hpricot(xml)
+ end
+
+ # Return Hpricot object.
+ def doc
+ @doc
+ end
+
+ # Return true if request is valid.
+ def is_valid_request?
+ (@doc/"isvalid").inner_html == "True"
+ end
+
+ # Return true if response has an error.
+ def has_error?
+ !(error.nil? || error.empty?)
+ end
+
+ # Return error message.
+ def error
+ Element.get(@doc, "error/message")
+ end
+
+ # Return an array of Amazon::Element item objects.
+ def items
+ unless @items
+ @items = (@doc/"item").collect {|item| Element.new(item)}
+ end
+ @items
+ end
+
+ # Return the first item (Amazon::Element)
+ def first_item
+ items.first
+ end
+
+ # Return current page no if :item_page option is when initiating the request.
+ def item_page
+ unless @item_page
+ @item_page = (@doc/"itemsearchrequest/itempage").inner_html.to_i
+ end
+ @item_page
+ end
+
+ # Return total results.
+ def total_results
+ unless @total_results
+ @total_results = (@doc/"totalresults").inner_html.to_i
+ end
+ @total_results
+ end
+
+ # Return total pages.
+ def total_pages
+ unless @total_pages
+ @total_pages = (@doc/"totalpages").inner_html.to_i
+ end
+ @total_pages
+ end
+ end
+
+ protected
+ def self.log(s)
+ return unless self.debug
+ if defined? RAILS_DEFAULT_LOGGER
+ RAILS_DEFAULT_LOGGER.error(s)
+ elsif defined? LOGGER
+ LOGGER.error(s)
+ else
+ puts s
+ end
+ end
+
+ private
+ def self.prepare_url(opts)
+ country = opts.delete(:country)
+ country = (country.nil?) ? 'us' : country
+ request_url = SERVICE_URLS[country.to_sym]
+ raise Amazon::RequestError, "Invalid country '#{country}'" unless request_url
+
+ qs = ''
+ opts.each {|k,v|
+ next unless v
+ v = v.join(',') if v.is_a? Array
+ qs << "&#{camelize(k.to_s)}=#{URI.encode(v.to_s)}"
+ }
+ "#{request_url}#{qs}"
+ end
+
+ def self.camelize(s)
+ s.to_s.gsub(/\/(.?)/) { "::" + $1.upcase }.gsub(/(^|_)(.)/) { $2.upcase }
+ end
+ end
+
+ # Internal wrapper class to provide convenient method to access Hpricot element value.
+ class Element
+ # Pass Hpricot::Elements object
+ def initialize(element)
+ @element = element
+ end
+
+ # Returns Hpricot::Elments object
+ def elem
+ @element
+ end
+
+ # Find Hpricot::Elements matching the given path. Example: element/"author".
+ def /(path)
+ elements = @element/path
+ return nil if elements.size == 0
+ elements
+ end
+
+ # Find Hpricot::Elements matching the given path, and convert to Amazon::Element.
+ # Returns an array Amazon::Elements if more than Hpricot::Elements size is greater than 1.
+ def search_and_convert(path)
+ elements = self./(path)
+ return unless elements
+ elements = elements.map{|element| Element.new(element)}
+ return elements.first if elements.size == 1
+ elements
+ end
+
+ # Get the text value of the given path, leave empty to retrieve current element value.
+ def get(path='')
+ Element.get(@element, path)
+ end
+
+ # Get the unescaped HTML text of the given path.
+ def get_unescaped(path='')
+ Element.get_unescaped(@element, path)
+ end
+
+ # Get the array values of the given path.
+ def get_array(path='')
+ Element.get_array(@element, path)
+ end
+
+ # Get the children element text values in hash format with the element names as the hash keys.
+ def get_hash(path='')
+ Element.get_hash(@element, path)
+ end
+
+ # Similar to #get, except an element object must be passed-in.
+ def self.get(element, path='')
+ return unless element
+ result = element.at(path)
+ result = result.inner_html if result
+ result
+ end
+
+ # Similar to #get_unescaped, except an element object must be passed-in.
+ def self.get_unescaped(element, path='')
+ result = get(element, path)
+ CGI::unescapeHTML(result) if result
+ end
+
+ # Similar to #get_array, except an element object must be passed-in.
+ def self.get_array(element, path='')
+ return unless element
+
+ result = element/path
+ if (result.is_a? Hpricot::Elements) || (result.is_a? Array)
+ parsed_result = []
+ result.each {|item|
+ parsed_result << Element.get(item)
+ }
+ parsed_result
+ else
+ [Element.get(result)]
+ end
+ end
+
+ # Similar to #get_hash, except an element object must be passed-in.
+ def self.get_hash(element, path='')
+ return unless element
+
+ result = element.at(path)
+ if result
+ hash = {}
+ result = result.children
+ result.each do |item|
+ hash[item.name.to_sym] = item.inner_html
+ end
+ hash
+ end
+ end
+
+ def to_s
+ elem.to_s if elem
+ end
+ end
+end
15 lib/asin_search_macro.rb
@@ -0,0 +1,15 @@
+class AsinSearchMacro < FilteredColumn::Macros::Base
+ def self.filter(attributes, inner_text='', text='')
+ @view = MephistoAmazon::Product_View
+ @delegate = MephistoAmazon::SearchDelegate.new
+ amazon_product = @delegate.asin_search(inner_text)
+
+ unless amazon_product == nil?
+ template = Liquid::Template.parse( File.read(MephistoAmazon::Product_View ) )
+ template.render( {'product' => amazon_product} )
+ else
+ template = Liquid::Template.parse( File.read( MephistoAmazon::Error_View ) )
+ template.render( {'msg' => "No matching product"} )
+ end
+ end
+end
13 lib/mephisto_amazon.rb
@@ -0,0 +1,13 @@
+require 'amazon/ecs'
+
+module MephistoAmazon
+
+ base = "#{RAILS_ROOT}/vendor/plugins/mephisto_amazon/views/"
+ Products_View = "#{base}products.liquid"
+ Product_View = "#{base}product.liquid"
+ Test_Search_View ="../views/test_search_results.liquid"
+ Error_View = "#{base}error.liquid"
+
+ def rlogger() RAILS_DEFAULT_LOGGER end
+
+end
32 lib/mephistoamazon/asin_search.rb
@@ -0,0 +1,32 @@
+module MephistoAmazon
+ class AsinSearch < Liquid::Block
+
+ attr_writer :delegate, :view
+ attr_reader :delegate
+
+ def initialize(tag_name, markup, tokens)
+ super
+ @markup = markup
+ @tokens = tokens
+ @view = Product_View
+ @delegate = MephistoAmazon::SearchDelegate.new
+
+ args = markup.scan(/\w+/)
+ end
+
+ def rlogger() RAILS_DEFAULT_LOGGER end
+
+ def render(context)
+ amazon_product = @delegate.asin_search(@markup)
+
+ unless amazon_product == nil?
+ template = Liquid::Template.parse( File.read( @view ) )
+ template.render( {'product' => amazon_product} )
+ else
+ template = Liquid::Template.parse( File.read( Error_Template ) )
+ template.render( {'msg' => "No matching product!"} )
+ end
+
+ end
+ end
+end
33 lib/mephistoamazon/product_drop.rb
@@ -0,0 +1,33 @@
+module MephistoAmazon
+ class ProductDrop < Liquid::Drop
+
+ def initialize(product)
+ @product = product
+ end
+
+ def asin
+ @product.get('asin')
+ end
+
+ def author
+ @product.get('author')
+ end
+
+ def title
+ @product.get('title')
+ end
+
+ def image_url_large
+ @product.get('largeimage/url')
+ end
+
+ def image_url_medium
+ @product.get('mediumimage/url')
+ end
+
+ def image_url_small
+ @product.get('smallimage/url')
+ end
+
+ end
+end
24 lib/mephistoamazon/search_delegate.rb
@@ -0,0 +1,24 @@
+module MephistoAmazon
+ class SearchDelegate
+
+ def initialize
+ Amazon::Ecs.options = {:aWS_access_key_id => Mephisto::Plugin[:amazon].amazon_dev_token}
+ end
+
+ def rlogger() RAILS_DEFAULT_LOGGER end
+
+ def asin_search(asin)
+ asin = asin.strip # trailing blank will make the request return nothing
+ # use :country => :fr to achieve the lookup on the french catalogue
+ products = Amazon::Ecs.item_lookup(asin, :response_group => 'Medium')
+ unless products == nil?
+ drop = to_drop products
+ end
+ drop
+ end
+
+ def to_drop(products)
+ MephistoAmazon::ProductDrop.new(products.first_item)
+ end
+ end
+end
11 lib/plugin.rb
@@ -0,0 +1,11 @@
+module Mephisto
+ module Plugins
+ class Amazon < Mephisto::Plugin
+ author 'Thibaut Barrère, based on previous work from Nicholas Faiz.'
+ version '1.2'
+ notes "Include Amazon content within Mephisto articles or layouts. See the README."
+
+ option :amazon_dev_token, "PUT-YOUR-DEV-TOKEN-HERE"
+ end
+ end
+end
1  no_mephisto_init.rb
@@ -0,0 +1 @@
+RAILS_ROOT = ''
23 test/asin_search_test.rb
@@ -0,0 +1,23 @@
+require File.dirname(__FILE__) + '/test_helper'
+
+class AsinSearchTest < Test::Unit::TestCase
+ include FlexMock::TestCase
+
+ def test_render
+ #mock out the search_delegate in the amazon_search tag
+ mock_delegate = flexmock("delegate")
+ tokens = ["123", "{% endasin_search %}"]
+ tag = MephistoAmazon::AsinSearch.new("asin_search", "123", tokens)
+ tag.view = MephistoAmazon::Test_Search_View
+ tag.delegate = mock_delegate
+
+ mock_response = flexmock("response")
+ mock_response.should_receive(:asin).and_return("123456789")
+ mock_response.should_receive(:image_url_small).and_return("foo.jpg")
+
+ #the code to close the tag in Liquid reads from the tokens array, so we have to include it there, rather than the markup
+ mock_delegate.should_receive(:asin_search).with("123").and_return(MephistoAmazon::ResponseDrop.new(mock_response))
+
+ output = tag.render nil
+ end
+end
17 test/harness.rb
@@ -0,0 +1,17 @@
+
+require File.dirname(__FILE__) + '/test_helper'
+
+require 'test/unit/testsuite'
+require 'test/unit/ui/reporter'
+require 'stringio'
+require 'test/unit/ui/console/testrunner'
+require 'fileutils'
+
+suite = Test::Unit::TestSuite.new("Mephisto Amazon test suite")
+
+suite << KeywordSearchTest.suite
+suite << SearchDelegateTest.suite
+
+FileUtils.mkdir_p 'build/report'
+Test::Unit::UI::Reporter.run(suite, '../build/report', :xml)
+Test::Unit::UI::Reporter.run(suite, '../build/report')
49 test/search_delegate_test.rb
@@ -0,0 +1,49 @@
+require File.dirname(__FILE__) + '/test_helper'
+
+require 'flexmock'
+
+class SearchDelegateTest < Test::Unit::TestCase
+ include FlexMock::TestCase
+
+ class AsinFixture
+ attr_reader :asin
+
+ def initialize
+ @asin = "foo"
+ end
+ end
+
+ def test_mode_search
+ searcher = MephistoAmazon::SearchDelegate.new()
+ mock_request = flexmock('request')
+ mock_response = flexmock('response')
+ mock_response.should_receive(:products).and_return([AsinFixture.new])
+
+
+ drop = MephistoAmazon::ResponseDrop.new AsinFixture.new
+ mock_request.should_receive(:keyword_search).and_return(mock_response)
+ #mock_request.should_receive(:keyword_search).with("eureka stockade", "Books").and_return(drop)
+
+ searcher.request = mock_request
+ result = searcher.search(MephistoAmazon::Books, "eureka stockade")
+
+ assert_equal(drop.asin, "foo")
+ end
+
+ def test_asin_search
+ searcher = MephistoAmazon::SearchDelegate.new()
+ mock_request = flexmock('request')
+ mock_response = flexmock('response')
+ mock_response.should_receive(:products).and_return(AsinFixture.new)
+
+
+ drop = MephistoAmazon::ResponseDrop.new AsinFixture.new
+ mock_request.should_receive(:asin_search).and_return(mock_response)
+ #mock_request.should_receive(:keyword_search).with("eureka stockade", "Books").and_return(drop)
+
+ searcher.request = mock_request
+ result = searcher.asin_search("123")
+
+ assert_equal(drop.asin, "foo")
+ end
+end
31 test/test_helper.rb
@@ -0,0 +1,31 @@
+# This file is a basic copy of the helper in Liquid's tests
+
+#!/usr/bin/env ruby
+require File.dirname(__FILE__) + '/../no_mephisto_init'
+
+require File.dirname(__FILE__) + '/../lib/mephisto_amazon'
+require File.dirname(__FILE__) + '/../lib/mephistoamazon/asin_search'
+require File.dirname(__FILE__) + '/../lib/mephistoamazon/search_delegate'
+require File.dirname(__FILE__) + '/../lib/mephistoamazon/product_drop'
+
+require 'flexmock'
+require 'test/unit'
+require 'test/unit/testresult'
+require 'test/unit/assertions'
+require 'liquid'
+require 'breakpoint'
+
+require File.dirname(__FILE__) + '/' + 'search_delegate_test'
+require File.dirname(__FILE__) + '/' + 'asin_search_test'
+
+Breakpoint.activate_drb
+
+module Test
+ module Unit
+ module Assertions
+ def assert_template_result(expected, template, assigns={}, message=nil)
+ assert_equal expected, Liquid::Template.parse(template).render(assigns)
+ end
+ end
+ end
+end
1  uninstall.rb
@@ -0,0 +1 @@
+# Uninstall hook code here
1  views/error.liquid
@@ -0,0 +1 @@
+#{msg}
12 views/product.liquid
@@ -0,0 +1,12 @@
+<div class="amazon_book">
+ {% if product.image_url_medium != null %}
+ <img src="{{product.image_url_medium}}"/>
+ {% else %}
+ <br/>
+ (No image available)
+ {% endif %}
+ <br/>
+ <ul>
+ <li><a href="http://www.amazon.com/exec/obidos/ASIN/{{product.asin}}/bilibilip-20">find at Amazon.com</a></li>
+ </ul>
+</div>
Please sign in to comment.
Something went wrong with that request. Please try again.