diff --git a/app/controllers/api/v8/courses/exercises_controller.rb b/app/controllers/api/v8/courses/exercises_controller.rb index bff193742..0523b3399 100644 --- a/app/controllers/api/v8/courses/exercises_controller.rb +++ b/app/controllers/api/v8/courses/exercises_controller.rb @@ -37,23 +37,14 @@ def index can_see_everything = current_user.administrator? || current_user.teacher?(course.organization) || current_user.assistant?(course) authorize! :read, course - exercises = course.exercises - unlocked_exercises = course.unlocks - .where(user_id: current_user.id) - .where(['valid_after IS NULL OR valid_after < ?', Time.now]) - .pluck(:exercise_name) - - unless can_see_everything - exercises = exercises.where(hidden: false, disabled_status: 0) - exercises = if unlocked_exercises.empty? - exercises.where(unlock_spec: nil) - else - exercises.where(["unlock_spec IS NULL OR exercises.name IN (#{unlocked_exercises.map { |_| '?' }.join(', ')})", *unlocked_exercises]) - end + if can_see_everything + unlocked_ids = course.exercises.pluck(:id).to_set + else + exercises = course.exercises.where(hidden: false, disabled_status: 0).to_a + unlocked_names = course.unlocked_exercise_names_for(current_user, exercises) + unlocked_ids = exercises.select { |ex| unlocked_names.include?(ex.name) }.to_set(&:id) end - exercises = exercises.pluck(:id) - all_exercises = course.exercises.includes(:available_points).where(disabled_status: 0) unless can_see_everything all_exercises = course.exercises @@ -79,7 +70,7 @@ def index deadline: ex.deadline_for(current_user), soft_deadline: ex.soft_deadline_for(current_user), disabled: ex.disabled?, - unlocked: exercises.include?(ex.id) + unlocked: unlocked_ids.include?(ex.id) } end diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index 119aab198..48fb55028 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -155,10 +155,14 @@ def manage_unlocks def save_unlocks authorize! :manage_unlocks, @course - groups = group_params - groups.each do |name, conditions| - array = conditions.values.reject(&:blank?) - @course.exercise_group_by_name(name).group_unlock_conditions = array.to_json + begin + group_params.each do |name, conditions| + array = conditions.values.reject(&:blank?) + group = @course.exercise_group_by_name(name) + Rails.logger.info "Unlock conditions of #{@course.name}/#{name} set by user #{current_user.id}: #{group.exercises(false).first&.unlock_spec || '[]'} -> #{array.to_json}" + group.group_unlock_conditions = array.to_json + end + ensure UncomputedUnlock.create_all_for_course(@course) end @@ -196,7 +200,7 @@ def assign_show_view_vars @exercises = @exercises.select { |ex| ex.visible_to?(current_user) } .natsort_by(&:name) @exercise_completion_status = ExerciseCompletionStatusGenerator.completion_status(current_user, @course) - @unlocks = current_user.unlocks.where(course: @course).where('valid_after IS NULL OR valid_after < ?', Time.zone.now).pluck(:exercise_name) + @unlocks = @course.unlocked_exercise_names_for(current_user, @exercises) if can?(:teach, @course) last_refresh = @course.course_template.course_template_refreshes.last diff --git a/app/models/course.rb b/app/models/course.rb index e50a89ca2..d3f1cba54 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -356,6 +356,21 @@ def exercises_by_name_or_group(name, force_reload = false) exercises.to_a.select { |ex| ex.name == name || (group && ex.belongs_to_exercise_group?(group)) } end + # Set of names of the exercises in `exercises_to_check` the user may open, including those that need no unlock. + # Batched form of Exercise#unlocked_for? without resolving pending unlocks. + def unlocked_exercise_names_for(user, exercises_to_check) + from_rows = unlocks.where(user_id: user.id).where('valid_after IS NULL OR valid_after < ?', Time.now).pluck(:exercise_name).to_set + exercises_to_check.select do |ex| + if !ex.requires_unlock? + true + elsif ex.unlocked_by_date? + ex.unlock_time_by_date + else + from_rows.include?(ex.name) + end + end.to_set(&:name) + end + def unlockable_exercises_for(user) UncomputedUnlock.resolve(self, user) unlocked = unlocks.where(user_id: user.id).pluck(:exercise_name) diff --git a/app/models/exercise.rb b/app/models/exercise.rb index 0cc416815..8cb93fbf5 100644 --- a/app/models/exercise.rb +++ b/app/models/exercise.rb @@ -353,11 +353,26 @@ def requires_explicit_unlock? deadline_spec_obj.depends_on_unlock_time? end + # Date-only unlocks are decided by the clock, not by unlock rows: a user without rows must not stay locked. + # Explicit-unlock exercises are excluded because their unlock row starts the deadline timer. + def unlocked_by_date? + UnlockSpec.date_only?(unlock_spec) && !requires_explicit_unlock? + end + + # Returns when the exercise unlocked for the user, or nil if it is locked or needs no unlock. def time_unlocked_for(user, resolve_unlocks = true) + return unlock_time_by_date if unlocked_by_date? + UncomputedUnlock.resolve(course, user) if resolve_unlocks unlocks.where(user_id: user).find_by('valid_after IS NULL OR valid_after < ?', Time.now)&.created_at end + # The unlock date of a date-only exercise once it has passed; nil before it. + def unlock_time_by_date + unlock_date = unlock_spec_obj.valid_after + unlock_date <= Time.now ? unlock_date : nil + end + def unlocked_for?(user, resolve_unlocks = true) res = !requires_unlock? || time_unlocked_for(user, resolve_unlocks) !!res diff --git a/app/models/exercise_group.rb b/app/models/exercise_group.rb index 3c2812364..f7f533357 100644 --- a/app/models/exercise_group.rb +++ b/app/models/exercise_group.rb @@ -81,11 +81,22 @@ def group_unlock_conditions exercises(false).map(&:unlock_conditions).first end + # The group's exercises keyed by raw unlock spec (nil for none); more than one key means + # `group_unlock_conditions` shows only the first exercise's conditions. + def exercises_by_unlock_spec + exercises(false).group_by(&:unlock_spec) + end + def group_unlock_conditions=(unlock_conditions) - exercises(false).each do |e| + group_exercises = exercises(false) + # Rows granted under other conditions keep their date: fast refresh never revokes them, so moving it would open them early. + conditions = UnlockSpec.non_date_conditions(unlock_conditions).sort + rows_to_move = group_exercises.select { |e| UnlockSpec.non_date_conditions(e.unlock_spec).sort == conditions }.map(&:name) + group_exercises.each do |e| e.unlock_spec = unlock_conditions e.save! end + update_unlock_dates(group_exercises.first, rows_to_move) unless group_exercises.empty? end def available_point_names @@ -107,6 +118,13 @@ def available_point_names end private + # Unlock rows copy the date at creation; keep existing rows in step when the date is edited. + def update_unlock_dates(exercise, exercise_names) + spec = exercise.unlock_spec_obj + return if spec.empty? + Unlock.where(course_id: course.id, exercise_name: exercise_names).update_all(valid_after: spec.valid_after) + end + def group_deadline(method) exercises(false).map { |n| n.send(method) }.first end diff --git a/app/models/uncomputed_unlock.rb b/app/models/uncomputed_unlock.rb index 575fec92b..80fbc2fd6 100644 --- a/app/models/uncomputed_unlock.rb +++ b/app/models/uncomputed_unlock.rb @@ -4,8 +4,8 @@ # Keeps track of whether a (course, user)'s unlocks have yet to be computed, enabling lazy computation. # A course refresh populates this table and the UnlockComputerTask background task -# consumes it by calling Unlock.refresh_unlocks on the entries. All code that accesses -# unlocks must ensure that UncomputedUnlock.resolve is called for the (course, user) first. +# consumes it by calling Unlock.refresh_unlocks on the entries. Code that needs +# up-to-date unlocks should call UncomputedUnlock.resolve for the (course, user) first. class UncomputedUnlock < ApplicationRecord belongs_to :course belongs_to :user diff --git a/app/models/unlock.rb b/app/models/unlock.rb index c68e4661a..68f5d5c78 100644 --- a/app/models/unlock.rb +++ b/app/models/unlock.rb @@ -4,9 +4,11 @@ # Stores whether and when an exercise has been unlocked for an user. # # An exercise is considered unlocked for a user if an unlock for -# (user, course, exercise, valid_after < Time.now) exists. +# (user, course, exercise, valid_after < Time.now) exists, except that exercises +# whose only condition is a date are unlocked by the clock (Exercise#unlocked_by_date?) +# and need no unlock. # The set of unlocks for a (course, user) is recomputed in the following cases: -# - the course is refreshed (since the specs might change) +# - the course is refreshed or unlock conditions are saved, for users with awarded points # - the user has a submission graded # # However, if the course has deadlines based on the unlock times of exercises, @@ -84,6 +86,6 @@ def self.fast_refresh_unlocks_impl(course, user, user_unlocks_by_exercise_name) ) end end - Unlock.import new_unlocks + Unlock.import new_unlocks, on_duplicate_key_ignore: true end end diff --git a/app/models/unlock_spec.rb b/app/models/unlock_spec.rb index 31f9d373c..7fbb694c3 100644 --- a/app/models/unlock_spec.rb +++ b/app/models/unlock_spec.rb @@ -31,6 +31,24 @@ def empty? # No unlock conditions - no Unlock object required to be unlocked @conditions.empty? && @valid_after.nil? end + # Whether the raw spec is only a date, so the clock alone decides the unlock. + # Reads the JSON without parsing the spec, since parsing a progress condition queries the course's exercises. + def self.date_only?(string) + conditions = raw_conditions(string) + conditions.any? && conditions.all? { |c| DateAndTimeUtils.looks_like_date_or_time(c) } + end + + # The raw spec's conditions other than dates. Works on specs that no longer parse. + def self.non_date_conditions(string) + raw_conditions(string).reject { |c| DateAndTimeUtils.looks_like_date_or_time(c) } + end + + def self.raw_conditions(string) + return [] if string.nil? + ActiveSupport::JSON.decode(string).map { |c| c.to_s.strip }.reject(&:empty?) + end + private_class_method :raw_conditions + attr_reader :raw_spec attr_reader :valid_after attr_reader :universal_descriptions diff --git a/app/views/courses/manage_unlocks.html.erb b/app/views/courses/manage_unlocks.html.erb index 1aca5b42d..0a5533a2d 100644 --- a/app/views/courses/manage_unlocks.html.erb +++ b/app/views/courses/manage_unlocks.html.erb @@ -75,6 +75,15 @@ class='btn btn-sm btn-link remove-condition-button'>remove
<% end %> + <% exercises_by_spec = group.exercises_by_unlock_spec %> + <% if exercises_by_spec.length > 1 %> +

