app/models/lightweight_activity.rb
class LightweightActivity < ActiveRecord::Base
include Publishable # defines methods to publish to portals
include PublicationStatus # defines publication status scopes and helpers
include FixedWidthLayout # defines fixed width options
include Accessible # defines font options
LAYOUT_MULTI_PAGE = 0
LAYOUT_SINGLE_PAGE = 1
LAYOUT_NOTEBOOK = 2
LAYOUT_OPTIONS = [
['Multi-page', LAYOUT_MULTI_PAGE],
['Single-page', LAYOUT_SINGLE_PAGE],
['Notebook', LAYOUT_NOTEBOOK]
]
# the override uses 0 as no override and then the rest of the layout options as 1-based
LAYOUT_OVERRIDE_OPTIONS = [
['None', 0],
['Multi-page', LAYOUT_MULTI_PAGE + 1],
['Single-page', LAYOUT_SINGLE_PAGE + 1],
['Notebook', LAYOUT_NOTEBOOK + 1]
]
STANDARD_EDITOR_MODE = 0
ITSI_EDITOR_MODE = 1
EDITOR_MODE_OPTIONS = [
['Standard', STANDARD_EDITOR_MODE],
['ITSI', ITSI_EDITOR_MODE]
]
attr_accessible :name, :user_id, :pages, :related, :description, :defunct,
:time_to_complete, :is_locked, :notes, :thumbnail_url, :project_id,
:portal_run_count, :layout, :editor_mode, :publication_hash, :copied_from_id,
:student_report_enabled, :show_submit_button, :project, :background_image,
:glossary_id, :hide_read_aloud, :font_size, :hide_question_numbers
belongs_to :user # Author
belongs_to :changed_by, :class_name => 'User'
has_many :plugins, as: :plugin_scope, :dependent => :destroy
has_many :pages,
:foreign_key => 'lightweight_activity_id',
:class_name => 'InteractivePage',
:order => :position,
:dependent => :destroy,
:inverse_of => :lightweight_activity
has_many :visible_pages, :foreign_key => 'lightweight_activity_id', :class_name => 'InteractivePage', :order => :position,
:conditions => {interactive_pages: {is_hidden: false}}
has_many :lightweight_activities_sequences, :dependent => :destroy
has_many :sequences, :through => :lightweight_activities_sequences
has_many :runs, :foreign_key => 'activity_id', :dependent => :destroy
belongs_to :project
belongs_to :glossary
has_many :imports, as: :import_item
belongs_to :copied_from_activity, :class_name => "LightweightActivity", :foreign_key => "copied_from_id"
# has_many :offerings, :dependent => :destroy, :as => :runnable, :class_name => "Portal::Offering"
# validates_length_of :name, :maximum => 50
validates :description, :related, :html => true
# Just a way of getting self.visible_pages with the embeddables eager-loaded
def visible_pages_with_embeddables
InteractivePage
.includes(sections: {page_items: :embeddable})
.where(:lightweight_activity_id => self.id, :is_hidden => false)
.order(:position)
end
def reportable_items
items = []
visible_pages_with_embeddables.each do |p|
items += p.reportable_items
end
items
end
def answers(run)
finder = Embeddable::AnswerFinder.new(run)
reportable_items.map { |q| finder.find_answer(q) }
end
def set_user!(receiving_user)
update_attribute(:user_id, receiving_user.id)
end
def publish!
update_attribute(:publication_status, 'public')
end
def name_with_id
"#{self.id}. #{self.name}"
end
def to_hash
# We're intentionally not copying:
# - user_id (the copying user should be the owner)
# - Pages (associations will be done differently)
{
name: name,
related: related,
publication_status: publication_status,
description: description,
time_to_complete: time_to_complete,
project: project,
thumbnail_url: thumbnail_url,
notes: notes,
layout: layout,
editor_mode: editor_mode,
student_report_enabled: student_report_enabled,
show_submit_button: show_submit_button,
hide_read_aloud: hide_read_aloud,
hide_question_numbers: hide_question_numbers,
font_size: font_size
}
end
def duplicate(new_owner, helper=nil)
helper = LaraDuplicationHelper.new if helper.nil?
new_activity = LightweightActivity.new(self.to_hash)
LightweightActivity.transaction do
new_activity.save!(validate: false)
# Clarify name
new_activity.name = "Copy of #{new_activity.name}"
new_activity.user = new_owner
new_activity.copied_from_id = self.id
self.pages.each do |p|
new_page = p.duplicate(helper)
new_page.lightweight_activity = new_activity
new_page.set_list_position(p.position)
new_page.save!(validate: false)
end
new_activity.fix_page_positions
self.plugins.each do |p|
new_activity.plugins.push(p.duplicate)
end
new_activity.glossary_id = self.glossary_id
end
new_activity
end
def fake_glossary_plugin_id
self.glossary_id * 1_000_000_000 + self.id
end
def export(host)
activity_json = self.as_json(only: [:id,
:name,
:related,
:description,
:time_to_complete,
:thumbnail_url,
:notes,
:layout,
:editor_mode,
:student_report_enabled,
:show_submit_button,
:background_image,
:defunct,
:hide_read_aloud,
:hide_question_numbers,
:font_size ])
activity_json[:version] = 2
activity_json[:project] = self.project ? self.project.export : nil
activity_json[:pages] = []
self.pages.each do |p|
activity_json[:pages] << p.export
end
activity_json[:plugins] = []
self.plugins.each do |p|
# only export plugins that have an approved_script
if p.approved_script
activity_json[:plugins] << p.export
end
end
# if the activity has a glossary model assiged to it, add the fake glossary plugin to the list of plugins
# replacing any existing glossary plugins and using the existing glossary plugin's approved script
if self.glossary_id && approved_glossary_script = Glossary.get_glossary_approved_script()
# remove any existing glossary script
activity_json[:plugins].delete_if { |plugin| plugin[:component_label] == "glossary" }
fake_glossary_plugin = {
id: fake_glossary_plugin_id(),
description: nil,
author_data: JSON.generate({
version:"1.0",
glossaryResourceId: "this-is-a-fake-glossary-resource-id",
s3Url: Rails.application.routes.url_helpers.api_v1_glossary_url(self.glossary_id, host: host, json_only: true)
}),
approved_script_label: "glossary",
component_label: "glossary",
approved_script: approved_glossary_script.to_hash
}
activity_json[:plugins] << fake_glossary_plugin
end
activity_json[:type] = "LightweightActivity"
activity_json[:export_site] = "Lightweight Activities Runtime and Authoring"
activity_json[:fixed_width_layout] = self.fixed_width_layout
return activity_json
end
def self.extract_from_hash(activity_json_object)
{
description: activity_json_object[:description],
name: activity_json_object[:name],
notes: activity_json_object[:notes],
related: activity_json_object[:related],
thumbnail_url: activity_json_object[:thumbnail_url],
student_report_enabled: activity_json_object[:student_report_enabled],
show_submit_button: activity_json_object[:show_submit_button],
time_to_complete: activity_json_object[:time_to_complete],
layout: activity_json_object[:layout],
editor_mode: activity_json_object[:editor_mode],
background_image: activity_json_object[:background_image]
}
end
def self.import(activity_json_object,new_owner,imported_activity_url=nil,helper=nil)
version = activity_json_object[:version] || 1
author_user = User.find_by_email(activity_json_object[:user_email]) if activity_json_object[:user_email]
import_activity = LightweightActivity.new(self.extract_from_hash(activity_json_object))
import_activity.imported_activity_url = imported_activity_url
import_activity.is_official = activity_json_object[:is_official]
import_activity.hide_read_aloud = activity_json_object[:hide_read_aloud]
import_activity.hide_question_numbers = activity_json_object[:hide_question_numbers]
import_activity.font_size = activity_json_object[:font_size]
import_activity.project = Project.find_or_create(activity_json_object[:project]) if activity_json_object[:project]
self.link_glossaries_on_import(activity_json_object, import_activity)
helper = LaraSerializationHelper.new if helper.nil?
LightweightActivity.transaction do
import_activity.save!(validate: false)
# Clarify name
import_activity.name = import_activity.name
# assign user specified in the json if exist else the importer becomes the author
import_activity.user = author_user || new_owner
import_activity.user.is_author = true
import_activity.user.save!
activity_json_object[:pages].each do |p|
import_page = InteractivePage.import(p, helper, version)
import_page.lightweight_activity = import_activity
import_page.set_list_position(p[:position])
import_page.save!(validate: false)
end
import_activity.fix_page_positions
end
import_activity
end
def self.link_glossaries_on_import(activity_json_object, import_activity)
# this option will be turned on during testing of activity imports during the
# LARA2 cutover. It is potentially dangerous as the domain of the glossary
# url is ignored so it is possible to link to the incorrect glossary
return unless ENV['ENABLE_DANGEROUS_GLOSSARY_LINKING_ON_IMPORT'] == "true"
return unless activity_json_object[:plugins]
glossary_plugin_object = activity_json_object[:plugins].find { |p| p[:approved_script_label] == "glossary" }
return unless glossary_plugin_object && glossary_plugin_object[:author_data]
author_data = JSON.parse(glossary_plugin_object[:author_data], :symbolize_names => true)
return unless author_data && author_data[:s3Url]
# this purposefully ignores the domain of the original glossary url
# so that we can import between domains when doing the LARA 2 cutover
matches = author_data[:s3Url].match /\/api\/v1\/glossaries\/(\d+)/
return unless matches && matches[1]
import_activity.glossary = Glossary.find_by_id(matches[1])
end
# TODO: Include acts_as_list? @pjmorse would hate that.
def position(seq)
seq.activities.each_with_index do |a,i|
return i+1 if a.id == self.id
end
raise "Activity #{id} is not part of Sequence #{seq.id}"
end
def serialize_for_portal_basic(host)
local_url = "#{host}#{Rails.application.routes.url_helpers.activity_path(self)}"
data = {
"type" => "Activity",
"name" => self.name,
"author_url" => "#{local_url}/edit",
"print_url" => "#{local_url}/print_blank",
"student_report_enabled" => student_report_enabled,
"show_submit_button" => show_submit_button,
"thumbnail_url" => thumbnail_url,
"is_locked" => self.is_locked,
"url" => activity_player_url(host),
"tool_id" => "https://activity-player.concord.org",
"append_auth_token" => true
}
data
end
def serialize_for_portal(host)
data = serialize_for_portal_basic(host)
data["source_type"] = "LARA"
data["create_url"] = data["url"]
data["author_email"] = user.email
# Description is not used by new Portal anymore. However, we still need to send it to support older Portal instances.
# Otherwise, the old Portal code would reset its description copy each time the activity was published.
# When all Portals are upgraded to v1.31 we can stop sending this property.
data["description"] = self.description
pages = []
visible_pages_with_embeddables.each do |page|
page_url = activity_player_url(host, page: page, preview: true)
elements = []
page.reportable_items.each do |embeddable|
if embeddable.respond_to?(:portal_hash)
elements.push(embeddable.portal_hash)
end
# Otherwise we don't support this embeddable type right now.
end
pages.push({
"name" => page.name,
"url" => page_url,
"elements" => elements
})
end
section = {
"name" => "#{self.name} Section",
"pages" => pages
}
data["sections"] = [section]
data
end
def serialize_for_report_service(host)
activity_url = "#{host}#{Rails.application.routes.url_helpers.activity_path(self)}"
data = {
id: "activity_" + self.id.to_s,
type: "activity",
name: self.name,
url: activity_url,
migration_status: migration_status,
preview_url: activity_player_url(host, preview: true)
}
pages = []
visible_pages_with_embeddables.each do |page|
questions = []
page.reportable_items.each do |embeddable|
questions.push(embeddable.report_service_hash) if embeddable.respond_to?(:report_service_hash)
# Otherwise we don't support this embeddable type right now.
end
page_url = "#{host}#{Rails.application.routes.url_helpers.page_path(page)}"
page_data = {
id: "page_" + page.id.to_s,
type: "page",
name: page.name,
url: page_url,
children: questions,
preview_url: activity_player_url(host, page: page, preview: true)
}
pages.push(page_data)
end
# Fake section to satisfy current report service requirement. Hopefully, it won't be necessary soon.
section = {
# There's only one, artificial section per activity, so it's fine to reuse activity ID.
id: "section_" + self.id.to_s,
type: "section",
name: "#{self.name} Section",
url: activity_url,
children: pages
}
# NOTE: there is no Activity Player runtime component for a section so the section
# doesn't have a preview_url
data[:children] = [ section ]
data
end
def for_sequence(seq)
lightweight_activities_sequences.detect { |a| a.sequence_id == seq.id}
end
def active_runs
# stored in lightweight_activities table, incremented by run-model
self.portal_run_count
end
def fix_page_positions
self.pages.map { |page| page.set_list_position(self.pages.index(page)+1) }
end
def fix_broken_portal_runs(auth_key=nil)
success_count = 0
fail_count = 0
self.runs.select { |run| !run.remote_endpoint.blank?}.each do |run|
if run.submit_answers_now(auth_key)
success_count = success_count + 1
else
fail_count = fail_count + 1
end
end
Rails.logger.info("fix broken portal runs for activity #{self.id} fail_count: #{fail_count} success_count: #{success_count}")
return { activity_id: self.id, fail_count: fail_count, success_count: success_count}
end
def self.search(query, _user) # user not used
where("name LIKE ?", "%#{query}%")
end
def activity_player_url(host, page: nil, preview: false, mode: nil)
api_url = "#{host}#{Rails.application.routes.url_helpers.api_v1_activity_path(self)}.json"
uri = URI.parse(ENV["ACTIVITY_PLAYER_URL"])
query = Rack::Utils.parse_query(uri.query)
query["activity"] = api_url
if page != nil
query["page"] = "page_#{page.id}"
end
if preview
query["preview"] = nil # adds 'preview' to query string as a valueless param
end
if mode
query["mode"] = mode
end
uri.query = Rack::Utils.build_query(query)
return uri.to_s
end
end