Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Splice embed - Implement Exercise Collection Page with iFrame and LTI Embeds #227

Open
wants to merge 20 commits into
base: staging
Choose a base branch
from
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/deploy-acos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: deploy-acos
on:
push:
branches:
- keweizhan

jobs:
build:
name: deploy-acos
runs-on: self-hosted
steps:
- name: ssh
uses: fifsky/ssh-action@master
with:
host: ${{ secrets.STAGING_HOST }}
user: ${{ secrets.STAGING_USERNAME }}
key: ${{ secrets.STAGING_KEY }}
port: ${{ secrets.STAGING_PORT }}
args: "-tt"
command: |
cd /home/deploy/code-workout/
docker compose pull
docker-compose down
docker-compose up -d
32 changes: 32 additions & 0 deletions .github/workflows/image-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: image-build
on:
push:
branches:
- keweizhan
jobs:
build:
name: code-workout-image
runs-on: self-hosted
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: opendsa/code-workout:latest
7 changes: 4 additions & 3 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ gem 'coffee-script-source'
gem 'test-unit', '~> 3.0.9'
gem 'nokogiri', '~> 1.10.4'
gem 'csv_shaper'
gem 'andand', github: 'raganwald/andand'
gem 'andand', git: 'https://github.com/raganwald/andand'
gem 'responders' # Can't move above 1.1 until migrating to rails 4.2+
gem 'friendly_id', '~> 5'
gem 'active_record-acts_as'
@@ -50,7 +50,7 @@ group :development, :test do
gem 'sqlite3', '~> 1.3.0'
gem 'rspec-rails'
gem 'annotate'
gem 'rails-erd', github: 'voormedia/rails-erd'
gem 'rails-erd', git: 'https://github.com/voormedia/rails-erd'
gem 'faker'
# Needed for debugging support in Aptana Studio. Disabled, since these
# two gems do not support Ruby 2.0 yet :-(.
@@ -113,7 +113,8 @@ group :deploy do
gem 'capistrano-bundler'
gem 'capistrano-rails'
gem 'capistrano-rvm'
gem 'capistrano3-puma', github: 'seuros/capistrano-puma'
gem 'capistrano3-puma', '~> 4.0.0',
git: 'https://github.com/seuros/capistrano-puma', branch: 'v4.x'
end

#for multi-color progress bar
19 changes: 11 additions & 8 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
GIT
remote: git://github.com/raganwald/andand.git
remote: https://github.com/raganwald/andand
revision: d6c4545b6649c70495c26e2038206c5fdb2d14d6
specs:
andand (1.3.3)

GIT
remote: git://github.com/seuros/capistrano-puma.git
revision: 6112323390cff15539d947882d72d937622cfdf4
remote: https://github.com/seuros/capistrano-puma
revision: b148515f78476b68ab8e09bcc494e82ceb53eba0
branch: v4.x
specs:
capistrano3-puma (4.0.0)
capistrano (~> 3.7)
capistrano-bundler
puma (~> 4.0)

GIT
remote: git://github.com/voormedia/rails-erd.git
revision: 0fbb1cdf2c84b06afd12974baace8d512bb798da
remote: https://github.com/voormedia/rails-erd
revision: 7c66258b6818c47b4d878c2ad7ff6decebdf834a
specs:
rails-erd (1.6.0)
rails-erd (1.7.2)
activerecord (>= 4.2)
activesupport (>= 4.2)
choice (~> 0.2.0)
@@ -391,6 +392,7 @@ GEM
http-cookie (>= 1.0.2, < 2.0)
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
rexml (3.2.6)
rspec-core (3.8.2)
rspec-support (~> 3.8.0)
rspec-expectations (3.8.4)
@@ -408,7 +410,8 @@ GEM
rspec-mocks (~> 3.8.0)
rspec-support (~> 3.8.0)
rspec-support (3.8.2)
ruby-graphviz (1.2.4)
ruby-graphviz (1.2.5)
rexml
ruby_parser (3.13.1)
sexp_processor (~> 4.9)
rubyzip (1.3.0)
@@ -510,7 +513,7 @@ DEPENDENCIES
capistrano-bundler
capistrano-rails
capistrano-rvm
capistrano3-puma!
capistrano3-puma (~> 4.0.0)!
capybara
carrierwave (= 1.3.2)
cocoon
30 changes: 30 additions & 0 deletions app/assets/stylesheets/custom.scss
Original file line number Diff line number Diff line change
@@ -569,3 +569,33 @@ ul.ui-autocomplete {
max-height: 5em;
height: auto;
}

