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