diff --git a/.github/workflows/deploy-acos.yml b/.github/workflows/deploy-acos.yml new file mode 100644 index 00000000..fac1e84b --- /dev/null +++ b/.github/workflows/deploy-acos.yml @@ -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 diff --git a/.github/workflows/image-build.yml b/.github/workflows/image-build.yml new file mode 100644 index 00000000..d7f46214 --- /dev/null +++ b/.github/workflows/image-build.yml @@ -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 diff --git a/Gemfile b/Gemfile index 43766f40..0046797c 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 6d7ffafa..e84689d2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,13 @@ 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) @@ -14,10 +15,10 @@ GIT 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 diff --git a/app/assets/stylesheets/custom.scss b/app/assets/stylesheets/custom.scss index 21c9fa4c..51ebe336 100644 --- a/app/assets/stylesheets/custom.scss +++ b/app/assets/stylesheets/custom.scss @@ -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; + } +} \ No newline at end of file diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index 767aea22..0f60c1cf 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -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 diff --git a/app/jobs/code_worker.rb b/app/jobs/code_worker.rb index febd1813..0d940e30 100644 --- a/app/jobs/code_worker.rb +++ b/app/jobs/code_worker.rb @@ -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') diff --git a/app/models/attempt.rb b/app/models/attempt.rb index 6d6548d4..a770040c 100644 --- a/app/models/attempt.rb +++ b/app/models/attempt.rb @@ -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 # diff --git a/app/models/coding_prompt.rb b/app/models/coding_prompt.rb index af2fef50..60fc6d23 100644 --- a/app/models/coding_prompt.rb +++ b/app/models/coding_prompt.rb @@ -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 = '' + 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 diff --git a/app/models/coding_prompt_answer.rb b/app/models/coding_prompt_answer.rb index 8a03d848..c4314412 100644 --- a/app/models/coding_prompt_answer.rb +++ b/app/models/coding_prompt_answer.rb @@ -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 diff --git a/app/models/exercise.rb b/app/models/exercise.rb index ee202140..1727ae0e 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -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 + "" + 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) diff --git a/app/models/exercise_owner.rb b/app/models/exercise_owner.rb index 778d240d..a9c8fd03 100644 --- a/app/models/exercise_owner.rb +++ b/app/models/exercise_owner.rb @@ -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) # # ============================================================================= diff --git a/app/models/workout.rb b/app/models/workout.rb index 065b9401..254ed7e8 100644 --- a/app/models/workout.rb +++ b/app/models/workout.rb @@ -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 diff --git a/app/models/workout_offering.rb b/app/models/workout_offering.rb index 8c8f0169..94335690 100644 --- a/app/models/workout_offering.rb +++ b/app/models/workout_offering.rb @@ -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,6 +144,7 @@ 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) && @@ -148,7 +152,7 @@ def can_be_seen_by?(user) (uscore == nil || !uscore.closed? || !workout_policy.andand.no_review_before_close || - now >= hard_deadline_for(user))) + (hard_deadline && now >= hard_deadline))) end # ------------------------------------------------------------------ diff --git a/app/models/workout_score.rb b/app/models/workout_score.rb index 820db401..93a40b13 100644 --- a/app/models/workout_score.rb +++ b/app/models/workout_score.rb @@ -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 diff --git a/app/views/exercises/embed_collection.html.haml b/app/views/exercises/embed_collection.html.haml new file mode 100644 index 00000000..186be717 --- /dev/null +++ b/app/views/exercises/embed_collection.html.haml @@ -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' } × + .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' } × + .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 + + + diff --git a/app/views/workouts/evaluate.html.haml b/app/views/workouts/evaluate.html.haml index e014fdd7..121d733f 100644 --- a/app/views/workouts/evaluate.html.haml +++ b/app/views/workouts/evaluate.html.haml @@ -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| diff --git a/config/deploy.rb b/config/deploy.rb index 43da4ad7..3fabc7e0 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -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 } diff --git a/config/routes.rb b/config/routes.rb index 4cdf4df6..5cabbdb9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 ... diff --git a/db/migrate/20240207035240_add_compound_index_to_workout_scores.rb b/db/migrate/20240207035240_add_compound_index_to_workout_scores.rb new file mode 100644 index 00000000..1be6f06f --- /dev/null +++ b/db/migrate/20240207035240_add_compound_index_to_workout_scores.rb @@ -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 diff --git a/db/migrate/20240207040304_add_compound_index_to_attempts.rb b/db/migrate/20240207040304_add_compound_index_to_attempts.rb new file mode 100644 index 00000000..6c3d48b6 --- /dev/null +++ b/db/migrate/20240207040304_add_compound_index_to_attempts.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index b1db4f6c..0ef93dc1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/spec/factories/attempts.rb b/spec/factories/attempts.rb index 8c5b912f..b696476c 100644 --- a/spec/factories/attempts.rb +++ b/spec/factories/attempts.rb @@ -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 # diff --git a/spec/factories/workout_scores.rb b/spec/factories/workout_scores.rb index 5f71c6a2..0b79207a 100644 --- a/spec/factories/workout_scores.rb +++ b/spec/factories/workout_scores.rb @@ -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 # diff --git a/usr/resources/Java/JavaTddPluginSupport.jar b/usr/resources/Java/JavaTddPluginSupport.jar index 9ca082f2..5defeab5 100644 Binary files a/usr/resources/Java/JavaTddPluginSupport.jar and b/usr/resources/Java/JavaTddPluginSupport.jar differ diff --git a/usr/resources/Java/junit-4.8.2.jar b/usr/resources/Java/junit-4.8.2.jar deleted file mode 100644 index 5b4bb849..00000000 Binary files a/usr/resources/Java/junit-4.8.2.jar and /dev/null differ diff --git a/usr/resources/Java/student.jar b/usr/resources/Java/student.jar index ec7704e0..b5f9c86e 100644 Binary files a/usr/resources/Java/student.jar and b/usr/resources/Java/student.jar differ