// syling for embed collection page for SPLICE
.exercise-container.card {
width: 200%; // Adjust width as needed
// padding-left: 0 //
}

.iframe-label-container {
clear: both; // Clear floats to ensure this container stays below preceding content
margin-top: 8px; // Add margin at the top to create space between this container and the preceding content
margin-bottom: 8px; // Adjust spacing below the label
// padding-left: 0
}

.iframe-label-container label {
display: block;
font-size: 11px;
margin-bottom: 5px;

}

.exercise-details {
margin-bottom: 10px; // Adds space before the button
padding-left: 20px;

input.form-control {
width: 40%; // text field the same width as the thumbnail
font-size: 9px;
}
}
42 changes: 41 additions & 1 deletion app/controllers/exercises_controller.rb
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@ class ExercisesController < ApplicationController


load_and_authorize_resource
skip_authorize_resource only: [:practice, :call_open_pop]
skip_authorize_resource only: [:practice, :call_open_pop, :embed_collection, :export]

#~ Action methods ...........................................................
after_action :allow_iframe, only: [:practice, :embed]
@@ -26,6 +26,46 @@ def index
@exercises = @exercises.page params[:page]
end

# This embed_collection fetches all exercises for the embed_collections page and provides a simplified iframe urls of exercises for SPLICE
def embed_collection
if current_user
@exercises = Exercise.visible_to_user(current_user)
else
@exercises = Exercise.publicly_visible
end

@exercises = @exercises.page(params[:page])
end

# The export function gets all exercises metadata for SPLICE
def export
# filter out stop/connector words for keywords from workout phrases or names
stop_words = ['the', 'and', 'a', 'to', 'of', 'in', 'for', 'on', 'with', 'as', 'by', 'at', 'from', 'is', 'that', 'which', 'it', 'an', 'be', 'this', 'are', 'we', 'can', 'if', 'has', 'but']

@exercises = Exercise.all
export_data = @exercises.map do |exercise|
workout_names = exercise.exercise_workouts.map { |ew| ew.workout.name }.uniq.push(exercise.name)
# split phrases into words, remove stop/connector words
keywords_array = workout_names.map { |phrase| phrase.downcase.split(/\W+/) }.flatten.uniq.reject { |word| stop_words.include?(word) || word.empty? }

{
"Platform_name": "Code-Workout",
"URL": "https://codeworkout.cs.vt.edu", # hardcoded URL for now
"LTI_Instructions_URL": "https://opendsa-server.cs.vt.edu/guides/opendsa-canvas",
"Exercise_type": Exercise::TYPE_NAMES[exercise.question_type],
"License": "Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)",
"Description": exercise.exercise_collection&.description,
"Author": "Edwards",
"Institution": "VT",
"Keywords": keywords_array,
"Exercise_Name": exercise.name,
"Iframe_URL": exercise.iframe_url,
"LTI_URL": exercise.lti_launch_url
}
end
render json: export_data
end


# -------------------------------------------------------------
# GET /exercises/download.csv
42 changes: 39 additions & 3 deletions app/jobs/code_worker.rb
Original file line number Diff line number Diff line change
@@ -53,12 +53,11 @@ def perform(attempt_id)
# compile and evaluate the attempt in a temporary location
attempt_dir = "usr/attempts/active/#{current_attempt}"
# puts "DIRECTORY",attempt_dir,"DIRECTORY"
FileUtils.mkdir_p(attempt_dir)
if !Dir[attempt_dir].empty?
puts 'WARNING, OVERWRITING EXISTING DIRECTORY = ' + attempt_dir
FileUtils.remove_dir(attempt_dir, true)
FileUtils.mkdir_p(attempt_dir)
end
FileUtils.mkdir_p(attempt_dir)
if !File.exist?(prompt.test_file_name)
# Workaround for bug in correctly pre-generating test file
# on exercise creation. If it doesn't exist, force regeneration
@@ -198,8 +197,45 @@ def perform(attempt_id)
def execute_javatest(class_name, attempt_dir, pre_lines, answer_lines)
if CodeWorkout::Config::CMD[:java].key? :daemon_url
url = CodeWorkout::Config::CMD[:java][:daemon_url] % {attempt_dir: attempt_dir}
response = Net::HTTP.get_response(URI.parse(url))
uri = URI.parse(url)

# response = Net::HTTP.get_response(URI.parse(url))
# puts "%{url} => response %{response.code}"

response = nil

max_retries = 3
for a in 1..max_retries do
begin
Net::HTTP.new(uri.hostname, uri.port).start do |http|
http.open_timeout = 4
response = http.request_get(uri.request_uri)

