From 0d7db21c1502343bfcafddca16cc1bd7fbdfe935 Mon Sep 17 00:00:00 2001 From: Alex Tharp Date: Mon, 5 Jun 2017 14:55:13 +0200 Subject: [PATCH 1/3] feat(API): ContentItems Feed --- app/api/v1/entities/content_item.rb | 5 ----- app/api/v1/resources/content_items.rb | 28 +++++++++++++++++++++++++++ app/interactors/get_content_items.rb | 8 ++++++++ app/models/content_item.rb | 26 +++++++++++++------------ 4 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 app/interactors/get_content_items.rb diff --git a/app/api/v1/entities/content_item.rb b/app/api/v1/entities/content_item.rb index 5234f34f4..b3bb0bd93 100644 --- a/app/api/v1/entities/content_item.rb +++ b/app/api/v1/entities/content_item.rb @@ -3,11 +3,6 @@ module Entities class ContentItem < Grape::Entity expose :id, documentation: {type: "Integer", desc: "Content Item ID", required: true} expose :publish_state, documentation: {type: "String", desc: "Publish state", required: true} - expose :published_at, documentation: {type: "dateTime", desc: "Date published", required: true} - expose :expired_at, documentation: {type: "dateTime", desc: "Date to expire", required: true} - expose :author, documentation: {type: "dateTime", desc: "Date published", required: true} do |content_item| - content_item.author.fullname - end expose :creator, documentation: {type: "dateTime", desc: "Date published", required: true} do |content_item| content_item.creator.fullname end diff --git a/app/api/v1/resources/content_items.rb b/app/api/v1/resources/content_items.rb index 15b52fcb3..14a601165 100644 --- a/app/api/v1/resources/content_items.rb +++ b/app/api/v1/resources/content_items.rb @@ -1,7 +1,13 @@ module V1 module Resources class ContentItems < Grape::API + helpers ::V1::Helpers::SharedParamsHelper + helpers ::V1::Helpers::ParamsHelper + resource :content_items do + include Grape::Kaminari + paginate per_page: 25 + desc "Create a content item", { entity: ::V1::Entities::ContentItem, params: ::V1::Entities::ContentItem.documentation, nickname: "createContentItem" } params do requires :content_type_id, type: Integer, desc: "content type of content item" @@ -27,6 +33,28 @@ class ContentItems < Grape::API present @content_items, with: ::V1::Entities::ContentItem, field_items: true end + + desc 'Show published content items', { entity: ::V1::Entities::ContentItem, nickname: "contentItemsFeed" } + params do + use :pagination + requires :content_type_name, type: String, desc: "content type of content item" + end + get :feed do + require_scope! 'view:content_items' + authorize! :view, ::ContentItem + + last_updated_at = ContentItem.last_updated_at + params_hash = Digest::MD5.hexdigest(declared(params).to_s) + cache_key = "feed-#{last_updated_at}-#{current_tenant.id}-#{params_hash}" + + content_items_page = ::Rails.cache.fetch(cache_key, expires_in: 30.minutes, race_condition_ttl: 10) do + content_items = ::GetContentItems.call(params: declared(clean_params(params), include_missing: false), tenant: current_tenant, published: true).content_items + set_paginate_headers(content_items) + ::V1::Entities::ContentItem.represent content_items.to_a, field_items: true + end + + content_items_page + end end end end diff --git a/app/interactors/get_content_items.rb b/app/interactors/get_content_items.rb new file mode 100644 index 000000000..ac080f00a --- /dev/null +++ b/app/interactors/get_content_items.rb @@ -0,0 +1,8 @@ +class GetContentItems + include Interactor + + def call + content_items = ContentType.find_by_name(context.params.content_type_name).content_items.order(created_at: :desc) + context.content_items = content_items.page(context.params.page).per(context.params.per_page) + end +end diff --git a/app/models/content_item.rb b/app/models/content_item.rb index f40017e91..8df301e40 100644 --- a/app/models/content_item.rb +++ b/app/models/content_item.rb @@ -3,18 +3,7 @@ class ContentItem < ApplicationRecord include Elasticsearch::Model include Elasticsearch::Model::Callbacks - state_machine do - state :draft - state :scheduled - - event :schedule do - transitions :to => :scheduled, :from => [:draft] - end - - event :draft do - transitions :to => :draft, :from => [:scheduled] - end - end + scope :last_updated_at, -> { order(updated_at: :desc).select('updated_at').first.updated_at } acts_as_paranoid @@ -32,6 +21,19 @@ class ContentItem < ApplicationRecord after_save :index after_save :update_tag_lists + state_machine do + state :draft + state :scheduled + + event :schedule do + transitions :to => :scheduled, :from => [:draft] + end + + event :draft do + transitions :to => :draft, :from => [:scheduled] + end + end + def self.taggable_fields Field.select { |field| field.field_type_instance.is_a?(TagFieldType) }.map { |field_item| field_item.name.parameterize('_') } end From 351571ceaa73534abe66a36cd983bfa299ef8117 Mon Sep 17 00:00:00 2001 From: Alex Tharp Date: Mon, 5 Jun 2017 14:56:12 +0200 Subject: [PATCH 2/3] feat(Seeds): Employer Integration ContentType; further API implementation --- Gemfile | 4 +- app/api/v1/resources/content_items.rb | 18 +- app/interactors/get_content_item.rb | 12 ++ app/interactors/get_content_items.rb | 2 +- config/initializers/doorkeeper.rb | 3 +- lib/tasks/employer/integration.rake | 261 ++++++++++++++++++++++++++ 6 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 app/interactors/get_content_item.rb create mode 100644 lib/tasks/employer/integration.rake diff --git a/Gemfile b/Gemfile index 260e9f0ae..fb7d6b805 100644 --- a/Gemfile +++ b/Gemfile @@ -37,8 +37,8 @@ gem 'acts-as-taggable-on', '~> 4.0' gem 'bcrypt', '~> 3.1.11' gem 'kaminari', '~> 0.17.0' gem 'grape-kaminari', git: 'https://github.com/toastercup/grape-kaminari.git', branch: 'set-paginate-headers-extraction' -gem 'elasticsearch-model', '~> 0.1' -gem 'elasticsearch-rails', '~> 0.1' +gem 'elasticsearch-model', '~> 5.0' +gem 'elasticsearch-rails', '~> 5.0' gem 'paranoia', '~> 2.3' gem 'pg', '~> 0.20.0' gem 'hashie-forbidden_attributes', '~> 0.1.1' diff --git a/app/api/v1/resources/content_items.rb b/app/api/v1/resources/content_items.rb index 14a601165..864511911 100644 --- a/app/api/v1/resources/content_items.rb +++ b/app/api/v1/resources/content_items.rb @@ -13,7 +13,7 @@ class ContentItems < Grape::API requires :content_type_id, type: Integer, desc: "content type of content item" end post do - require_scope! 'create:content_items' + require_scope! 'modify:content_items' authorize! :create, ::ContentItem @content_item = ::ContentItem.new(params.merge(author_id: current_user.id, creator_id: current_user.id)) @@ -47,13 +47,17 @@ class ContentItems < Grape::API params_hash = Digest::MD5.hexdigest(declared(params).to_s) cache_key = "feed-#{last_updated_at}-#{current_tenant.id}-#{params_hash}" - content_items_page = ::Rails.cache.fetch(cache_key, expires_in: 30.minutes, race_condition_ttl: 10) do - content_items = ::GetContentItems.call(params: declared(clean_params(params), include_missing: false), tenant: current_tenant, published: true).content_items - set_paginate_headers(content_items) - ::V1::Entities::ContentItem.represent content_items.to_a, field_items: true - end + content_items = ::GetContentItems.call(params: declared(clean_params(params), include_missing: false), tenant: current_tenant, published: true).content_items + set_paginate_headers(content_items) + ::V1::Entities::ContentItem.represent content_items.to_a, field_items: true + end - content_items_page + desc 'Show a published content item', { entity: ::V1::Entities::ContentItem, nickname: "showFeedContentItem" } + get 'feed/*id' do + @content_item = ::GetContentItem.call(id: params[:id], published: true, tenant: current_tenant.id).content_item + not_found! unless @content_item + authorize! :view, @content_item + present @content_item, with: ::V1::Entities::ContentItem, field_items: true end end end diff --git a/app/interactors/get_content_item.rb b/app/interactors/get_content_item.rb new file mode 100644 index 000000000..6ac89dc1c --- /dev/null +++ b/app/interactors/get_content_item.rb @@ -0,0 +1,12 @@ +class GetContentItem + include Interactor + + def call + content_item = ::ContentItem + #content_item = content_item.find_by_tenant_id(context.tenant) if context.tenant + #content_item = content_item.published if context.published + #content_item = content_item.find_by_id_or_slug(context.id) + content_item = content_item.find_by_id(context.id) + context.content_item = content_item + end +end diff --git a/app/interactors/get_content_items.rb b/app/interactors/get_content_items.rb index ac080f00a..9fe2a4e82 100644 --- a/app/interactors/get_content_items.rb +++ b/app/interactors/get_content_items.rb @@ -2,7 +2,7 @@ class GetContentItems include Interactor def call - content_items = ContentType.find_by_name(context.params.content_type_name).content_items.order(created_at: :desc) + content_items = ContentType.find_by_name(context.params.content_type_name.titleize).content_items.order(created_at: :desc) context.content_items = content_items.page(context.params.page).per(context.params.per_page) end end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index c9abd248b..d3d7aabd9 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -39,7 +39,8 @@ optional_scopes 'view:users', 'modify:users', 'view:tenants', 'modify:tenants', 'view:posts', 'modify:posts', 'view:media', 'modify:media', 'view:applications', 'modify:applications', 'view:bulk_jobs', 'modify:bulk_jobs', 'view:documents', 'modify:documents', - 'view:snippets', 'modify:snippets', 'view:webpages', 'modify:webpages', 'view:content_types' + 'view:snippets', 'modify:snippets', 'view:webpages', 'modify:webpages', 'view:content_types', + 'modify:content_types', 'view:content_items', 'modify:content_items' # Change the way client credentials are retrieved from the request object. # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then diff --git a/lib/tasks/employer/integration.rake b/lib/tasks/employer/integration.rake new file mode 100644 index 000000000..0953468a5 --- /dev/null +++ b/lib/tasks/employer/integration.rake @@ -0,0 +1,261 @@ +Bundler.require(:default, Rails.env) + +namespace :employer do + namespace :integration do + desc 'Seed Employer Blog ContentType and Fields' + task seed: :environment do + def category_tree + tree = Tree.new + tree.add_node({ name: "Category 1" }) + tree.add_node({ name: "Category 2" }) + tree.add_node({ name: "Category 3" }) + tree + end + + puts "Creating Employer Integration ContentType..." + integration = ContentType.new({ + name: "Employer Integration", + description: "3rd Party CareerBuilder Integrations", + icon: "device hub", + creator_id: 1, + contract_id: 1, + publishable: true + }) + integration.save! + + puts "Creating Fields..." + integration.fields.new(name: 'Body', field_type: 'text_field_type', metadata: {parse_widgets: true}, validations: { presence: true }) + integration.fields.new(name: 'Title', field_type: 'text_field_type', validations: {presence: true}) + integration.fields.new(name: 'Description', field_type: 'text_field_type', validations: {presence: true}) + integration.fields.new(name: 'Slug', field_type: 'text_field_type', validations: {presence: true, uniqueness: true}) + integration.fields.new(name: 'Tags', field_type: 'tag_field_type') + integration.fields.new(name: 'Publish Date', field_type: 'date_time_field_type', metadata: {state: 'Published'}) + integration.fields.new(name: 'Expiration Date', field_type: 'date_time_field_type', metadata: {state: 'Expired'}) + integration.fields.new(name: 'SEO Title', field_type: 'text_field_type', validations: {presence: true, uniqueness: true }) + integration.fields.new(name: 'SEO Description', field_type: 'text_field_type', validations: {presence: true}) + integration.fields.new(name: 'SEO Keywords', field_type: 'tag_field_type') + integration.fields.new(name: 'No Index', field_type: 'boolean_field_type') + integration.fields.new(name: 'No Follow', field_type: 'boolean_field_type') + integration.fields.new(name: 'No Snippet', field_type: 'boolean_field_type') + integration.fields.new(name: 'No ODP', field_type: 'boolean_field_type') + integration.fields.new(name: 'No Archive', field_type: 'boolean_field_type') + integration.fields.new(name: 'No Image Index', field_type: 'boolean_field_type') + integration.fields.new(name: 'Categories', field_type: 'tree_field_type', metadata: {allowed_values: category_tree}) + integration.fields.new(name: 'Featured Image', field_type: 'content_item_field_type', + metadata: { + field_name: 'Asset' + }) + + puts "Saving Employer Integration..." + integration.save! + + puts "Creating Wizard Decorators..." + wizard_hash = { + "steps": [ + { + "name": "Write", + "columns": [ + { + "grid_width": 12, + "elements": [ + { + "id": integration.fields.find_by_name('Title').id + }, + { + "id": integration.fields.find_by_name('Body').id, + "render_method": "wysiwyg", + "input": { + "display": { + "styles": { + "height": "500px" + } + } + } + } + ] + } + ] + }, + + { + "name": "Details", + "columns": [ + { + "grid_width": 6, + "elements": [ + { + "id": integration.fields.find_by_name('Description').id, + "tooltip": 'This is a short description and will be used as the preview text for an employer before they click into the integration.' + }, + { + "id": integration.fields.find_by_name('Publish Date').id + }, + { + "id": integration.fields.find_by_name('Expiration Date').id + } + ] + }, + { + "grid_width": 6, + "elements": [ + { + "id": integration.fields.find_by_name('Tags').id + }, + { + "id": integration.fields.find_by_name('Slug').id, + "tooltip": "This is your integrations's URL. Between each word, place a hyphen. Best if between 35-50 characters and don't include years/dates." + } + ] + } + ] + }, + { + "name": "Categorize", + "columns": [ + { + "grid_width": 4, + "elements": [ + { + "id": integration.fields.find_by_name('Categories').id, + "render_method": "checkboxes" + } + ] + }, + { + "grid_width": 8, + "elements": [ + { + "id": integration.fields.find_by_name('Featured Image').id, + "render_method": "popup" + } + ] + } + ] + }, + { + "name": "Search", + "columns": [ + { + "grid_width": 6, + "elements": [ + { + "id": integration.fields.find_by_name('SEO Title').id, + "tooltip": 'Please use <70 characters for your SEO title for optimal appearance in search results.' + }, + { + "id": integration.fields.find_by_name('SEO Description').id, + "tooltip": 'The description should optimally be between 150-160 characters and keyword rich.' + }, + { + "id": integration.fields.find_by_name('SEO Keywords').id, + "tooltip": 'Utilize the recommended keywords as tags to boost your SEO performance.' + } + ] + }, + { + "grid_width": 6, + "description": "Select these if you don't want your integration to be indexed by search engines like Google", + "elements": [ + { + "id": integration.fields.find_by_name('No Index').id + }, + { + "id": integration.fields.find_by_name('No Follow').id + }, + { + "id": integration.fields.find_by_name('No Snippet').id + }, + { + "id": integration.fields.find_by_name('No ODP').id + }, + { + "id": integration.fields.find_by_name('No Archive').id + }, + { + "id": integration.fields.find_by_name('No Image Index').id + } + ] + } + ] + } + ] + } + + wizard_decorator = Decorator.new(name: "Wizard", data: wizard_hash) + wizard_decorator.save! + + ContentableDecorator.create!({ + decorator_id: wizard_decorator.id, + contentable_id: integration.id, + contentable_type: 'ContentType' + }) + + puts "Creating Index Decorators..." + index_hash = { + "columns": + [ + { + "name": "Title", + "grid_width": 3, + "cells": [{ + "field": { + "id": integration.fields.find_by_name('Title').id + } + }] + }, + { + "name": "Integration Details", + "cells": [ + { + "field": { + "id": integration.fields.find_by_name('Description').id + }, + "display": { + "classes": [ + "bold", + "upcase" + ] + } + }, + { + "field": { + "id": integration.fields.find_by_name('Slug').id + } + }, + { + "field": { + "method": "publish_state" + } + } + ] + }, + { + "name": "Tags", + "cells": [ + { + "field": { + "id": integration.fields.find_by_name('Tags').id + }, + "display": { + "classes": [ + "tag", + "rounded" + ] + } + } + ] + } + ] + } + + index_decorator = Decorator.new(name: "Index", data: index_hash) + index_decorator.save! + + ContentableDecorator.create!({ + decorator_id: index_decorator.id, + contentable_id: integration.id, + contentable_type: 'ContentType' + }) + end + end +end From 5091ea8e9a1fb504d8e6b6c01f6235919006db5c Mon Sep 17 00:00:00 2001 From: Alex Tharp Date: Sat, 8 Jul 2017 01:18:38 -0500 Subject: [PATCH 3/3] fix(API): ensure cached content_items feed endpoint retains pagination headers --- app/api/v1/resources/content_items.rb | 13 +++++++++---- app/interactors/get_content_items.rb | 2 +- lib/tasks/employer/integration.rake | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/api/v1/resources/content_items.rb b/app/api/v1/resources/content_items.rb index 864511911..5c5f17f51 100644 --- a/app/api/v1/resources/content_items.rb +++ b/app/api/v1/resources/content_items.rb @@ -37,7 +37,7 @@ class ContentItems < Grape::API desc 'Show published content items', { entity: ::V1::Entities::ContentItem, nickname: "contentItemsFeed" } params do use :pagination - requires :content_type_name, type: String, desc: "content type of content item" + requires :content_type_name, type: String, desc: 'ContentType of ContentItem' end get :feed do require_scope! 'view:content_items' @@ -47,9 +47,14 @@ class ContentItems < Grape::API params_hash = Digest::MD5.hexdigest(declared(params).to_s) cache_key = "feed-#{last_updated_at}-#{current_tenant.id}-#{params_hash}" - content_items = ::GetContentItems.call(params: declared(clean_params(params), include_missing: false), tenant: current_tenant, published: true).content_items - set_paginate_headers(content_items) - ::V1::Entities::ContentItem.represent content_items.to_a, field_items: true + content_items = ::Rails.cache.fetch(cache_key, expires_in: 30.minutes, race_condition_ttl: 10) do + content_items = ::GetContentItems.call(params: declared(clean_params(params), include_missing: false), tenant: current_tenant, published: true).content_items + paginated_content_items = paginate(content_items).records.to_a + {records: paginated_content_items, headers: header} + end + + header.merge!(content_items[:headers]) + ::V1::Entities::ContentItem.represent content_items[:records], field_items: true end desc 'Show a published content item', { entity: ::V1::Entities::ContentItem, nickname: "showFeedContentItem" } diff --git a/app/interactors/get_content_items.rb b/app/interactors/get_content_items.rb index 9fe2a4e82..886cdea32 100644 --- a/app/interactors/get_content_items.rb +++ b/app/interactors/get_content_items.rb @@ -3,6 +3,6 @@ class GetContentItems def call content_items = ContentType.find_by_name(context.params.content_type_name.titleize).content_items.order(created_at: :desc) - context.content_items = content_items.page(context.params.page).per(context.params.per_page) + context.content_items = content_items end end diff --git a/lib/tasks/employer/integration.rake b/lib/tasks/employer/integration.rake index 0953468a5..b2efaaa6c 100644 --- a/lib/tasks/employer/integration.rake +++ b/lib/tasks/employer/integration.rake @@ -2,7 +2,7 @@ Bundler.require(:default, Rails.env) namespace :employer do namespace :integration do - desc 'Seed Employer Blog ContentType and Fields' + desc 'Seed Employer Integration ContentType and Fields' task seed: :environment do def category_tree tree = Tree.new @@ -12,7 +12,7 @@ namespace :employer do tree end - puts "Creating Employer Integration ContentType..." + puts 'Creating Employer Integration ContentType...' integration = ContentType.new({ name: "Employer Integration", description: "3rd Party CareerBuilder Integrations",