+ The exercises in this group have different unlock conditions; saving sets them all to the ones shown. + <% exercises_by_spec.each do |spec, exercises| %> +
<%= spec || 'none' %>: <%= exercises.map(&:name).join(', ') %> + <% end %> +

+ <% end %> 1 + @report.warnings << "Exercise #{exercise.name} was added to a group whose exercises have different unlock conditions; set them on the manage unlocks page" + elsif specs.first + exercise.unlock_spec = specs.first + @report.notices << "Exercise #{exercise.name} inherited the unlock conditions of its group" + end + rescue UnlockSpec::InvalidSyntaxError, RuntimeError => e + @report.warnings << "Exercise #{exercise.name} could not inherit the unlock conditions of its group: #{e.message}" + end + def set_docker_image default_image = Exercise.new.docker_image @rust_data['exercises'].each do |exercise| diff --git a/spec/controllers/api/v8/core/courses_controller_spec.rb b/spec/controllers/api/v8/core/courses_controller_spec.rb index 81d15fdc3..15c420e34 100644 --- a/spec/controllers/api/v8/core/courses_controller_spec.rb +++ b/spec/controllers/api/v8/core/courses_controller_spec.rb @@ -78,6 +78,22 @@ def show_course expect(exs[1]['completed']).to be_falsey end + describe 'with a date-only unlock and no unlock rows' do + it 'lists the exercise as unlocked after the date' do + course.exercises[0].update!(unlock_spec: ['1.1.2011'].to_json) + + ex = show_course['course']['exercises'].find { |e| e['name'] == 'Exercise1' } + expect(ex['locked']).to be false + end + + it 'does not list the exercise before the date' do + course.exercises[0].update!(unlock_spec: [(Time.zone.now + 1.day).strftime('%d.%m.%Y %H:%M')].to_json) + + names = show_course['course']['exercises'].map { |e| e['name'] } + expect(names).not_to include('Exercise1') + end + end + describe 'and as guest user' do it 'should respond with a 401' do controller.current_user = Guest.new diff --git a/spec/controllers/api/v8/courses/exercises_controller_spec.rb b/spec/controllers/api/v8/courses/exercises_controller_spec.rb index 91c5e0324..508148238 100644 --- a/spec/controllers/api/v8/courses/exercises_controller_spec.rb +++ b/spec/controllers/api/v8/courses/exercises_controller_spec.rb @@ -50,6 +50,25 @@ end end + describe 'As a student without unlock rows' do + let(:token) { double resource_owner_id: user.id, acceptable?: true } + + def unlocked_flags + get :index, params: { course_id: course.id } + JSON.parse(response.body).to_h { |ex| [ex['name'], ex['unlocked']] } + end + + it 'marks a date-unlocked exercise as unlocked once the date has passed' do + exercise.update!(unlock_spec: ['1.1.2011'].to_json) + expect(unlocked_flags['testexercise']).to be true + end + + it 'marks a date-unlocked exercise as locked before the date' do + exercise.update!(unlock_spec: [(Time.zone.now + 1.day).strftime('%d.%m.%Y %H:%M')].to_json) + expect(unlocked_flags['testexercise']).to be false + end + end + describe 'As a guest' do let(:token) { nil } describe 'when searching for exercises' do diff --git a/spec/controllers/courses_controller_spec.rb b/spec/controllers/courses_controller_spec.rb index 397a9ee20..c687deb1d 100644 --- a/spec/controllers/courses_controller_spec.rb +++ b/spec/controllers/courses_controller_spec.rb @@ -336,6 +336,29 @@ def get_show_json(options = {}, parse_json = true) get :manage_unlocks, params: { organization_id: @organization.slug, id: @course.id } expect(response.code.to_i).to eq(403) end + + describe 'when teacher' do + render_views + + before :each do + @course = FactoryBot.create :course, organization: @organization + Teachership.create(user: @user, organization: @organization) + controller.current_user = @user + @course.exercises.create(name: 'g-e1') + @course.exercises.create(name: 'g-e2') + end + + it 'does not warn when the exercises of a group share unlock conditions' do + get :manage_unlocks, params: { organization_id: @organization.slug, id: @course.id } + expect(response.body).not_to include('have different unlock conditions') + end + + it 'warns when the exercises of a group have different unlock conditions' do + @course.exercises.find_by(name: 'g-e1').update!(unlock_spec: ['1.1.2011'].to_json) + get :manage_unlocks, params: { organization_id: @organization.slug, id: @course.id } + expect(response.body).to include('have different unlock conditions') + end + end end describe 'POST save_unlocks' do @@ -405,6 +428,14 @@ def get_show_json(options = {}, parse_json = true) expect(spec).to include('5% of e2') end end + + it 'replaces stored conditions that no longer parse' do + @course.exercises.create(name: 'g-e1').update_column(:unlock_spec, ['exercise removed'].to_json) + + post :save_unlocks, params: { organization_id: @organization.slug, id: @course.id, group: { g: { '0' => '1.2.2000' } } } + + expect(@course.exercises.find_by(name: 'g-e1').unlock_spec).to eq(['1.2.2000'].to_json) + end end it 'when non-teacher should respond with a 403' do diff --git a/spec/features/teacher_edits_unlock_dates_spec.rb b/spec/features/teacher_edits_unlock_dates_spec.rb index f498f08cc..efefad941 100644 --- a/spec/features/teacher_edits_unlock_dates_spec.rb +++ b/spec/features/teacher_edits_unlock_dates_spec.rb @@ -21,7 +21,6 @@ RefreshCourseTask.new.run @course.exercise_group_by_name('').group_unlock_conditions = ['1.1.2011'].to_json - UncomputedUnlock.create_all_for_course_eager(@course) visit '/' end diff --git a/spec/lib/course_refresh_database_updater_spec.rb b/spec/lib/course_refresh_database_updater_spec.rb index fc2d94f65..22785e378 100644 --- a/spec/lib/course_refresh_database_updater_spec.rb +++ b/spec/lib/course_refresh_database_updater_spec.rb @@ -31,6 +31,36 @@ expect(@course2.exercises[0].name).to eq('MyExercise') end + describe 'unlock conditions of a new exercise in an existing group' do + before :each do + add_exercise('Week1/A') + add_exercise('Week1/B') + refresh_courses + end + + def new_exercise_in_week1 + add_exercise('Week1/C') + refresh_courses + @course.exercises.find_by(name: 'Week1-C') + end + + it 'inherits the conditions the group shares' do + @course.exercise_group_by_name('Week1').group_unlock_conditions = ['1.1.2011'].to_json + + expect(new_exercise_in_week1.unlock_spec).to eq(['1.1.2011'].to_json) + end + + it 'stays without conditions when the group has none' do + expect(new_exercise_in_week1.unlock_spec).to be_nil + end + + it 'is not given conditions when the group disagrees' do + @course.exercises.find_by(name: 'Week1-A').update!(unlock_spec: ['1.1.2011'].to_json) + + expect(new_exercise_in_week1.unlock_spec).to be_nil + end + end + # test that git branch is changed for all courses of the same template it 'clones the given git branch' do @local_clone.chdir do diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index 35a78fd37..14013a67c 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -299,6 +299,32 @@ def assert_destroyed(obj) end end + describe '#unlocked_exercise_names_for' do + before :each do + @course = FactoryBot.create(:course) + @user = FactoryBot.create(:user) + @open = FactoryBot.create(:exercise, course: @course, name: 'open') + @dated_past = FactoryBot.create(:exercise, course: @course, name: 'dated_past', unlock_spec: ['1.1.2011'].to_json) + @dated_future = FactoryBot.create(:exercise, course: @course, name: 'dated_future', unlock_spec: ['1.1.2999'].to_json) + @by_points = FactoryBot.create(:exercise, course: @course, name: 'by_points', unlock_spec: ['1.1.2011', 'exercise open'].to_json) + @course.reload + end + + it 'includes exercises without conditions and date-only exercises past their date without unlock rows' do + expect(@course.unlocked_exercise_names_for(@user, @course.exercises.to_a)).to contain_exactly('open', 'dated_past') + end + + it 'includes progress-based exercises only when the user has an unlock row' do + Unlock.create!(user: @user, course: @course, exercise_name: 'by_points', valid_after: Time.zone.now - 1.hour) + expect(@course.unlocked_exercise_names_for(@user, @course.exercises.to_a)).to contain_exactly('open', 'dated_past', 'by_points') + end + + it 'ignores unlock rows of date-only exercises whose date is still ahead' do + Unlock.create!(user: @user, course: @course, exercise_name: 'dated_future', valid_after: Time.zone.now - 1.day) + expect(@course.unlocked_exercise_names_for(@user, @course.exercises.to_a)).not_to include('dated_future') + end + end + describe 'contains_unlock_deadlines?' do before :each do @course = FactoryBot.create(:course) diff --git a/spec/models/exercise/unlock_without_rows_spec.rb b/spec/models/exercise/unlock_without_rows_spec.rb new file mode 100644 index 000000000..99c49f7ae --- /dev/null +++ b/spec/models/exercise/unlock_without_rows_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Exercise, type: :model do + include ActiveSupport::Testing::TimeHelpers + + describe 'date-only unlock for a user without unlock rows' do + let(:course) { FactoryBot.create(:course) } + let(:user) { FactoryBot.create(:user) } + let(:exercise) { FactoryBot.create(:returnable_exercise, course: course, name: 'week1-01_first') } + + def unlock_spec_of(*conditions) + exercise.unlock_spec = conditions.to_json + exercise.save! + end + + before :each do + expect(Unlock.where(user_id: user.id)).to be_empty + expect(UncomputedUnlock.where(user_id: user.id)).to be_empty + end + + it 'is unlocked, visible, downloadable and submittable after the date' do + unlock_spec_of((Time.zone.now - 1.day).strftime('%d.%m.%Y %H:%M')) + + expect(exercise).to be_unlocked_for(user) + expect(exercise.visible_to?(user)).to be true + expect(exercise.downloadable_by?(user)).to be true + expect(exercise.submittable_by?(user)).to be true + end + + it 'is locked before the date' do + unlock_spec_of((Time.zone.now + 1.day).strftime('%d.%m.%Y %H:%M')) + + expect(exercise).not_to be_unlocked_for(user) + expect(exercise.submittable_by?(user)).to be false + expect(exercise.downloadable_by?(user)).to be false + end + + it 'unlocks exactly at the Helsinki wall-clock time' do + unlock_spec_of('26.10.2026 12:00') + + travel_to Time.utc(2026, 10, 26, 9, 59, 59) do + expect(exercise).not_to be_unlocked_for(user) + end + travel_to Time.utc(2026, 10, 26, 10, 0, 0) do + expect(exercise).to be_unlocked_for(user) + end + end + + it 'does not create unlock rows while evaluating' do + unlock_spec_of('1.1.2011') + + expect(exercise).to be_unlocked_for(user) + expect(Unlock.count).to eq(0) + end + + it 'reports the unlock date as the unlock time' do + unlock_spec_of('1.1.2011') + + expect(exercise.time_unlocked_for(user)).to eq(Time.zone.parse('2011-01-01')) + end + + it 'ignores a stale row for a date that moved into the future' do + unlock_spec_of((Time.zone.now + 1.day).strftime('%d.%m.%Y %H:%M')) + Unlock.create!(user: user, course: course, exercise_name: exercise.name, valid_after: Time.zone.now - 1.day) + + expect(exercise).not_to be_unlocked_for(user) + end + + it 'stays locked for a progress-based condition without a row' do + other = FactoryBot.create(:exercise, course: course, name: 'week0-01_other') + AvailablePoint.create!(exercise_id: other.id, name: 'ap1') + unlock_spec_of('1.1.2011', 'exercise week0') + + expect(exercise).not_to be_unlocked_for(user) + end + + it 'stays locked for an explicit-unlock exercise without a row and creates none' do + exercise.deadline_spec = ['unlock + 5 days'].to_json + unlock_spec_of('1.1.2011') + exercise.reload + expect(exercise.requires_explicit_unlock?).to be true + + expect(exercise).not_to be_unlocked_for(user) + expect(exercise.submittable_by?(user)).to be false + expect(Unlock.count).to eq(0) + end + end +end diff --git a/spec/models/exercise_group_spec.rb b/spec/models/exercise_group_spec.rb index c535f4f90..4fda95f4f 100644 --- a/spec/models/exercise_group_spec.rb +++ b/spec/models/exercise_group_spec.rb @@ -25,4 +25,66 @@ expect(ex.exercise_group.parent.exercises(true)).not_to include(ex3) expect(ex.exercise_group.parent.exercises(true)).not_to include(ex4) end + + describe '#group_unlock_conditions=' do + let(:user) { FactoryBot.create(:user) } + let!(:ex1) { FactoryBot.create(:exercise, course: course, name: 'week1-01') } + let!(:ex2) { FactoryBot.create(:exercise, course: course, name: 'week1-02') } + let!(:other) { FactoryBot.create(:exercise, course: course, name: 'week2-01') } + let(:group) { course.exercise_group_by_name('week1') } + + before :each do + [ex1, ex2, other].each do |ex| + Unlock.create!(user: user, course: course, exercise_name: ex.name, valid_after: Time.zone.parse('2011-01-01')) + end + end + + it 'moves the date of existing unlock rows of the group' do + group.group_unlock_conditions = ['5.5.2012'].to_json + + expect(Unlock.where(exercise_name: %w[week1-01 week1-02]).pluck(:valid_after)).to all(eq(Time.zone.parse('2012-05-05'))) + expect(Unlock.find_by(exercise_name: 'week2-01').valid_after).to eq(Time.zone.parse('2011-01-01')) + end + + it 'keeps the date of unlock rows granted under other conditions' do + group.group_unlock_conditions = ['1.1.2030'].to_json + group.group_unlock_conditions = ['exercise week2-01'].to_json + + expect(Unlock.where(exercise_name: %w[week1-01 week1-02]).pluck(:valid_after)).to all(eq(Time.zone.parse('2030-01-01'))) + end + + it 'moves the date when only the order of the other conditions changes' do + FactoryBot.create(:exercise, course: course, name: 'week3-01') + group.group_unlock_conditions = ['exercise week2-01', 'exercise week3-01', '1.1.2030'].to_json + group.group_unlock_conditions = ['exercise week3-01', 'exercise week2-01', '5.5.2012'].to_json + + expect(Unlock.where(exercise_name: %w[week1-01 week1-02]).pluck(:valid_after)).to all(eq(Time.zone.parse('2012-05-05'))) + end + + it 'leaves unlock rows alone when the conditions are cleared' do + group.group_unlock_conditions = [].to_json + + expect(Unlock.where(exercise_name: %w[week1-01 week1-02]).pluck(:valid_after)).to all(eq(Time.zone.parse('2011-01-01'))) + end + end + + describe '#exercises_by_unlock_spec' do + let!(:ex1) { FactoryBot.create(:exercise, course: course, name: 'week1-01') } + let!(:ex2) { FactoryBot.create(:exercise, course: course, name: 'week1-02') } + let(:group) { course.exercise_group_by_name('week1') } + + it 'has one key when all exercises share the conditions' do + expect(group.exercises_by_unlock_spec.keys).to eq([nil]) + group.group_unlock_conditions = ['5.5.2012'].to_json + expect(group.exercises_by_unlock_spec.keys).to eq([['5.5.2012'].to_json]) + end + + it 'has a key per distinct set of conditions' do + ex1.update!(unlock_spec: ['5.5.2012'].to_json) + course.reload + + expect(course.exercise_group_by_name('week1').exercises_by_unlock_spec.transform_values { |es| es.map(&:name) }) + .to eq(['5.5.2012'].to_json => ['week1-01'], nil => ['week1-02']) + end + end end diff --git a/spec/models/unlock_spec.rb b/spec/models/unlock_spec.rb index b9fdee4e6..7f16ac418 100644 --- a/spec/models/unlock_spec.rb +++ b/spec/models/unlock_spec.rb @@ -40,6 +40,13 @@ expect(unlocks.second.exercise_name).to eq('ex2') end + it 'ignores an unlock that a concurrent refresh created first' do + Unlock.create!(user: @user, course: @course, exercise_name: 'ex1', valid_after: Time.zone.now) + + expect { Unlock.send(:fast_refresh_unlocks_impl, @course, @user, {}) }.not_to raise_error + expect(Unlock.where(user_id: @user.id, exercise_name: 'ex1').count).to eq(1) + end + it "doesn't recreate old unlocks" do Unlock.refresh_unlocks(@course, @user) u = Unlock.where(exercise_name: 'ex1').first