if response.nil?
puts "GET #{url} => try #{a} no response"
elsif response.kind_of? Net::HTTPSuccess # response.code == 200
break
else
puts "GET #{url} => try #{a} bad response: #{response.code}"
end
# puts "%{url} => response %{response.code}"

# pause before retrying
sleep(4)
end
rescue => e
puts "GET #{url} => try #{a} error: #{e.message}"
end
end
if response.nil? then
puts "Server backend error [no response] for #{attempt_dir}"
return "Server backend error [no response]. Please resubmit your answer."
elsif !(response.kind_of? Net::HTTPSuccess) # response.code != 200
puts "Server backend error #{response.code} for #{attempt_dir}:"
puts response.body
return "Server backend error #{response.code}. Please resubmit your answer."
end

else
cmd = CodeWorkout::Config::CMD[:java][:cmd] % {attempt_dir: attempt_dir}
# puts(cmd + '>> err.log 2>> err.log')
10 changes: 6 additions & 4 deletions app/models/attempt.rb
Original file line number Diff line number Diff line change
@@ -20,10 +20,12 @@
#
# Indexes
#
# index_attempts_on_active_score_id (active_score_id)
# index_attempts_on_exercise_version_id (exercise_version_id)
# index_attempts_on_user_id (user_id)
# index_attempts_on_workout_score_id (workout_score_id)
# idx_attempts_on_user_exercise_version (user_id,exercise_version_id)
# idx_attempts_on_workout_score_exercise_version (workout_score_id,exercise_version_id)
# index_attempts_on_active_score_id (active_score_id)
# index_attempts_on_exercise_version_id (exercise_version_id)
# index_attempts_on_user_id (user_id)
# index_attempts_on_workout_score_id (workout_score_id)
#
# Foreign Keys
#
138 changes: 137 additions & 1 deletion app/models/coding_prompt.rb
Original file line number Diff line number Diff line change
@@ -135,11 +135,18 @@ def regenerate_tests
# -------------------------------------------------------------
def set_defaults
# Should the default class name be the same across all languages?
# TODO: auto-guess method name from starter code
# TODO: auto-guess class name from wrapper code or starter code
case self.language
when 'Java'
self.class_name ||= 'Answer'
self.wrapper_code ||= "public class Answer\n{\n ___\n}\n"
# TODO: auto-guess method name from starter code
when 'Python'
self.class_name ||= 'Answer'
self.wrapper_code ||= "___\n"
when 'C++'
self.class_name ||= 'Answer'
self.wrapper_code ||= "class Answer\n{\n ___\n}\n"
end
end

@@ -178,6 +185,11 @@ def parse_tests
parse_CxxTest_tests
return
end
when 'Python'
if self.test_script =~ /\s*(import|def|assert|from)\s/
parse_Python_tests
return
end
end
# Default, if none of above cases return
parse_CSV_tests(self.test_script)
@@ -493,4 +505,128 @@ def parse_CxxTest_tests
File.write(test_file_name, junit)
end


# -------------------------------------------------------------
def parse_Python_tests
pyunit = self.test_script.gsub(/\r\n/, "\n")

# First, collect any embedded static tests
pyunit.scan(
/(?:#\p{Blank}*static\p{Blank}*tests\p{Blank}*:\p{Blank}*(.*\n(?:\p{Blank}*#.*\n)*))/i
) do |tests1, tests2|
if tests2.blank?
tests = tests1.gsub(/^\p{Blank}*\/\/\p{Blank}*/, '').gsub(/\p{Blank}*$/, '')
else
tests = tests2.gsub(/^\p{Blank}*(\*\p{Blank}*)?/, '').gsub(/\p{Blank}*$/, '')
end
tests.sub!(/\n*$/m, "\n")
parse_CSV_tests(tests)
end

# Now, extract metadata about and rename each test method
pyunit.gsub!(
/((?:\p{Blank}*#.*\n))*(\s*def\s+)([a-zA-Z0-9_]+)(\s*\(\s*self\s*\)\s*:)/
) do |match|
comment = Regexp.last_match(1)
attrs = "" # Can support this later
publicvoid = Regexp.last_match(2)
name = Regexp.last_match(3)
args = Regexp.last_match(4)

if name =~ /^test/ || attrs =~ /@Test\b/
tc = TestCase.new(
weight: 1.0,
coding_prompt: self,
input: '',
expected_output: '',
example: false,
hidden: false,
static: false,
screening: false)

tc.description = '<No Test Description Provided!>'
desc = nil
# Attempt to pull description string from comments
if comment =~ /description\s*:\s*((?:[^*\r\n]|(?:\*+[^*\/\r\n]))*)(?:\*\/\s*)?$/i
desc = $1
end
# Attempt to pull description string from attribute, which overrides
if attrs =~ /@(?:Description|Hint)\s*\(\s*"\s*((?:[^"]|\\")*)\s*"\s*\)/
desc = $1.gsub(/\\"/, '"')
end
# If no description, try to pull it from the method name
if desc.blank? && name =~ /^(?:test)?(.*)(?:_*[0-9]+)?$/
namedesc = $1
if !namedesc.blank?
namedesc = namedesc.sub(/^_+/, '').sub(/_+$/, '')
if !namedesc.blank?
if namedesc =~ /^((?:(?:example|screening|hidden)_)+)([^_].*)$/
prefix = $1
suffix = $2
else
prefix = ''
suffix = namedesc
end
if suffix.blank?
if !prefix.blank?
desc = prefix
end
else
desc = prefix.gsub(/_/, ':') + suffix.underscore.split(/_+/).join(' ').capitalize
end
end
end
end
tc.parse_description_specifier(desc)

# look for "example" tag in comments or attribute
if comment =~ /example\s*:\s*true\s*(?:\*\/\s*)?$/i || attrs =~ /@Example\b/
tc.example = true
end
# look for "hidden" tag in comments or attribute
if comment =~ /hidden\s*:\s*true\s*(?:\*\/\s*)?$/i || attrs =~ /@Hidden\b/
tc.hidden = true
end
# look for "screening" tag in comments or attribute
if comment =~ /screening\s*:\s*true\s*(?:\*\/\s*)?$/i || attrs =~ /@Screening\b/
tc.screening = true
end

# Attempt to pull negative feedback string from comments
nfb = nil
if comment =~ /negative\s*feedback\s*:\s*((?:[^*\r\n]|(?:\*+[^*\/\r\n]))*)(?:\*\/\s*)?$/i
nfb = $1
end
# Attempt to pull negative feedback string from attributes
if attrs =~ /@NegativeFeedback\s*\(\s*"\s*((?:[^"]|\\")*)\s*"\s*\)/
nfb = $1.gsub(/\\"/, '"')
end
tc.parse_negative_feedback_specifier(nfb)

# Attempt to pull test case weight from comments
if comment =~ /(?:scoring\s*)?weight\s*:\s*([0-9]+(?:\.[0-9]*)?)\s*(?:\*\/\s*)?$/i
tc.weight = $1.to_f
end
# Attempt to pull test case weight from attributes
if attrs =~ /@(?:Scoring)?Weight\s*\(\s*([0-9]+(?:\.[0-9]*)?)\s*\)/
tc.weight = $1.to_f
end

if tc.save
self.test_cases << tc
# Rename test method to include TestCase id
name = "#{name}_#{tc.id}"
else
puts "error saving test case: #{tc.errors.full_messages.to_s}"
end
end

rewrite = "#{comment}#{attrs}#{publicvoid}#{name}#{args}"
# puts "rewritten test decl:\n#{rewrite}\n\n"
rewrite
end
# puts "pyunit after rewrite:\n#{pyunit}"
File.write(test_file_name, pyunit)
end

end
39 changes: 38 additions & 1 deletion app/models/coding_prompt_answer.rb
Original file line number Diff line number Diff line change
@@ -70,8 +70,45 @@ def without_comments
#~ Private instance methods .................................................
private

# This approach doesn't really work, since comment characters can
# be embedded inside string literals. We can probably come up with
# slightly better way of handling majority of string literal situations
# without doing a full parse, but that'll wait for a while.
REMOVE_COMMENTS_REGEX = {
'Java' => /(\/\*([^*]|(\*+[^*\/]))*\*+\/)|(\/\/[^\r\n]*)/
'Java' => /(\/\*([^*]|(\*+[^*\/]))*\*+\/)|(\/\/[^\r\n]*)/,
'C++' => /(\/\*([^*]|(\*+[^*\/]))*\*+\/)|(\/\/[^\r\n]*)/,
'Python' => /(#[^\r\n]*)/
}


# A Python example of handling string literals for this problem, from:
# https://stackoverflow.com/questions/2319019/using-regex-to-remove-comments-from-source-files
#
# def remove_comments(string):
# pattern = r"(\".*?\"|\'.*?\')|(/\*.*?\*/|//[^\r\n]*$)"
# # first group captures quoted strings (double or single)
# # second group captures comments (//single-line or /* multi-line */)
# regex = re.compile(pattern, re.MULTILINE|re.DOTALL)
# def _replacer(match):
# # if the 2nd group (capturing comments) is not None,
# # it means we have captured a non-quoted (real) comment string.
# if match.group(2) is not None:
# return "" # so we will return empty to remove the comment
# else: # otherwise, we will return the 1st group
# return match.group(1) # captured quoted-string
# return regex.sub(_replacer, string)

# Another regex (doesn't handle string literals) from:
# https://stackoverflow.com/questions/5522733/removing-comments-in-javascript-using-ruby
#
# regexp_long = / # Match she-bang style C-comment
# \/\*! # Opening delimiter.
# [^*]*\*+ # {normal*} Zero or more non-*, one or more *
# (?: # Begin {(special normal*)*} construct.
# [^*\/] # {special} a non-*, non-\/ following star.
# [^*]*\*+ # More {normal*}
# )* # Finish "Unrolling-the-Loop"
# \/ # Closing delimiter.
# /x
# result = subject.gsub(regexp_long, '')
end
15 changes: 15 additions & 0 deletions app/models/exercise.rb
Original file line number Diff line number Diff line change
@@ -109,6 +109,7 @@ class Exercise < ActiveRecord::Base
scope :visible_through_user, -> (u) { joins{exercise_owners.outer}.joins{exercise_collection.outer}.
where{ (exercise_owners.owner == u) | (exercise_collection.user == u) } }

attr_accessor :iframe_url # to get values define in iframe url function for SPLICE

#~ Class methods ............................................................

@@ -193,6 +194,20 @@ def self.publicly_visible
return public_exercise.union(public_license)
end

# the iframeurl, iframe-embedcode and ltilaunch is to display and export each exercises information for SPLICE
def iframe_url
base_url = "https://code-workout.cs.vt.edu" # to be dynamically fetched maybe from a config file
"#{base_url}/gym/exercises/#{self.id}/practice"
end

def iframe_embed_code
"<iframe src='#{self.iframe_url}' height='600' width='150%'></iframe>"
end

def lti_launch_url
base_url = "https://code-workout.cs.vt.edu" # to be fetched dynamically
"#{base_url}/lti/launch/gym/exercises/#{self.id}/practice"
end

# -------------------------------------------------------------
def self.visible_through_user_group(user)
3 changes: 1 addition & 2 deletions app/models/exercise_owner.rb
Original file line number Diff line number Diff line change
@@ -13,8 +13,7 @@
#
# Foreign Keys
#
# exercise_owners_exercise_id_fk (exercise_id => exercises.id)
# exercise_owners_owner_id_fk (owner_id => users.id)
# exercise_owners_owner_id_fk (owner_id => users.id)
#

# =============================================================================
38 changes: 13 additions & 25 deletions app/models/workout.rb
Original file line number Diff line number Diff line change
@@ -153,7 +153,7 @@ def first_exercise
end

# ------------------------------------------------------------
# Given a current exercise, get the next exercise in the
# Given a current exercise, get the next exercise in the
# workout. Return the first exercise if the current exercise
# is `nil`. Return the first exercise if the current exercise
# does not belong to this workout.
@@ -223,7 +223,7 @@ def xp_distribution(u_id)
gap_per = 100 - earned_per - remaining_per
return [earned, remaining, gap, earned_per, remaining_per, gap_per]
end

# -------------------------------------------------------------
# Save this workout with the specified params. Remove any
# exercises that have been marked for removal.
@@ -263,7 +263,7 @@ def update_or_create(params)
exercise_workout.save!
end

return self.save ? self : false
return self.save ? self : false
end

# ----------------------------------------------------------------------------
@@ -273,7 +273,7 @@ def add_workout_offerings(course_offerings, common)
workout_offerings = [] # Workout offerings added from this submission.
course_offerings.each do |id, offering|
course_offering = CourseOffering.find(id)
workout_offering = WorkoutOffering.find_by(workout: self,
workout_offering = WorkoutOffering.find_by(workout: self,
course_offering: course_offering)
if workout_offering.blank?
workout_offering = WorkoutOffering.new
@@ -340,27 +340,15 @@ def deep_clone!
# -------------------------------------------------------------
def score_for(user, workout_offering = nil,
lis_outcome_service_url = nil, lis_result_sourcedid = nil)
if workout_offering && (lis_outcome_service_url || lis_result_sourcedid)
workout_scores.where(
user: user,
workout_offering: workout_offering,
lis_outcome_service_url: lis_outcome_service_url,
lis_result_sourcedid: lis_result_sourcedid
).order('updated_at DESC').first
elsif lis_outcome_service_url || lis_result_sourcedid
workout_scores.where(
user: user,
workout_offering: nil,
lis_outcome_service_url: lis_outcome_service_url,
lis_result_sourcedid: lis_result_sourcedid
).order('updated_at DESC').first
elsif workout_offering # can assume that the first one is what we want
workout_scores.where(
user: user,
workout_offering: workout_offering
).order('updated_at DESC').first
else # only user is specified
workout_scores.where(user: user, workout_offering: nil).first
scores = workout_scores.where(
user: user, workout_offering: workout_offering).order('updated_at DESC')
if lis_outcome_service_url || lis_result_sourcedid
workout_scores.to_ary.detect do |s|
s.lis_outcome_service_url == lis_outcome_service_url and
s.lis_result_sourcedid == lis_result_sourcedid
end
else
workout_scores.first
end
end

8 changes: 6 additions & 2 deletions app/models/workout_offering.rb
Original file line number Diff line number Diff line change
@@ -75,7 +75,10 @@ def score_for(user)
if user.nil?
return nil
else
workout_scores.where(user: user).order('updated_at DESC').first
# Explicitly include workout id in search for faster search using
# the compound index
workout_scores.where(user: user, workout: workout).
order('updated_at DESC').first
end
end

@@ -141,14 +144,15 @@ def can_be_seen_by?(user)
now = Time.zone.now
uscore = score_for(user)
opens = opening_date_for(user)
hard_deadline = hard_deadline_for(user)
course_offering.is_staff?(user) ||
(((opens == nil) || (opens <= now)) &&
course_offering.is_enrolled?(user) &&
published &&
(uscore == nil ||
!uscore.closed? ||
!workout_policy.andand.no_review_before_close ||
now >= hard_deadline_for(user)))
(hard_deadline && now >= hard_deadline)))
end

# ------------------------------------------------------------------
39 changes: 25 additions & 14 deletions app/models/workout_score.rb
Original file line number Diff line number Diff line change
@@ -21,10 +21,11 @@
#
# Indexes
#
# index_workout_scores_on_lti_workout_id (lti_workout_id)
# index_workout_scores_on_user_id (user_id)
# index_workout_scores_on_workout_id (workout_id)
# workout_scores_workout_offering_id_fk (workout_offering_id)
# idx_ws_on_user_workout_workout_offering (user_id,workout_id,workout_offering_id)
# index_workout_scores_on_lti_workout_id (lti_workout_id)
# index_workout_scores_on_user_id (user_id)
# index_workout_scores_on_workout_id (workout_id)
# workout_scores_workout_offering_id_fk (workout_offering_id)
#
# Foreign Keys
#
@@ -235,16 +236,26 @@ def attempts_left_for_exercise_version(exercise_version)
# -------------------------------------------------------------
def scoring_attempt_for(exercise)
workout_score = self
Attempt.joins{exercise_version}.
where{(active_score_id == workout_score.id) &
(exercise_version.exercise_id == exercise.id)}.first

# First, check for current version only, which is faster
Attempt.where(
active_score_id: workout_score.id,
exercise_version_id: exercise.current_version_id).first ||

# Or, if that is nil, try search over all versions
Attempt.joins{exercise_version}.
where{(active_score_id == workout_score.id) &
(exercise_version.exercise_id == exercise.id)}.first
end


# -------------------------------------------------------------
def previous_attempt_for(exercise)
attempts.joins{exercise_version}.
where{exercise_version.exercise_id == exercise.id}.first
# First, check for current version only, which is faster
attempts.where(exercise_version_id: exercise.current_version_id).first ||
# Or, if that is nil, try search over all versions
attempts.joins{exercise_version}.
where{exercise_version.exercise_id == exercise.id}.first
end


@@ -269,10 +280,7 @@ def record_attempt(attempt)
needs_repost = false
self.with_lock do
scored_for_this = self.scored_attempts.
joins(exercise_version: :exercise).
where(exercise_version: {
exercise: attempt.exercise_version.exercise
})
where(exercise_version_id: attempt.exercise_version_id)

last_attempt = scored_for_this.first

@@ -390,9 +398,12 @@ def self.score_fix1
end
end
ws.workout.exercises.each do |e|
a = ws.attempts.joins{exercise_version}.
a = ws.attempts.where(exercise_version_id: e.current_version_id).
order('submit_time DESC').first ||
ws.attempts.joins{exercise_version}.
where{(exercise_version.exercise_id == e.id)}.
order('submit_time DESC').first

if a
a.active_score = ws
if !a.save
64 changes: 64 additions & 0 deletions app/views/exercises/embed_collection.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
.container
%ol.breadcrumb.mb-4
%li= link_to 'Home', root_path
%li= link_to 'Gym', gym_url
%li.active Exercises

%h1.mb-3 Exercises

- if can? :create, Exercise
%p.mb-4= link_to 'Create New', new_exercise_path, class: 'btn btn-primary'

- if @exercises.size > 0
.row
- @exercises.in_groups_of(2, false) do |group|
- group.each do |exercise|
.col-md-6.mb-4
.exercise-container.card
.card-body
= render partial: 'exercise', locals: { exercise: exercise, user: current_user }

- if exercise.iframe_url.present?
.exercise-details.mt-3
.iframe-label-container
%label.mb-1 IFrame Embed Code:
%input.form-control.mb-2{ id: "iframe-code-#{exercise.id}", type: 'text', value: exercise.iframe_embed_code, readonly: true }
.buttons.mt-2
%button.btn.btn-primary.mr-2{ 'data-toggle' => 'modal', 'data-target' => "#infoModal-#{exercise.id}" }
Generate LTI Launch Info
%button.btn.btn-primary{ 'data-toggle' => 'modal', 'data-target' => "#previewModal-#{exercise.id}" }
Preview
.modal.fade{ id: "infoModal-#{exercise.id}", tabindex: '-1', role: 'dialog', 'aria-labelledby' => "infoModalLabel-#{exercise.id}", 'aria-hidden' => 'true' }
.modal-dialog{ role: 'document' }
.modal-content
.modal-header
%h5.modal-title
Iframe and LTI Launch Information
%button.close{ type: 'button', 'data-dismiss' => 'modal', 'aria-label' => 'Close' }
%span{ 'aria-hidden' => 'true' } &times;
.modal-body
%p
%strong IFrame URL:
%input.form-control{ type: 'text', value: exercise.iframe_url, readonly: true }
%p
%strong LTI Launch URL:
%input.form-control{ type: 'text', value: exercise.lti_launch_url, readonly: true, disabled: true }
.modal.fade{ id: "previewModal-#{exercise.id}", tabindex: '-1', role: 'dialog', 'aria-labelledby' => "previewModalLabel-#{exercise.id}", 'aria-hidden' => 'true' }
.modal-dialog{ role: 'document' }
.modal-content
.modal-header
%h5.modal-title Preview
%button.close{ type: 'button', 'data-dismiss' => 'modal', 'aria-label' => 'Close' }
%span{ 'aria-hidden' => 'true' } &times;
.modal-body
%iframe{ src: exercise.iframe_url, width: '100%', height: '500', frameborder: '0', allowfullscreen: true }
.clearfix
= paginate @exercises
- else
%p.mt-4
No public exercises are available to view right now. Please wait for contributors to add more.

-# this page is to display the catalog of code workout's exercises for SPLICE
2 changes: 1 addition & 1 deletion app/views/workouts/evaluate.html.haml
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
- @current_workout.exercises.each do |exer|
%tr
%td=exer.name
%td=Attempt.where(exercise_version: exer.current_version).last.score.round(2)
%td=Attempt.user_attempt(@user, exer.current_version, @user_workout_score)
%td=ExerciseWorkout.findExercisePoints(exer.id, @current_workout.id)
%br
- @workout_feedback.each do |line|
2 changes: 1 addition & 1 deletion config/deploy.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
set :application, 'code-workout'
set :repo_url, 'git://github.com/web-cat/code-workout.git'
set :repo_url, 'git@github.com:web-cat/code-workout.git'

# ask :branch, proc { `git rev-parse --abbrev-ref HEAD`.chomp }

3 changes: 2 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -43,7 +43,7 @@
scope :gym do
# The top-level gym route
get '/' => 'workouts#gym', as: :gym

get 'exercises/embed_collection' => 'exercises#embed_collection', as: :exercises_embed_collection # route to view all exercise metadata (iframe url and launch url) for SPLICE
# /gym/exercises ...
get 'exercises/call_open_pop' => 'exercises#call_open_pop'
get 'exercises_import' => 'exercises#upload_yaml'
@@ -67,6 +67,7 @@
get 'exercises/download_attempt_data' =>
'exercises#download_attempt_data', as: :download_exercise_attempt_data
# At the bottom, so the routes above take precedence over existing ids
get 'exercises/export' => 'exercises#export', as: :exercises_export #route to export exercises with SPLICE model
resources :exercises

# /gym/workouts ...
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddCompoundIndexToWorkoutScores < ActiveRecord::Migration
def change
add_index :workout_scores, [:user_id, :workout_id, :workout_offering_id],
name: 'idx_ws_on_user_workout_workout_offering'
end
end
8 changes: 8 additions & 0 deletions db/migrate/20240207040304_add_compound_index_to_attempts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class AddCompoundIndexToAttempts < ActiveRecord::Migration
def change
add_index :attempts, [:user_id, :exercise_version_id],
name: 'idx_attempts_on_user_exercise_version'
add_index :attempts, [:workout_score_id, :exercise_version_id],
name: 'idx_attempts_on_workout_score_exercise_version'
end
end
6 changes: 4 additions & 2 deletions db/schema.rb
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 20211101005101) do
ActiveRecord::Schema.define(version: 20240207040304) do

create_table "active_admin_comments", force: :cascade do |t|
t.string "namespace", limit: 255
@@ -47,7 +47,9 @@

add_index "attempts", ["active_score_id"], name: "index_attempts_on_active_score_id", using: :btree
add_index "attempts", ["exercise_version_id"], name: "index_attempts_on_exercise_version_id", using: :btree
add_index "attempts", ["user_id", "exercise_version_id"], name: "idx_attempts_on_user_exercise_version", using: :btree
add_index "attempts", ["user_id"], name: "index_attempts_on_user_id", using: :btree
add_index "attempts", ["workout_score_id", "exercise_version_id"], name: "idx_attempts_on_workout_score_exercise_version", using: :btree
add_index "attempts", ["workout_score_id"], name: "index_attempts_on_workout_score_id", using: :btree

create_table "attempts_tag_user_scores", id: false, force: :cascade do |t|
@@ -693,6 +695,7 @@
end

add_index "workout_scores", ["lti_workout_id"], name: "index_workout_scores_on_lti_workout_id", using: :btree
add_index "workout_scores", ["user_id", "workout_id", "workout_offering_id"], name: "idx_ws_on_user_workout_workout_offering", using: :btree
add_index "workout_scores", ["user_id"], name: "index_workout_scores_on_user_id", using: :btree
add_index "workout_scores", ["workout_id"], name: "index_workout_scores_on_workout_id", using: :btree
add_index "workout_scores", ["workout_offering_id"], name: "workout_scores_workout_offering_id_fk", using: :btree
@@ -730,7 +733,6 @@
add_foreign_key "course_offerings", "courses", name: "course_offerings_course_id_fk"
add_foreign_key "course_offerings", "terms", name: "course_offerings_term_id_fk"
add_foreign_key "courses", "organizations", name: "courses_organization_id_fk"
add_foreign_key "exercise_owners", "exercises", name: "exercise_owners_exercise_id_fk"
add_foreign_key "exercise_owners", "users", column: "owner_id", name: "exercise_owners_owner_id_fk"
add_foreign_key "exercise_versions", "irt_data", column: "irt_data_id", name: "exercise_versions_irt_data_id_fk"
add_foreign_key "exercise_versions", "stems", name: "exercise_versions_stem_id_fk"
10 changes: 6 additions & 4 deletions spec/factories/attempts.rb
Original file line number Diff line number Diff line change
@@ -20,10 +20,12 @@
#
# Indexes
#
# index_attempts_on_active_score_id (active_score_id)
# index_attempts_on_exercise_version_id (exercise_version_id)
# index_attempts_on_user_id (user_id)
# index_attempts_on_workout_score_id (workout_score_id)
# idx_attempts_on_user_exercise_version (user_id,exercise_version_id)
# idx_attempts_on_workout_score_exercise_version (workout_score_id,exercise_version_id)
# index_attempts_on_active_score_id (active_score_id)
# index_attempts_on_exercise_version_id (exercise_version_id)
# index_attempts_on_user_id (user_id)
# index_attempts_on_workout_score_id (workout_score_id)
#
# Foreign Keys
#
9 changes: 5 additions & 4 deletions spec/factories/workout_scores.rb
Original file line number Diff line number Diff line change
@@ -21,10 +21,11 @@
#
# Indexes
#
# index_workout_scores_on_lti_workout_id (lti_workout_id)
# index_workout_scores_on_user_id (user_id)
# index_workout_scores_on_workout_id (workout_id)
# workout_scores_workout_offering_id_fk (workout_offering_id)
# idx_ws_on_user_workout_workout_offering (user_id,workout_id,workout_offering_id)
# index_workout_scores_on_lti_workout_id (lti_workout_id)
# index_workout_scores_on_user_id (user_id)
# index_workout_scores_on_workout_id (workout_id)
# workout_scores_workout_offering_id_fk (workout_offering_id)
#
# Foreign Keys
#
Binary file modified usr/resources/Java/JavaTddPluginSupport.jar
Binary file not shown.
Binary file removed usr/resources/Java/junit-4.8.2.jar
Binary file not shown.
Binary file modified usr/resources/Java/student.jar
Binary file not shown.