diff --git a/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb b/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb index cfee13c58d1..bcddbafa3cc 100644 --- a/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb +++ b/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb @@ -4,7 +4,7 @@ module Course::Assessment::LiveFeedback::ThreadConcern def safe_create_and_save_thread_info submission_question = Course::Assessment::SubmissionQuestion.where( - submission_id: @submission, question_id: @answer.question + attempt_id: @submission, question_id: @answer.question ).first submission_question.with_lock do diff --git a/app/controllers/concerns/course/assessment/submission/koditsu/answers_concern.rb b/app/controllers/concerns/course/assessment/submission/koditsu/answers_concern.rb index 596f32f3ee1..d78a80c3849 100644 --- a/app/controllers/concerns/course/assessment/submission/koditsu/answers_concern.rb +++ b/app/controllers/concerns/course/assessment/submission/koditsu/answers_concern.rb @@ -74,7 +74,7 @@ def update_all_answer_status(submission_answers) def build_answer_object(question, answer, submitted_answer) { id: answer.id, - submission_id: answer.submission_id, + attempt_id: answer.attempt_id, question_id: question.id, workflow_state: 'submitted', correct: submitted_answer['exprTestcaseResults'].all? { |tc| tc['result']['success'] }, diff --git a/app/controllers/concerns/course/assessment/submission/koditsu/submissions_concern.rb b/app/controllers/concerns/course/assessment/submission/koditsu/submissions_concern.rb index 67249576aaf..537aa833a1d 100644 --- a/app/controllers/concerns/course/assessment/submission/koditsu/submissions_concern.rb +++ b/app/controllers/concerns/course/assessment/submission/koditsu/submissions_concern.rb @@ -39,7 +39,7 @@ def submission_status_hash def process_all_submissions create_new_submissions_if_not_existing - @submission_hash = Course::Assessment::Submission.where(assessment: @assessment).to_h do |s| + @submission_hash = @assessment.submissions.to_h do |s| [s.creator_id, s] end @@ -59,8 +59,12 @@ def process_submission(submission, cm_submission) end def create_new_submissions_if_not_existing - existing_submission_user_ids = Course::Assessment::Submission.where(assessment: @assessment). - pluck(:creator_id) + # `creator_id` is not a column on Submission's own (small) table — it lives on + # `course_assessment_attempts` (Step 2c). Unqualified, `pluck` can't tell which joined table's + # `creator_id` to read (Submission's own `acts_as :experience_points_record` default scope also + # joins `course_experience_points_records`, which ALSO has a `creator_id` column), so Postgres + # rejects it as ambiguous — same fix as `submissions_controller.rb#user_ids_without_submission`. + existing_submission_user_ids = @assessment.submissions.pluck('course_assessment_attempts.creator_id') koditsu_submission_user_ids = @cu_submission_hash.keys.map { |creator, _| creator.id } user_ids_without_submission = koditsu_submission_user_ids - existing_submission_user_ids @@ -77,8 +81,7 @@ def create_new_submissions_if_not_existing def create_new_submission_for(creator, course_user) User.with_stamper(creator) do - new_submission = @assessment.submissions.new(creator: creator, - course_user: course_user) + new_submission = @assessment.build_submission(creator: creator, course_user: course_user) success = @assessment.create_new_submission(new_submission, course_user) raise ActiveRecord::Rollback unless success @@ -96,7 +99,13 @@ def update_submission(cm_submission, state, submitted_at) end def process_submission_answers(submission, cm_submission) - answers = Course::Assessment::Answer.includes(:question).where(submission_id: cm_submission.id) + # `cm_submission.id` is now the small Submission table's OWN id, not the Attempt id the + # `attempt_id` FK needs — `course_assessment_submissions.id` is an independent serial column + # (see the Task 1 backfill migration), not guaranteed equal to `attempt_id`. This was silently + # correct pre-split, when `cm_submission` was a `Submission` instance mapped directly onto + # `course_assessment_attempts` (so `.id` and `attempt_id` were the same value); the split + # exposed it. `attempt_id` is a real, undelegated column already on Submission's own table. + answers = Course::Assessment::Answer.includes(:question).where(attempt_id: cm_submission.attempt_id) build_answer_hash(answers) diff --git a/app/controllers/concerns/course/statistics/counts_concern.rb b/app/controllers/concerns/course/statistics/counts_concern.rb index c5e73564513..4f202b6b89f 100644 --- a/app/controllers/concerns/course/statistics/counts_concern.rb +++ b/app/controllers/concerns/course/statistics/counts_concern.rb @@ -10,7 +10,7 @@ def num_attempted_students_hash attempted_submissions_count = ActiveRecord::Base.connection.execute(" SELECT cas.assessment_id AS id, COUNT(DISTINCT cas.creator_id) AS count - FROM course_assessment_submissions cas + FROM course_assessment_attempts cas WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) @@ -26,7 +26,7 @@ def num_submitted_students_hash submitted_submissions_count = ActiveRecord::Base.connection.execute(" SELECT cas.assessment_id AS id, COUNT(DISTINCT cas.creator_id) AS count - FROM course_assessment_submissions cas + FROM course_assessment_attempts cas WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) @@ -45,7 +45,7 @@ def num_late_students_hash @reference_times_hash = reference_times_hash(@assessments.pluck(:id), current_course.id) all_submissions = ActiveRecord::Base.connection.execute(" SELECT cu.id AS course_user_id, cas.assessment_id, MAX(cas.submitted_at) as submitted_at - FROM course_assessment_submissions cas + FROM course_assessment_attempts cas JOIN course_users cu ON cu.user_id = cas.creator_id WHERE @@ -64,7 +64,7 @@ def latest_submission_time_hash latest_submissions = ActiveRecord::Base.connection.execute(" SELECT cas.assessment_id AS id, MAX(cas.submitted_at) AS latest_submitted_at - FROM course_assessment_submissions cas + FROM course_assessment_attempts cas WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) diff --git a/app/controllers/concerns/course/statistics/grades_concern.rb b/app/controllers/concerns/course/statistics/grades_concern.rb index 5918006f91a..d9d27622f9e 100644 --- a/app/controllers/concerns/course/statistics/grades_concern.rb +++ b/app/controllers/concerns/course/statistics/grades_concern.rb @@ -9,8 +9,8 @@ def grade_statistics_hash SELECT ca.assessment_id AS id, AVG(ca.grade) AS avg, STDDEV(ca.grade) AS stdev FROM ( SELECT cas.creator_id, cas.assessment_id, SUM(caa.grade) AS grade - FROM course_assessment_submissions cas - JOIN course_assessment_answers caa ON cas.id = caa.submission_id + FROM course_assessment_attempts cas + JOIN course_assessment_answers caa ON cas.id = caa.attempt_id WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 06d090f1da7..dc0042c7e01 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -34,15 +34,15 @@ def answer_statistics_hash SELECT caa_inner.id, caa_inner.question_id, - caa_inner.submission_id, + caa_inner.attempt_id AS submission_id, caa_inner.correct, caa_inner.grade, caa_inner.workflow_state, - ROW_NUMBER() OVER (PARTITION BY caa_inner.question_id, caa_inner.submission_id ORDER BY caa_inner.created_at DESC) AS row_num + ROW_NUMBER() OVER (PARTITION BY caa_inner.question_id, caa_inner.attempt_id ORDER BY caa_inner.created_at DESC) AS row_num FROM course_assessment_answers caa_inner JOIN - course_assessment_submissions cas_inner ON caa_inner.submission_id = cas_inner.id + course_assessment_attempts cas_inner ON caa_inner.attempt_id = cas_inner.id WHERE cas_inner.assessment_id = #{assessment_params[:id]} ) AS caa_ranked @@ -53,12 +53,12 @@ def answer_statistics_hash attempt_count AS ( SELECT caa.question_id, - caa.submission_id, + caa.attempt_id AS submission_id, COUNT(*) AS attempt_count FROM course_assessment_answers caa - JOIN course_assessment_submissions cas ON caa.submission_id = cas.id + JOIN course_assessment_attempts cas ON caa.attempt_id = cas.id WHERE cas.assessment_id = #{assessment_params[:id]} AND caa.workflow_state != 'attempting' - GROUP BY caa.question_id, caa.submission_id + GROUP BY caa.question_id, caa.attempt_id ) SELECT CASE WHEN jsonb_array_length(attempt_info.submission_info) = 1 OR attempt_info.submission_info->0->>3 != 'attempting' diff --git a/app/controllers/concerns/course/statistics/times_concern.rb b/app/controllers/concerns/course/statistics/times_concern.rb index 37f2c819651..382e22916d5 100644 --- a/app/controllers/concerns/course/statistics/times_concern.rb +++ b/app/controllers/concerns/course/statistics/times_concern.rb @@ -10,7 +10,7 @@ def duration_statistics_hash FROM ( SELECT cas.creator_id, cas.assessment_id, EXTRACT(EPOCH FROM cas.submitted_at) - EXTRACT(EPOCH FROM cas.created_at) AS duration - FROM course_assessment_submissions cas + FROM course_assessment_attempts cas WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) diff --git a/app/controllers/course/assessment/assessments_controller.rb b/app/controllers/course/assessment/assessments_controller.rb index 3c7c83bd487..db0e87fee08 100644 --- a/app/controllers/course/assessment/assessments_controller.rb +++ b/app/controllers/course/assessment/assessments_controller.rb @@ -459,7 +459,7 @@ def submissions if @assessment.submissions.loaded? @assessment.submissions.select { |s| s.creator_id == current_user.id } else - @assessment.submissions.where(creator_id: current_user.id) + @assessment.submissions.by_user(current_user) end end diff --git a/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb b/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb index bbcf902942b..904222ed91a 100644 --- a/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb +++ b/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb @@ -66,7 +66,7 @@ def create_topic_subscription # Ensure all group managers get a notification when someone adds a programming annotation # to the answer. - answer_course_user = @answer.submission.course_user + answer_course_user = @answer.submission.submission.course_user answer_course_user.my_managers.each do |manager| @discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/controllers/course/assessment/submission/submissions_controller.rb b/app/controllers/course/assessment/submission/submissions_controller.rb index bdae979fcc2..5358a25d3eb 100644 --- a/app/controllers/course/assessment/submission/submissions_controller.rb +++ b/app/controllers/course/assessment/submission/submissions_controller.rb @@ -41,7 +41,7 @@ def index def create # rubocop:disable Metrics/AbcSize authorize! :access, @assessment - existing_submission = @assessment.submissions.find_by(creator: current_user) + existing_submission = @assessment.submissions.by_user(current_user).first create_success_response(existing_submission) and return if existing_submission ActiveRecord::Base.transaction do @@ -110,7 +110,7 @@ def generate_live_feedback system_thread = Course::Assessment::LiveFeedback::Thread. joins(:submission_question). where( - submission_question: { submission_id: @submission.id, question_id: @answer.question.id }, + submission_question: { attempt_id: @submission.id, question_id: @answer.question.id }, is_active: true ). first @@ -141,7 +141,7 @@ def fetch_live_feedback_chat submission = answer.submission question = answer.question - submission_question = Course::Assessment::SubmissionQuestion.where(submission_id: submission.id, + submission_question = Course::Assessment::SubmissionQuestion.where(attempt_id: submission.id, question_id: question.id).first @thread = Course::Assessment::LiveFeedback::Thread.where(submission_question_id: submission_question.id). @@ -423,7 +423,12 @@ def course_user_ids def user_ids_without_submission existing_submissions = @assessment.submissions.by_users(course_user_ids.pluck(:user_id)) - user_ids_with_submission = existing_submissions.pluck(:creator_id) + # `creator_id` is not a column on Submission's own (small) table — it lives on + # `course_assessment_attempts` (Step 2c). Unqualified, Rails can't tell `pluck` which joined + # table's `creator_id` to read (Submission's own `acts_as :experience_points_record` default + # scope also joins `course_experience_points_records`, which ALSO has a `creator_id` column), + # so Postgres rejects it as ambiguous. Qualify explicitly. + user_ids_with_submission = existing_submissions.pluck('course_assessment_attempts.creator_id') course_user_ids.pluck(:user_id) - user_ids_with_submission end diff --git a/app/controllers/course/assessment/submission_question/comments_controller.rb b/app/controllers/course/assessment/submission_question/comments_controller.rb index 7fd47cf2d54..965aef7bd27 100644 --- a/app/controllers/course/assessment/submission_question/comments_controller.rb +++ b/app/controllers/course/assessment/submission_question/comments_controller.rb @@ -34,7 +34,7 @@ def create_topic_subscription @discussion_topic.ensure_subscribed_by(@submission_question.submission.creator) # Ensure all group managers get a notification when someone comments on this submission question - submission_question_course_user = @submission_question.submission.course_user + submission_question_course_user = @submission_question.submission.submission.course_user submission_question_course_user.my_managers.each do |manager| @discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/controllers/course/assessment/submission_question/submission_questions_controller.rb b/app/controllers/course/assessment/submission_question/submission_questions_controller.rb index 6396dc6b88c..154d3d3153d 100644 --- a/app/controllers/course/assessment/submission_question/submission_questions_controller.rb +++ b/app/controllers/course/assessment/submission_question/submission_questions_controller.rb @@ -3,7 +3,14 @@ class Course::Assessment::SubmissionQuestion::SubmissionQuestionsController < Co load_resource :assessment, class: 'Course::Assessment', through: :course, parent: false def all_answers - @submission = @assessment.submissions.find(all_answers_params[:submission_id]) + # `all_answers_params[:submission_id]` is, by the statistics feature's own established + # convention (see `Course::Statistics::AssessmentsController#submission_statistics`/ + # `#live_feedback_statistics`, which serialize `Attempt#id` under the wire key `id`/ + # `submissionId`), an Attempt id — not Submission's own (small-table) id. Find by attempt, then + # navigate to the real Submission for `authorize!` (CanCan's `can :read, ...Submission` rules + # match on subject class — see `Answer#can_read_grade?`'s identical fix, Step 2d). Genuine bug + # the split exposed; not listed in the brief's file set, found via the acceptance gate. + @submission = @assessment.attempts.find(all_answers_params[:submission_id]).submission authorize!(:read, @submission) @submission_question = @submission. submission_questions. diff --git a/app/controllers/course/material/materials_controller.rb b/app/controllers/course/material/materials_controller.rb index ff7bdc20560..07b552c71d5 100644 --- a/app/controllers/course/material/materials_controller.rb +++ b/app/controllers/course/material/materials_controller.rb @@ -44,9 +44,9 @@ def material_params def create_submission current_course_user = current_course.course_users.find_by(user: current_user) @assessment = @folder.owner - existing_submission = @assessment.submissions.find_by(creator: current_user) + existing_submission = @assessment.submissions.by_user(current_user).first unless existing_submission - @submission = @assessment.submissions.new(course_user: current_course_user) + @submission = @assessment.build_submission(course_user: current_course_user) @submission.session_id = authentication_service.generate_authentication_token success = @assessment.create_new_submission(@submission, current_user) diff --git a/app/controllers/course/plagiarism/assessments_controller.rb b/app/controllers/course/plagiarism/assessments_controller.rb index 2bdbced3a8f..4688a357612 100644 --- a/app/controllers/course/plagiarism/assessments_controller.rb +++ b/app/controllers/course/plagiarism/assessments_controller.rb @@ -201,7 +201,7 @@ def fetch_plagiarism_data_submissions(submission_ids) ccu.id AS creator_course_user_id, ccu.name AS creator_course_user_name, vcu.id AS viewer_course_user_id - FROM course_assessment_submissions cas + FROM course_assessment_attempts cas INNER JOIN course_assessments ca ON cas.assessment_id = ca.id INNER JOIN course_lesson_plan_items clpi ON clpi.actable_id = ca.id AND clpi.actable_type = 'Course::Assessment' INNER JOIN course_assessment_tabs tab ON ca.tab_id = tab.id diff --git a/app/controllers/course/statistics/aggregate_controller.rb b/app/controllers/course/statistics/aggregate_controller.rb index f28b8d6d353..ad2b4ccbb42 100644 --- a/app/controllers/course/statistics/aggregate_controller.rb +++ b/app/controllers/course/statistics/aggregate_controller.rb @@ -65,14 +65,19 @@ def fetch_course_get_help_data(start_date, end_date) get_help_data = Course::Assessment::LiveFeedback::Message.find_by_sql(<<-SQL) SELECT DISTINCT ON (t.submission_creator_id, s.assessment_id, sq.question_id) m.id, m.content, m.created_at, t.submission_creator_id, - s.assessment_id, sq.submission_id, sq.question_id, + s.assessment_id, sub.id AS submission_id, sq.question_id, COUNT(*) OVER ( PARTITION BY t.submission_creator_id, s.assessment_id, sq.question_id ) AS message_count FROM live_feedback_messages m INNER JOIN live_feedback_threads t ON m.thread_id = t.id INNER JOIN course_assessment_submission_questions sq ON t.submission_question_id = sq.id - INNER JOIN course_assessment_submissions s ON sq.submission_id = s.id + INNER JOIN course_assessment_attempts s ON sq.attempt_id = s.id + -- `sq.attempt_id`/`s.id` is the Attempt's own id, not the (course-coupled) Submission's own + -- (small-table) id the JSON response's `submissionId` is expected to be — post-split, these + -- are two different id spaces (Step 1's extension table has its own serial `id`). Genuine bug + -- the split exposed; not listed in the brief's file set, found via the acceptance gate. + INNER JOIN course_assessment_submissions sub ON sub.attempt_id = s.id INNER JOIN course_assessments a ON s.assessment_id = a.id INNER JOIN course_assessment_tabs tab ON a.tab_id = tab.id INNER JOIN course_assessment_categories cat ON tab.category_id = cat.id @@ -145,10 +150,10 @@ def correctness_hash ON tab.category_id = cat.id INNER JOIN course_assessments ca ON ca.tab_id = tab.id - INNER JOIN course_assessment_submissions cas + INNER JOIN course_assessment_attempts cas ON cas.assessment_id = ca.id INNER JOIN course_assessment_answers caa - ON caa.submission_id = cas.id + ON caa.attempt_id = cas.id INNER JOIN course_assessment_questions caq ON caa.question_id = caq.id INNER JOIN course_users cu diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index d158d64a013..64b21c619b5 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -21,7 +21,14 @@ def submission_statistics includes(programming_questions: [:language]). calculated(:maximum_grade, :question_count). find(assessment_params[:id]) - submissions = Course::Assessment::Submission.unscoped. + # `Course::Assessment::Submission` has no `assessment_id` column post-split (it's Attempt-only, + # reached through the delegate); querying it directly like this raised + # `PG::UndefinedColumn` once the split landed. `Attempt` carries `assessment_id`, `grade`, and + # `grader_ids` natively/as `calculated`, so it is a drop-in replacement here — the jbuilder view + # for this action (`_submission.json.jbuilder`) only ever reads `workflow_state`, `submitted_at`, + # `grade`, `graded?`/`published?`, `grader_ids`, all present on Attempt. (Genuine bug the split + # exposed, mirroring the two call sites the design spike already named in this file.) + submissions = Course::Assessment::Attempt.unscoped. where(assessment_id: assessment_params[:id]). calculated(:grade, :grader_ids) @course_users_hash = preload_course_users_hash(current_course) @@ -39,7 +46,9 @@ def ancestor_statistics calculated(:maximum_grade). find(assessment_params[:id]) authorize!(:read_ancestor, @assessment) - submissions = Course::Assessment::Submission.unscoped. + # Same `assessment_id`-column bug as `submission_statistics` above — `Attempt` is the + # drop-in replacement (see the comment there). + submissions = Course::Assessment::Attempt.unscoped. preload(creator: :course_users). where(assessment_id: assessment_params[:id]). calculated(:grade) @@ -52,7 +61,10 @@ def ancestor_statistics def live_feedback_statistics @assessment = Course::Assessment.unscoped.includes(:questions). find(assessment_params[:id]) - @submissions = Course::Assessment::Submission.unscoped. + # id/creator_id/workflow_state all live on Attempt post-split; this action only ever reads + # those three columns (unscoped + a narrow .select), so querying Attempt directly is equivalent + # in Phase 1b (every Attempt still has exactly one Submission). + @submissions = Course::Assessment::Attempt.unscoped. select(:id, :creator_id, :workflow_state). where(assessment_id: assessment_params[:id]) @@ -64,7 +76,7 @@ def live_feedback_statistics def live_feedback_history user_id = CourseUser.joins(:user).where(id: params[:course_user_id]).pluck('users.id').first - @submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id], creator_id: user_id) + @submissions = Course::Assessment::Attempt.where(assessment_id: assessment_params[:id], creator_id: user_id) @question = Course::Assessment::Question.find(params[:question_id]) create_submission_question_id_hash([@question]) @@ -131,9 +143,9 @@ def create_student_live_feedback_hash submission_hash = @submissions.index_by(&:creator_id) final_grade_hash = Course::Assessment::Answer.where( - submission_id: @submissions.pluck(:id), + attempt_id: @submissions.pluck(:id), current_answer: true - ).to_h { |answer| [[answer.submission_id, answer.question_id], answer&.grade&.to_f || 0] } + ).to_h { |answer| [[answer.attempt_id, answer.question_id], answer&.grade&.to_f || 0] } @student_live_feedback_hash = @all_students.to_h do |student| submission = submission_hash[student.user_id] @@ -238,7 +250,7 @@ def feedback_messages_cte(student_ids, submission_question_ids) def feedback_answers_cte <<-SQL SELECT - a.submission_id, + a.attempt_id AS submission_id, a.question_id, a.created_at, a.grade, @@ -249,7 +261,7 @@ def feedback_answers_cte FROM feedback_messages f JOIN live_feedback_threads lft ON lft.submission_creator_id = f.submission_creator_id AND lft.submission_question_id = f.submission_question_id JOIN course_assessment_submission_questions sq ON sq.id = lft.submission_question_id - JOIN course_assessment_answers a ON a.submission_id = sq.submission_id AND a.question_id = sq.question_id + JOIN course_assessment_answers a ON a.attempt_id = sq.attempt_id AND a.question_id = sq.question_id SQL end @@ -330,10 +342,10 @@ def create_question_order_hash def create_submission_question_id_hash(questions) @submission_question_id_hash = Course::Assessment::SubmissionQuestion.unscoped. - select(:id, :submission_id, :question_id). - where(submission_id: @submissions.pluck(:id), + select(:id, :attempt_id, :question_id). + where(attempt_id: @submissions.pluck(:id), question_id: questions.pluck(:id)).to_h do |sq| - [[sq.submission_id, sq.question_id], sq.id] + [[sq.attempt_id, sq.question_id], sq.id] end end end diff --git a/app/controllers/system/admin/get_help_controller.rb b/app/controllers/system/admin/get_help_controller.rb index 3123c13496b..79bd65d4970 100644 --- a/app/controllers/system/admin/get_help_controller.rb +++ b/app/controllers/system/admin/get_help_controller.rb @@ -44,14 +44,14 @@ def fetch_system_get_help_data(start_date, end_date) get_help_data = Course::Assessment::LiveFeedback::Message.find_by_sql(<<-SQL) SELECT DISTINCT ON (t.submission_creator_id, s.assessment_id, sq.question_id) m.id, m.content, m.created_at, t.submission_creator_id, - s.assessment_id, sq.submission_id, sq.question_id, + s.assessment_id, sq.attempt_id AS submission_id, sq.question_id, COUNT(*) OVER ( PARTITION BY t.submission_creator_id, s.assessment_id, sq.question_id ) AS message_count FROM live_feedback_messages m INNER JOIN live_feedback_threads t ON m.thread_id = t.id INNER JOIN course_assessment_submission_questions sq ON t.submission_question_id = sq.id - INNER JOIN course_assessment_submissions s ON sq.submission_id = s.id + INNER JOIN course_assessment_attempts s ON sq.attempt_id = s.id WHERE m.creator_id != #{User::SYSTEM_USER_ID} AND m.created_at >= '#{start_date.utc.iso8601}' AND m.created_at <= '#{end_date.utc.iso8601}' diff --git a/app/controllers/system/admin/instance/get_help_controller.rb b/app/controllers/system/admin/instance/get_help_controller.rb index b5ecc160be3..d3b73329ded 100644 --- a/app/controllers/system/admin/instance/get_help_controller.rb +++ b/app/controllers/system/admin/instance/get_help_controller.rb @@ -42,14 +42,14 @@ def fetch_instance_get_help_data(start_date, end_date) get_help_data = Course::Assessment::LiveFeedback::Message.find_by_sql(<<-SQL) SELECT DISTINCT ON (t.submission_creator_id, s.assessment_id, sq.question_id) m.id, m.content, m.created_at, t.submission_creator_id, - s.assessment_id, sq.submission_id, sq.question_id, + s.assessment_id, sq.attempt_id AS submission_id, sq.question_id, COUNT(*) OVER ( PARTITION BY t.submission_creator_id, s.assessment_id, sq.question_id ) AS message_count FROM live_feedback_messages m INNER JOIN live_feedback_threads t ON m.thread_id = t.id INNER JOIN course_assessment_submission_questions sq ON t.submission_question_id = sq.id - INNER JOIN course_assessment_submissions s ON sq.submission_id = s.id + INNER JOIN course_assessment_attempts s ON sq.attempt_id = s.id INNER JOIN course_assessments a ON s.assessment_id = a.id INNER JOIN course_assessment_tabs tab ON a.tab_id = tab.id INNER JOIN course_assessment_categories cat ON tab.category_id = cat.id diff --git a/app/controllers/system/admin/marketplace_access_blocks_controller.rb b/app/controllers/system/admin/marketplace_access_blocks_controller.rb new file mode 100644 index 00000000000..3bb19d952a6 --- /dev/null +++ b/app/controllers/system/admin/marketplace_access_blocks_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAccessBlocksController < System::Admin::Controller + def create + block = Course::Assessment::Marketplace::AccessBlock.new( + user_id: params[:user_id], creator: current_user + ) + if block.save + render json: { id: block.id, userId: block.user_id }, status: :ok + else + render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request + end + end + + def destroy + block = Course::Assessment::Marketplace::AccessBlock.find(params[:id]) + if block.destroy + head :ok + else + render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request + end + end +end diff --git a/app/controllers/system/admin/marketplace_access_controller.rb b/app/controllers/system/admin/marketplace_access_controller.rb new file mode 100644 index 00000000000..50f21b0da89 --- /dev/null +++ b/app/controllers/system/admin/marketplace_access_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAccessController < System::Admin::Controller + def index + query = Course::Assessment::Marketplace::AccessListQuery.new + @rows = query.rows + @summary = query.summary + end +end diff --git a/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb b/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb new file mode 100644 index 00000000000..80d4b8ff007 --- /dev/null +++ b/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAllowlistRulesController < System::Admin::Controller + # `preview` is a collection action with no id, so CanCan's default loader would try + # `find(params[:id])`. It builds its own unsaved rule; System::Admin::Controller's + # `authorize_admin` already gates the whole controller. + load_and_authorize_resource :allowlist_rule, + class: 'Course::Assessment::Marketplace::AllowlistRule', + parent: false, except: [:preview] + + def index + # "Everyone" is a page-level mode, not a table row: expose its presence as `@everyone_rule` + # and show only the scoped rules in the table. + @everyone_rule = @allowlist_rules.rule_type_everyone.first + @allowlist_rules = @allowlist_rules.where.not(rule_type: :everyone).includes(:user, :instance) + end + + def create + if @allowlist_rule.save + # `render partial:` (not `render 'rule'`) — the view is the `_rule` partial. Mirrors + # System::Admin::AnnouncementsController#create (`render partial: '.../announcement_data'`). + render partial: 'rule', locals: { rule: @allowlist_rule }, status: :ok + else + render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request + end + end + + def preview + rule = Course::Assessment::Marketplace::AllowlistRule.new(allowlist_rule_params) + unless rule.valid? + render json: { errors: rule.errors.full_messages.to_sentence }, status: :bad_request + return + end + + query = Course::Assessment::Marketplace::RulePreviewQuery.new(rule) + @rows = query.rows + @summary = query.summary + end + + def destroy + if @allowlist_rule.destroy + head :ok + else + render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request + end + end + + private + + def allowlist_rule_params + params.require(:allowlist_rule).permit(:rule_type, :user_id, :instance_id, :email_domain, :email) + end +end diff --git a/app/helpers/system/admin/marketplace_access_helper.rb b/app/helpers/system/admin/marketplace_access_helper.rb new file mode 100644 index 00000000000..364d0cf864c --- /dev/null +++ b/app/helpers/system/admin/marketplace_access_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +module System::Admin::MarketplaceAccessHelper + # The value half of a rule's label ("Email domain · "), for the audit list's reason column. + # @param [Course::Assessment::Marketplace::AllowlistRule] rule + # @return [String, nil] + def marketplace_rule_label_value(rule) + case rule.rule_type + when 'user' then rule.user&.name + when 'instance' then rule.instance&.name + when 'email_domain' then rule.email_domain + end + end +end diff --git a/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb b/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb index e3ea3bc9426..b74008b2730 100644 --- a/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb +++ b/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb @@ -10,7 +10,11 @@ def perform_tracked(assessment, submission_id, submitter) instance = Course.unscoped { assessment.course.instance } ActsAsTenant.with_tenant(instance) do - submission = Course::Assessment::Submission.find_by(id: submission_id) + # `submission_id` here is the id `create_force_submission_job` (Attempt#create_force_submission_job) + # scheduled with, which is `attempt.id` — this job's own param name predates the split and is + # not renamed here (renaming it is exactly the kind of cosmetic diff the repo's diff-hygiene + # rule forbids without a functional reason). + submission = Course::Assessment::Attempt.find_by(id: submission_id)&.submission return unless submission force_submit(submission, submitter) diff --git a/app/jobs/course/assessment/submission/force_submitting_job.rb b/app/jobs/course/assessment/submission/force_submitting_job.rb index 81204ad5c84..4b0a9d83d6c 100644 --- a/app/jobs/course/assessment/submission/force_submitting_job.rb +++ b/app/jobs/course/assessment/submission/force_submitting_job.rb @@ -50,9 +50,7 @@ def force_create_and_submit_submissions(assessment, user_ids, user_ids_without_s # @param [Course::Assessment] assessment The assessment of which a submission is to be created. # @param [CourseUser] course_user The course user whose submission is to be created. def create_submission(assessment, course_user) - submission = assessment.submissions.new(creator: course_user.user, course_user: course_user) - - assessment.submissions.new(creator: course_user.user) + submission = assessment.build_submission(creator: course_user.user, course_user: course_user) success = assessment.create_new_submission(submission, course_user) raise ActiveRecord::Rollback unless success diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb index e7a7972085c..45dc1e7922b 100644 --- a/app/models/components/course/assessment_marketplace_ability_component.rb +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -4,12 +4,46 @@ module Course::AssessmentMarketplaceAbilityComponent def define_permissions allow_admins_publish_to_marketplace if user&.administrator? - allow_managers_access_marketplace if course_user&.manager_or_owner? + # System admins keep marketplace access via `can :manage, :all` (Ability#initialize); do not + # emit a `cannot` for them or it would revoke that. For everyone else, access is per-person. + if course && !user&.administrator? + if can_access_marketplace? + allow_managers_access_marketplace + else + # `Course::CourseAbilityComponent` grants managers/owners a blanket `can :manage, Course`, + # which (CanCan's `:manage` matches any action) would otherwise satisfy `:access_marketplace` + # regardless of the allow-list. This component runs after that one in the `define_permissions` + # super chain, so a `cannot` here takes precedence. This line is load-bearing. + cannot :access_marketplace, Course, id: course.id + end + end super end private + # Access is per-person, not per-current-course-role: anyone who is baseline-capable (manages/owns + # >=1 course anywhere, OR is an instructor/administrator in any instance) and passes the allow-list + # may browse, whatever their role in the course they are viewing. + def can_access_marketplace? + marketplace_baseline_capable? && marketplace_visible_to_user? + end + + # The two peer baseline capabilities for the marketplace. Either qualifies; the allow-list narrows. + def marketplace_baseline_capable? + user&.course_manager_or_owner? || user&.instance_instructor_or_administrator? + end + + # Part of the TEMPORARY allow-list gate (see the retirement seam on `can_access_marketplace?`). + # When the allow-list is retired this whole method is deleted; the block check goes with it. + def marketplace_visible_to_user? + return true if user&.administrator? + + Course::Assessment::Marketplace::AllowlistRule.grants_access?(user) && + !Course::Assessment::Marketplace::AccessBlock.blocked?(user) + end + + def allow_admins_publish_to_marketplace can :publish_to_marketplace, Course::Assessment end @@ -23,4 +57,4 @@ def allow_managers_access_marketplace assessment.marketplace_listing&.published? || false end end -end +end \ No newline at end of file diff --git a/app/models/concerns/course/assessment/new_submission_concern.rb b/app/models/concerns/course/assessment/new_submission_concern.rb index b4841c97dff..13ea1e49424 100644 --- a/app/models/concerns/course/assessment/new_submission_concern.rb +++ b/app/models/concerns/course/assessment/new_submission_concern.rb @@ -12,7 +12,11 @@ def create_new_submission(new_submission, current_user) raise ActiveRecord::Rollback end raise ActiveRecord::Rollback unless new_submission.save - raise ActiveRecord::Rollback unless qbas.update_all(submission_id: new_submission.id) + # `question_bundle_assignments.submission_id` now targets course_assessment_attempts (the + # FK repoint, Step 2d) — it must hold an Attempt id, not the small Submission table's own + # (different) id. `attempt_id` is a real, undelegated column already on Submission's own + # table (the FK to its attempt), so no delegate is needed to read it. + raise ActiveRecord::Rollback unless qbas.update_all(submission_id: new_submission.attempt_id) success = true end diff --git a/app/models/concerns/course/assessment/submission/workflow_event_concern.rb b/app/models/concerns/course/assessment/submission/workflow_event_concern.rb index 51741c682ad..3cce67310f1 100644 --- a/app/models/concerns/course/assessment/submission/workflow_event_concern.rb +++ b/app/models/concerns/course/assessment/submission/workflow_event_concern.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true module Course::Assessment::Submission::WorkflowEventConcern extend ActiveSupport::Concern - include Course::LessonPlan::PersonalizationConcern - include Course::Assessment::Submission::CikgoTaskCompletionConcern - - included do - before_validation :assign_experience_points, if: :workflow_state_changed? - end protected @@ -21,13 +15,7 @@ def finalise(_ = nil) finalise_current_answers answers.reload # Reload answers after finalising - assign_zero_experience_points - - # Trigger timeline recomputation - # NB: We are not recomputing on unsubmission because unsubmit is not done by the student - # It will recompute again when resubmission occurs. This also prevents the timings for - # the unsubmitted item from changing e.g. from other submissions that the student has done. - update_personalized_timeline_for_user(course_user) + after_finalise_hook end # Handles the marking of a submission. @@ -49,13 +37,9 @@ def unmark(_ = nil) def publish(_ = nil, send_email = true) # rubocop:disable Style/OptionalBooleanParameter publish_answers - self.publisher = User.stamper || User.system self.published_at = Time.zone.now - self.awarder = User.stamper || User.system - self.awarded_at = Time.zone.now - publish_delayed_posts - send_email_after_publishing(send_email) + after_publish_hook(send_email) end # Handles the unsubmission of a submitted submission. @@ -66,13 +50,10 @@ def unsubmit(_ = nil) recreate_current_answers answers.reload - self.points_awarded = nil - self.draft_points_awarded = nil - self.awarded_at = nil - self.awarder = nil self.submitted_at = nil - self.publisher = nil self.published_at = nil + + after_unsubmit_hook end # Handles re-submitting a published submission's programming answers when there are @@ -83,29 +64,45 @@ def resubmit_programming @unsubmitting = true unsubmit_current_answers(only_programming: true) - self.points_awarded = nil - self.draft_points_awarded = nil - self.awarded_at = nil - self.awarder = nil - self.publisher = nil self.published_at = nil + after_unsubmit_hook + current_answers.select(&:attempting?).each(&:finalise!) - assign_zero_experience_points + after_resubmit_programming_hook end - private + # --- Hook seams, called at the exact points the old, single-table WorkflowEventConcern used to + # inline EXP-specific / course-coupled work. `Attempt` and `Submission` are sibling classes joined + # by association, not superclass/subclass, so these cannot be Ruby method overrides — they forward + # to the associated Submission if one exists. A preview Attempt (Phase 2) has no Submission, so + # `submission` is `nil` and these are genuinely no-ops; that's the actual mechanism behind "hooks + # that no-op by default", not polymorphism. The real bodies are defined on + # Course::Assessment::Submission (see submission.rb). + # + # `assign_zero_experience_points` is shared by `finalise` and `resubmit_programming` in the old + # code (workflow_event_concern.rb:24,95 pre-split) with no obvious single hook seam for the second + # call site — `after_resubmit_programming_hook` is a third hook added for exactly that call, + # flagged here as the residual judgment call the design spike left open (§5, §3.2). + def after_finalise_hook + submission&.after_finalise_hook + end - # finalise event (from attempting) - Assign 0 points as there are no questions. - def assign_zero_experience_points - return unless assessment.questions.empty? + def after_publish_hook(send_email = true) + submission&.after_publish_hook(send_email) + end - self.points_awarded = 0 - self.awarded_at = Time.zone.now - self.awarder = User.stamper || User.system + def after_unsubmit_hook + submission&.after_unsubmit_hook end + def after_resubmit_programming_hook + submission&.after_resubmit_programming_hook + end + + private + # When a submission is finalised, we will compare the current answer and the latest non-current answers. # If they are the same, remove the current answer and mark the latest non-current answer as the current answer # to avoid re-grading. @@ -183,70 +180,12 @@ def delete_attempting_current_answers answers.current_answers.with_attempting_state.each(&:destroy!) end - def send_email_after_publishing(send_email) - return unless send_email && persisted? && !assessment.autograded? && - submission_graded_email_enabled? && - submission_graded_email_subscribed? - - execute_after_commit { Course::Mailer.submission_graded_email(self).deliver_later } - end - - def submission_graded_email_enabled? - is_enabled_as_phantom = course_user.phantom? && email_enabled.phantom - is_enabled_as_regular = !course_user.phantom? && email_enabled.regular - is_enabled_as_phantom || is_enabled_as_regular - end - - def submission_graded_email_subscribed? - !course_user.email_unsubscriptions.where(course_settings_email_id: email_enabled.id).exists? - end - - def email_enabled - assessment.course.email_enabled(:assessments, :grades_released, assessment.tab.category.id) - end - - # Defined outside of the workflow transition as points_awarded and draft_points_awarded are - # not set during the event transition, hence they are not modifiable within the method itself. - def assign_experience_points - # publish event (from grade) - Deduce points awarded from draft or updated attribute. - if workflow_state == 'published' && - (workflow_state_was == 'graded' || workflow_state_was == 'submitted') - self.points_awarded ||= draft_points_awarded - self.draft_points_awarded = nil - end - end - def publish_answers answers.each do |answer| answer.publish! if answer.submitted? || answer.evaluated? end end - def publish_delayed_posts - return if assessment.autograded? - - # Publish delayed comments for each question of a submission - submission_question_topics = submission_questions.flat_map(&:discussion_topic) - update_delayed_topics_and_posts(submission_question_topics) - - # Publish delayed annotations for each programming question of a submission - programming_answers = answers.where('actable_type = ?', Course::Assessment::Answer::Programming.name) - annotation_topics = programming_answers.flat_map(&:specific). - flat_map(&:files).flat_map(&:annotations).map(&:discussion_topic) - update_delayed_topics_and_posts(annotation_topics) - end - - # Update read mark for topic and delayed for posts - def update_delayed_topics_and_posts(topics) - topics.each do |topic| - delayed_posts = topic.posts.only_delayed_posts - next if delayed_posts.empty? - - topic.read_marks.where('reader_id = ?', creator.id)&.destroy_all # Remove 'mark as read' (if any) - delayed_posts.update_all(workflow_state: 'published') - end - end - # When a submission is unsubmitted, every current_answer is copied as and flagged as attempting. # The new copied answer is then marked as current_answer which is the answer that can be modified # by users. The old current_answer is unmarked as current_answer and is kept as graded past answer. diff --git a/app/models/concerns/course_user/staff_concern.rb b/app/models/concerns/course_user/staff_concern.rb index 885bcd2a5f8..498d8237739 100644 --- a/app/models/concerns/course_user/staff_concern.rb +++ b/app/models/concerns/course_user/staff_concern.rb @@ -28,12 +28,20 @@ def self.order_by_average_marking_time(staff) def published_submissions @published_submissions ||= Course::Assessment::Submission. + joins(:attempt). joins(experience_points_record: :course_user). where('course_users.role = ?', CourseUser.roles[:student]). where('course_users.phantom = ?', false). + # `publisher_id` is Submission's own (small-table) column now — the one Task 1 actually + # backfilled and that publishing actually writes to (Step 2c's `after_publish_hook`); + # `course_assessment_attempts.publisher_id` is a stale, no-longer-written residual column + # from before the split. `published_at`/`submitted_at` remain Attempt-only, so still need the + # join added above (both columns are qualified below since `publisher_id` now exists on both + # joined tables and would otherwise be ambiguous). Genuine bug the split exposed; not listed + # in the brief's file set, found via the acceptance gate. where('course_assessment_submissions.publisher_id = ?', user_id). where('course_users.course_id = ?', course_id). - pluck(:published_at, :submitted_at). + pluck('course_assessment_attempts.published_at', 'course_assessment_attempts.submitted_at'). map { |published_at, submitted_at| { published_at: published_at, submitted_at: submitted_at } } end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index e489dce65d0..b4136ebf2b2 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -35,10 +35,17 @@ class Course::Assessment < ApplicationRecord belongs_to :monitor, class_name: 'Course::Monitoring::Monitor', optional: true - # `submissions` association must be put before `questions`, so that all answers will be deleted - # first when deleting the course. Otherwise due to the foreign key `question_id` in answers table, - # questions cannot be deleted. - has_many :submissions, inverse_of: :assessment, dependent: :destroy + # `attempts` association must be put before `questions`, so that all answers will be deleted + # first when deleting the course (via Attempt's own `dependent: :destroy` cascade to answers etc.) + # — otherwise, due to the foreign key `question_id` in the answers table, questions cannot be + # deleted. `submissions` is now a `through:` association and cannot itself carry `dependent:` — + # the destroy ordering guarantee comes from `attempts` instead. + has_many :attempts, class_name: 'Course::Assessment::Attempt', inverse_of: :assessment, + dependent: :destroy + # UNCHANGED semantics for every existing read call site: an assessment's "submissions" are still + # only course-member submissions. A submission-less (preview, Phase 2) attempt is excluded by the + # join with no filter needed. + has_many :submissions, through: :attempts, source: :submission has_many :question_assessments, class_name: 'Course::QuestionAssessment', inverse_of: :assessment, dependent: :destroy @@ -130,8 +137,15 @@ class Course::Assessment < ApplicationRecord # Includes the submissions by the provided user. # @param [User] user The user to preload submissions for. scope :with_submissions_by, (lambda do |user| - submissions = Course::Assessment::Submission.by_user(user). - where(assessment: distinct(false).pluck(:id)).ordered_by_date + # `.where(assessment: ...)` used to be a hash condition against Submission's own real + # `belongs_to :assessment` FK column; post-split, `assessment` is a delegated method, not a + # column, so this raised `PG::UndefinedColumn: column course_assessment_submissions.assessment + # does not exist`. `.by_user(user)` is a plain `where(attempt_id: ...)` subquery (Step 2c/later + # correction — see `by_user`'s own comment for why it isn't a join), so join `:attempt` + # explicitly here to filter by `assessment_id`. + submissions = Course::Assessment::Submission.by_user(user).joins(:attempt). + where(course_assessment_attempts: { assessment_id: distinct(false).pluck(:id) }). + ordered_by_date all.to_a.tap do |result| preloader = ActiveRecord::Associations::Preloader.new(records: result, @@ -181,6 +195,28 @@ def self.max_grades(assessment_ids) rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] } end + # Builds a new (unsaved) course-coupled Submission together with its backing Attempt. + # + # Replaces `assessment.submissions.new(...)`, which stopped working once `submissions` became a + # `has_many :through` association (Phase 1b) — a `through:` collection proxy has no single owning + # foreign key to set, and building a Submission always requires building its Attempt in the same + # breath. + # + # @param [Hash] attributes A mix of Attempt attributes (only :creator is used by any call site + # today) and Submission attributes (:course_user, :session_id). Attempt-level keys are pulled + # out explicitly; everything else is passed straight to the built Submission. + # @return [Course::Assessment::Submission] an unsaved Submission with its (also unsaved) Attempt + # already built and associated. Callers `.save`/`.save!` it exactly as they did the old + # `assessment.submissions.new(...).save`. + def build_submission(attributes = {}) + attempt_attributes = attributes.slice(:creator) + submission_attributes = attributes.except(:creator) + + attempts.new(attempt_attributes).tap do |attempt| + attempt.build_submission(submission_attributes) + end.submission + end + def to_partial_path 'course/assessment/assessments/assessment' end diff --git a/app/models/course/assessment/answer.rb b/app/models/course/assessment/answer.rb index 884718e55a3..ef50cf1a8db 100644 --- a/app/models/course/assessment/answer.rb +++ b/app/models/course/assessment/answer.rb @@ -52,8 +52,27 @@ class Course::Assessment::Answer < ApplicationRecord validates :actable_id, uniqueness: { scope: [:actable_type], allow_nil: true, if: -> { actable_type? && actable_id_changed? } } - belongs_to :submission, inverse_of: :answers + # Name kept as `:submission` (Phase 1c renames it to `:attempt`); target repointed to Attempt, + # since `attempt_id` now identifies an Attempt row, not a Submission row (design spike §5.3). + belongs_to :submission, class_name: 'Course::Assessment::Attempt', inverse_of: :answers, + foreign_key: 'attempt_id' belongs_to :question, class_name: 'Course::Assessment::Question', inverse_of: nil + + # Coerce a `Course::Assessment::Submission` passed here into its `Attempt` (the association's + # real target post-repoint). `Course::Assessment::Question(Concern)#attempt`/`#not_answered`/etc. + # accept either a `Submission` or an `Attempt` as their "submission" argument (both are valid, + # 1:1-co-referential representations of the same attempt during Phase 1b), and thread it straight + # into `Answer::.new(submission: ...)` — without this coercion, passing the `Submission` + # half raises `ActiveRecord::AssociationTypeMismatch` (`belongs_to`'s writer strictly checks + # `record.is_a?(reflection.klass)`). Genuine bug the split exposed (reproduced by + # `spec/models/course/assessment_spec.rb`'s `.questions.attempt(submission)`/`#step`/ + # `#next_unanswered` examples, which call these helpers directly with a `Submission`) — fixed here + # rather than at each call site so every existing caller (production and spec) keeps working + # unchanged. + def submission=(value) + value = value.attempt if value.is_a?(Course::Assessment::Submission) + super + end belongs_to :grader, class_name: 'User', inverse_of: nil, optional: true has_one :auto_grading, class_name: 'Course::Assessment::Answer::AutoGrading', dependent: :destroy, inverse_of: :answer, autosave: true @@ -71,7 +90,7 @@ class Course::Assessment::Answer < ApplicationRecord scope :without_attempting_state, -> { where.not(workflow_state: :attempting) } scope :non_current_answers, -> { where(current_answer: false) } scope :current_answers, -> { where(current_answer: true) } - scope :belonging_to_submissions, ->(submissions) { where(submission_id: submissions) } + scope :belonging_to_submissions, ->(submissions) { where(attempt_id: submissions) } # Autogrades the answer. This saves the answer if there are pending changes. # @@ -126,7 +145,13 @@ def grade_inline? end def can_read_grade?(ability) - submission.published? || ability.can?(:grade, submission) || + # Was `ability.can?(:grade, submission)` — with `submission` now resolving to an Attempt + # instance, that check would silently always be false (CanCan matches on subject *class*, and + # the only registered rule is `can :grade, Course::Assessment::Submission, ...`). Route through + # the answer's own `can :grade, Course::Assessment::Answer, submission: { assessment: ... }` + # rule instead (assessment_ability.rb) — same permission, unaffected by what class the + # association returns. (Design spike §5.2's chosen fix, option (b).) + submission.published? || ability.can?(:grade, self) || (submission.assessment.autograded? && !submission.assessment.allow_partial_submission) || ( submission.assessment.autograded? && diff --git a/app/models/course/assessment/attempt.rb b/app/models/course/assessment/attempt.rb new file mode 100644 index 00000000000..e338cde0bc5 --- /dev/null +++ b/app/models/course/assessment/attempt.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true +class Course::Assessment::Attempt < ApplicationRecord + include Workflow + include Course::Assessment::Submission::WorkflowEventConcern + include Course::Assessment::Submission::AnswersConcern + + attr_accessor :has_unsubmitted_or_draft_answer + + FORCE_SUBMIT_DELAY = 5.minutes + + after_save :auto_grade_submission, if: :submitted? + after_save :retrieve_codaveri_feedback, if: :submitted? + after_create :create_force_submission_job, if: :attempting? + + workflow do + state :attempting do + # TODO: Change the if condition to use a symbol when the Workflow gem is upgraded to 1.3.0. + event :finalise, transitions_to: :published, + if: proc { |submission| submission.assessment.questions.empty? } + event :finalise, transitions_to: :submitted + end + state :submitted do + event :unsubmit, transitions_to: :attempting + event :mark, transitions_to: :graded + event :publish, transitions_to: :published + end + state :graded do + # Revert to submitted state but keep the grading info. + event :unmark, transitions_to: :submitted + event :publish, transitions_to: :published + end + state :published do + event :unsubmit, transitions_to: :attempting + # Resubmit programming questions for grading, used to regrade autograded + # submissions when assessment booleans are modified + event :resubmit_programming, transitions_to: :submitted + end + end + + validate :validate_unique_submission, on: :create + validate :validate_autograded_no_partial_answer, if: :submitted? + validates :submitted_at, presence: true, unless: :attempting? + validates :workflow_state, length: { maximum: 255 }, presence: true + validates :creator, presence: true + validates :updater, presence: true + validates :assessment, presence: true + + belongs_to :assessment, inverse_of: :attempts + + has_one :submission, class_name: 'Course::Assessment::Submission', inverse_of: :attempt, + dependent: :destroy + + has_many :submission_questions, class_name: 'Course::Assessment::SubmissionQuestion', + foreign_key: 'attempt_id', dependent: :destroy, inverse_of: :submission + + # @!attribute [r] answers + # The answers associated with this attempt. There can be more than one answer per question, + # this is because every answer is saved over time. Use the {.latest} scope of the answers if + # only the latest answer for each question is desired. + has_many :answers, class_name: 'Course::Assessment::Answer', dependent: :destroy, + foreign_key: 'attempt_id', inverse_of: :submission do + include Course::Assessment::Submission::AnswersConcern + end + has_many :multiple_response_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::MultipleResponse' + has_many :text_response_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::TextResponse' + has_many :programming_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::Programming' + has_many :scribing_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::Scribing' + has_many :forum_post_response_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::ForumPostResponse' + has_many :question_bundle_assignments, class_name: 'Course::Assessment::QuestionBundleAssignment', + inverse_of: :submission, dependent: :destroy + + has_many :logs, class_name: 'Course::Assessment::Submission::Log', + inverse_of: :submission, dependent: :destroy + + accepts_nested_attributes_for :answers + + # @!attribute [r] graded_at + # Returns the time the submission was graded. + # @return [Time] + calculated :graded_at, (lambda do + Course::Assessment::Answer.unscope(:order). + where('course_assessment_answers.attempt_id = course_assessment_attempts.id'). + select('max(course_assessment_answers.graded_at)') + end) + + # @!attribute [r] log_count + # Returns the total number of access logs for the submission. + calculated :log_count, (lambda do + Course::Assessment::Submission::Log.select("count('*')"). + where('course_assessment_submission_logs.submission_id = course_assessment_attempts.id') + end) + + # @!attribute [r] grade + # Returns the total grade of the submissions. + calculated :grade, (lambda do + Course::Assessment::Answer.unscope(:order). + where('course_assessment_answers.attempt_id = course_assessment_attempts.id + AND course_assessment_answers.current_answer = true'). + select('sum(course_assessment_answers.grade)') + end) + + # @!attribute [r] grader_ids + # Returns the grader_ids of a submission + calculated :grader_ids, (lambda do + Course::Assessment::Answer.unscope(:order). + where('course_assessment_answers.attempt_id = course_assessment_attempts.id + AND course_assessment_answers.current_answer = true'). + select('ARRAY_REMOVE(ARRAY_AGG(DISTINCT(course_assessment_answers.grader_id)), NULL)') + end) + + # @!method self.by_user(user) + # Finds all the attempts by the given user. + # @param [User] user The user to filter attempts by + scope :by_user, ->(user) { where(creator: user) } + + # @!method self.by_users(user) + # @param [Integer|Array] user_ids The user ids to filter attempts by + scope :by_users, ->(user_ids) { where(creator_id: user_ids) } + + # @!method self.from_category(category) + # Finds all the attempts in the given category. + # @param [Course::Assessment::Category] category The category to filter attempts by + scope :from_category, (lambda do |category| + where(assessment_id: category.assessments.select(:id)) + end) + + # @!method self.ordered_by_date + # Orders the attempts by date of creation. This defaults to reverse chronological order + # (newest attempt first). + scope :ordered_by_date, ->(direction = :desc) { order(created_at: direction) } + + # @!method self.ordered_by_submitted_date + # Orders the attempts by date of submission (newest submission first). + scope :ordered_by_submitted_date, -> { order(submitted_at: :desc) } + + # @!method self.confirmed + # Returns attempts which have been submitted (which may or may not be graded). + scope :confirmed, -> { where(workflow_state: [:submitted, :graded, :published]) } + + scope :pending_for_grading, (lambda do + where(workflow_state: [:submitted, :graded]). + joins(:assessment). + where('course_assessments.autograded = ?', false) + end) + + # Names the two populations so that picking one is a deliberate act (design spec §3.4). Any + # staff-facing count/list/export must go through `assessment.submissions`, never `.attempts` — + # `.attempts` includes previews (Phase 2), `.submissions` never does. + scope :course_submissions, -> { joins(:submission) } + scope :previews, -> { where.missing(:submission) } + + alias_method :finalise=, :finalise! + alias_method :mark=, :mark! + alias_method :unmark=, :unmark! + alias_method :publish=, :publish! + alias_method :unsubmit=, :unsubmit! + + # Creates an Auto Grading job for this attempt. This saves the attempt if there are pending + # changes. + # + # @param [Boolean] only_ungraded Whether grading should be done ONLY for + # ungraded_answers, or for all answers regardless of workflow state + # + # @return [Course::Assessment::Submission::AutoGradingJob] The job instance. + def auto_grade!(only_ungraded: false) + # Fully qualified: bare `AutoGradingJob` relied on this method being lexically nested inside + # `Course::Assessment::Submission` on the pre-split model — that lookup breaks once the method + # lives on `Attempt` instead. + Course::Assessment::Submission::AutoGradingJob.perform_later(self, only_ungraded) + end + + # Creates an Auto Feedback job for this attempt. + # + # @return [Course::Assessment::Submission::AutoFeedbackJob] The job instance. + def auto_feedback! + if assessment.course.component_enabled?(Course::CodaveriComponent) & + (assessment.course.codaveri_feedback_workflow != 'none') + Course::Assessment::Submission::AutoFeedbackJob.perform_later(self) + end + end + + def unsubmitting? + !!@unsubmitting + end + + def submission_view_blocked?(course_user) + !attempting? && !published? && assessment.block_student_viewing_after_submitted? && course_user&.student? + end + + def questions + assessment.randomization.nil? ? assessment.questions : assigned_questions + end + + # The assigned questions for this attempt, ordered by question_group and question_bundle_question + def assigned_questions + Course::Assessment::Question. + joins(question_bundles: [:question_group, question_bundle_assignments: :submission]). + merge(Course::Assessment::Attempt.where(id: self)). + merge(Course::Assessment::QuestionGroup.order(:weight)). + merge(Course::Assessment::QuestionBundleQuestion.order(:weight)). + extending(Course::Assessment::QuestionsConcern) + end + + def create_force_submission_job + return unless assessment.time_limit + + Course::Assessment::Submission::ForceSubmitTimedSubmissionJob. + set(wait_until: created_at + assessment.time_limit.minutes + FORCE_SUBMIT_DELAY). + perform_later(assessment, id, creator) + end + + # The answers with current_answer flag set to true, filtering out orphaned answers to questions + # which are no longer assigned to the attempt for randomized assessment. + # + # If there are multiple current_answers for a particular question, return the first one. + # This guards against a race condition creating multiple current_answers for a given + # question in load_or_create_answers. + def current_answers + if assessment.randomization.nil? + # Filtering by question ids is not needed for non-randomized assessment as it adds more query time. + filtered_answers = answers + else + # Can't do filtering in AR because `answer` may not be persisted, and AR is dumb. + question_ids = questions.pluck(:id) + filtered_answers = answers.select { |answer| answer.question_id.in? question_ids } + end + filtered_answers.select(&:current_answer?).group_by(&:question_id).map { |pair| pair[1].first } + end + + # @return [Array] Current answers to programming questions + def current_programming_answers + current_answers.select { |ans| ans.actable_type == Course::Assessment::Answer::Programming.name } + end + + # Loads basic information about the past answers of each question + def answer_history + answers. + without_attempting_state. + group_by(&:question_id). + map do |pair| + { + question_id: pair[0], + answers: pair[1].map do |answer| + { + id: answer.id, + createdAt: answer.created_at&.iso8601, + currentAnswer: answer.current_answer, + workflowState: answer.workflow_state + } + end + } + end + end + + # Returns the count of user messages for each question in the attempt. + def user_get_help_message_counts + Course::Assessment::SubmissionQuestion.find_by_sql(<<-SQL) + SELECT + q.id AS question_id, + COUNT(m.id) AS message_count + FROM course_assessment_submission_questions sq + INNER JOIN course_assessment_questions q ON sq.question_id = q.id + INNER JOIN course_assessment_question_programming pq + ON q.actable_id = pq.id AND q.actable_type = 'Course::Assessment::Question::Programming' + INNER JOIN course_assessment_attempts s ON sq.attempt_id = s.id + LEFT JOIN live_feedback_threads t ON t.submission_question_id = sq.id + LEFT JOIN live_feedback_messages m ON m.thread_id = t.id AND m.creator_id != #{User::SYSTEM_USER_ID} + WHERE + s.id = #{id} + AND pq.live_feedback_enabled = TRUE + GROUP BY q.id; + SQL + end + + # Returns all graded answers of the question in current attempt. + def evaluated_or_graded_answers(question) + answers.select { |a| a.question_id == question.id && (a.evaluated? || a.graded?) } + end + + private + + # Validate that the attempt creator does not have an existing attempt for this assessment. + # + # (Reclassified from Submission per the design spike §3.1: it enforces the DB's + # `unique_assessment_id_and_creator_id` index, which lives on `course_assessment_attempts` — a + # "one attempt per creator per assessment" rule, not a submission-specific one. The i18n key is a + # wire/UX string and is deliberately NOT renamed even though the validation now lives here.) + def validate_unique_submission + existing = Course::Assessment::Attempt.find_by(assessment_id: assessment.id, creator_id: creator.id) + return unless existing + + errors.clear + errors.add(:base, I18n.t('activerecord.errors.models.course/assessment/' \ + 'submission.submission_already_exists')) + end + + # Validate that there is no unsubmitted updated answer for autograded assessment that + # does not allow partial submission + def validate_autograded_no_partial_answer + return unless assessment.autograded && !assessment.allow_partial_submission + + errors.add(:base, :autograded_no_partial_answer) if has_unsubmitted_or_draft_answer + end + + # Queues the attempt for auto grading, after the attempt has changed to the submitted state. + def auto_grade_submission + return unless saved_change_to_workflow_state? + + execute_after_commit do + auto_grade!(only_ungraded: true) + end + end + + # Retrieve codaveri feedback only for current answers of codaveri programming question type + # for finalised attempts. + def retrieve_codaveri_feedback + return unless saved_change_to_workflow_state? + + execute_after_commit do + auto_feedback! + end + end +end diff --git a/app/models/course/assessment/marketplace/access_block.rb b/app/models/course/assessment/marketplace/access_block.rb new file mode 100644 index 00000000000..03841d618a6 --- /dev/null +++ b/app/models/course/assessment/marketplace/access_block.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::AccessBlock < ApplicationRecord + belongs_to :user, inverse_of: false + belongs_to :creator, class_name: 'User', inverse_of: false + + # Paired with the DB unique index on user_id: a user is blocked at most once. + validates :user_id, uniqueness: true + + # Whether +user+ has been individually disabled from the marketplace. Global (not tenant-scoped), + # mirroring AllowlistRule. + # @param [User] user + # @return [Boolean] + def self.blocked?(user) + return false unless user + + where(user_id: user.id).exists? + end + + # @return [Array] user ids of every block (for per-page status annotation). + def self.blocked_user_ids + pluck(:user_id) + end +end diff --git a/app/models/course/assessment/marketplace/access_list_query.rb b/app/models/course/assessment/marketplace/access_list_query.rb new file mode 100644 index 00000000000..d0480acc479 --- /dev/null +++ b/app/models/course/assessment/marketplace/access_list_query.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true +# Computes the marketplace access audit list: every user who is baseline-capable (manages/owns >=1 +# course, OR is an instructor/administrator in any instance) AND cleared by the allow-list, PLUS +# every individually blocked user regardless of rule match — an orphaned block must stay visible and +# clearable. Blocked users are INCLUDED and flagged. Not paginated server-side - the eligible set is +# bounded (managers + instance staff) and the frontend paginates/searches client-side, matching the +# rules page which also fetches its whole list at once. +class Course::Assessment::Marketplace::AccessListQuery + AllowlistRule = Course::Assessment::Marketplace::AllowlistRule + AccessBlock = Course::Assessment::Marketplace::AccessBlock + RuleMatchQuery = Course::Assessment::Marketplace::RuleMatchQuery + + # `allowed_by_rules` holds EVERY rule matching the user, not one precedence winner: the admin uses + # it to answer "if I delete this rule, who loses access?", and one reason answers that wrongly. + Row = Struct.new(:user, :course_count, :instance_role, :allowed_by_rules, :block_id, + :system_admin, keyword_init: true) do + def blocked? + block_id.present? + end + + def system_admin? + system_admin.present? + end + end + + # @return [Array] + def rows + @rows ||= annotate(listed_users.to_a) + end + + # @return [Hash] + def summary + { + total_with_access: rows.count { |row| !row.blocked? }, + total_blocked: rows.count(&:blocked?), + open_to_everyone: everyone? + } + end + + # Baseline-eligible users the allow-list currently clears. A block does not remove someone from + # this set — being blocked is a separate decision layered on top of being allowed. + # @return [Set] + def allowed_user_ids + # System admins hold a blanket `can :manage, :all`, so they have the marketplace whatever the + # rules say. This table is the audit source of truth, so they are always in it — omitting them + # would show an admin as having no access while they in fact bypass every gate. + @allowed_user_ids ||= (everyone? ? baseline_ids.to_set : rules_by_user.keys.to_set) | admin_ids + end + + private + + # Batches every per-user annotation into one query each, keyed by user id. + def annotate(users) + ids = users.map(&:id) + counts = managed_course_counts(ids) + staff = instance_staff_roles(ids) + block_ids = block_ids_by_user(ids) + + users.map do |user| + Row.new(user: user, course_count: counts[user.id] || 0, + instance_role: staff[user.id], allowed_by_rules: rules_by_user[user.id] || [], + block_id: block_ids[user.id], system_admin: admin_ids.include?(user.id)) + end + end + + def managed_course_counts(ids) + CourseUser.managers.where(user_id: ids).group(:user_id).count + end + + def block_ids_by_user(ids) + AccessBlock.where(user_id: ids).pluck(:user_id, :id).to_h + end + + def blocked_ids + @blocked_ids ||= AccessBlock.pluck(:user_id).to_set + end + + def listed_users + User.where(id: (allowed_user_ids | blocked_ids).to_a).includes(:emails).order(:name) + end + + def admin_ids + @admin_ids ||= User.administrator.pluck(:id).to_set + end + + def baseline_ids + @baseline_ids ||= baseline_scope.pluck(:id) + end + + # CourseUser is not tenant-scoped ("any course"); InstanceUser IS, so .unscoped for "any instance". + def baseline_scope + User.where(id: CourseUser.managers.select(:user_id)). + or(User.where(id: instance_staff_scope.select(:user_id))). + or(User.administrator) + end + + def instance_staff_scope + InstanceUser.unscoped.where(role: [:instructor, :administrator]) + end + + def everyone? + return @everyone if defined?(@everyone) + + @everyone = AllowlistRule.rule_type_everyone.exists? + end + + # An `everyone` rule is a page-level mode, not a per-row reason, so it contributes no scoped rules. + def scoped_rules + @scoped_rules ||= if everyone? + [] + else + # `user`/`instance` are read when labelling each row's reasons; preload them + # once here rather than once per rule per row. + AllowlistRule.where.not(rule_type: :everyone). + includes(:user, :instance).order(:id).to_a + end + end + + # user id => [AllowlistRule], every rule matching that user, in rules-table order. + def rules_by_user + @rules_by_user ||= scoped_rules.each_with_object({}) do |rule, map| + RuleMatchQuery.new(rule).user_ids_within(baseline_ids).each do |id| + (map[id] ||= []) << rule + end + end + end + + def instance_staff_roles(ids) + InstanceUser.unscoped.where(user_id: ids, role: [:instructor, :administrator]). + group(:user_id).maximum(:role). + transform_values { |role| InstanceUser.roles.key(role) } + end +end diff --git a/app/models/course/assessment/marketplace/allowlist_rule.rb b/app/models/course/assessment/marketplace/allowlist_rule.rb new file mode 100644 index 00000000000..b164934db3e --- /dev/null +++ b/app/models/course/assessment/marketplace/allowlist_rule.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::AllowlistRule < ApplicationRecord + enum :rule_type, + { user: 0, instance: 1, email_domain: 2, everyone: 3 }, + prefix: true + + belongs_to :user, class_name: 'User', inverse_of: false, optional: true + belongs_to :instance, inverse_of: false, optional: true + + # Transient: the admin form identifies a `user` rule by email (user IDs are not shown anywhere + # in the admin panel). Resolved to the owning user before validation; the stored row keeps the + # `user_id` FK, so the rule means "this person" even if they later change email. + attr_accessor :email + + before_validation :resolve_user_from_email, if: -> { rule_type_user? && email.present? } + + # When an email was supplied, `resolve_user_from_email` reports its own failure; skip the generic + # presence check in that path so the message is exactly "No user with that email." (not a pair). + before_validation :normalize_email_domain, if: :rule_type_email_domain? + + validates :user, presence: true, if: -> { rule_type_user? && email.blank? } + validates :instance, presence: true, if: :rule_type_instance? + validates :email_domain, presence: true, if: :rule_type_email_domain? + + # Identical rules grant nothing extra and make the rules table unreadable. Each is paired with a + # partial unique index. `scope: :rule_type` matters on the unresolved-email path: a user rule + # whose email matched nobody keeps user_id NULL, and Rails checks that as `user_id IS NULL`, + # which matches every instance and email-domain rule — reporting a bogus duplicate on top of the + # real "No user with that email." Scoping confines the check to rules of the same type. + validates :user_id, uniqueness: { scope: :rule_type, message: 'already has the same rule.' }, + if: :rule_type_user? + validates :instance_id, uniqueness: { scope: :rule_type, message: 'already has the same rule.' }, + if: :rule_type_instance? + validates :email_domain, uniqueness: { scope: :rule_type, message: 'already has the same rule.' }, + if: :rule_type_email_domain? + # "Everyone" is the widest rule; only one may exist. Paired with a partial unique index. + validates :rule_type, uniqueness: true, if: :rule_type_everyone? + + # Whether the marketplace is visible to +user+ per the allow-list. The rules table itself is + # global (not tenant-scoped), but `user.instance_users` IS tenant-scoped (acts_as_tenant), so + # in a request an `instance` rule matches only while browsing the allow-listed instance — + # it grants that instance's users access *there*, not membership-based access everywhere. + # An `everyone` rule grants every authenticated user (the `nil` guard still excludes anonymous). + # Baseline (manager/owner OR instructor/admin) is checked separately in the ability component. + # @param [User] user + # @return [Boolean] + def self.grants_access?(user) + return false unless user + + rule_type_everyone.exists? || + rule_type_user.where(user_id: user.id).exists? || + rule_type_instance.where(instance_id: user.instance_users.select(:instance_id)).exists? || + email_domain_matches?(user) + end + + # @param [User] user + # @return [Boolean] + def self.email_domain_matches?(user) + domains = user.emails.pluck(:email).filter_map { |e| e.split('@').last&.downcase }.uniq + return false if domains.empty? + + rule_type_email_domain.where('LOWER(email_domain) IN (?)', domains).exists? + end + + private + + # Matching is case-insensitive everywhere, so the stored form is normalized on write. This keeps + # the uniqueness index a plain column comparison instead of a functional LOWER() index. + def normalize_email_domain + self.email_domain = email_domain&.strip&.downcase + end + + def resolve_user_from_email + self.user = User.with_email_addresses([email.strip.downcase]).first + errors.add(:base, 'No user with that email.') if user.nil? + end +end diff --git a/app/models/course/assessment/marketplace/rule_match_query.rb b/app/models/course/assessment/marketplace/rule_match_query.rb new file mode 100644 index 00000000000..9722c373364 --- /dev/null +++ b/app/models/course/assessment/marketplace/rule_match_query.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +# Which baseline-eligible users does a single allow-list rule match? The rule may be unsaved, so the +# admin can preview a rule's effect before adding it. This is the one place that knows each rule +# type's matching semantics; AccessListQuery and the preview endpoint both go through it. +# +# Deliberately NOT the same as AllowlistRule.grants_access?, which reads the tenant-scoped +# `user.instance_users` at request time. Here `instance` rules match globally via +# InstanceUser.unscoped, because the audit list is not browsing any one instance. +class Course::Assessment::Marketplace::RuleMatchQuery + # @param [Course::Assessment::Marketplace::AllowlistRule] rule persisted or in-memory + def initialize(rule) + @rule = rule + end + + # @param [Array] candidate_user_ids the users to test + # @return [Set] the subset of +candidate_user_ids+ this rule matches + def user_ids_within(candidate_user_ids) + return Set.new if candidate_user_ids.empty? + + case @rule.rule_type + when 'everyone' then candidate_user_ids.to_set + when 'user' then matched_user(candidate_user_ids) + when 'instance' then matched_instance_members(candidate_user_ids) + when 'email_domain' then matched_domain_holders(candidate_user_ids) + else Set.new + end + end + + private + + def matched_user(ids) + (@rule.user_id.present? && ids.include?(@rule.user_id)) ? Set[@rule.user_id] : Set.new + end + + def matched_instance_members(ids) + return Set.new if @rule.instance_id.blank? + + InstanceUser.unscoped.where(user_id: ids, instance_id: @rule.instance_id). + pluck(:user_id).to_set + end + + def matched_domain_holders(ids) + domain = @rule.email_domain&.strip&.downcase + return Set.new if domain.blank? + + User::Email.where.not(confirmed_at: nil).where(user_id: ids). + where('LOWER(SPLIT_PART(email, ?, 2)) = ?', '@', domain). + pluck(:user_id).to_set + end +end diff --git a/app/models/course/assessment/marketplace/rule_preview_query.rb b/app/models/course/assessment/marketplace/rule_preview_query.rb new file mode 100644 index 00000000000..6e73cc3a40e --- /dev/null +++ b/app/models/course/assessment/marketplace/rule_preview_query.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true +# Answers "if I add this rule, who gets access?" for a rule that has not been saved, so the admin +# sees the effect before committing. Persists nothing. +class Course::Assessment::Marketplace::RulePreviewQuery + AccessBlock = Course::Assessment::Marketplace::AccessBlock + AccessListQuery = Course::Assessment::Marketplace::AccessListQuery + RuleMatchQuery = Course::Assessment::Marketplace::RuleMatchQuery + + Row = Struct.new(:user, :course_count, :instance_role, :already_has_access, :blocked, + keyword_init: true) + + # @param [Course::Assessment::Marketplace::AllowlistRule] rule an unsaved, valid rule + def initialize(rule) + @rule = rule + @access_list = AccessListQuery.new + end + + # @return [Array] + def rows + # Blocked first, then already-has-access, then the newly granted, each group still by name: a + # rule can match hundreds, and the people this rule does NOT newly reach are the only reason to + # read the list at all — buried on page 14 of an alphabetical list they may as well not be + # shown. A stable sort_by on the group rank alone, so the name order the database chose + # survives inside each group. + @rows ||= annotate(matched_users.to_a). + each_with_index.sort_by { |row, index| [group_rank(row), index] }.map(&:first) + end + + # @return [Hash] + def summary + { + matched_count: rows.size, + # A rule grants nothing to someone another rule already clears, and nothing at all to a + # blocked user — adding a rule does not unblock anyone. + new_count: rows.count { |row| !row.already_has_access && !row.blocked }, + blocked_count: rows.count(&:blocked), + open_to_everyone: @access_list.summary[:open_to_everyone] + } + end + + private + + # Same precedence as the row's status marker, which shows Blocked over already-has-access. + def group_rank(row) + return 0 if row.blocked + return 1 if row.already_has_access + + 2 + end + + def matched_ids + @matched_ids ||= RuleMatchQuery.new(@rule).user_ids_within(baseline_ids) + end + + # Only baseline-eligible staff can ever reach the marketplace, so a rule matching anyone else + # grants nothing and must not be counted. + def baseline_ids + @baseline_ids ||= User.where(id: CourseUser.managers.select(:user_id)). + or(User.where(id: instance_staff_scope.select(:user_id))).pluck(:id) + end + + def instance_staff_scope + InstanceUser.unscoped.where(role: [:instructor, :administrator]) + end + + def matched_users + User.where(id: matched_ids.to_a).includes(:emails).order(:name) + end + + def annotate(users) + ids = users.map(&:id) + counts = CourseUser.managers.where(user_id: ids).group(:user_id).count + staff = instance_staff_roles(ids) + allowed = @access_list.allowed_user_ids + blocked = AccessBlock.where(user_id: ids).pluck(:user_id).to_set + + users.map { |user| build_row(user, counts, staff, allowed, blocked) } + end + + def build_row(user, counts, staff, allowed, blocked) + Row.new(user: user, course_count: counts[user.id] || 0, instance_role: staff[user.id], + already_has_access: allowed.include?(user.id), blocked: blocked.include?(user.id)) + end + + def instance_staff_roles(ids) + InstanceUser.unscoped.where(user_id: ids, role: [:instructor, :administrator]). + group(:user_id).maximum(:role). + transform_values { |role| InstanceUser.roles.key(role) } + end +end diff --git a/app/models/course/assessment/question_bundle_assignment.rb b/app/models/course/assessment/question_bundle_assignment.rb index 50fd7cf23dd..5ebe6832f21 100644 --- a/app/models/course/assessment/question_bundle_assignment.rb +++ b/app/models/course/assessment/question_bundle_assignment.rb @@ -3,13 +3,23 @@ class Course::Assessment::QuestionBundleAssignment < ApplicationRecord belongs_to :user, inverse_of: :question_bundle_assignments belongs_to :assessment, class_name: 'Course::Assessment', foreign_key: :assessment_id, inverse_of: :question_bundle_assignments - belongs_to :submission, class_name: 'Course::Assessment::Submission', optional: true, + belongs_to :submission, class_name: 'Course::Assessment::Attempt', optional: true, foreign_key: :submission_id, inverse_of: :question_bundle_assignments belongs_to :question_bundle, class_name: 'Course::Assessment::QuestionBundle', foreign_key: :bundle_id, inverse_of: :question_bundle_assignments validate :submission_belongs_to_assessment_and_user + # Coerce a `Course::Assessment::Submission` passed here into its `Attempt` (the association's + # real target post-repoint) — mirrors the same coercion on `Course::Assessment::Answer`/ + # `Course::Assessment::SubmissionQuestion` (see `Answer#submission=` for the full rationale). + # `spec/controllers/course/assessment/submission/submissions_controller_spec.rb`'s + # `randomized_submission` fixture passes the `Submission` half directly. + def submission=(value) + value = value.attempt if value.is_a?(Course::Assessment::Submission) + super + end + private def submission_belongs_to_assessment_and_user diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index cb5df4c80f6..4524573a959 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -1,151 +1,157 @@ # frozen_string_literal: true class Course::Assessment::Submission < ApplicationRecord - include Workflow include Generic::CollectionConcern - include Course::Assessment::Submission::WorkflowEventConcern include Course::Assessment::Submission::TodoConcern include Course::Assessment::Submission::NotificationConcern - include Course::Assessment::Submission::AnswersConcern - - attr_accessor :has_unsubmitted_or_draft_answer + include Course::Assessment::Submission::CikgoTaskCompletionConcern + include Course::LessonPlan::PersonalizationConcern acts_as_experience_points_record - FORCE_SUBMIT_DELAY = 5.minutes - - after_save :auto_grade_submission, if: :submitted? - after_save :retrieve_codaveri_feedback, if: :submitted? - after_create :create_force_submission_job, if: :attempting? + # Submission's own (small) table has no `creator_id`/`updater_id` columns any more — that + # stamping now belongs entirely to Attempt, via its own independent userstamp setup. Without this, + # `activerecord-userstamp`'s globally-registered `before_validation :set_creator_attribute` (added + # to every `ApplicationRecord` subclass, not just ones with real userstamp columns) still fires + # here: `acts_as`'s `ReflectionsWithActsAs#_reflections` merges in the acting-as + # `experience_points_record`'s OWN `:creator` reflection as a fallback wherever Submission has no + # reflection of its own (Submission genuinely doesn't, post-split), so + # `reflect_on_association(:creator)` returns non-nil and the callback proceeds to call `creator` + # on self — resolving to the DELEGATED reader (Step 2c), not the EXP record's own column, and + # raising `ActiveSupport::DelegationError` whenever `attempt` is nil (e.g. a bare + # `Course::Assessment::Submission.new`). Reproduced by shoulda-matchers' `belong_to(:attempt)` + # matcher, which builds exactly that. Turning stamping off here is not a loss: nothing on this + # table was ever stamped by it. + self.record_userstamp = false + + belongs_to :attempt, class_name: 'Course::Assessment::Attempt', inverse_of: :submission, + autosave: true + # `publisher_id` is a real column on Submission's own (small) table (Task 1's extension table) — + # carried over unchanged from the pre-split model. Omitted from the design spike's own draft of + # this file, but `after_publish_hook`/`after_unsubmit_hook` below both assign `self.publisher =`, + # which needs this association to exist (mechanical fix — reproduced with a bare + # `NoMethodError: undefined method 'publisher=' for an instance of Course::Assessment::Submission` + # otherwise; not a design change). + belongs_to :publisher, class_name: 'User', inverse_of: nil, optional: true - workflow do - state :attempting do - # TODO: Change the if condition to use a symbol when the Workflow gem is upgraded to 1.3.0. - event :finalise, transitions_to: :published, - if: proc { |submission| submission.assessment.questions.empty? } - event :finalise, transitions_to: :submitted - end - state :submitted do - event :unsubmit, transitions_to: :attempting - event :mark, transitions_to: :graded - event :publish, transitions_to: :published - end - state :graded do - # Revert to submitted state but keep the grading info. - event :unmark, transitions_to: :submitted - event :publish, transitions_to: :published - end - state :published do - event :unsubmit, transitions_to: :attempting - # Resubmit programming questions for grading, used to regrade autograded - # submissions when assessment booleans are modified - event :resubmit_programming, transitions_to: :submitted - end - end + # Attempt-level surface, kept working through delegation (design spec §3.1). Grouped by why each + # member is here — every one has a real external call site (grepped, not guessed; see the + # "Corrections" section above for the ~14 members the design spike's own list omitted: creator/ + # creator_id/updater/updater_id, grade/graded_at/log_count/grader_ids, and the four `=` writers). + # + # `create_new_answers` (from Course::Assessment::Submission::AnswersConcern, mixed into Attempt) + # is likewise a real, mechanical omission from the design spike's own delegate list — it has 5 + # real call sites (`submission.create_new_answers`/`@submission.create_new_answers` in + # force_submitting_job.rb, materials_controller.rb, submissions_controller.rb, + # update_service.rb, and the koditsu concern) and would otherwise raise `NoMethodError` on every + # one of them. + # `saved_change_to_workflow_state?`/`workflow_state_before_last_save` are likewise real, + # mechanical omissions: `TodoConcern`'s, `CikgoTaskCompletionConcern`'s, and + # `NotificationConcern`'s own `after_save` callbacks (all three still included on Submission, + # Step 2c) read one or the other, and `submissions_controller.rb`'s `signals` guard reads + # `@submission.saved_change_to_workflow_state?` too. `workflow_state` is not a real column on + # Submission's own (small) table any more, so Rails does not auto-generate either dirty-tracking + # method here — without delegating them, every one of those raises `NoMethodError` the first time + # a `Submission#save!` fires (reproduced via `create(:submission, :attempting, ...)`/ + # `create(:submission, :submitted, ...)`, which raise from `TodoConcern`'s and + # `NotificationConcern`'s `after_save` respectively). Delegating still reads the right thing: by + # the time `finalise!`/`publish!`/etc. call `save!` on the Submission itself, `attempt`'s own + # dirty state from the `attempt.finalise!`/etc. call immediately above it is still fresh (attempt + # has not been saved again since). + # `allow_nil: true`: a `Submission` with no `attempt` only ever exists transiently (a bare `.new`, + # e.g. shoulda-matchers' `belong_to(:attempt)` matcher building one to verify the association is + # required — reproduced by that exact matcher raising `ActiveSupport::DelegationError` without + # this). The `belongs_to :attempt` above already independently enforces "every real Submission has + # an attempt" via its own (required-by-default) presence validation; delegate reads/writes don't + # need to ALSO raise a hard error for the same absent-attempt case — returning/no-op'ing nil here + # is strictly more graceful, and no production call site ever holds a persisted, valid Submission + # with a nil `attempt` regardless of this setting. + # `creator=`/`updater=` (writers, not just the readers the design spike's own list named): + # without them, `submission.creator = x`/`submission.updater = x` fall through to `acts_as`'s own + # `method_missing` and silently write `experience_points_record.creator`/`.updater` instead (a + # REAL column there too, so no error — just the wrong row). Reproduced by + # `spec/models/course/assessment/submission_spec.rb`'s `#send_submit_notification` example, which + # calls `submission1.updater = user1` directly and then reads `creator == updater` (both + # delegated readers, i.e. `attempt.creator`/`attempt.updater`) — without the writer delegated too, + # `attempt.updater` never actually changes and the equality check silently fails. + delegate :assessment, + :workflow_state, :workflow_state=, + :submitted_at, :submitted_at=, + :published_at, :published_at=, + :creator, :creator=, :creator_id, :updater, :updater=, :updater_id, + :attempting?, :submitted?, :graded?, :published?, :unsubmitting?, + :saved_change_to_workflow_state?, :workflow_state_before_last_save, + :answers, :answers=, :submission_questions, :questions, :assigned_questions, + :current_answers, :current_programming_answers, :answer_history, + :evaluated_or_graded_answers, :user_get_help_message_counts, :create_new_answers, + :mark!, :unmark!, + :auto_grade!, :auto_feedback!, + :submission_view_blocked?, + :graded_at, :log_count, :grader_ids, + :logs, + to: :attempt, allow_nil: true Course::Assessment::Answer.after_save do |answer| Course::Assessment::Submission.on_dependent_status_change(answer) end - validate :validate_consistent_user, :validate_unique_submission, on: :create + # `last_graded_time` used to be satisfied for every new row by a DB column default + # (`db/migrate/20211024140630_..., default: Time.now`, baked into `course_assessment_attempts` as + # a frozen historical timestamp literal — a well-known Rails `add_column ... default: Time.now` + # gotcha, not a deliberately "always this exact date" business rule). Task 1's extension table + # (`course_assessment_submissions`) deliberately does NOT carry that stale default forward (see + # its migration and the backfill spec's explicit `last_graded_time: nil` case) — so `presence: + # true` below would otherwise fail on every newly-built Submission. Replicate the same + # "always populated on creation" guarantee at the Ruby level instead of adding a migration (Task + # 2 has none). Mechanical fix — reproduced via `FactoryBot.build(:submission, ...).valid?` → + # `last_graded_time can't be blank`; not a validation-intent change. + before_validation :ensure_last_graded_time + + validate :validate_consistent_user, on: :create validate :validate_awarded_attributes, if: :published? - validate :validate_autograded_no_partial_answer, if: :submitted? - validates :submitted_at, presence: true, unless: :attempting? - validates :workflow_state, length: { maximum: 255 }, presence: true - validates :creator, presence: true - validates :updater, presence: true - validates :assessment, presence: true validates :last_graded_time, presence: true - belongs_to :assessment, inverse_of: :submissions - - has_many :submission_questions, class_name: 'Course::Assessment::SubmissionQuestion', - dependent: :destroy, inverse_of: :submission - - # @!attribute [r] answers - # The answers associated with this submission. There can be more than one answer per submission, - # this is because every answer is saved over time. Use the {.latest} scope of the answers if - # only the latest answer for each question is desired. - has_many :answers, class_name: 'Course::Assessment::Answer', dependent: :destroy, - inverse_of: :submission do - include Course::Assessment::Submission::AnswersConcern - end - has_many :multiple_response_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::MultipleResponse' - has_many :text_response_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::TextResponse' - has_many :programming_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::Programming' - has_many :scribing_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::Scribing' - has_many :forum_post_response_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::ForumPostResponse' - has_many :question_bundle_assignments, class_name: 'Course::Assessment::QuestionBundleAssignment', - inverse_of: :submission, dependent: :destroy - - belongs_to :publisher, class_name: 'User', inverse_of: nil, optional: true - - has_many :logs, class_name: 'Course::Assessment::Submission::Log', - inverse_of: :submission, dependent: :destroy - - accepts_nested_attributes_for :answers - - # @!attribute [r] graded_at - # Returns the time the submission was graded. - # @return [Time] - calculated :graded_at, (lambda do - Course::Assessment::Answer.unscope(:order). - where('course_assessment_answers.submission_id = course_assessment_submissions.id'). - select('max(course_assessment_answers.graded_at)') - end) - - # @!attribute [r] log_count - # Returns the total number of access logs for the submission. - calculated :log_count, (lambda do - Course::Assessment::Submission::Log.select("count('*')"). - where('course_assessment_submission_logs.submission_id = course_assessment_submissions.id') - end) - - # @!attribute [r] grade - # Returns the total grade of the submissions. - calculated :grade, (lambda do - Course::Assessment::Answer.unscope(:order). - where('course_assessment_answers.submission_id = course_assessment_submissions.id - AND course_assessment_answers.current_answer = true'). - select('sum(course_assessment_answers.grade)') - end) - - # @!attribute [r] grader_ids - # Returns the grader_ids of a submission - calculated :grader_ids, (lambda do - Course::Assessment::Answer.unscope(:order). - where('course_assessment_answers.submission_id = course_assessment_submissions.id - AND course_assessment_answers.current_answer = true'). - select('ARRAY_REMOVE(ARRAY_AGG(DISTINCT(course_assessment_answers.grader_id)), NULL)') - end) + # `belongs_to :attempt, autosave: true` (above) makes Rails validate the associated Attempt as + # part of validating Submission, and — when it's invalid — copy its messages onto + # `errors[:attempt]` (`full_messages` prefixes them with "Attempt ", e.g. "Attempt Looks like you + # have already created a submission..."). Pre-split, `validate_unique_submission` lived directly + # on this same row and added straight to `errors[:base]` (unprefixed) — exactly what + # `submissions_controller.rb#create`'s `errors.full_messages.to_sentence` rescue path renders to + # the student. Re-home the cascaded messages under `:base` so that UX is unchanged; this only + # affects presentation — the underlying save is rejected either way. (Genuine bug the split + # exposed: reproduced by `spec/models/course/assessment/submission_spec.rb`'s "a submission for + # the user and assessment already exists" example, which asserts on `errors.messages[:base]`.) + validate :repoint_attempt_errors_to_base, on: :create # @!method self.by_user(user) # Finds all the submissions by the given user. - # @param [User] user The user to filter submissions by - scope :by_user, ->(user) { where(creator: user) } + # + # Subquery, not `joins(:attempt).merge(...)`: `@assessment.submissions` is itself a `has_many + # :through` (Step 2e) that ALREADY joins `course_assessment_attempts` implicitly to get from + # `assessment` to `submission`. Adding another explicit `joins(:attempt)` on top (the shape every + # other scope below still uses) gives Postgres TWO separate joins to the same table under two + # different aliases — harmless for a plain `.where`/`.count`, but `.pluck(:creator_id)` (a column + # that exists on `course_assessment_attempts` but nowhere on Submission's own table, so Rails + # can't qualify it the way it does `:id`) becomes `PG::AmbiguousColumn`, because both joined + # copies of the table expose it. Reproduced by + # `submissions_controller.rb#user_ids_without_submission`'s `existing_submissions.pluck(:creator_id)` + # (called via `@assessment.submissions.by_users(...)`, exercised by + # `spec/controllers/.../submissions_controller_spec.rb`'s `force_submit_all` examples). The + # subquery form never adds a second join at all, so it's safe called either way (directly on + # `Course::Assessment::Submission` or through `@assessment.submissions`). + scope :by_user, ->(user) { where(attempt_id: Course::Assessment::Attempt.by_user(user).select(:id)) } # @!method self.by_users(user) - # @param [Integer|Array] user_ids The user ids to filter submissions by - scope :by_users, ->(user_ids) { where(creator_id: user_ids) } + scope :by_users, (lambda do |user_ids| + where(attempt_id: Course::Assessment::Attempt.by_users(user_ids).select(:id)) + end) # @!method self.from_category(category) - # Finds all the submissions in the given category. - # @param [Course::Assessment::Category] category The category to filter submissions by scope :from_category, (lambda do |category| - where(assessment_id: category.assessments.select(:id)) + joins(:attempt).merge(Course::Assessment::Attempt.from_category(category)) end) scope :from_course, (lambda do |course| - joins(assessment: { tab: :category }). + joins(attempt: { assessment: { tab: :category } }). where('course_assessment_categories.course_id = ?', course.id) end) @@ -155,23 +161,37 @@ class Course::Assessment::Submission < ApplicationRecord end) # @!method self.ordered_by_date - # Orders the submissions by date of creation. This defaults to reverse chronological order - # (newest submission first). + # Orders the submissions by date of creation (newest first). Left as a plain, undelegated scope + # on Submission's own `created_at` rather than wrapped through `:attempt` — every Submission row + # is created in the same request as its Attempt in Phase 1b (no code path creates one without + # the other; previews don't exist yet), so ordering by either column is behaviourally + # indistinguishable today. Flagged (per the design spike §3.1c) as worth a second look once + # Phase 2 introduces attempts whose submission is created later than the attempt itself. scope :ordered_by_date, ->(direction = :desc) { order(created_at: direction) } - # @!method self.ordered_by_submitted date - # Orders the submissions by date of submission (newest submission first). - scope :ordered_by_submitted_date, -> { order(submitted_at: :desc) } + # @!method self.ordered_by_submitted_date + scope :ordered_by_submitted_date, (lambda do + joins(:attempt).merge(Course::Assessment::Attempt.ordered_by_submitted_date) + end) # @!method self.confirmed - # Returns submissions which have been submitted (which may or may not be graded). - scope :confirmed, -> { where(workflow_state: [:submitted, :graded, :published]) } - - scope :pending_for_grading, (lambda do - where(workflow_state: [:submitted, :graded]). - joins(:assessment). - where('course_assessments.autograded = ?', false) - end) + scope :confirmed, -> { joins(:attempt).merge(Course::Assessment::Attempt.confirmed) } + + scope :pending_for_grading, -> { joins(:attempt).merge(Course::Assessment::Attempt.pending_for_grading) } + + # `with__state`/`without__state` are auto-generated, per workflow state, by the + # `workflow-activerecord` gem's own `Scopes` module — for whichever class actually calls + # `workflow do ... end` (Step 2a: that's `Attempt` now, not `Submission`). Wrapped here the same + # way as `confirmed`/`pending_for_grading` above, for the four real call sites this task's own + # diff touches or the acceptance gate exercises (`submissions_controller.rb`, + # `force_submitting_job.rb`, `course/condition/assessment.rb`, + # `skills_mastery_preload_service.rb`, `spec/helpers/course/assessment/submissions_helper_spec.rb`) + # — a real, mechanical omission from the design spike's own list, not covered by the generic + # delegate list because these are class-level scopes, not instance methods. + scope :with_attempting_state, -> { joins(:attempt).merge(Course::Assessment::Attempt.with_attempting_state) } + scope :with_submitted_state, -> { joins(:attempt).merge(Course::Assessment::Attempt.with_submitted_state) } + scope :with_graded_state, -> { joins(:attempt).merge(Course::Assessment::Attempt.with_graded_state) } + scope :with_published_state, -> { joins(:attempt).merge(Course::Assessment::Attempt.with_published_state) } SUBMISSIONS_PER_PAGE = 25 # Filter submissions by category_id, assessment_id, group_id and/or user_id (creator) @@ -180,189 +200,215 @@ class Course::Assessment::Submission < ApplicationRecord if filter_params[:category_id].present? result = result.from_category(Course::Assessment::Category.find(filter_params[:category_id])) end - result = result.where(assessment_id: filter_params[:assessment_id]) if filter_params[:assessment_id].present? + if filter_params[:assessment_id].present? + result = result.joins(:attempt). + where(course_assessment_attempts: { assessment_id: filter_params[:assessment_id] }) + end result = result.from_group(filter_params[:group_id]) if filter_params[:group_id].present? result = result.by_user(filter_params[:user_id]) if filter_params[:user_id].present? result end) - alias_method :finalise=, :finalise! - alias_method :mark=, :mark! - alias_method :unmark=, :unmark! - alias_method :publish=, :publish! - alias_method :unsubmit=, :unsubmit! - - # Creates an Auto Grading job for this submission. This saves the submission if there are pending - # changes. - # - # @param [Boolean] only_ungraded Whether grading should be done ONLY for - # ungraded_answers, or for all answers regardless of workflow state - # - # @return [Course::Assessment::Submission::AutoGradingJob] The job instance. - def auto_grade!(only_ungraded: false) - AutoGradingJob.perform_later(self, only_ungraded) + # Return the points awarded for the submission. + # If submission is 'graded', return the draft value, otherwise, the return the points awarded. + def current_points_awarded + published? ? points_awarded : draft_points_awarded end - # Creates an Auto Feedback job for this submission. - # - # @return [Course::Assessment::Submission::AutoFeedbackJob] The job instance. - def auto_feedback! - if assessment.course.component_enabled?(Course::CodaveriComponent) & - (assessment.course.codaveri_feedback_workflow != 'none') - AutoFeedbackJob.perform_later(self) - end + # `assessment_id` is deliberately NOT in the plain `delegate` list above: `.grade_summary` + # (below) is a raw-SQL `find_by_sql` on this class that itself SELECTs a column literally named + # `assessment_id` (`cas.assessment_id`, unrelated to the small table's own columns — `find_by_sql` + # rows carry whatever the query returns as ad hoc, non-schema attributes). A plain `delegate` + # would shadow that with `attempt.assessment_id`, which raises `ActiveModel::MissingAttributeError` + # on these rows (they never SELECT `attempt_id`, so `attempt` itself can't be read). Prefer the + # raw-SQL-loaded attribute when present (i.e. this is a `grade_summary` pseudo-row); otherwise + # delegate normally, exactly like every other member of the list above. Genuine bug the split + # exposed — reproduced by `spec/models/course/assessment/submission_spec.rb`'s + # `.grade_summary` examples reading `results.first.assessment_id`. + def assessment_id + has_attribute?(:assessment_id) ? read_attribute(:assessment_id) : attempt&.assessment_id end - def unsubmitting? - !!@unsubmitting + # Same reasoning as `assessment_id` above: `.grade_summary`'s raw SQL also SELECTs + # `SUM(caa.grade) AS grade`, conflicting with the delegated `calculated :grade` on Attempt. + def grade + has_attribute?(:grade) ? read_attribute(:grade) : attempt&.grade end - def submission_view_blocked?(course_user) - !attempting? && !published? && assessment.block_student_viewing_after_submitted? && course_user&.student? + def self.on_dependent_status_change(answer) + return unless answer.saved_changes.key?(:grade) + + # `answer.submission` resolves to an Attempt post-Phase-1b FK repoint (the association name + # stays `:submission`, only `class_name:` changed). `last_graded_time` is a course-coupled + # column that only exists on the real Submission, reached via the Attempt's `has_one + # :submission`. (Correction found while reading the real code — not covered by the design + # spike, which only discussed the two `can_read_grade?`-adjacent call sites.) + answer.submission.submission&.last_graded_time = Time.now end - def questions - assessment.randomization.nil? ? assessment.questions : assigned_questions + # Returns an array of submission rows for the given students and assessments. + # Each row has: student_id (creator_id), assessment_id, grade (float). + # Only graded/published submissions are included. + # + # Unchanged from Phase 1a: already reads `course_assessment_attempts` directly (a raw-SQL class + # method, not touching Submission's own table), so it needs no changes for this split. + def self.grade_summary(student_ids:, assessment_ids:) + return [] if student_ids.empty? || assessment_ids.empty? + + find_by_sql( + sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids]) + SELECT cas.creator_id AS student_id, cas.assessment_id, + cas.id AS submission_id, SUM(caa.grade) AS grade + FROM course_assessment_attempts cas + JOIN course_assessment_answers caa ON caa.attempt_id = cas.id + WHERE cas.creator_id IN (?) + AND cas.assessment_id IN (?) + AND cas.workflow_state IN ('graded', 'published') + AND caa.current_answer = TRUE + GROUP BY cas.creator_id, cas.assessment_id, cas.id + SQL + ) end - # The assigned questions for this submission, ordered by question_group and question_bundle_question - def assigned_questions - Course::Assessment::Question. - joins(question_bundles: [:question_group, question_bundle_assignments: :submission]). - merge(Course::Assessment::Submission.where(id: self)). - merge(Course::Assessment::QuestionGroup.order(:weight)). - merge(Course::Assessment::QuestionBundleQuestion.order(:weight)). - extending(Course::Assessment::QuestionsConcern) + def finalise!(*args) + ActiveRecord::Base.transaction do + attempt.finalise!(*args) + save! + end end - def create_force_submission_job - return unless assessment.time_limit + def publish!(*args) + ActiveRecord::Base.transaction do + attempt.publish!(*args) + save! + end + end - Course::Assessment::Submission::ForceSubmitTimedSubmissionJob. - set(wait_until: created_at + assessment.time_limit.minutes + FORCE_SUBMIT_DELAY). - perform_later(assessment, id, creator) + def unsubmit!(*args) + ActiveRecord::Base.transaction do + attempt.unsubmit!(*args) + save! + end end - # The answers with current_answer flag set to true, filtering out orphaned answers to questions which are no longer - # assigned to the submission for randomized assessment. - # - # If there are multiple current_answers for a particular question, return the first one. - # This guards against a race condition creating multiple current_answers for a given - # question in load_or_create_answers. - def current_answers - if assessment.randomization.nil? - # Filtering by question ids is not needed for non-randomized assessment as it adds more query time. - filtered_answers = answers - else - # Can't do filtering in AR because `answer` may not be persisted, and AR is dumb. - question_ids = questions.pluck(:id) - filtered_answers = answers.select { |answer| answer.question_id.in? question_ids } + def resubmit_programming!(*args) + ActiveRecord::Base.transaction do + attempt.resubmit_programming!(*args) + save! end - filtered_answers.select(&:current_answer?).group_by(&:question_id).map { |pair| pair[1].first } end - # @return [Array] Current answers to programming questions - def current_programming_answers - current_answers.select { |ans| ans.actable_type == Course::Assessment::Answer::Programming.name } + # Placed after the method definitions above (rather than alongside the `delegate` call, as in the + # design spike's own draft): `alias_method` resolves its target at the point it is *evaluated*, and + # `finalise!`/`publish!`/`unsubmit!` are plain methods defined on this class further down in the + # same body (not delegated, unlike `mark!`/`unmark!` — see the two-table transaction wrapping + # above), so aliasing them any earlier raises `NameError: undefined method` at class-load time. + # (Mechanical fix; no change to which methods get aliased or what they do.) + alias_method :finalise=, :finalise! + alias_method :mark=, :mark! + alias_method :unmark=, :unmark! + alias_method :publish=, :publish! + alias_method :unsubmit=, :unsubmit! + + # --- Hook bodies called by Course::Assessment::Attempt's WorkflowEventConcern, at the point the + # old, single-table WorkflowEventConcern used to inline this EXP-specific / course-coupled work. + # Public (not private/protected) because they are invoked with an explicit receiver + # (`submission&.after_x_hook`) from a *different* object (the Attempt instance) — `protected` + # would not permit that call, since Attempt and Submission are not related by inheritance. + + def after_finalise_hook + assign_zero_experience_points + update_personalized_timeline_for_user(course_user) end - # Loads basic information about the past answers of each question - def answer_history - answers. - without_attempting_state. - group_by(&:question_id). - map do |pair| - { - question_id: pair[0], - answers: pair[1].map do |answer| - { - id: answer.id, - createdAt: answer.created_at&.iso8601, - currentAnswer: answer.current_answer, - workflowState: answer.workflow_state - } - end - } - end + # `send_email` is threaded from `Attempt#publish`'s own `send_email` argument (design spike §3.2's + # flagged decision: the argument has nowhere else to travel once the EXP-side email logic moves + # into a hook call). Also folds in the old `assign_experience_points` before_validation's only + # real effect (`points_awarded ||= draft_points_awarded` on a graded/submitted → published + # transition) — that guard's condition is only ever true during a `publish` event, so it is + # behaviourally identical to running it here. + def after_publish_hook(send_email) + self.points_awarded ||= draft_points_awarded + self.draft_points_awarded = nil + self.publisher = User.stamper || User.system + self.awarder = User.stamper || User.system + self.awarded_at = Time.zone.now + publish_delayed_posts + send_email_after_publishing(send_email) end - # Returns the count of user messages for each question in the submission. - def user_get_help_message_counts - Course::Assessment::SubmissionQuestion.find_by_sql(<<-SQL) - SELECT - q.id AS question_id, - COUNT(m.id) AS message_count - FROM course_assessment_submission_questions sq - INNER JOIN course_assessment_questions q ON sq.question_id = q.id - INNER JOIN course_assessment_question_programming pq - ON q.actable_id = pq.id AND q.actable_type = 'Course::Assessment::Question::Programming' - INNER JOIN course_assessment_submissions s ON sq.submission_id = s.id - LEFT JOIN live_feedback_threads t ON t.submission_question_id = sq.id - LEFT JOIN live_feedback_messages m ON m.thread_id = t.id AND m.creator_id != #{User::SYSTEM_USER_ID} - WHERE - s.id = #{id} - AND pq.live_feedback_enabled = TRUE - GROUP BY q.id; - SQL + def after_unsubmit_hook + self.points_awarded = nil + self.draft_points_awarded = nil + self.awarded_at = nil + self.awarder = nil + self.publisher = nil end - # Returns all graded answers of the question in current submission. - def evaluated_or_graded_answers(question) - answers.select { |a| a.question_id == question.id && (a.evaluated? || a.graded?) } + # Residual judgment call (flagged, per the design spike §5/§3.2): `assign_zero_experience_points` + # is called from both `finalise` and `resubmit_programming` in the old code, but only `finalise` + # has an obvious single hook seam (`after_finalise_hook`). `resubmit_programming`'s call happens + # after its own point-clearing (which reuses `after_unsubmit_hook`) and after re-finalising + # current answers, so it gets its own third hook rather than overloading either existing one. + def after_resubmit_programming_hook + assign_zero_experience_points end - # Return the points awarded for the submission. - # If submission is 'graded', return the draft value, otherwise, the return the points awarded. - def current_points_awarded - published? ? points_awarded : draft_points_awarded + private + + # finalise event (from attempting) - Assign 0 points as there are no questions. + def assign_zero_experience_points + return unless assessment.questions.empty? + + self.points_awarded = 0 + self.awarded_at = Time.zone.now + self.awarder = User.stamper || User.system end - def self.on_dependent_status_change(answer) - return unless answer.saved_changes.key?(:grade) + def send_email_after_publishing(send_email) + return unless send_email && persisted? && !assessment.autograded? && + submission_graded_email_enabled? && + submission_graded_email_subscribed? - answer.submission.last_graded_time = Time.now + execute_after_commit { Course::Mailer.submission_graded_email(self).deliver_later } end - # Returns an array of submission rows for the given students and assessments. - # Each row has: student_id (creator_id), assessment_id, grade (float). - # Only graded/published submissions are included. - def self.grade_summary(student_ids:, assessment_ids:) - return [] if student_ids.empty? || assessment_ids.empty? + def submission_graded_email_enabled? + is_enabled_as_phantom = course_user.phantom? && email_enabled.phantom + is_enabled_as_regular = !course_user.phantom? && email_enabled.regular + is_enabled_as_phantom || is_enabled_as_regular + end - find_by_sql( - sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids]) - SELECT cas.creator_id AS student_id, cas.assessment_id, - cas.id AS submission_id, SUM(caa.grade) AS grade - FROM course_assessment_submissions cas - JOIN course_assessment_answers caa ON caa.submission_id = cas.id - WHERE cas.creator_id IN (?) - AND cas.assessment_id IN (?) - AND cas.workflow_state IN ('graded', 'published') - AND caa.current_answer = TRUE - GROUP BY cas.creator_id, cas.assessment_id, cas.id - SQL - ) + def submission_graded_email_subscribed? + !course_user.email_unsubscriptions.where(course_settings_email_id: email_enabled.id).exists? end - private + def email_enabled + assessment.course.email_enabled(:assessments, :grades_released, assessment.tab.category.id) + end - # Queues the submission for auto grading, after the submission has changed to the submitted state. - def auto_grade_submission - return unless saved_change_to_workflow_state? + def publish_delayed_posts + return if assessment.autograded? - execute_after_commit do - # Grade only ungraded answers regardless of state as we dont want to regrade graded/evaluated answers. - auto_grade!(only_ungraded: true) - end + # Publish delayed comments for each question of a submission + submission_question_topics = submission_questions.flat_map(&:discussion_topic) + update_delayed_topics_and_posts(submission_question_topics) + + # Publish delayed annotations for each programming question of a submission + programming_answers = answers.where('actable_type = ?', Course::Assessment::Answer::Programming.name) + annotation_topics = programming_answers.flat_map(&:specific). + flat_map(&:files).flat_map(&:annotations).map(&:discussion_topic) + update_delayed_topics_and_posts(annotation_topics) end - # Retrieve codaveri feedback only for current answers of codaveri programming question type - # for finalised submissions. - def retrieve_codaveri_feedback - return unless saved_change_to_workflow_state? + # Update read mark for topic and delayed for posts + def update_delayed_topics_and_posts(topics) + topics.each do |topic| + delayed_posts = topic.posts.only_delayed_posts + next if delayed_posts.empty? - execute_after_commit do - auto_feedback! + topic.read_marks.where('reader_id = ?', creator.id)&.destroy_all # Remove 'mark as read' (if any) + delayed_posts.update_all(workflow_state: 'published') end end @@ -374,17 +420,6 @@ def validate_consistent_user errors.add(:experience_points_record, :inconsistent_user) end - # Validate that the submission creator does not have an existing submission for this assessment. - def validate_unique_submission - existing = Course::Assessment::Submission.find_by(assessment_id: assessment.id, - creator_id: creator.id) - return unless existing - - errors.clear - errors.add(:base, I18n.t('activerecord.errors.models.course/assessment/' \ - 'submission.submission_already_exists')) - end - # Validate that the awarder and awarded_at is present for published submissions def validate_awarded_attributes return if awarded_at && awarder @@ -392,11 +427,19 @@ def validate_awarded_attributes errors.add(:experience_points_record, :absent_award_attributes) end - # Validate that there is no unsubmitted updated answer for autograded assessment that - # does not allow partial submission - def validate_autograded_no_partial_answer - return unless assessment.autograded && !assessment.allow_partial_submission + def ensure_last_graded_time + self.last_graded_time ||= Time.zone.now + end + + def repoint_attempt_errors_to_base + # Rails' autosave-association-validation error key for a nested `:base` error is the dotted + # `:'attempt.base'` (association name + attribute), not a bare `:attempt` (confirmed by reading + # `sub.errors.to_hash` directly — not guessed). + key = :'attempt.base' + return if errors[key].blank? - errors.add(:base, :autograded_no_partial_answer) if has_unsubmitted_or_draft_answer + messages = errors[key] + errors.delete(key) + messages.each { |message| errors.add(:base, message) } end end diff --git a/app/models/course/assessment/submission/log.rb b/app/models/course/assessment/submission/log.rb index 195cde38e1b..da2bbf30bca 100644 --- a/app/models/course/assessment/submission/log.rb +++ b/app/models/course/assessment/submission/log.rb @@ -1,8 +1,15 @@ # frozen_string_literal: true class Course::Assessment::Submission::Log < ApplicationRecord + # Rails derives a nested model's table name from its parent's `table_name` + # (`Submission.table_name.singularize + "_logs"`). Since Submission now maps to + # `course_assessment_attempts`, that derivation yields the nonexistent + # `course_assessment_attempt_logs`. Pin the real table name explicitly. (Phase 1a workaround; + # revisit in 1b when Submission stops owning the attempts table.) + self.table_name = 'course_assessment_submission_logs' + validates :submission, presence: true - belongs_to :submission, class_name: 'Course::Assessment::Submission', + belongs_to :submission, class_name: 'Course::Assessment::Attempt', inverse_of: :logs scope :ordered_by_date, ->(direction = :desc) { order(created_at: direction) } diff --git a/app/models/course/assessment/submission_question.rb b/app/models/course/assessment/submission_question.rb index 130c97ad02d..989f384fb0e 100644 --- a/app/models/course/assessment/submission_question.rb +++ b/app/models/course/assessment/submission_question.rb @@ -5,14 +5,28 @@ class Course::Assessment::SubmissionQuestion < ApplicationRecord validates :submission, presence: true validates :question, presence: true - validates :submission_id, uniqueness: { scope: [:question_id], if: -> { question_id? && submission_id_changed? } } - validates :question_id, uniqueness: { scope: [:submission_id], if: -> { submission_id? && question_id_changed? } } + validates :attempt_id, uniqueness: { scope: [:question_id], if: -> { question_id? && attempt_id_changed? } } + validates :question_id, uniqueness: { scope: [:attempt_id], if: -> { attempt_id? && question_id_changed? } } - belongs_to :submission, class_name: 'Course::Assessment::Submission', + # The underlying column is `attempt_id` (renamed from `submission_id`); foreign_key kept explicit + # since the association name stays `submission`. + belongs_to :submission, class_name: 'Course::Assessment::Attempt', foreign_key: 'attempt_id', inverse_of: :submission_questions belongs_to :question, class_name: 'Course::Assessment::Question', inverse_of: :submission_questions + # Coerce a `Course::Assessment::Submission` passed here into its `Attempt` (the association's + # real target post-repoint) — mirrors the same coercion on `Course::Assessment::Answer` (see its + # comment for the full rationale). Both `spec/factories/course_assessment_submission_questions.rb`'s + # own default `submission { create(:submission, ...) }` and + # `spec/models/course/assessment/submission_spec.rb`'s own `create(:course_assessment_submission_question, + # submission: submission, ...)` pass the `Submission` half, which otherwise raises + # `ActiveRecord::AssociationTypeMismatch`. + def submission=(value) + value = value.attempt if value.is_a?(Course::Assessment::Submission) + super + end + has_many :threads, class_name: 'Course::Assessment::LiveFeedback::Thread', inverse_of: :submission_question, dependent: :destroy after_initialize :set_course, if: :new_record? @@ -26,14 +40,16 @@ class Course::Assessment::SubmissionQuestion < ApplicationRecord # joining { discussion_topic }.selecting { discussion_topic.id } unscoped. joins(:submission). - where(Course::Assessment::Submission.arel_table[:creator_id].in(user_id)). + # `creator_id` lives on Course::Assessment::Attempt post-repoint — `:submission` now joins + # course_assessment_attempts, so the arel_table reference must match. + where(Course::Assessment::Attempt.arel_table[:creator_id].in(user_id)). joins(:discussion_topic). select(Course::Discussion::Topic.arel_table[:id]) end) # Gets the SubmissionQuestion of a specific submission scope :from_submission, (lambda do |submission_id| - find_by(submission_id: submission_id) + find_by(attempt_id: submission_id) end) def notify(post) diff --git a/app/models/course/condition/assessment.rb b/app/models/course/condition/assessment.rb index 4ed34f3e143..1236808b704 100644 --- a/app/models/course/condition/assessment.rb +++ b/app/models/course/condition/assessment.rb @@ -1,11 +1,33 @@ # frozen_string_literal: true -class Course::Condition::Assessment < ApplicationRecord +# The class observes two tables (Attempt + Submission) post-split (see the after_save hooks below), +# which pushes it just past the default class-length limit. +class Course::Condition::Assessment < ApplicationRecord # rubocop:disable Metrics/ClassLength include ActiveSupport::NumberHelper include DuplicationStateTrackingConcern acts_as_condition - # Trigger for evaluating the satisfiability of conditionals for a course user + # Trigger for evaluating the satisfiability of conditionals for a course user. + # + # Split across two `after_save` registrations, one per table that can actually change: `workflow_state` + # lives on Attempt (Step 2a), `last_graded_time` stays on Submission (Step 2c). Registering both + # halves on `Submission.after_save` alone (the pre-split shape) and reading + # `submission.saved_change_to_workflow_state?` (delegated to `attempt`) goes stale across + # separate Submission-level saves: Rails' dirty-tracking reflects `attempt`'s OWN most recent + # save, not "did workflow_state change as part of *this* Submission save" — so a later, unrelated + # `submission.save!` (e.g. one with nothing new to persist) still reports the OLD transition as + # freshly changed, double-firing (or firing when nothing on this attempt actually just + # transitioned). Reading `attempt.saved_change_to_workflow_state?` directly, from an `after_save` + # registered on `Attempt` itself, does not have this problem. Genuine bug the split exposed; not + # listed in the brief's file set, found via the acceptance gate. + Course::Assessment::Attempt.after_save do |attempt| + next unless attempt.saved_change_to_workflow_state? && attempt.submission + + Course::Condition::Assessment.on_dependent_status_change(attempt.submission) + end + Course::Assessment::Submission.after_save do |submission| + next unless submission.saved_changes.key?(:last_graded_time) + Course::Condition::Assessment.on_dependent_status_change(submission) end @@ -52,11 +74,21 @@ def self.dependent_class Course::Assessment.name end + # The "did workflow_state/last_graded_time just change" check now lives in each of the two + # `after_save` registrations above (one per table that can actually change) instead of here. + # + # A single `publish!` legitimately trips BOTH registrations in one transaction (Attempt's + # `workflow_state` and Submission's `last_graded_time` both change), which pre-split — one hook on + # one row — collapsed to a single re-evaluation. Guard so it still does: register the + # after-commit re-evaluation once per submission per pending commit (the two registrations receive + # the same Submission instance via `inverse_of`), preserving the "exactly once per status change" + # contract instead of double-firing. def self.on_dependent_status_change(submission) - return unless submission.saved_changes.key?(:workflow_state) || - submission.saved_changes.key?(:last_graded_time) + return if submission.instance_variable_get(:@evaluate_conditional_pending) + submission.instance_variable_set(:@evaluate_conditional_pending, true) submission.execute_after_commit do + submission.instance_variable_set(:@evaluate_conditional_pending, false) evaluate_conditional_for(submission.course_user) end end @@ -79,12 +111,19 @@ def initialize_duplicate(duplicator, other) private def submitted_submissions_by_user(user) - # TODO: Replace with Rails 5 ActiveRecord::Relation#or with named scope - assessment.submissions.by_user(user).where(workflow_state: [:submitted, :graded, :published]) + # `workflow_state` is Attempt-only post-split; `.confirmed` (Step 2c) already wraps this exact + # set of states (`[:submitted, :graded, :published]`) through `:attempt`. Genuine bug the split + # exposed; not listed in the brief's file set, found via the acceptance gate. + assessment.submissions.by_user(user).confirmed end def published_submissions_with_minimum_grade_exists?(user, minimum_grade_percentage) - assessment.submissions.by_user(user).with_published_state.eager_load(:answers, assessment: :questions).any? do |sub| + # `:answers`/`:assessment` are delegated methods, not real associations, on Submission any + # more (Step 2c) — `eager_load(:answers, assessment: :questions)` raised + # `ActiveRecord::ConfigurationError`. Both now live on `:attempt`. Genuine bug the split + # exposed; not listed in the brief's file set, found via the acceptance gate. + assessment.submissions.by_user(user).with_published_state. + eager_load(attempt: [:answers, assessment: :questions]).any? do |sub| sub.grade.to_f >= sub.questions.sum(:maximum_grade).to_f * minimum_grade_percentage / 100.0 end end diff --git a/app/models/course_user.rb b/app/models/course_user.rb index 6682fb1fac4..bef2569d136 100644 --- a/app/models/course_user.rb +++ b/app/models/course_user.rb @@ -118,11 +118,16 @@ class CourseUser < ApplicationRecord # @!attribute [r] assessment_submission_count # Returns the total number of submitted assessment submissions by CourseUser in this course calculated :assessment_submission_count, (lambda do + # `assessment` is no longer a real association on Submission (Step 2c) — it's delegated to + # `attempt`, so `joins(assessment: ...)` raised `ActiveRecord::ConfigurationError`. Join through + # `:attempt` first (a real association), matching the same shape as `Submission.from_course`'s + # own `joins(attempt: { assessment: { tab: :category } })`. Genuine bug the split exposed; not + # listed in the brief's file set, found via the acceptance gate. Course::Assessment::Submission.select('count(*)'). - joins(assessment: { tab: :category }). - where('course_assessment_submissions.creator_id = course_users.user_id'). + joins(attempt: { assessment: { tab: :category } }). + where('course_assessment_attempts.creator_id = course_users.user_id'). where('course_assessment_categories.course_id = course_users.course_id'). - where(course_assessment_submissions: { workflow_state: [:submitted, :graded, :published] }) + where(course_assessment_attempts: { workflow_state: [:submitted, :graded, :published] }) end) scope :staff, -> { where(role: STAFF_ROLES) } diff --git a/app/models/user.rb b/app/models/user.rb index 75aae3fc495..be7e37d6790 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -76,6 +76,22 @@ def deleted has_one :cikgo_user, dependent: :destroy, inverse_of: :user + # Both tables FK to users with no ON DELETE, so without these the admin panel's delete-user + # action dies with PG::ForeignKeyViolation for anyone who is allow-listed or blocked. Destroying + # is the right semantic for both: each row is *about* this user and means nothing without them. + has_many :marketplace_allowlist_rules, class_name: 'Course::Assessment::Marketplace::AllowlistRule', + inverse_of: false, dependent: :destroy + has_many :marketplace_access_blocks, class_name: 'Course::Assessment::Marketplace::AccessBlock', + inverse_of: false, dependent: :destroy + # Blocks this user ISSUED. Not `dependent:` anything — destroying them would silently restore + # marketplace access for everyone this admin ever blocked, and `creator_id` is NOT NULL so it + # cannot be nullified either. `reassign_issued_marketplace_blocks` hands authorship to the + # Deleted user instead, which keeps the block standing and satisfies the FK. + has_many :issued_marketplace_access_blocks, class_name: 'Course::Assessment::Marketplace::AccessBlock', + foreign_key: :creator_id, inverse_of: false, + dependent: nil + before_destroy :reassign_issued_marketplace_blocks + accepts_nested_attributes_for :emails scope :ordered_by_name, -> { order(:name) } @@ -96,6 +112,26 @@ def built_in? id == User::SYSTEM_USER_ID || id == User::DELETED_USER_ID end + # Whether the user manages or owns at least one course, in any instance. This is the baseline + # capability for the assessment marketplace: browsing is then further gated by the allow-list. + # `course_users` is not tenant-scoped (CourseUser has no acts_as_tenant), so this correctly + # spans all instances. + # + # @return [Boolean] + def course_manager_or_owner? + course_users.managers.exists? + end + + # Whether the user is an instructor or administrator InstanceUser in ANY instance. This is the + # second baseline capability for the assessment marketplace, a peer of course_manager_or_owner?. + # `instance_users` IS tenant-scoped (acts_as_tenant), so bypass the tenant to span all instances. + # + # @return [Boolean] + def instance_instructor_or_administrator? + ActsAsTenant.without_tenant do + instance_users.where(role: [:instructor, :administrator]).exists? + end + end # Pick the default email and set it as primary email. This method would immediately set the # attributes in the database. # @@ -136,6 +172,14 @@ def build_course_user_from_invitation(invitation) private + # Hands any marketplace blocks this user issued to the Deleted user, so destroying an admin does + # not lift the blocks they put in place (nor trip the NOT NULL FK on `creator_id`). + def reassign_issued_marketplace_blocks + return if id == User::DELETED_USER_ID + + issued_marketplace_access_blocks.update_all(creator_id: User::DELETED_USER_ID) + end + # Gets the default email address record. # # @return [User::Email] The user's primary email address record. diff --git a/app/services/course/assessment/answer/ai_generated_post_service.rb b/app/services/course/assessment/answer/ai_generated_post_service.rb index f89c84086b5..3e684ff6030 100644 --- a/app/services/course/assessment/answer/ai_generated_post_service.rb +++ b/app/services/course/assessment/answer/ai_generated_post_service.rb @@ -80,7 +80,7 @@ def create_topic_subscription(discussion_topic) # Ensure the student who wrote the answer amd all group managers # gets notified when someone comments on his answer discussion_topic.ensure_subscribed_by(@answer.submission.creator) - answer_course_user = @answer.submission.course_user + answer_course_user = @answer.submission.submission.course_user answer_course_user.my_managers.each do |manager| discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb b/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb index c11d2c74c0f..2c64625da0c 100644 --- a/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb +++ b/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb @@ -153,7 +153,7 @@ def create_topic_subscription(discussion_topic) # Ensure all group managers get a notification when someone adds a programming annotation # to the answer. - answer_course_user = @answer.submission.course_user + answer_course_user = @answer.submission.submission.course_user answer_course_user.my_managers.each do |manager| discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/services/course/assessment/submission/update_service.rb b/app/services/course/assessment/submission/update_service.rb index 7db382b251b..faed5789b8b 100644 --- a/app/services/course/assessment/submission/update_service.rb +++ b/app/services/course/assessment/submission/update_service.rb @@ -78,8 +78,14 @@ def create_missing_submission_questions questions_without_submission_questions = questions_to_attempt - questions_with_submission_questions new_submission_questions = [] questions_without_submission_questions.each do |question| + # `SubmissionQuestion#submission` now targets `Course::Assessment::Attempt` (the FK repoint, + # Step 2d) — assigning a `Course::Assessment::Submission` instance here raises + # `ActiveRecord::AssociationTypeMismatch` (belongs_to's writer strictly checks + # `record.is_a?(reflection.klass)`). `@submission.attempt` is the real, undelegated + # association already on Submission's own table. (Genuine bug the split exposed; not listed + # in the brief's file set, found via the acceptance gate.) new_submission_questions << - Course::Assessment::SubmissionQuestion.new(submission: @submission, question: question) + Course::Assessment::SubmissionQuestion.new(submission: @submission.attempt, question: question) end import_success = true diff --git a/app/views/course/assessment/rubrics/rubric_answers.json.jbuilder b/app/views/course/assessment/rubrics/rubric_answers.json.jbuilder index 8a09a4e765f..19ea42e2f4b 100644 --- a/app/views/course/assessment/rubrics/rubric_answers.json.jbuilder +++ b/app/views/course/assessment/rubrics/rubric_answers.json.jbuilder @@ -7,7 +7,7 @@ json.array! @answers do |answer| # Whether this is the submission's latest (current) attempt -- the same flag the past-answers views use -- # so the frontend can show only the latest answer per student. json.currentAnswer answer.current_answer? - json.submissionId answer.submission_id + json.submissionId answer.attempt_id json.submissionStatus answer.submission.workflow_state json.grade answer.grade.to_f if answer.evaluated? || answer.graded? # Show exactly what the LLM sees when grading (RBR: the response; forum post: the assembled posts + parent diff --git a/app/views/notifiers/course/assessment/submission_question/comment_notifier/replied/user_notifications/email.html.slim b/app/views/notifiers/course/assessment/submission_question/comment_notifier/replied/user_notifications/email.html.slim index ade6133f41e..26d1fd449df 100644 --- a/app/views/notifiers/course/assessment/submission_question/comment_notifier/replied/user_notifications/email.html.slim +++ b/app/views/notifiers/course/assessment/submission_question/comment_notifier/replied/user_notifications/email.html.slim @@ -1,7 +1,9 @@ - post = @object - submission_question = post.topic.actable -- course_user = submission_question.submission.course_user -- assessment = submission_question.submission.assessment +- attempt = submission_question.submission +- submission = attempt.submission +- course_user = submission.course_user +- assessment = attempt.assessment - question = submission_question.question - course = assessment.course - host = course.instance.host @@ -15,7 +17,7 @@ = format_html(t('.message', topic: link_to("#{assessment.title}: #{question_assessment.display_title}", edit_course_assessment_submission_url(course, assessment, - submission_question.submission, + submission, step: step, host: host)), post: post.text_to_email, post_author: post.author_name)) diff --git a/app/views/system/admin/marketplace_access/index.json.jbuilder b/app/views/system/admin/marketplace_access/index.json.jbuilder new file mode 100644 index 00000000000..332f37027eb --- /dev/null +++ b/app/views/system/admin/marketplace_access/index.json.jbuilder @@ -0,0 +1,22 @@ +# frozen_string_literal: true +json.users @rows do |row| + json.id row.user.id + json.name row.user.name + json.email row.user.email + json.courseCount row.course_count + json.instanceRole row.instance_role + json.allowedByRules row.allowed_by_rules do |rule| + json.id rule.id + json.ruleType rule.rule_type + json.labelValue marketplace_rule_label_value(rule) + end + json.systemAdmin row.system_admin? + json.blocked row.blocked? + json.blockId row.block_id +end + +json.summary do + json.totalWithAccess @summary[:total_with_access] + json.totalBlocked @summary[:total_blocked] + json.openToEveryone @summary[:open_to_everyone] +end diff --git a/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder new file mode 100644 index 00000000000..d05e2b8ec38 --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true +json.id rule.id +json.ruleType rule.rule_type +json.userId rule.user_id +json.userName rule.user&.name +json.userEmail rule.user&.email +json.instanceId rule.instance_id +json.instanceName rule.instance&.name +json.emailDomain rule.email_domain diff --git a/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder new file mode 100644 index 00000000000..2b07f8a2ad8 --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true +json.rules @allowlist_rules do |rule| + json.partial! 'rule', rule: rule +end +json.everyoneRuleId @everyone_rule&.id diff --git a/app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder new file mode 100644 index 00000000000..d6fae59011f --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true +json.matchedCount @summary[:matched_count] +json.newCount @summary[:new_count] +json.blockedCount @summary[:blocked_count] +json.openToEveryone @summary[:open_to_everyone] + +json.users @rows do |row| + json.id row.user.id + json.name row.user.name + json.email row.user.email + json.courseCount row.course_count + json.instanceRole row.instance_role + json.alreadyHasAccess row.already_has_access + json.blocked row.blocked +end \ No newline at end of file diff --git a/client/app/api/system/Admin.ts b/client/app/api/system/Admin.ts index 40eac58adc4..cd144072edb 100644 --- a/client/app/api/system/Admin.ts +++ b/client/app/api/system/Admin.ts @@ -5,6 +5,14 @@ import { } from 'types/course/announcements'; import { CourseListData } from 'types/system/courses'; import { InstanceListData, InstancePermissions } from 'types/system/instances'; +import { + AllowlistRuleData, + AllowlistRuleFormData, +} from 'types/system/marketplaceAllowlist'; +import { + AllowlistRulePreviewData, + MarketplaceAccessData, +} from 'types/system/marketplaceAccess'; import { AdminStats, UserListData } from 'types/users'; import BaseSystemAPI from '../Base'; @@ -173,4 +181,104 @@ export default class AdminAPI extends BaseSystemAPI { getDeploymentInfo(): Promise> { return this.client.get(`${AdminAPI.#urlPrefix}/deployment_info`); } + + /** + * Fetches the marketplace allow-list rules. + */ + indexMarketplaceAllowlistRules(): Promise< + AxiosResponse<{ rules: AllowlistRuleData[]; everyoneRuleId: number | null }> + > { + return this.client.get( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + ); + } + + /** + * Creates a marketplace allow-list rule. + */ + createMarketplaceAllowlistRule( + params: AllowlistRuleFormData, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + { + allowlist_rule: { + rule_type: params.ruleType, + instance_id: params.instanceId, + email_domain: params.emailDomain, + email: params.email, + }, + }, + ); + } + + /** + * Dry run for a prospective allow-list rule: reports who it would let in, without saving it. + * Runs the same validations as create, so a duplicate rule is reported here as a 400. + */ + previewMarketplaceAllowlistRule( + params: AllowlistRuleFormData, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/preview`, + { + allowlist_rule: { + rule_type: params.ruleType, + instance_id: params.instanceId, + email_domain: params.emailDomain, + email: params.email, + }, + }, + ); + } + + /** + * Opens the marketplace to everyone by creating the single `everyone` allow-list rule. + * Returns the created rule; only its `id` is consumed (to later restrict). + */ + openMarketplaceToEveryone(): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + { allowlist_rule: { rule_type: 'everyone' } }, + ); + } + + /** + * Deletes a marketplace allow-list rule. + */ + deleteMarketplaceAllowlistRule(id: number): Promise { + return this.client.delete( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/${id}`, + ); + } + + /** + * Fetches the marketplace access audit list (everyone with effective access, blocked flagged). + */ + indexMarketplaceAccess(): Promise> { + return this.client.get(`${AdminAPI.#urlPrefix}/marketplace_access`); + } + + /** + * Blocks (disables) a user's marketplace access. Returns the created block's id. + */ + blockMarketplaceUser( + userId: number, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_access_blocks`, + { + user_id: userId, + }, + ); + } + + /** + * Removes a block, re-enabling the user's marketplace access. + */ + unblockMarketplaceUser(blockId: number): Promise { + return this.client.delete( + `${AdminAPI.#urlPrefix}/marketplace_access_blocks/${blockId}`, + ); + } } diff --git a/client/app/bundles/system/admin/admin/AdminNavigator.tsx b/client/app/bundles/system/admin/admin/AdminNavigator.tsx index c445a5b9d15..ddb5875f3c7 100644 --- a/client/app/bundles/system/admin/admin/AdminNavigator.tsx +++ b/client/app/bundles/system/admin/admin/AdminNavigator.tsx @@ -5,6 +5,7 @@ import { Category, Chat, Group, + Storefront, } from '@mui/icons-material'; import useTranslation from 'lib/hooks/useTranslation'; @@ -32,6 +33,10 @@ const translations = defineMessages({ id: 'system.admin.admin.AdminNavigator.getHelp', defaultMessage: 'Get Help', }, + marketplace: { + id: 'system.admin.admin.AdminNavigator.marketplace', + defaultMessage: 'Marketplace Access', + }, systemAdminPanel: { id: 'system.admin.admin.AdminNavigator.systemAdminPanel', defaultMessage: 'System Admin Panel', @@ -64,6 +69,11 @@ const AdminNavigator = (): JSX.Element => { title: t(translations.courses), path: '/admin/courses', }, + { + icon: , + title: t(translations.marketplace), + path: '/admin/marketplace_allowlist_rules', + }, { icon: , title: t(translations.getHelp), diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx new file mode 100644 index 00000000000..f450aa2ba73 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { FilterList } from '@mui/icons-material'; +import { + Badge, + Button, + Checkbox, + Divider, + FormControlLabel, + IconButton, + Menu, + Tooltip, + Typography, +} from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +export interface RuleOption { + id: number; + label: string; +} + +interface Props { + showActive: boolean; + showBlocked: boolean; + onToggleActive: () => void; + onToggleBlocked: () => void; + /** Empty when the marketplace is open to everyone — the rule group is then meaningless. */ + ruleOptions: RuleOption[]; + /** + * Ids the admin has UNchecked. Tracking exclusions rather than inclusions means a newly added + * rule is filtered in by default, with no state to resynchronise when `ruleOptions` changes. + */ + uncheckedRuleIds: Set; + onToggleRule: (id: number) => void; + onClear: () => void; +} + +const translations = defineMessages({ + trigger: { + id: 'system.admin.admin.MarketplaceAccessFilter.trigger', + defaultMessage: 'Filter', + }, + status: { + id: 'system.admin.admin.MarketplaceAccessFilter.status', + defaultMessage: 'Status', + }, + active: { + id: 'system.admin.admin.MarketplaceAccessFilter.active', + defaultMessage: 'Active', + }, + blocked: { + id: 'system.admin.admin.MarketplaceAccessFilter.blocked', + defaultMessage: 'Blocked', + }, + allowedByRule: { + id: 'system.admin.admin.MarketplaceAccessFilter.allowedByRule', + defaultMessage: 'Allowed by rule', + }, + clearAll: { + id: 'system.admin.admin.MarketplaceAccessFilter.clearAll', + defaultMessage: 'Clear all', + }, +}); + +/** + * A bespoke filter popover rather than the shared table's built-in per-column filtering + * (`filterable` + `filterProps`). The built-in machinery could in fact handle both the array-valued + * rules column (via `filterProps.getValue`/`shouldInclude`) and the synthetic System-admin option + * (`getValue` is arbitrary) — those two objections are false. The real reason is that built-in + * filtering is table-internal in the three respects this feature needs externalised: + * + * 1. The filtered result never leaves the table. `TableTemplate` exposes no callback for it, and + * the count feeds pagination internally — yet the section renders a + * "Filtered: N with access · M blocked" line that needs the filtered set outside the table. + * (Decisive.) + * 2. Render location. `MuiFilterMenu` renders inside a column header; the design is one filter + * icon in the toolbar spanning Status AND rules, with a single badge and one "Clear all" — + * built-in yields two header icons, two badges, two independent clears. + * 3. Checked-by-default is unreachable. In the built-in filter the selection array IS the filter, + * so a both-on Status default would need the selection inverted, putting checkmarks on exactly + * the wrong items. This component instead tracks EXCLUSIONS, so a newly added rule filters in + * by default with no state to resynchronise. + */ +const MarketplaceAccessFilter = ({ + showActive, + showBlocked, + onToggleActive, + onToggleBlocked, + ruleOptions, + uncheckedRuleIds, + onToggleRule, + onClear, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [anchor, setAnchor] = useState(null); + + const activeCount = + (showActive ? 0 : 1) + (showBlocked ? 0 : 1) + uncheckedRuleIds.size; + + const label = t(translations.trigger); + + return ( + <> + + + setAnchor(event.currentTarget)} + > + + + + + + setAnchor(null)} + open={Boolean(anchor)} + > +
+ + {t(translations.status)} + + + + } + label={t(translations.active)} + /> + + + } + label={t(translations.blocked)} + /> + + {ruleOptions.length > 0 && ( + <> + + + + {t(translations.allowedByRule)} + + + {ruleOptions.map((option) => ( + onToggleRule(option.id)} + /> + } + label={option.label} + /> + ))} + + )} + + +
+
+ + ); +}; + +export default MarketplaceAccessFilter; diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx new file mode 100644 index 00000000000..e893af73ee3 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx @@ -0,0 +1,639 @@ +import { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button, Chip, Typography } from '@mui/material'; +import { + AllowedByRule, + MarketplaceAccessUser, +} from 'types/system/marketplaceAccess'; +import { AllowlistRuleData } from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import MarketplaceAccessFilter, { RuleOption } from './MarketplaceAccessFilter'; + +/** + * Filter id for the synthetic "System admin" option. Negative so it can never collide with a real + * allow-list rule id, which is what the other options carry. + */ +const SYSTEM_ADMIN_OPTION_ID = -1; + +interface Props { + /** Owned by the page, not this section: the toggle and this list must never disagree. */ + openToEveryone: boolean; + /** Bumped by the page on every rule mutation; a change refetches the list. */ + ruleVersion: number; + /** The page's current scoped rules, used to label the filter's rule checkboxes. */ + rules: AllowlistRuleData[]; + /** + * Published after each fetch: rule id => number of listed users that rule grants access to. The + * rules table above the section consumes it to flag rules that match nobody. A rule granting zero + * people contributes no key, so a zero-match rule is simply absent from the map. + */ + onMatchCounts?: (counts: Map) => void; +} + +const translations = defineMessages({ + heading: { + id: 'system.admin.admin.MarketplaceAccessSection.heading', + defaultMessage: 'People with access', + }, + summary: { + id: 'system.admin.admin.MarketplaceAccessSection.summary', + defaultMessage: 'Total with access: {count} · {mode}', + }, + summaryWithBlocked: { + id: 'system.admin.admin.MarketplaceAccessSection.summaryWithBlocked', + defaultMessage: + 'Total with access: {count} · Total blocked: {blocked} · {mode}', + }, + filteredCounts: { + id: 'system.admin.admin.MarketplaceAccessSection.filteredCounts', + defaultMessage: 'Filtered: {count} with access · {blocked} blocked', + }, + modeOpen: { + id: 'system.admin.admin.MarketplaceAccessSection.modeOpen', + defaultMessage: 'Open to everyone', + }, + modeScoped: { + id: 'system.admin.admin.MarketplaceAccessSection.modeScoped', + defaultMessage: 'Scoped to the rules above', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceAccessSection.fetchFailure', + defaultMessage: 'Failed to load the marketplace access list.', + }, + colName: { + id: 'system.admin.admin.MarketplaceAccessSection.colName', + defaultMessage: 'Name', + }, + colEmail: { + id: 'system.admin.admin.MarketplaceAccessSection.colEmail', + defaultMessage: 'Email', + }, + colEligibleVia: { + id: 'system.admin.admin.MarketplaceAccessSection.colEligibleVia', + defaultMessage: 'Eligible via', + }, + colAllowedBy: { + id: 'system.admin.admin.MarketplaceAccessSection.colAllowedBy', + defaultMessage: 'Allowed by', + }, + colStatus: { + id: 'system.admin.admin.MarketplaceAccessSection.colStatus', + defaultMessage: 'Status', + }, + colActions: { + id: 'system.admin.admin.MarketplaceAccessSection.colActions', + defaultMessage: 'Actions', + }, + managesCourses: { + id: 'system.admin.admin.MarketplaceAccessSection.managesCourses', + defaultMessage: 'Manages {count, plural, one {# course} other {# courses}}', + }, + instanceInstructor: { + id: 'system.admin.admin.MarketplaceAccessSection.instanceInstructor', + defaultMessage: 'Instance instructor', + }, + instanceAdministrator: { + id: 'system.admin.admin.MarketplaceAccessSection.instanceAdministrator', + defaultMessage: 'Instance administrator', + }, + allowedEveryone: { + id: 'system.admin.admin.MarketplaceAccessSection.allowedEveryone', + defaultMessage: 'Everyone', + }, + allowedNothing: { + id: 'system.admin.admin.MarketplaceAccessSection.allowedNothing', + defaultMessage: 'No matching rule', + }, + systemAdmin: { + id: 'system.admin.admin.MarketplaceAccessSection.systemAdmin', + defaultMessage: 'System admin', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAccessSection.typeUser', + defaultMessage: 'User', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAccessSection.typeInstance', + defaultMessage: 'Instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAccessSection.typeEmailDomain', + defaultMessage: 'Email domain', + }, + statusActive: { + id: 'system.admin.admin.MarketplaceAccessSection.statusActive', + defaultMessage: 'Active', + }, + statusBlocked: { + id: 'system.admin.admin.MarketplaceAccessSection.statusBlocked', + defaultMessage: 'Blocked', + }, + disable: { + id: 'system.admin.admin.MarketplaceAccessSection.disable', + defaultMessage: 'Block', + }, + reEnable: { + id: 'system.admin.admin.MarketplaceAccessSection.reEnable', + defaultMessage: 'Unblock', + }, + disableSuccess: { + id: 'system.admin.admin.MarketplaceAccessSection.disableSuccess', + defaultMessage: 'Access blocked for this user.', + }, + disableFailure: { + id: 'system.admin.admin.MarketplaceAccessSection.disableFailure', + defaultMessage: 'Failed to block access.', + }, + reEnableSuccess: { + id: 'system.admin.admin.MarketplaceAccessSection.reEnableSuccess', + defaultMessage: 'Access unblocked for this user.', + }, + reEnableFailure: { + id: 'system.admin.admin.MarketplaceAccessSection.reEnableFailure', + defaultMessage: 'Failed to unblock access.', + }, + searchPlaceholder: { + id: 'system.admin.admin.MarketplaceAccessSection.searchPlaceholder', + defaultMessage: 'Search by name or email', + }, + dormantHeading: { + id: 'system.admin.admin.MarketplaceAccessSection.dormantHeading', + defaultMessage: 'Dormant blocks ({count})', + }, + dormantExplanation: { + id: 'system.admin.admin.MarketplaceAccessSection.dormantExplanation', + defaultMessage: + 'These people are blocked but no rule currently grants them access. The block denies ' + + 'nothing today — but it would take effect again if a rule starts matching them, so clear ' + + 'it if it is no longer wanted.', + }, + clearBlock: { + id: 'system.admin.admin.MarketplaceAccessSection.clearBlock', + defaultMessage: 'Clear block', + }, +}); + +const MarketplaceAccessSection = ({ + openToEveryone, + ruleVersion, + rules, + onMatchCounts, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [users, setUsers] = useState([]); + const [showActive, setShowActive] = useState(true); + const [showBlocked, setShowBlocked] = useState(true); + const [uncheckedRuleIds, setUncheckedRuleIds] = useState>( + new Set(), + ); + + useEffect(() => { + let cancelled = false; + setIsRefreshing(true); + + SystemAPI.admin + .indexMarketplaceAccess() + .then((response) => { + if (cancelled) return; + setUsers(response.data.users); + // Publish per-rule grant counts for the rules table above. Built from rows, so a rule that + // grants access to nobody contributes no key at all — its absence is the zero-match signal. + const counts = new Map(); + response.data.users.forEach((user) => { + user.allowedByRules.forEach((rule) => { + counts.set(rule.id, (counts.get(rule.id) ?? 0) + 1); + }); + }); + onMatchCounts?.(counts); + }) + .catch(() => toast.error(t(translations.fetchFailure))) + .finally(() => { + if (cancelled) return; + setIsLoading(false); + setIsRefreshing(false); + }); + + return () => { + cancelled = true; + }; + }, [ruleVersion]); + + const handleDisable = async (user: MarketplaceAccessUser): Promise => { + try { + const response = await SystemAPI.admin.blockMarketplaceUser(user.id); + setUsers((current) => + current.map((u) => + u.id === user.id + ? { ...u, blocked: true, blockId: response.data.id } + : u, + ), + ); + toast.success(t(translations.disableSuccess)); + } catch { + toast.error(t(translations.disableFailure)); + } + }; + + /** + * Whether anything currently grants this person access, ignoring any block. Mirrors the server's + * own notion of "allowed": the role, the everyone-mode, or at least one matching rule. Note that + * everyone-mode deliberately sends no per-row rules, so an empty `allowedByRules` is NOT on its + * own a signal that someone has no access. + */ + const isAllowed = (user: MarketplaceAccessUser): boolean => + user.systemAdmin || openToEveryone || user.allowedByRules.length > 0; + + /** Blocked, but nothing would grant them access anyway — the block denies nothing today. */ + const isDormantBlock = (user: MarketplaceAccessUser): boolean => + user.blocked && !isAllowed(user); + + const handleReEnable = async (user: MarketplaceAccessUser): Promise => { + if (user.blockId === null) return; + try { + await SystemAPI.admin.unblockMarketplaceUser(user.blockId); + setUsers((current) => + // Someone listed ONLY because they were blocked has no reason to stay once the block goes — + // patching the row in place would leave them as "Active · No matching rule", counted as + // having access they do not have. Mirrors the server: listed iff allowed OR blocked. + current.flatMap((u) => { + if (u.id !== user.id) return [u]; + return isAllowed(u) ? [{ ...u, blocked: false, blockId: null }] : []; + }), + ); + toast.success(t(translations.reEnableSuccess)); + } catch { + toast.error(t(translations.reEnableFailure)); + } + }; + + const eligibleVia = (user: MarketplaceAccessUser): string => { + // A system admin's eligibility comes from the role, not from courses or instance membership — + // and they are listed even when they have neither, where the other branches say nothing. + if (user.systemAdmin) return t(translations.systemAdmin); + + const parts: string[] = []; + if (user.courseCount > 0) { + parts.push(t(translations.managesCourses, { count: user.courseCount })); + } + if (user.instanceRole === 'instructor') { + parts.push(t(translations.instanceInstructor)); + } + if (user.instanceRole === 'administrator') { + parts.push(t(translations.instanceAdministrator)); + } + return parts.length > 0 ? parts.join('; ') : '—'; + }; + + const typeLabels: Record = { + user: t(translations.typeUser), + instance: t(translations.typeInstance), + email_domain: t(translations.typeEmailDomain), + }; + + const ruleLabel = (rule: AllowedByRule): string => + `${typeLabels[rule.ruleType]} (${rule.labelValue ?? `#${rule.id}`})`; + + // Every reason, not one winner: the admin reads this column to decide which rules are safe to + // delete, and a single reason answers that question wrongly. + const renderAllowedBy = (user: MarketplaceAccessUser): JSX.Element => { + // Ahead of both other branches: the role is why they have access, and it outlives any rule + // change — saying "Everyone" or naming a rule would misattribute it. + if (user.systemAdmin) return {t(translations.systemAdmin)}; + if (openToEveryone) return {t(translations.allowedEveryone)}; + if (user.allowedByRules.length === 0) { + return {t(translations.allowedNothing)}; + } + + return ( +
+ {user.allowedByRules.map((rule) => ( + {ruleLabel(rule)} + ))} +
+ ); + }; + + const ruleOptionLabel = (rule: AllowlistRuleData): string => { + switch (rule.ruleType) { + case 'user': + return `${typeLabels.user} (${rule.userName ?? `#${rule.userId}`})`; + case 'instance': + return `${typeLabels.instance} (${ + rule.instanceName ?? `#${rule.instanceId}` + })`; + default: + return `${typeLabels.email_domain} (${rule.emailDomain ?? ''})`; + } + }; + + // Open to everyone means every row is granted by the mode, not by a rule, so the group is hidden. + // System admin is a reason in its own right, so it gets an option whenever any listed user is + // one — including in everyone-mode, where their access still comes from the role, not the mode. + const ruleOptions: RuleOption[] = [ + ...(users.some((user) => user.systemAdmin) + ? [{ id: SYSTEM_ADMIN_OPTION_ID, label: t(translations.systemAdmin) }] + : []), + ...(openToEveryone + ? [] + : rules.map((rule) => ({ id: rule.id, label: ruleOptionLabel(rule) }))), + ]; + + const toggleRule = (id: number): void => + setUncheckedRuleIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const clearFilters = (): void => { + setShowActive(true); + setShowBlocked(true); + setUncheckedRuleIds(new Set()); + }; + + const matchesFilter = (user: MarketplaceAccessUser): boolean => { + if (user.blocked ? !showBlocked : !showActive) return false; + if (uncheckedRuleIds.size === 0) return true; + + // Being a system admin is a reason alongside the rules, so an admin survives the filter while + // that option stays checked — without this they carry no reasons at all and would vanish the + // moment any rule box is unchecked. + const reasonIds = user.allowedByRules.map((rule) => rule.id); + if (user.systemAdmin) reasonIds.push(SYSTEM_ADMIN_OPTION_ID); + // Everyone-mode grants access outside the rules, so only the admin option can filter there. + if (openToEveryone && !user.systemAdmin) return true; + + return reasonIds.some((id) => !uncheckedRuleIds.has(id)); + }; + + const columns: ColumnTemplate[] = [ + { + of: 'name', + title: t(translations.colName), + searchable: true, + cell: (user) => ( + + {user.name} + + ), + }, + { + of: 'email', + title: t(translations.colEmail), + searchable: true, + cell: (user) => user.email, + }, + { + id: 'eligibleVia', + title: t(translations.colEligibleVia), + cell: (user) => eligibleVia(user), + }, + { + id: 'allowedBy', + title: t(translations.colAllowedBy), + cell: (user) => renderAllowedBy(user), + }, + { + id: 'status', + title: t(translations.colStatus), + // This column's content changes with row STATE (Active↔Blocked), so its intrinsic width + // changes as people are blocked, shifting every column to its left. A width on the cell + // itself does NOT fix that: under table-layout:auto a cell width is only a suggestion, and + // the browser still distributes slack using each column's max-content width. Pinning the + // width on a wrapper INSIDE the cell makes that max-content constant, which is what actually + // holds the layout still. `whitespace-nowrap` keeps an overlong translation overflowing + // visibly rather than wrapping and silently reintroducing the shift. + className: 'whitespace-nowrap', + cell: (user) => ( +
+ +
+ ), + }, + { + id: 'action', + title: t(translations.colActions), + // Same reasoning as `status` above: Block↔Unblock. Sized to `Unblock`, the wider of the + // two, so flipping a row never moves its neighbours. + className: 'whitespace-nowrap', + cell: (user) => + // No action for a system admin: `can :manage, :all` outranks the allow-list, so a block + // would not actually revoke anything — the row would read "Blocked" while they kept full + // access. Better to offer nothing than an action that silently does nothing. + user.systemAdmin ? null : ( +
+ {/* + `min-w-0 px-0` on both: MUI gives a Button horizontal padding and a 64px min-width, so + the label sits inset from the cell edge (misaligned with the `Actions` header) by an + amount that DIFFERS per label — `Block` is narrower than the min-width and gets + centred in the leftover space, `Unblock` is not. Stripping both makes the button hug + its text, so header and both states start at the same x. + */} + {user.blocked ? ( + + ) : ( + + )} +
+ ), + }, + ]; + + // No status or reason columns: every row here is dormant-blocked and allowed by nothing, so + // those cells would repeat the section heading on every line. + const dormantColumns: ColumnTemplate[] = [ + { + of: 'name', + title: t(translations.colName), + cell: (user) => ( + + {user.name} + + ), + }, + { + of: 'email', + title: t(translations.colEmail), + cell: (user) => user.email, + }, + { + id: 'action', + title: t(translations.colActions), + className: 'whitespace-nowrap', + cell: (user) => ( +
+ +
+ ), + }, + ]; + + if (isLoading) return ; + + // Derived from the rows, not the server summary: block/unblock patch rows locally without a + // refetch, so a summary-bound count would drift the moment an admin disables someone. + // Split first: a dormant block is not a person with access, so it is counted out of the headline + // totals and out of the main table, and gets its own section below. + const dormantUsers = users.filter(isDormantBlock); + const accessUsers = users.filter((user) => !isDormantBlock(user)); + const totalWithAccess = accessUsers.filter((user) => !user.blocked).length; + const totalBlocked = accessUsers.filter((user) => user.blocked).length; + const filteredUsers = accessUsers.filter(matchesFilter); + // Only the filter menu is observable here — the search box lives inside Table and narrows the + // rows after this point, so a search alone does not surface the line. + const isFiltered = filteredUsers.length < accessUsers.length; + const mode = openToEveryone + ? t(translations.modeOpen) + : t(translations.modeScoped); + + // Remount the main table when the filter state changes so pagination snaps back to the first + // page: an admin on page 2 who narrows the filter below one page of results would otherwise be + // stranded on an empty page. The shared Table keeps pagination internal with no external setter + // and does not auto-reset the page index on a data change, so a key change is the only in-section + // lever. Keyed on the filter state alone (not the fetched data), so a background refetch does not + // disturb the current page. The dormant table below is unfiltered and needs none of this. + const filterKey = `${showActive}:${showBlocked}:${[...uncheckedRuleIds] + .sort((a, b) => a - b) + .join(',')}`; + + return ( +
+ {t(translations.heading)} + + + {totalBlocked > 0 + ? t(translations.summaryWithBlocked, { + count: totalWithAccess, + blocked: totalBlocked, + mode, + }) + : t(translations.summary, { count: totalWithAccess, mode })} + + + {/* + Only while the filter is narrowing: unfiltered, this line would repeat the totals verbatim. + The totals above stay put as the audit anchor — this answers the narrower question the + filter poses ("of the people this rule lets in, how many are blocked?"), which nothing else + on the page reports. + */} + {isFiltered && ( + + {t(translations.filteredCounts, { + count: filteredUsers.filter((user) => !user.blocked).length, + blocked: filteredUsers.filter((user) => user.blocked).length, + })} + + )} + +
+ user.id.toString()} + pagination={{ + initialPageSize: 20, + rowsPerPage: [10, 20, 50, DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (user, filterValue?: string): boolean => { + if (!filterValue) return true; + const query = filterValue.toLowerCase().trim(); + return ( + user.name.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query) + ); + }, + }, + }} + toolbar={{ + show: true, + buttons: [ + setShowActive((on) => !on)} + onToggleBlocked={(): void => setShowBlocked((on) => !on)} + onToggleRule={toggleRule} + ruleOptions={ruleOptions} + showActive={showActive} + showBlocked={showBlocked} + uncheckedRuleIds={uncheckedRuleIds} + />, + ], + }} + /> + + + {dormantUsers.length > 0 && ( +
+ + {t(translations.dormantHeading, { count: dormantUsers.length })} + + + + {t(translations.dormantExplanation)} + + +
+
user.id.toString()} + pagination={{ + initialPageSize: 10, + rowsPerPage: [10, 20, 50, DEFAULT_TABLE_ROWS_PER_PAGE], + }} + /> + + + )} + + ); +}; + +export default MarketplaceAccessSection; diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx new file mode 100644 index 00000000000..d2a218286e1 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, FormControlLabel, Switch, Typography } from '@mui/material'; + +import Prompt from 'lib/components/core/dialogs/Prompt'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + openToEveryone: boolean; + onOpenToEveryone: () => Promise; + onRestrict: () => Promise; +} + +const translations = defineMessages({ + scopedTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle', + defaultMessage: 'Access is limited to the rules below.', + }, + everyoneTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle', + defaultMessage: + 'The marketplace is open to all eligible staff: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive.', + }, + toggleLabel: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.toggleLabel', + defaultMessage: 'Open to everyone', + }, + openConfirmTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle', + defaultMessage: 'Open marketplace to everyone?', + }, + openConfirmBody: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody', + defaultMessage: + 'This makes the marketplace visible to all eligible staff: course managers/owners and instance instructors/administrators. You can restrict it again at any time; your scoped rules are kept.', + }, + restrictConfirmTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle', + defaultMessage: 'Restrict to scoped rules?', + }, + restrictConfirmBody: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody', + defaultMessage: + 'The marketplace will again be limited to the rules below. Eligible staff not covered by a rule will lose access.', + }, + confirmOpen: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen', + defaultMessage: 'Open to everyone', + }, + confirmRestrict: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict', + defaultMessage: 'Restrict', + }, +}); + +const MarketplaceAllowlistModeBanner = ({ + openToEveryone, + onOpenToEveryone, + onRestrict, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleConfirm = async (): Promise => { + setSubmitting(true); + try { + await (openToEveryone ? onRestrict() : onOpenToEveryone()); + setIsConfirmOpen(false); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + setIsConfirmOpen(true)} + /> + } + label={ + + {t(translations.toggleLabel)} + + } + labelPlacement="start" + sx={{ mr: 1 }} + /> + } + className="mb-4 [&_.MuiAlert-action]:items-center [&_.MuiAlert-action]:pt-0" + severity={openToEveryone ? 'success' : 'info'} + > + {openToEveryone + ? t(translations.everyoneTitle) + : t(translations.scopedTitle)} + + + setIsConfirmOpen(false)} + open={isConfirmOpen} + primaryColor={openToEveryone ? 'error' : 'primary'} + primaryDisabled={submitting} + primaryLabel={ + openToEveryone + ? t(translations.confirmRestrict) + : t(translations.confirmOpen) + } + title={ + openToEveryone + ? t(translations.restrictConfirmTitle) + : t(translations.openConfirmTitle) + } + > + {openToEveryone + ? t(translations.restrictConfirmBody) + : t(translations.openConfirmBody)} + + + ); +}; + +export default MarketplaceAllowlistModeBanner; diff --git a/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx b/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx new file mode 100644 index 00000000000..8a865a50e8f --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx @@ -0,0 +1,949 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; +import TestApp from 'utilities/TestApp'; + +import SystemAPI from 'api/system'; + +import MarketplaceAccessSection from '../MarketplaceAccessSection'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => mock.reset()); + +const ACCESS_URL = '/admin/marketplace_access'; +const BLOCKS_URL = '/admin/marketplace_access_blocks'; + +const activeUser = { + id: 1, + name: 'Jane Tan', + email: 'jane@nus.edu.sg', + courseCount: 3, + instanceRole: null, + allowedByRules: [ + { id: 10, ruleType: 'email_domain' as const, labelValue: 'nus.edu.sg' }, + ], + systemAdmin: false, + blocked: false, + blockId: null, +}; + +/** Blocked AND still allowed by a rule — a LIVE block, so they belong in the main table. */ +const blockedUser = { + id: 2, + name: 'Kumar Raj', + email: 'kumar@sch.edu.sg', + courseCount: 0, + instanceRole: 'instructor' as const, + allowedByRules: [ + { id: 10, ruleType: 'email_domain' as const, labelValue: 'nus.edu.sg' }, + ], + systemAdmin: false, + blocked: true, + blockId: 55, +}; + +/** + * Blocked with nothing granting them access — their rule was deleted while the block stood. The + * block denies nothing today, so this one belongs in the dormant section, not the main table. + */ +const dormantUser = { + id: 4, + name: 'Dormant Dan', + email: 'dan@sch.edu.sg', + courseCount: 1, + instanceRole: null, + allowedByRules: [], + systemAdmin: false, + blocked: true, + blockId: 77, +}; + +const adminUser = { + id: 3, + name: 'Root Admin', + email: 'root@coursemology.org', + courseCount: 0, + instanceRole: null, + allowedByRules: [], + systemAdmin: true, + blocked: false, + blockId: null, +}; + +const DOMAIN_RULE = { + id: 10, + ruleType: 'email_domain' as const, + userId: null, + userName: null, + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: 'nus.edu.sg', +}; + +const USER_RULE = { + id: 11, + ruleType: 'user' as const, + userId: 1, + userName: 'Jane Tan', + userEmail: 'jane@nus.edu.sg', + instanceId: null, + instanceName: null, + emailDomain: null, +}; + +const renderSection = (props?: { + openToEveryone?: boolean; + ruleVersion?: number; + rules?: (typeof DOMAIN_RULE | typeof USER_RULE)[]; +}): ReturnType => + render( + , + ); + +const accessGetCount = (): number => + mock.history.get.filter((request) => request.url === ACCESS_URL).length; + +it('renders the access list with annotations and a summary', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText('Jane Tan')).toBeVisible(); + expect(page.getByText('jane@nus.edu.sg')).toBeVisible(); + expect(page.getByText('Manages 3 courses')).toBeVisible(); + expect(page.getByText('Instance instructor')).toBeVisible(); + // Both fixtures are allowed by the same rule, so the label appears once per row. + expect(page.getAllByText('Email domain (nus.edu.sg)')).toHaveLength(2); + expect(page.getByText('Active')).toBeVisible(); + expect(page.getByText('Blocked')).toBeVisible(); +}); + +it('names the blocked total in the subtitle when anyone is blocked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + + expect( + await page.findByText( + 'Total with access: 1 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); +}); + +it('omits the blocked segment when nobody is blocked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + expect( + await page.findByText('Total with access: 1 · Scoped to the rules above'), + ).toBeVisible(); +}); + +it('reads the mode from props rather than the fetched summary', async () => { + // The parent owns the toggle, so a stale server summary must not win. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection({ openToEveryone: true }); + + expect( + await page.findByText('Total with access: 1 · Open to everyone'), + ).toBeVisible(); +}); + +it('shows Everyone as the reason when the marketplace is open to everyone', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true }); + + expect(await page.findByText('Everyone')).toBeVisible(); + expect(page.queryByText('Email domain (nus.edu.sg)')).not.toBeInTheDocument(); +}); + +it('lists every rule that grants a user access', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [ + { + ...activeUser, + allowedByRules: [ + { id: 10, ruleType: 'email_domain', labelValue: 'nus.edu.sg' }, + { id: 11, ruleType: 'user', labelValue: 'Jane Tan' }, + ], + }, + ], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText('Email domain (nus.edu.sg)')).toBeVisible(); + expect(page.getByText('User (Jane Tan)')).toBeVisible(); +}); + +it('moves a block with no matching rule into the dormant list', async () => { + // Their rule was deleted while the block stood. The block denies nothing today, so they are not + // "people with access" — but it must stay visible and clearable, because re-adding a matching + // rule would silently leave them blocked. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, dormantUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + expect(page.getByText('Dormant blocks (1)')).toBeVisible(); + expect(page.getByText('Dormant Dan')).toBeVisible(); + // Counted out of the headline totals, which describe people with access. + expect( + page.getByText('Total with access: 1 · Scoped to the rules above'), + ).toBeVisible(); +}); + +it('keeps a block that a rule still backs in the main table', async () => { + // This block IS denying access right now, so it belongs with the people it applies to. + mock.onGet(ACCESS_URL).reply(200, { + users: [blockedUser], + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Kumar Raj'); + + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); + expect( + page.getByText( + 'Total with access: 0 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); +}); + +it('shows no dormant section when there are no dormant blocks', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); +}); + +it('treats a block as dormant only outside everyone-mode', async () => { + // Everyone-mode grants access outside the rules, so an empty allowedByRules is not "no access" — + // the block is live and the row stays in the main table. + mock.onGet(ACCESS_URL).reply(200, { + users: [dormantUser], + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true }); + await page.findByText('Dormant Dan'); + + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); +}); + +it('clears a dormant block and drops the row', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, dormantUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + mock.onDelete(`${BLOCKS_URL}/77`).reply(200); + + const page = renderSection(); + await page.findByText('Dormant Dan'); + + fireEvent.click(page.getByRole('button', { name: 'Clear block' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${BLOCKS_URL}/77`); + + // Nothing grants them access, so clearing the block removes their last reason to be listed. + await waitFor(() => + expect(page.queryByText('Dormant Dan')).not.toBeInTheDocument(), + ); + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); +}); + +it('pins the width of the two state-driven columns', async () => { + // Status and Actions are the only columns whose content changes with row STATE + // (Active↔Blocked, Block↔Unblock), so under table-layout:auto they resize as people are + // blocked and shift every column to their left. The width must sit on a wrapper INSIDE the cell, + // not on the cell: a table cell's width is only a suggestion under auto layout, so a cell-level + // class leaves the shift in place. jsdom does no layout, so this asserts the wrapper exists and + // is pinned in BOTH states; the visual claim is covered by manual verification. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + // Both status states, so a width applied to only one branch of the ternary would fail. + expect(page.getByText('Blocked').closest('div.w-28')).toBeInTheDocument(); + expect(page.getByText('Active').closest('div.w-28')).toBeInTheDocument(); + + // Both action states, for the same reason. + const reEnable = page.getByRole('button', { name: 'Unblock' }); + const disable = page.getByRole('button', { name: 'Block' }); + expect(reEnable.closest('div.w-24')).toBeInTheDocument(); + expect(disable.closest('div.w-24')).toBeInTheDocument(); + + // MUI's button padding and 64px min-width inset each label from the cell edge by a per-label + // amount, so the two states and the column header start at different x without these. + expect(reEnable).toHaveClass('min-w-0', 'px-0'); + expect(disable).toHaveClass('min-w-0', 'px-0'); +}); + +it('labels a system admin in both reason columns', async () => { + // The admin manages nothing and matches no rule, so without the systemAdmin branch these cells + // would read '—' and 'No matching rule' for someone who in fact bypasses every gate. + mock.onGet(ACCESS_URL).reply(200, { + users: [adminUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Root Admin'); + + expect(page.getAllByText('System admin')).toHaveLength(2); + expect(page.queryByText('No matching rule')).not.toBeInTheDocument(); +}); + +it('labels a system admin as such even when open to everyone', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [adminUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true }); + await page.findByText('Root Admin'); + + expect(page.getAllByText('System admin')).toHaveLength(2); + expect(page.queryByText('Everyone')).not.toBeInTheDocument(); +}); + +it('reports filtered counts only while the filter narrows the set', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + // Unfiltered: the line would only repeat the totals, so it is absent. + expect(page.queryByText(/^Filtered:/)).not.toBeInTheDocument(); + + await toggleFilter(page, 'Active'); + + expect( + await page.findByText('Filtered: 0 with access · 1 blocked'), + ).toBeVisible(); + // The totals stay put as the audit anchor rather than being rewritten by the filter. + expect( + page.getByText( + 'Total with access: 1 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); + + await toggleFilter(page, 'Active'); + + await waitFor(() => + expect(page.queryByText(/^Filtered:/)).not.toBeInTheDocument(), + ); +}); + +it('offers no disable action for a system admin', async () => { + // Blocking an admin cannot revoke anything (`can :manage, :all` outranks the allow-list), so the + // action would be a lie — the row would say "Blocked" while they kept full access. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, adminUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Root Admin'); + + // Exactly one Block button, and it belongs to the non-admin. + expect(page.getAllByRole('button', { name: 'Block' })).toHaveLength(1); + expect( + page.queryByRole('button', { name: 'Unblock' }), + ).not.toBeInTheDocument(); +}); + +it('filters system admins in and out via their own option', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, adminUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Root Admin'); + + await toggleFilter(page, 'System admin'); + await waitFor(() => + expect(page.queryByText('Root Admin')).not.toBeInTheDocument(), + ); + expect(page.getByText('Jane Tan')).toBeVisible(); + + await toggleFilter(page, 'System admin'); + await waitFor(() => expect(page.getByText('Root Admin')).toBeVisible()); +}); + +it('keeps a system admin listed when a rule box is unchecked', async () => { + // An admin carries no rules, so treating rules as the only reasons would drop them from the + // table the moment any rule filter is touched. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, adminUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Root Admin'); + + await toggleFilter(page, 'Email domain (nus.edu.sg)'); + + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + expect(page.getByText('Root Admin')).toBeVisible(); +}); + +it('offers no system-admin option when nobody listed is one', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + await openFilter(page); + + expect( + page.queryByRole('checkbox', { name: 'System admin' }), + ).not.toBeInTheDocument(); +}); + +it('links each name to that user', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + const link = await page.findByRole('link', { name: 'Jane Tan' }); + expect(link).toHaveAttribute('href', '/users/1'); +}); + +it('refetches the list when the rule version changes', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + expect(accessGetCount()).toBe(1); + + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + await waitFor(() => expect(accessGetCount()).toBe(2)); + expect(await page.findByText('Kumar Raj')).toBeVisible(); +}); + +it('does not refetch when unrelated props change', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + expect(accessGetCount()).toBe(1); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + await waitFor(() => + expect( + page.getByText('Total with access: 1 · Open to everyone'), + ).toBeVisible(), + ); + expect(accessGetCount()).toBe(1); +}); + +it('disables an active user and flips the row to Blocked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + mock.onPost(BLOCKS_URL).reply(200, { id: 77, userId: 1 }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + fireEvent.click(page.getByRole('button', { name: 'Block' })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ user_id: 1 }); + + expect(await page.findByRole('button', { name: 'Unblock' })).toBeVisible(); + expect(page.getByText('Blocked')).toBeVisible(); +}); + +it('updates the subtitle counts after a local disable, without refetching', async () => { + // Block/unblock patch rows in place, so counts must come from the rows, not the server summary. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + mock.onPost(BLOCKS_URL).reply(200, { id: 77, userId: 1 }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + fireEvent.click(page.getByRole('button', { name: 'Block' })); + + expect( + await page.findByText( + 'Total with access: 0 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); + expect(accessGetCount()).toBe(1); +}); + +it('re-enables a blocked user and flips the row to Active', async () => { + // A rule still allows them, so unblocking leaves them listed — the row flips rather than going. + mock.onGet(ACCESS_URL).reply(200, { + users: [ + { + ...blockedUser, + allowedByRules: [ + { + id: 10, + ruleType: 'email_domain' as const, + labelValue: 'nus.edu.sg', + }, + ], + }, + ], + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: false }, + }); + mock.onDelete(`${BLOCKS_URL}/55`).reply(200); + + const page = renderSection(); + await page.findByText('Kumar Raj'); + + fireEvent.click(page.getByRole('button', { name: 'Unblock' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${BLOCKS_URL}/55`); + + expect(await page.findByRole('button', { name: 'Block' })).toBeVisible(); + expect(page.getByText('Active')).toBeVisible(); +}); + +it('keeps an unblocked user listed in everyone-mode, where rules are empty by design', async () => { + // Everyone-mode grants access outside the rules, so an empty allowedByRules is NOT a signal that + // they have no access — dropping on empty alone would wrongly remove them here. + mock.onGet(ACCESS_URL).reply(200, { + users: [dormantUser], // no rules at all, so only the everyone-mode branch can keep them + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: true }, + }); + mock.onDelete(`${BLOCKS_URL}/77`).reply(200); + + const page = renderSection({ openToEveryone: true }); + await page.findByText('Dormant Dan'); + + fireEvent.click(page.getByRole('button', { name: 'Unblock' })); + + expect(await page.findByRole('button', { name: 'Block' })).toBeVisible(); + expect(page.getByText('Dormant Dan')).toBeVisible(); +}); + +it('searches by name and email', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await userEvent.type( + page.getByPlaceholderText('Search by name or email'), + 'kumar@', + ); + + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +// Anchor on the menu's role, not on its "Status" heading: the table has a Status column, so +// findByText('Status') matches both the column header and the filter panel. +const openFilter = async (page: ReturnType): Promise => { + fireEvent.click(page.getByRole('button', { name: 'Filter' })); + await page.findByRole('menu'); +}; + +const closeFilter = async (page: ReturnType): Promise => { + // MUI's Menu renders a modal backdrop over the table. Close it before asserting on, or typing + // into, anything underneath — otherwise the assertions race the overlay rather than the filter. + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(page.queryByRole('menu')).not.toBeInTheDocument()); +}; + +/** Open the filter, toggle one checkbox by its accessible name, then close it. */ +const toggleFilter = async ( + page: ReturnType, + checkboxName: string, +): Promise => { + await openFilter(page); + fireEvent.click(page.getByRole('checkbox', { name: checkboxName })); + await closeFilter(page); +}; + +it('shows both active and blocked users by default', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText('Jane Tan')).toBeVisible(); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it('shows only blocked users when Active is unchecked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await toggleFilter(page, 'Active'); + + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it('filters to the users a specific rule grants access to', async () => { + const otherUser = { + ...activeUser, + id: 3, + name: 'Wei Ling', + email: 'wei@moe.gov.sg', + allowedByRules: [ + { id: 11, ruleType: 'user' as const, labelValue: 'Wei Ling' }, + ], + }; + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, otherUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection({ rules: [DOMAIN_RULE, USER_RULE] }); + await page.findByText('Jane Tan'); + + // Uncheck the user rule; only the domain-granted user should remain. + await toggleFilter(page, 'User (Jane Tan)'); + + await waitFor(() => + expect(page.queryByText('Wei Ling')).not.toBeInTheDocument(), + ); + // Assert on the email, not the name: 'Jane Tan' is also the user rule's checkbox label, so a + // name query would match two elements whenever the filter menu is open. + expect(page.getByText('jane@nus.edu.sg')).toBeVisible(); +}); + +it('hides the rule group when the marketplace is open to everyone', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true, rules: [DOMAIN_RULE] }); + await page.findByText('Jane Tan'); + await openFilter(page); + + // Scoped to the menu: the table also has a Status column header. + expect(within(page.getByRole('menu')).getByText('Status')).toBeVisible(); + expect(page.queryByText('Allowed by rule')).not.toBeInTheDocument(); + expect( + page.queryByRole('checkbox', { name: 'Email domain (nus.edu.sg)' }), + ).not.toBeInTheDocument(); +}); + +it('badges the filter button while any box is unchecked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + await openFilter(page); + + fireEvent.click(page.getByRole('checkbox', { name: 'Active' })); + + // Scope to the badge: a bare '1' would also match pagination and count text. + expect( + await page.findByText('1', { selector: '.MuiBadge-badge' }), + ).toBeVisible(); +}); + +it('composes the filter with the search field', async () => { + const otherBlocked = { + ...blockedUser, + id: 4, + name: 'Siti Nur', + email: 'siti@sch.edu.sg', + blockId: 56, + }; + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser, otherBlocked], + summary: { totalWithAccess: 1, totalBlocked: 2, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await toggleFilter(page, 'Active'); + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + + await userEvent.type( + page.getByPlaceholderText('Search by name or email'), + 'siti', + ); + + await waitFor(() => + expect(page.queryByText('Kumar Raj')).not.toBeInTheDocument(), + ); + expect(page.getByText('Siti Nur')).toBeVisible(); +}); + +it('restores everything when the filter is cleared', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await toggleFilter(page, 'Active'); + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + + await openFilter(page); + fireEvent.click(page.getByRole('button', { name: 'Clear all' })); + await closeFilter(page); + + expect(await page.findByText('Jane Tan')).toBeVisible(); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it("publishes each rule's grant count after the access list loads", async () => { + const onMatchCounts = jest.fn(); + mock.onGet(ACCESS_URL).reply(200, { + users: [ + { + ...activeUser, + allowedByRules: [ + { id: 10, ruleType: 'email_domain', labelValue: 'nus.edu.sg' }, + { id: 11, ruleType: 'user', labelValue: 'Jane Tan' }, + ], + }, + blockedUser, // allowedByRules: [rule 10] + ], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = render( + , + ); + await page.findByText('Jane Tan'); + + await waitFor(() => expect(onMatchCounts).toHaveBeenCalled()); + const counts: Map = + onMatchCounts.mock.calls[onMatchCounts.mock.calls.length - 1][0]; + // Rule 10 grants both listed users; rule 11 grants only the first. + expect(counts.get(10)).toBe(2); + expect(counts.get(11)).toBe(1); +}); + +it('omits a rule that grants access to nobody from the published counts', async () => { + // A zero-match rule contributes no key — its absence is what the rules table reads as "nobody". + const onMatchCounts = jest.fn(); + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], // allowedByRules: [rule 10] only + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = render( + , + ); + await page.findByText('Jane Tan'); + + await waitFor(() => expect(onMatchCounts).toHaveBeenCalled()); + const counts: Map = + onMatchCounts.mock.calls[onMatchCounts.mock.calls.length - 1][0]; + expect(counts.has(11)).toBe(false); + expect(counts.get(10)).toBe(1); +}); + +it('republishes counts when the rule version changes', async () => { + const onMatchCounts = jest.fn(); + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], // rule 10 grants 1 + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = render( + , + ); + await page.findByText('Jane Tan'); + await waitFor(() => expect(onMatchCounts).toHaveBeenCalledTimes(1)); + + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], // rule 10 now grants 2 + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + await waitFor(() => expect(onMatchCounts).toHaveBeenCalledTimes(2)); + const counts: Map = + onMatchCounts.mock.calls[onMatchCounts.mock.calls.length - 1][0]; + expect(counts.get(10)).toBe(2); +}); + +it('returns to the first page when the filter narrows the result set', async () => { + // 21 people are granted by the domain rule and 4 by the user rule, so the list spans two pages at + // the default page size of 20. An admin on page 2 who filters out the domain rule drops to a + // single page — the table must snap back to page 1 rather than strand them on an empty page 2. + const domainUsers = Array.from({ length: 21 }, (_, i) => ({ + id: i + 1, + name: `Domain User ${i + 1}`, + email: `domain${i + 1}@nus.edu.sg`, + courseCount: 1, + instanceRole: null, + allowedByRules: [ + { id: 10, ruleType: 'email_domain', labelValue: 'nus.edu.sg' }, + ], + systemAdmin: false, + blocked: false, + blockId: null, + })); + const userRuleUsers = Array.from({ length: 4 }, (_, i) => ({ + id: 100 + i, + name: `Rule User ${i + 1}`, + email: `rule${i + 1}@moe.gov.sg`, + courseCount: 1, + instanceRole: null, + allowedByRules: [{ id: 11, ruleType: 'user', labelValue: 'Jane Tan' }], + systemAdmin: false, + blocked: false, + blockId: null, + })); + mock.onGet(ACCESS_URL).reply(200, { + users: [...domainUsers, ...userRuleUsers], + summary: { totalWithAccess: 25, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection({ rules: [DOMAIN_RULE, USER_RULE] }); + await page.findByText('Domain User 1'); + + // Go to page 2 — the four user-rule people live here, past the first 20 domain users. + fireEvent.click(page.getByRole('button', { name: 'Go to next page' })); + await page.findByText('Rule User 1'); + expect(page.queryByText('Domain User 1')).not.toBeInTheDocument(); + + // Filter out the domain rule: only the four user-rule people remain — a single page. + await toggleFilter(page, 'Email domain (nus.edu.sg)'); + + // Snapped back to page 1: the remaining people are visible, not stranded behind an empty page 2. + expect(await page.findByText('Rule User 1')).toBeVisible(); + expect(page.getByText('Rule User 4')).toBeVisible(); +}); diff --git a/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx b/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx new file mode 100644 index 00000000000..91881017aea --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx @@ -0,0 +1,467 @@ +import { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Alert, + Autocomplete, + Box, + Chip, + MenuItem, + TextField, + Typography, +} from '@mui/material'; +import { AxiosError } from 'axios'; +import { AllowlistRulePreviewData } from 'types/system/marketplaceAccess'; +import { + AllowlistRuleFormData, + AllowlistRuleType, +} from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import Prompt from 'lib/components/core/dialogs/Prompt'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface InstanceOption { + id: number; + name: string; +} + +interface Props { + open: boolean; + onClose: () => void; + onSubmit: (data: AllowlistRuleFormData) => Promise; +} + +const translations = defineMessages({ + title: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.title', + defaultMessage: 'Add marketplace access rule', + }, + ruleType: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.ruleType', + defaultMessage: 'Rule type', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeUser', + defaultMessage: 'Specific eligible user', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance', + defaultMessage: 'All eligible users in an instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain', + defaultMessage: 'All eligible users with an email domain', + }, + userEmail: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.userEmail', + defaultMessage: 'Eligible user email', + }, + eligibilityHint: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.eligibilityHint', + defaultMessage: + 'Eligible users refer to course managers & owners (of any course) and instance instructors & administrators (of any instance).', + }, + instanceLabel: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.instanceId', + defaultMessage: 'Instance', + }, + emailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain', + defaultMessage: 'Email domain (e.g. schools.gov.sg)', + }, + next: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.next', + defaultMessage: 'Next', + }, + back: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.back', + defaultMessage: 'Back', + }, + confirmAdd: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.confirmAdd', + defaultMessage: 'Confirm add', + }, + counts: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.counts', + defaultMessage: + 'Grants access to {matched, plural, one {# eligible user} other {# eligible users}}', + }, + countsOfMatched: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.countsOfMatched', + defaultMessage: + 'Grants access to {granted} of {matched, plural, one {# eligible user} other {# eligible users}}', + }, + countsExistingClause: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.countsExistingClause', + defaultMessage: '{existing} already had access', + }, + countsBlockedClause: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.countsBlockedClause', + defaultMessage: '{blocked} blocked individually', + }, + noMatches: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.noMatches', + defaultMessage: 'This rule matches nobody eligible right now.', + }, + openToEveryone: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.openToEveryone', + defaultMessage: + 'The marketplace is currently open to everyone; this rule takes effect only if you restrict access again.', + }, + previewFailure: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.previewFailure', + defaultMessage: 'Could not preview this rule.', + }, + markerNew: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.markerNew', + defaultMessage: 'New', + }, + markerExisting: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.markerExisting', + defaultMessage: 'Already has access', + }, + markerBlocked: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.markerBlocked', + defaultMessage: 'Blocked', + }, + managesCourses: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.managesCourses', + defaultMessage: 'Manages {count, plural, one {# course} other {# courses}}', + }, + colName: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.colName', + defaultMessage: 'Name', + }, + colEligibleVia: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.colEligibleVia', + defaultMessage: 'Eligible via', + }, + colStatus: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.colStatus', + defaultMessage: 'Status', + }, + searchPlaceholder: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.searchPlaceholder', + defaultMessage: 'Search by name or email', + }, +}); + +const MarketplaceAllowlistRuleForm = ({ + open, + onClose, + onSubmit, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [step, setStep] = useState<1 | 2>(1); + const [ruleType, setRuleType] = useState('email_domain'); + const [value, setValue] = useState(''); + const [instanceId, setInstanceId] = useState(null); + const [instances, setInstances] = useState([]); + const [instancesLoaded, setInstancesLoaded] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [previewing, setPreviewing] = useState(false); + const [preview, setPreview] = useState(null); + // A validation verdict (400) blocks the add; a transport failure does not. + const [rejection, setRejection] = useState(null); + const [previewFailed, setPreviewFailed] = useState(false); + + // The instance list is only needed for the `instance` rule type, so fetch it lazily the first + // time that type is selected — keeps the page's initial load free of an unused request. + useEffect(() => { + if (ruleType !== 'instance' || instancesLoaded) return; + SystemAPI.admin.indexInstances().then((response) => { + setInstances( + response.data.instances.map((instance) => ({ + id: instance.id, + name: instance.name, + })), + ); + setInstancesLoaded(true); + }); + }, [ruleType, instancesLoaded]); + + const buildData = (): AllowlistRuleFormData => { + switch (ruleType) { + case 'user': + return { ruleType, email: value.trim() }; + case 'instance': + return { ruleType, instanceId: instanceId ?? undefined }; + default: + return { ruleType, emailDomain: value.trim() }; + } + }; + + const reset = (): void => { + setStep(1); + setRuleType('email_domain'); + setValue(''); + setInstanceId(null); + setPreview(null); + setRejection(null); + setPreviewFailed(false); + }; + + const handleClose = (): void => { + reset(); + onClose(); + }; + + const goToPreview = async (): Promise => { + setStep(2); + setPreviewing(true); + setPreview(null); + setRejection(null); + setPreviewFailed(false); + + try { + const response = + await SystemAPI.admin.previewMarketplaceAllowlistRule(buildData()); + setPreview(response.data); + } catch (error) { + const response = error instanceof AxiosError ? error.response : undefined; + const message = response?.data?.errors; + if (response?.status === 400 && message) setRejection(message); + else setPreviewFailed(true); + } finally { + setPreviewing(false); + } + }; + + const submit = async (): Promise => { + setSubmitting(true); + await onSubmit(buildData()).finally(() => setSubmitting(false)); + reset(); + }; + + const valueLabel = { + user: t(translations.userEmail), + instance: t(translations.instanceLabel), + email_domain: t(translations.emailDomain), + }[ruleType]; + + const missingValue = + ruleType === 'instance' ? instanceId === null : value.trim() === ''; + + const marker = (user: AllowlistRulePreviewData['users'][number]): string => { + if (user.blocked) return t(translations.markerBlocked); + if (user.alreadyHasAccess) return t(translations.markerExisting); + return t(translations.markerNew); + }; + + // Blocked is the one status that means the rule does not reach this person, so it is the one + // worth colouring; New and Already-has-access are both benign and stay neutral. + const markerColor = ( + user: AllowlistRulePreviewData['users'][number], + ): 'warning' | 'default' => (user.blocked ? 'warning' : 'default'); + + const previewColumns: ColumnTemplate< + AllowlistRulePreviewData['users'][number] + >[] = [ + { + of: 'name', + title: t(translations.colName), + searchable: true, + cell: (user) => ( +
+ + {user.name} + + + + {user.email} + +
+ ), + }, + { + id: 'eligibleVia', + title: t(translations.colEligibleVia), + cell: (user) => + t(translations.managesCourses, { count: user.courseCount }), + }, + { + id: 'status', + title: t(translations.colStatus), + // Fixed width, wide enough for the longest marker: the table sizes columns from the rows on + // the CURRENT page, so a page holding a Blocked chip was laying out differently from a page + // of nothing but New, and the whole table shifted as the admin paged through. + className: 'w-[16rem]', + cell: (user) => ( + + ), + }, + ]; + + const renderCounts = (): JSX.Element => { + if (preview === null) return ; + if (preview.openToEveryone) { + return {t(translations.openToEveryone)}; + } + if (preview.matchedCount === 0) { + return {t(translations.noMatches)}; + } + + // "N are new" was noise when everyone is new (the common case); the useful signal is who the + // rule does NOT reach, so name those groups only when there IS one. A blocked user keeps their + // individual block — the rule grants them nothing — so they are neither granted nor "existing". + const blocked = preview.blockedCount; + const existing = preview.matchedCount - preview.newCount - blocked; + const clauses = [ + existing > 0 && t(translations.countsExistingClause, { existing }), + blocked > 0 && t(translations.countsBlockedClause, { blocked }), + ].filter(Boolean); + + const headline = + clauses.length > 0 + ? t(translations.countsOfMatched, { + granted: preview.newCount, + matched: preview.matchedCount, + }) + : t(translations.counts, { matched: preview.matchedCount }); + + return ( + + {[headline, ...clauses].join(' · ')} + + ); + }; + + const renderStepTwo = (): JSX.Element => { + if (previewing) return ; + if (rejection !== null) return {rejection}; + if (previewFailed) { + return {t(translations.previewFailure)}; + } + + // The prebuilt Table, not a hand-rolled list: a domain or instance rule routinely matches + // hundreds of people, which needs pagination and search, and its real columns keep the three + // headers aligned for free. With nobody matched there is nothing to page or search, so the + // headers and pagination chrome would be furniture around an empty box — the counts line + // already says what happened. + const users = preview?.users ?? []; + + return ( +
+ {renderCounts()} + + {users.length > 0 && ( +
user.id.toString()} + pagination={{ initialPageSize: 10, rowsPerPage: [10, 20, 50, 100] }} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (user, filterValue?: string): boolean => { + if (!filterValue) return true; + const query = filterValue.toLowerCase().trim(); + return ( + user.name.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query) + ); + }, + }, + }} + /> + )} + + ); + }; + + return ( + setStep(1)} + onClose={handleClose} + open={open} + primaryDisabled={ + step === 1 + ? missingValue + : submitting || previewing || rejection !== null + } + primaryLabel={ + step === 1 ? t(translations.next) : t(translations.confirmAdd) + } + secondaryLabel={step === 2 ? t(translations.back) : undefined} + title={t(translations.title)} + > + {step === 1 ? ( +
+ { + setRuleType(e.target.value as AllowlistRuleType); + setValue(''); + setInstanceId(null); + }} + select + value={ruleType} + > + {t(translations.typeUser)} + {t(translations.typeInstance)} + + {t(translations.typeEmailDomain)} + + + + {ruleType === 'instance' ? ( + instance.name} + isOptionEqualToValue={(instance, chosen): boolean => + instance.id === chosen.id + } + onChange={(_, instance): void => + setInstanceId(instance?.id ?? null) + } + options={instances} + renderInput={(inputProps): JSX.Element => ( + + )} + renderOption={(optionProps, instance): JSX.Element => ( + + {instance.name} + + )} + value={ + instances.find((instance) => instance.id === instanceId) ?? null + } + /> + ) : ( + setValue(e.target.value)} + value={value} + /> + )} + + {/* `caption` renders inline by default, which drops the parent's vertical rhythm. */} + + {t(translations.eligibilityHint)} + +
+ ) : ( +
{renderStepTwo()}
+ )} +
+ ); +}; + +export default MarketplaceAllowlistRuleForm; diff --git a/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx b/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx new file mode 100644 index 00000000000..90bddf501f9 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx @@ -0,0 +1,543 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { act, fireEvent, render, waitFor } from 'test-utils'; + +import SystemAPI from 'api/system'; +import { LOADING_INDICATOR_TEST_ID } from 'lib/components/core/LoadingIndicator'; + +import MarketplaceAllowlistRuleForm from '../MarketplaceAllowlistRuleForm'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => mock.reset()); + +const PREVIEW_URL = '/admin/marketplace_allowlist_rules/preview'; +const NUS_DOMAIN = 'nus.edu.sg'; +const EMAIL_DOMAIN_SUBTITLE = 'Email domain (e.g. schools.gov.sg)'; +const CONFIRM_ADD = 'Confirm add'; +const GRANT_ACCESS_TO_STAFF = 'Grants access to 1 eligible user'; + +const previewUser = { + id: 1, + name: 'Jane Tan', + email: 'jane@nus.edu.sg', + courseCount: 2, + instanceRole: null, + alreadyHasAccess: false, + blocked: false, +}; + +const renderForm = ( + onSubmit = jest.fn().mockResolvedValue(undefined), + onClose = jest.fn(), +): { + page: ReturnType; + onSubmit: jest.Mock; + onClose: jest.Mock; +} => { + const page = render( + , + ); + return { page, onSubmit, onClose }; +}; + +const fillDomainAndAdvance = async ( + page: ReturnType, + domain = NUS_DOMAIN, +): Promise => { + // findBy, not getBy: test-utils' render mounts providers asynchronously, so the dialog's fields + // are not in the DOM on the first tick. + await userEvent.type( + await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE), + domain, + ); + fireEvent.click(page.getByRole('button', { name: 'Next' })); +}; + +it('previews the rule once when advancing to step 2', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 12, + newCount: 5, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'Grants access to 5 of 12 eligible users · 7 already had access', + ), + ).toBeVisible(); + // Settle any post-response re-render before pinning the count: a duplicate request fired from an + // effect would be recorded AFTER the counts paint, so asserting at paint time would miss exactly + // the failure this guards against. + await act(async () => { + await Promise.resolve(); + }); + expect(mock.history.post).toHaveLength(1); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'email_domain', email_domain: NUS_DOMAIN }, + }); +}); + +it('lists the matched people with links and a new marker', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 2, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [ + previewUser, + { + ...previewUser, + id: 2, + name: 'Kumar Raj', + email: 'kumar@nus.edu.sg', + // Distinct from Jane's 2 so each row's count is queryable on its own; also covers the + // singular arm of the `{count, plural, ...}` message. + courseCount: 1, + alreadyHasAccess: true, + }, + ], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + const link = await page.findByRole('link', { name: 'Jane Tan' }); + expect(link).toHaveAttribute('href', '/users/1'); + expect(page.getByText('New')).toBeVisible(); + expect(page.getByText('Already has access')).toBeVisible(); + expect(page.getByText('Manages 2 courses')).toBeVisible(); + expect(page.getByText('Manages 1 course')).toBeVisible(); + expect(page.getByText('jane@nus.edu.sg')).toBeVisible(); +}); + +it('heads the preview list with its three columns', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Name')).toBeVisible(); + expect(page.getByText('Eligible via')).toBeVisible(); + expect(page.getByText('Status')).toBeVisible(); +}); + +it('drops the table entirely when nobody is matched', async () => { + // Column headers and pagination chrome around an empty box say nothing the counts line has not + // already said. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 0, + newCount: 0, + blockedCount: 0, + openToEveryone: false, + users: [], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + await page.findByText('This rule matches nobody eligible right now.'); + expect(page.queryByRole('table')).not.toBeInTheDocument(); + expect(page.queryByText('Eligible via')).not.toBeInTheDocument(); + expect( + page.queryByPlaceholderText('Search by name or email'), + ).not.toBeInTheDocument(); +}); + +it('marks a blocked match', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 0, + blockedCount: 1, + openToEveryone: false, + users: [{ ...previewUser, blocked: true }], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Blocked')).toBeVisible(); +}); + +it('names blocked matches apart from those who already had access', async () => { + // The counts line used to derive its "already had access" number as matched - new, which swept + // blocked people into it and claimed the rule granted them access. They are held back by their + // own block, which the rule does not lift, so they are neither granted nor pre-existing. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 10, + newCount: 6, + blockedCount: 3, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'Grants access to 6 of 10 eligible users · 1 already had access · 3 blocked individually', + ), + ).toBeVisible(); +}); + +it('omits the already-had-access clause when every exclusion is a block', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 200, + newCount: 197, + blockedCount: 3, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'Grants access to 197 of 200 eligible users · 3 blocked individually', + ), + ).toBeVisible(); +}); + +it('prefers the blocked marker over already-has-access', async () => { + // A blocked person may also already hold access; "Blocked" is the marker that matters, because + // the rule will not let them in either way. Without this the two branches could be swapped and + // every other example would still pass. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 0, + blockedCount: 1, + openToEveryone: false, + users: [{ ...previewUser, alreadyHasAccess: true, blocked: true }], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Blocked')).toBeVisible(); + expect(page.queryByText('Already has access')).not.toBeInTheDocument(); +}); + +it('shows a loading state while the preview is in flight', async () => { + let release = (): void => {}; + mock.onPost(PREVIEW_URL).reply( + () => + new Promise((resolve) => { + release = (): void => + resolve([ + 200, + { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }, + ]); + }), + ); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByTestId(LOADING_INDICATOR_TEST_ID)).toBeVisible(); + // Confirming before the verdict lands would create a rule the admin never previewed. + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeDisabled(); + + release(); + + expect(await page.findByText(GRANT_ACCESS_TO_STAFF)).toBeVisible(); + expect(page.queryByTestId(LOADING_INDICATOR_TEST_ID)).not.toBeInTheDocument(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('flags a zero-match rule with a warning severity, and keeps it addable', async () => { + // The rule matching nobody reports a problem, so the alert is a warning, not an info note; but a + // zero-match rule is still legitimate (e.g. pre-provisioning a domain before its staff exist), so + // the add stays enabled. Asserting the severity, not just the text, is what pins info→warning. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 0, + newCount: 0, + blockedCount: 0, + openToEveryone: false, + users: [], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + const message = await page.findByText( + 'This rule matches nobody eligible right now.', + ); + expect(message).toBeVisible(); + expect(message.closest('.MuiAlert-root')).toHaveClass( + 'MuiAlert-standardWarning', + ); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('explains that the rule is inert while the marketplace is open to everyone', async () => { + // matchedCount 0 as well, so this also pins the branch ORDER: the open-to-everyone message must + // win over the "matches nobody" one, which is the more useful thing to say here. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 0, + newCount: 0, + blockedCount: 0, + openToEveryone: true, + users: [], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'The marketplace is currently open to everyone; this rule takes effect only if you restrict access again.', + ), + ).toBeVisible(); +}); + +it('blocks a duplicate rule and reports the server message', async () => { + mock.onPost(PREVIEW_URL).reply(400, { + errors: 'Email domain already has the same rule.', + }); + + const { page, onSubmit } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText('Email domain already has the same rule.'), + ).toBeVisible(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeDisabled(); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +it('still allows adding when the preview request itself fails', async () => { + // A preview outage is not a verdict on the rule; it must not block creation. + mock.onPost(PREVIEW_URL).reply(500); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Could not preview this rule.')).toBeVisible(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('treats a 400 with no message as an outage, not a verdict', async () => { + // Only a 400 that says what is wrong is a rejection. A bare 400 is a broken response, and must + // take the soft path rather than silently blocking creation with no explanation. + mock.onPost(PREVIEW_URL).reply(400, {}); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Could not preview this rule.')).toBeVisible(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('submits the rule from step 2', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page, onSubmit } = renderForm(); + await fillDomainAndAdvance(page); + await page.findByText(GRANT_ACCESS_TO_STAFF); + + fireEvent.click(page.getByRole('button', { name: CONFIRM_ADD })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + ruleType: 'email_domain', + emailDomain: NUS_DOMAIN, + }), + ); +}); + +it('keeps the entered value when going back to step 1', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + await page.findByText(GRANT_ACCESS_TO_STAFF); + + fireEvent.click(page.getByRole('button', { name: 'Back' })); + + expect(await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE)).toHaveValue( + NUS_DOMAIN, + ); +}); + +it('resets to a clean step 1 when cancelled', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 4, + newCount: 2, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page, onClose } = renderForm(); + await fillDomainAndAdvance(page); + await page.findByText( + 'Grants access to 2 of 4 eligible users · 2 already had access', + ); + + fireEvent.click(page.getByRole('button', { name: 'Cancel' })); + + expect(onClose).toHaveBeenCalled(); + + // The dialog stays mounted (its `open` belongs to the parent), so the reset is observable: back + // at step 1, value cleared, cached preview discarded. Without this the next open would resume + // mid-flow, showing a preview of a rule the admin already abandoned. + expect(await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE)).toHaveValue(''); + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + expect( + page.queryByText( + 'Grants access to 2 of 4 eligible users · 2 already had access', + ), + ).not.toBeInTheDocument(); +}); + +it('previews a user rule from an email address', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + + fireEvent.mouseDown(await page.findByLabelText('Rule type')); + fireEvent.click(page.getByRole('option', { name: 'Specific eligible user' })); + + // Surrounding whitespace is a paste artefact, not part of the address. + await userEvent.type( + page.getByLabelText('Eligible user email'), + ' jane@nus.edu.sg ', + ); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + + await page.findByText(GRANT_ACCESS_TO_STAFF); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'user', email: 'jane@nus.edu.sg' }, + }); +}); + +it('previews an instance rule, loading the instance list lazily and once', async () => { + mock.onGet('/admin/instances').reply(200, { + instances: [ + { id: 1, name: 'Default', host: 'coursemology.org' }, + { id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }, + ], + }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 3, + newCount: 3, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + + // The instance list is not fetched until the instance rule type is chosen. + expect(await page.findByLabelText('Rule type')).toBeVisible(); + expect(mock.history.get).toHaveLength(0); + + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'All eligible users in an instance' }), + ); + + await waitFor(() => + expect( + mock.history.get.filter((r) => r.url === '/admin/instances'), + ).toHaveLength(1), + ); + + // An instance rule has no value until an instance is actually picked. + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + + await page.findByText('Grants access to 3 eligible users'); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'instance', instance_id: 2 }, + }); + expect( + mock.history.get.filter((r) => r.url === '/admin/instances'), + ).toHaveLength(1); +}); + +it('clears the entered value when the rule type changes', async () => { + const { page } = renderForm(); + + await userEvent.type( + await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE), + NUS_DOMAIN, + ); + + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click(page.getByRole('option', { name: 'Specific eligible user' })); + + // A domain is not a plausible email, so it must not carry over into the new field. + expect(page.getByLabelText('Eligible user email')).toHaveValue(''); + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); +}); + +it('does not submit from step 1', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page, onSubmit } = renderForm(); + await fillDomainAndAdvance(page); + + // Next only previews; the rule is created solely by the step 2 confirmation. + await page.findByText(GRANT_ACCESS_TO_STAFF); + expect(onSubmit).not.toHaveBeenCalled(); + expect(page.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); +}); + +it('disables Next until a value is entered', async () => { + const { page } = renderForm(); + + expect(await page.findByRole('button', { name: 'Next' })).toBeDisabled(); + + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); diff --git a/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx b/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx new file mode 100644 index 00000000000..93b4cf6b2c9 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx @@ -0,0 +1,179 @@ +import { ReactNode } from 'react'; +import { defineMessages } from 'react-intl'; +import { StorefrontOutlined, WarningAmber } from '@mui/icons-material'; +import { Tooltip, Typography } from '@mui/material'; +import { AllowlistRuleData } from 'types/system/marketplaceAllowlist'; + +import DeleteButton from 'lib/components/core/buttons/DeleteButton'; +import Link from 'lib/components/core/Link'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + rules: AllowlistRuleData[]; + onDelete: (id: number) => Promise; + disabled?: boolean; + action?: ReactNode; + /** + * Rule id => number of listed users that rule grants access to. Null until the access list below + * has loaded — an unknown count must NOT render as zero, or every rule flashes a warning on load. + * A loaded map with no entry for a rule means it genuinely matches nobody: that is the warning. + */ + matchCounts?: Map | null; +} + +const translations = defineMessages({ + colType: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colType', + defaultMessage: 'Type', + }, + colTarget: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colTarget', + defaultMessage: 'Grants access to', + }, + colActions: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colActions', + defaultMessage: 'Actions', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeUser', + defaultMessage: 'User', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeInstance', + defaultMessage: 'Instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain', + defaultMessage: 'Email domain', + }, + deleteConfirm: { + id: 'system.admin.admin.MarketplaceAllowlistTable.deleteConfirm', + defaultMessage: 'Remove this marketplace access rule?', + }, + emptyTitle: { + id: 'system.admin.admin.MarketplaceAllowlistTable.emptyTitle', + defaultMessage: 'No access rules yet', + }, + emptyHint: { + id: 'system.admin.admin.MarketplaceAllowlistTable.emptyHint', + defaultMessage: + 'The marketplace stays hidden from everyone except system administrators. Add a rule to grant access.', + }, + zeroMatchWarning: { + id: 'system.admin.admin.MarketplaceAllowlistTable.zeroMatchWarning', + defaultMessage: + 'No eligible staff currently match this rule, so it grants access to nobody.', + }, +}); + +const MarketplaceAllowlistTable = ({ + rules, + onDelete, + disabled = false, + action, + matchCounts = null, +}: Props): JSX.Element => { + const { t } = useTranslation(); + + const typeLabels: Record = { + user: t(translations.typeUser), + instance: t(translations.typeInstance), + email_domain: t(translations.typeEmailDomain), + }; + + const targetOf = (rule: AllowlistRuleData): string => { + switch (rule.ruleType) { + case 'instance': + return rule.instanceName ?? `#${rule.instanceId}`; + default: + return rule.emailDomain ?? ''; + } + }; + + const renderUserTarget = (rule: AllowlistRuleData): JSX.Element => ( + + + {rule.userName ?? `#${rule.userId}`} + + {rule.userEmail && ` (${rule.userEmail})`} + + ); + + // A loaded map (not null) with no entry for this rule means no listed user is granted by it, i.e. + // it matches nobody. Null is "not loaded yet", which must stay silent. + const matchesNobody = (rule: AllowlistRuleData): boolean => + matchCounts !== null && !matchCounts.has(rule.id); + + const renderTarget = (rule: AllowlistRuleData): JSX.Element => ( + + {matchesNobody(rule) && ( + + + + )} + {rule.ruleType === 'user' ? renderUserTarget(rule) : targetOf(rule)} + + ); + + const columns: ColumnTemplate[] = [ + { + of: 'ruleType', + title: t(translations.colType), + cell: (rule) => typeLabels[rule.ruleType], + }, + { + id: 'target', + title: t(translations.colTarget), + cell: (rule) => renderTarget(rule), + }, + { + id: 'actions', + title: t(translations.colActions), + cell: (rule) => ( + => onDelete(rule.id)} + /> + ), + }, + ]; + + const emptyState = ( +
+ + + + {t(translations.emptyTitle)} + + + + {t(translations.emptyHint)} + +
+ ); + + return ( +
+ {action &&
{action}
} + +
+
rule.id.toString()} + renderEmpty={emptyState} + /> + + + ); +}; + +export default MarketplaceAllowlistTable; diff --git a/client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx b/client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx new file mode 100644 index 00000000000..1c48b23adfa --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx @@ -0,0 +1,99 @@ +import { render } from 'test-utils'; + +import MarketplaceAllowlistTable from '../MarketplaceAllowlistTable'; + +const ZERO_MATCH_WARNING = + 'No eligible staff currently match this rule, so it grants access to nobody.'; + +const DOMAIN_RULE = { + id: 10, + ruleType: 'email_domain' as const, + userId: null, + userName: null, + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: 'typo.edu.sg', +}; + +const USER_RULE = { + id: 11, + ruleType: 'user' as const, + userId: 7, + userName: 'Jane Tan', + userEmail: 'jane@nus.edu.sg', + instanceId: null, + instanceName: null, + emailDomain: null, +}; + +const INSTANCE_RULE = { + id: 12, + ruleType: 'instance' as const, + userId: null, + userName: null, + userEmail: null, + instanceId: 3, + instanceName: 'NUS', + emailDomain: null, +}; + +const renderTable = ( + matchCounts: Map | null, + rules: (typeof DOMAIN_RULE | typeof USER_RULE | typeof INSTANCE_RULE)[] = [ + DOMAIN_RULE, + ], +): ReturnType => + render( + , + ); + +it('warns on a rule that a loaded access list grants to nobody', async () => { + // Empty map = the list has loaded and this rule has no entry, so it matches nobody. The tooltip + // text is reachable by accessible name (aria-label) without hovering. + const page = renderTable(new Map()); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); + // The icon only qualifies the target; the value itself is still shown. + expect(page.getByText('typo.edu.sg')).toBeVisible(); +}); + +it('does not warn on a rule that grants access to at least one person', async () => { + const page = renderTable(new Map([[10, 3]])); + + expect(await page.findByText('typo.edu.sg')).toBeVisible(); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('shows no warning before the access list has loaded', async () => { + // Null = unknown, not zero. A warning here would flash an icon on every rule on first paint — + // the regression this guards against. + const page = renderTable(null); + + expect(await page.findByText('typo.edu.sg')).toBeVisible(); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('warns on a zero-match user rule, not only email-domain rules', async () => { + // The condition is matchCounts.has(id), uniform across rule types. Narrowing it to email_domain + // would leave a user rule that manages nobody just as invisible as it is today. + const page = renderTable(new Map(), [USER_RULE]); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); + expect(page.getByRole('link', { name: 'Jane Tan' })).toBeInTheDocument(); +}); + +it('warns on a zero-match instance rule, completing the three rule types', async () => { + // matchesNobody keys off matchCounts.has(id) and never branches on ruleType, so the instance + // path must warn identically. This also exercises the only otherwise-untested target branch: + // targetOf's instanceName render. + const page = renderTable(new Map(), [INSTANCE_RULE]); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); + // The icon only qualifies the target; the instance name is still shown. + expect(page.getByText('NUS')).toBeVisible(); +}); diff --git a/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx new file mode 100644 index 00000000000..071d62650ae --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx @@ -0,0 +1,203 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { Typography } from '@mui/material'; +import { AxiosError } from 'axios'; +import { + AllowlistRuleData, + AllowlistRuleFormData, +} from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import AddButton from 'lib/components/core/buttons/AddButton'; +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import toast from 'lib/hooks/toast'; + +import MarketplaceAllowlistRuleForm from '../components/forms/MarketplaceAllowlistRuleForm'; +import MarketplaceAccessSection from '../components/MarketplaceAccessSection'; +import MarketplaceAllowlistModeBanner from '../components/MarketplaceAllowlistModeBanner'; +import MarketplaceAllowlistTable from '../components/tables/MarketplaceAllowlistTable'; + +type Props = WrappedComponentProps; + +const translations = defineMessages({ + addRule: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.addRule', + defaultMessage: 'Add access rule', + }, + eligibility: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.eligibility', + defaultMessage: + 'Available to course managers & owners (of any course) and instance instructors & administrators (of any instance). They must also match one of the rules below.', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.fetchFailure', + defaultMessage: 'Failed to load marketplace access rules.', + }, + createSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.createSuccess', + defaultMessage: 'Access rule added.', + }, + createFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.createFailure', + defaultMessage: 'Failed to add access rule.', + }, + deleteSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess', + defaultMessage: 'Access rule removed.', + }, + deleteFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.deleteFailure', + defaultMessage: 'Failed to remove access rule.', + }, + openSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.openSuccess', + defaultMessage: 'Marketplace opened to all course managers.', + }, + openFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.openFailure', + defaultMessage: 'Failed to open the marketplace to everyone.', + }, + restrictSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess', + defaultMessage: 'Marketplace restricted to the scoped rules.', + }, + restrictFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.restrictFailure', + defaultMessage: 'Failed to restrict the marketplace.', + }, +}); + +const MarketplaceAllowlistIndex: FC = ({ intl }) => { + const [isLoading, setIsLoading] = useState(true); + const [isFormOpen, setIsFormOpen] = useState(false); + const [rules, setRules] = useState([]); + const [everyoneRuleId, setEveryoneRuleId] = useState(null); + // Bumped on every rule mutation. Adding a domain rule changes who is in the access list in ways + // the client cannot compute locally, so the list must refetch rather than patch itself. + const [ruleVersion, setRuleVersion] = useState(0); + // Published by the access section after each fetch; passed to the rules table so it can flag rules + // that grant access to nobody. Null until the first fetch resolves (unknown ≠ zero). + const [matchCounts, setMatchCounts] = useState | null>( + null, + ); + + useEffect(() => { + SystemAPI.admin + .indexMarketplaceAllowlistRules() + .then((response) => { + setRules(response.data.rules); + setEveryoneRuleId(response.data.everyoneRuleId ?? null); + }) + .catch(() => toast.error(intl.formatMessage(translations.fetchFailure))) + .finally(() => setIsLoading(false)); + }, []); + + const openToEveryone = everyoneRuleId !== null; + const invalidateAccessList = (): void => { + // Blank the counts until the refetch this triggers resolves: they are derived from the access + // list, so between a mutation and the fresh fetch they are stale. After a Restrict, the + // everyone-mode counts are an empty map that would mark every scoped rule as matching nobody. + // Null means "unknown, don't warn", same as before the first load. + setMatchCounts(null); + setRuleVersion((version) => version + 1); + }; + + const handleCreate = async (data: AllowlistRuleFormData): Promise => { + try { + const response = + await SystemAPI.admin.createMarketplaceAllowlistRule(data); + setRules((current) => [...current, response.data]); + invalidateAccessList(); + toast.success(intl.formatMessage(translations.createSuccess)); + setIsFormOpen(false); + } catch (error) { + // Surface the server's reason (e.g. the duplicate-rule message) — the generic fallback + // would discard exactly the message that was written for this case. + const message = + error instanceof AxiosError ? error.response?.data?.errors : undefined; + toast.error(message ?? intl.formatMessage(translations.createFailure)); + } + }; + + const handleDelete = async (id: number): Promise => { + try { + await SystemAPI.admin.deleteMarketplaceAllowlistRule(id); + setRules((current) => current.filter((rule) => rule.id !== id)); + invalidateAccessList(); + toast.success(intl.formatMessage(translations.deleteSuccess)); + } catch { + toast.error(intl.formatMessage(translations.deleteFailure)); + } + }; + + const handleOpenToEveryone = async (): Promise => { + try { + const response = await SystemAPI.admin.openMarketplaceToEveryone(); + setEveryoneRuleId(response.data.id); + invalidateAccessList(); + toast.success(intl.formatMessage(translations.openSuccess)); + } catch { + toast.error(intl.formatMessage(translations.openFailure)); + } + }; + + const handleRestrict = async (): Promise => { + if (everyoneRuleId === null) return; + try { + await SystemAPI.admin.deleteMarketplaceAllowlistRule(everyoneRuleId); + setEveryoneRuleId(null); + invalidateAccessList(); + toast.success(intl.formatMessage(translations.restrictSuccess)); + } catch { + toast.error(intl.formatMessage(translations.restrictFailure)); + } + }; + + if (isLoading) return ; + + return ( + + + {intl.formatMessage(translations.eligibility)} + + + + setIsFormOpen(true)} + > + {intl.formatMessage(translations.addRule)} + + } + disabled={openToEveryone} + matchCounts={openToEveryone ? null : matchCounts} + onDelete={handleDelete} + rules={rules} + /> + + setIsFormOpen(false)} + onSubmit={handleCreate} + open={isFormOpen} + /> + + + + ); +}; + +export default injectIntl(MarketplaceAllowlistIndex); diff --git a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx new file mode 100644 index 00000000000..2fe80de10ad --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx @@ -0,0 +1,639 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import SystemAPI from 'api/system'; + +import MarketplaceAllowlistIndex from '../MarketplaceAllowlistIndex'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => { + mock.reset(); + mock.onGet('/admin/marketplace_access').reply(200, { + users: [], + summary: { totalWithAccess: 0, openToEveryone: false }, + }); +}); + +const INDEX_URL = '/admin/marketplace_allowlist_rules'; +const EMAIL_DOMAIN = 'schools.gov.sg'; +const NUS_DOMAIN = 'nus.edu.sg'; +const EMAIL_DOMAIN_SUBTITLE = 'Email domain (e.g. schools.gov.sg)'; +const OPEN_TO_EVERYONE = 'Open to everyone'; +const ADD_ACCESS_RULE = 'Add access rule'; +const allowlistGetCount = (): number => + mock.history.get.filter((request) => request.url === INDEX_URL).length; +const RULES = [ + { + id: 1, + ruleType: 'email_domain', + userId: null, + userName: null, + instanceId: null, + instanceName: null, + emailDomain: EMAIL_DOMAIN, + }, +]; +const PREVIEW_URL = '/admin/marketplace_allowlist_rules/preview'; +const accessGetCount = (): number => + mock.history.get.filter( + (request) => request.url === '/admin/marketplace_access', + ).length; +// Step 2's preview is a POST too, so `mock.history.post[0]` is the preview, not the create. +const createPosts = (): typeof mock.history.post => + mock.history.post.filter((request) => request.url === INDEX_URL); + +/** + * Click step 2's "Confirm add". The button is disabled while the preview request is in flight, so + * a click fired the moment it appears is swallowed — wait for it to enable first. + */ +const confirmAdd = async (page: ReturnType): Promise => { + const button = await page.findByRole('button', { name: 'Confirm add' }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); +}; + +it('renders the allow-list rules from the API', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + const page = render(, { at: [INDEX_URL] }); + + // Await the fetch firing before asserting the rendered row, so mount + request and the + // subsequent re-render each get their own waitFor budget (a single window is flaky under load). + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); +}); + +it('creates an email-domain rule from the add dialog', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(INDEX_URL).reply(200, { + id: 2, + ruleType: 'email_domain', + userId: null, + userName: null, + instanceId: null, + instanceName: null, + emailDomain: NUS_DOMAIN, + }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + // Rule type defaults to Email domain; fill the value field. (Search fields need userEvent — + // see client/CLAUDE-testing.md; a plain TextField accepts userEvent.type too.) + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(createPosts()).toHaveLength(1)); + expect(JSON.parse(createPosts()[0].data)).toEqual({ + allowlist_rule: { rule_type: 'email_domain', email_domain: NUS_DOMAIN }, + }); + await waitFor(() => expect(page.getByText(NUS_DOMAIN)).toBeVisible()); +}); + +it('deletes a rule after confirmation', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + fireEvent.click(page.getByTestId('DeleteIconButton')); + fireEvent.click(page.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + await waitFor(() => + expect(page.queryByText(EMAIL_DOMAIN)).not.toBeInTheDocument(), + ); +}); + +it('opens the marketplace to everyone from the banner', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: null }); + mock.onPost(INDEX_URL).reply(200, { id: 99 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + // Scoped state: the banner switch is off; flipping it on prompts to open. + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + + // Confirm inside the dialog (its primary button shares the label, so scope to the dialog). + const dialog = page.getByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: OPEN_TO_EVERYONE }), + ); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'everyone' }, + }); + await waitFor(() => + expect( + page.getByText( + 'The marketplace is open to all eligible staff: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive.', + ), + ).toBeVisible(), + ); +}); + +it('restricts the marketplace to scoped rules from the banner', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => + expect( + page.getByText( + 'The marketplace is open to all eligible staff: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive.', + ), + ).toBeVisible(), + ); + + // Open state: the banner switch is on; flipping it off prompts to restrict. + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${INDEX_URL}/42`); + await waitFor(() => + expect( + page.getByText('Access is limited to the rules below.'), + ).toBeVisible(), + ); +}); + +it('disables adding and removing rules while the marketplace is open to everyone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + // Open-to-everyone means the scoped rules are preserved but inactive: no add, no delete. + expect(page.getByRole('button', { name: ADD_ACCESS_RULE })).toBeDisabled(); + expect(page.getByTestId('DeleteIconButton')).toBeDisabled(); +}); + +it('disables Next until a required value is entered', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + + // Default rule type is email_domain → value required → Add disabled while empty. + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + + // Entering the required value enables it. + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); + +it('creates a user rule from an email', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(INDEX_URL).reply(200, { + id: 4, + ruleType: 'user', + userId: 7, + userName: 'Teacher', + userEmail: 'teacher@school.edu', + instanceId: null, + instanceName: null, + emailDomain: null, + }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click(page.getByRole('option', { name: 'Specific eligible user' })); + + await userEvent.type( + page.getByLabelText('Eligible user email'), + 'teacher@school.edu', + ); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(createPosts()).toHaveLength(1)); + expect(JSON.parse(createPosts()[0].data)).toEqual({ + allowlist_rule: { rule_type: 'user', email: 'teacher@school.edu' }, + }); + + const link = await page.findByRole('link', { name: 'Teacher' }); + expect(link).toHaveAttribute('href', '/users/7'); + expect(page.getByText('(teacher@school.edu)')).toBeVisible(); +}); + +it('clears the entered value when the rule type changes', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + + // Enter an email domain, then switch the rule type to "Specific eligible user". + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click(page.getByRole('option', { name: 'Specific eligible user' })); + + // The new value field must start empty, not carry over NUS_DOMAIN. + expect(page.getByLabelText('Eligible user email')).toHaveValue(''); +}); + +it('shows who is eligible for the marketplace', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByText( + 'Available to course managers & owners (of any course) and instance instructors & administrators (of any instance). They must also match one of the rules below.', + ), + ).toBeVisible(); +}); + +it('renders a user rule as a link to the user with their email', async () => { + mock.onGet(INDEX_URL).reply(200, { + rules: [ + { + id: 5, + ruleType: 'user', + userId: 42, + userName: 'Administrator', + userEmail: 'admin@org.sg', + instanceId: null, + instanceName: null, + emailDomain: null, + }, + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + const link = await page.findByRole('link', { name: 'Administrator' }); + expect(link).toHaveAttribute('href', '/users/42'); + expect(page.getByText('(admin@org.sg)')).toBeVisible(); +}); + +it('creates an instance rule by picking an instance from the dropdown', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onGet('/admin/instances').reply(200, { + instances: [ + { id: 1, name: 'Default', host: 'coursemology.org' }, + { id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }, + ], + }); + mock.onPost(INDEX_URL).reply(200, { + id: 6, + ruleType: 'instance', + userId: null, + userName: null, + userEmail: null, + instanceId: 2, + instanceName: 'Alpha', + emailDomain: null, + }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'All eligible users in an instance' }), + ); + + // Selecting the instance rule type lazily fetches the instance list. + await waitFor(() => + expect(mock.history.get.some((r) => r.url === '/admin/instances')).toBe( + true, + ), + ); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(createPosts()).toHaveLength(1)); + expect(JSON.parse(createPosts()[0].data)).toEqual({ + allowlist_rule: { rule_type: 'instance', instance_id: 2 }, + }); + await waitFor(() => expect(page.getByText('Alpha')).toBeVisible()); +}); + +it('renders a user rule without an email suffix when none is present', async () => { + mock.onGet(INDEX_URL).reply(200, { + rules: [ + { + id: 8, + ruleType: 'user', + userId: 12, + userName: 'No Email User', + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: null, + }, + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + const link = await page.findByRole('link', { name: 'No Email User' }); + expect(link).toHaveAttribute('href', '/users/12'); + // Guard: no ` (…)` suffix — the cell's text is exactly the user name. + // (A `/\(.*\)/` regex would false-match the eligibility subtitle's "(of any course)"; + // the exact textContent check is robust and still fails if the guard is dropped, since a + // null email would render "No Email User (null)".) + expect(link.parentElement?.textContent).toBe('No Email User'); +}); + +it('disables Next for an instance rule until an instance is picked', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onGet('/admin/instances').reply(200, { + instances: [ + { id: 1, name: 'Default', host: 'coursemology.org' }, + { id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }, + ], + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'All eligible users in an instance' }), + ); + await waitFor(() => + expect(mock.history.get.some((r) => r.url === '/admin/instances')).toBe( + true, + ), + ); + + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); + +it('keeps the Open to everyone toggle label on a single line', async () => { + // The open-state banner body is long enough to wrap, which used to drag the toggle label with it. + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + + const label = await page.findByText(OPEN_TO_EVERYONE); + expect(label).toHaveClass('whitespace-nowrap'); +}); + +it('refreshes the access list after a rule is added', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + mock.onPost(INDEX_URL).reply(200, { + id: 2, + ruleType: 'email_domain', + userId: null, + userName: null, + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: NUS_DOMAIN, + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('refreshes the access list after a rule is deleted', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByTestId('DeleteIconButton')); + fireEvent.click(page.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('refreshes the access list after the marketplace is opened to everyone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: null }); + mock.onPost(INDEX_URL).reply(200, { id: 99 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: OPEN_TO_EVERYONE }), + ); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('refreshes the access list after the marketplace is restricted again', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('surfaces the server message when a rule is rejected as a duplicate', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + mock.onPost(INDEX_URL).reply(400, { + errors: 'Email domain already has the same rule.', + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + // The specific message, not the generic "Failed to add access rule." + expect( + await page.findByText('Email domain already has the same rule.'), + ).toBeVisible(); +}); + +const ZERO_MATCH_WARNING = + 'No eligible staff currently match this rule, so it grants access to nobody.'; + +it('flags a rule that the loaded access list grants to nobody', async () => { + // beforeEach returns no access-list users, so the single email-domain rule matches nobody. + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); +}); + +it('does not flag a rule that the access list grants to someone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onGet('/admin/marketplace_access').reply(200, { + users: [ + { + id: 1, + name: 'Jane Tan', + email: 'jane@schools.gov.sg', + courseCount: 1, + instanceRole: null, + allowedByRules: [ + { id: 1, ruleType: 'email_domain', labelValue: EMAIL_DOMAIN }, + ], + systemAdmin: false, + blocked: false, + blockId: null, + }, + ], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = render(, { at: [INDEX_URL] }); + // Wait for the access list to render (counts are published only after it resolves). + await page.findByText('jane@schools.gov.sg'); + + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('suppresses zero-match warnings while the marketplace is open to everyone', async () => { + // Everyone-mode empties scoped_rules, so every rule would report zero — but the mode banner + // already says the rules are moot, so the page passes null and shows no icons at all. + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('does not flash zero-match warnings while a refetch after restrict is in flight', async () => { + // Restrict flips openToEveryone off and triggers a refetch. Until it resolves, the previously + // published counts are stale: everyone-mode publishes an empty map, which would mark every scoped + // rule as matching nobody. invalidateAccessList must blank matchCounts to null so no false warning + // shows in that window. + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + let releaseSecond = (): void => {}; + let accessCalls = 0; + mock.onGet('/admin/marketplace_access').reply(() => { + accessCalls += 1; + if (accessCalls === 1) { + // Everyone-mode: users carry no per-rule reasons, so the published map is empty. + return [ + 200, + { users: [], summary: { totalWithAccess: 0, openToEveryone: true } }, + ]; + } + // Second fetch (after restrict) stays pending until released. + return new Promise((resolve) => { + releaseSecond = (): void => + resolve([ + 200, + { + users: [ + { + id: 1, + name: 'Jane', + email: 'jane@schools.gov.sg', + courseCount: 1, + instanceRole: null, + allowedByRules: [ + { id: 1, ruleType: 'email_domain', labelValue: EMAIL_DOMAIN }, + ], + systemAdmin: false, + blocked: false, + blockId: null, + }, + ], + summary: { + totalWithAccess: 1, + totalBlocked: 0, + openToEveryone: false, + }, + }, + ]); + }); + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(accessGetCount()).toBe(1)); + await page.findByText(EMAIL_DOMAIN); + + // Restrict: toggle off, then confirm in the dialog. + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + // Refetch is now in flight (second GET pending). No stale zero-match warning may show. + await waitFor(() => expect(accessGetCount()).toBe(2)); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); + + // Let the refetch resolve; Jane matches rule 1, so still no warning. + releaseSecond(); + await page.findByText('jane@schools.gov.sg'); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); diff --git a/client/app/routers/courseless/systemAdmin.tsx b/client/app/routers/courseless/systemAdmin.tsx index 79edf10de6f..2e1bba29aac 100644 --- a/client/app/routers/courseless/systemAdmin.tsx +++ b/client/app/routers/courseless/systemAdmin.tsx @@ -67,6 +67,17 @@ const systemAdminRouter: Translated = (_) => ({ ).default, }), }, + { + path: 'marketplace_allowlist_rules', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'MarketplaceAllowlistIndex' */ + 'bundles/system/admin/admin/pages/MarketplaceAllowlistIndex' + ) + ).default, + }), + }, { path: 'get_help', lazy: async (): Promise> => ({ diff --git a/client/app/types/system/marketplaceAccess.ts b/client/app/types/system/marketplaceAccess.ts new file mode 100644 index 00000000000..6c3ddd96815 --- /dev/null +++ b/client/app/types/system/marketplaceAccess.ts @@ -0,0 +1,53 @@ +import { AllowlistRuleType } from 'types/system/marketplaceAllowlist'; + +/** + * One rule granting a user access. A user may be granted by several rules at once — the audit list + * shows all of them, because the admin uses that column to decide which rules are safe to delete. + */ +export interface AllowedByRule { + id: number; + ruleType: AllowlistRuleType; + labelValue: string | null; +} + +export interface MarketplaceAccessUser { + id: number; + name: string; + email: string; + courseCount: number; + instanceRole: 'instructor' | 'administrator' | null; + allowedByRules: AllowedByRule[]; + /** System admins bypass every gate, so they are listed and labelled regardless of the rules. */ + systemAdmin: boolean; + blocked: boolean; + blockId: number | null; +} + +export interface MarketplaceAccessData { + users: MarketplaceAccessUser[]; + summary: { + totalWithAccess: number; + totalBlocked: number; + openToEveryone: boolean; + }; +} + +export interface MarketplaceRulePreviewUser { + id: number; + name: string; + email: string; + courseCount: number; + instanceRole: 'instructor' | 'administrator' | null; + alreadyHasAccess: boolean; + blocked: boolean; +} + +export interface AllowlistRulePreviewData { + matchedCount: number; + /** Matched users who are neither already cleared by another rule nor blocked. */ + newCount: number; + /** Matched users held back by an individual block, which a rule does not lift. */ + blockedCount: number; + openToEveryone: boolean; + users: MarketplaceRulePreviewUser[]; +} diff --git a/client/app/types/system/marketplaceAllowlist.ts b/client/app/types/system/marketplaceAllowlist.ts new file mode 100644 index 00000000000..bae03b1f717 --- /dev/null +++ b/client/app/types/system/marketplaceAllowlist.ts @@ -0,0 +1,19 @@ +export type AllowlistRuleType = 'user' | 'instance' | 'email_domain'; + +export interface AllowlistRuleData { + id: number; + ruleType: AllowlistRuleType; + userId: number | null; + userName: string | null; + userEmail: string | null; + instanceId: number | null; + instanceName: string | null; + emailDomain: string | null; +} + +export interface AllowlistRuleFormData { + ruleType: AllowlistRuleType; + email?: string; + instanceId?: number; + emailDomain?: string; +} diff --git a/client/locales/en.json b/client/locales/en.json index 0073cd7ca72..f0c47c5e34d 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -8780,6 +8780,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "Get Help" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "Marketplace Access" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "Unable to fetch announcements" }, @@ -8864,6 +8867,297 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "Renamed {field} from {prevValue} to {newValue}" }, + "system.admin.admin.MarketplaceAccessFilter.trigger": { + "defaultMessage": "Filter" + }, + "system.admin.admin.MarketplaceAccessFilter.status": { + "defaultMessage": "Status" + }, + "system.admin.admin.MarketplaceAccessFilter.active": { + "defaultMessage": "Active" + }, + "system.admin.admin.MarketplaceAccessFilter.blocked": { + "defaultMessage": "Blocked" + }, + "system.admin.admin.MarketplaceAccessFilter.allowedByRule": { + "defaultMessage": "Allowed by rule" + }, + "system.admin.admin.MarketplaceAccessFilter.clearAll": { + "defaultMessage": "Clear all" + }, + "system.admin.admin.MarketplaceAccessSection.heading": { + "defaultMessage": "People with access" + }, + "system.admin.admin.MarketplaceAccessSection.summary": { + "defaultMessage": "Total with access: {count} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.summaryWithBlocked": { + "defaultMessage": "Total with access: {count} · Total blocked: {blocked} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.filteredCounts": { + "defaultMessage": "Filtered: {count} with access · {blocked} blocked" + }, + "system.admin.admin.MarketplaceAccessSection.modeOpen": { + "defaultMessage": "Open to everyone" + }, + "system.admin.admin.MarketplaceAccessSection.modeScoped": { + "defaultMessage": "Scoped to the rules above" + }, + "system.admin.admin.MarketplaceAccessSection.fetchFailure": { + "defaultMessage": "Failed to load the marketplace access list." + }, + "system.admin.admin.MarketplaceAccessSection.colName": { + "defaultMessage": "Name" + }, + "system.admin.admin.MarketplaceAccessSection.colEmail": { + "defaultMessage": "Email" + }, + "system.admin.admin.MarketplaceAccessSection.colEligibleVia": { + "defaultMessage": "Eligible via" + }, + "system.admin.admin.MarketplaceAccessSection.colAllowedBy": { + "defaultMessage": "Allowed by" + }, + "system.admin.admin.MarketplaceAccessSection.colStatus": { + "defaultMessage": "Status" + }, + "system.admin.admin.MarketplaceAccessSection.colActions": { + "defaultMessage": "Actions" + }, + "system.admin.admin.MarketplaceAccessSection.managesCourses": { + "defaultMessage": "Manages {count, plural, one {# course} other {# courses}}" + }, + "system.admin.admin.MarketplaceAccessSection.instanceInstructor": { + "defaultMessage": "Instance instructor" + }, + "system.admin.admin.MarketplaceAccessSection.instanceAdministrator": { + "defaultMessage": "Instance administrator" + }, + "system.admin.admin.MarketplaceAccessSection.allowedEveryone": { + "defaultMessage": "Everyone" + }, + "system.admin.admin.MarketplaceAccessSection.allowedNothing": { + "defaultMessage": "No matching rule" + }, + "system.admin.admin.MarketplaceAccessSection.systemAdmin": { + "defaultMessage": "System admin" + }, + "system.admin.admin.MarketplaceAccessSection.typeUser": { + "defaultMessage": "User" + }, + "system.admin.admin.MarketplaceAccessSection.typeInstance": { + "defaultMessage": "Instance" + }, + "system.admin.admin.MarketplaceAccessSection.typeEmailDomain": { + "defaultMessage": "Email domain" + }, + "system.admin.admin.MarketplaceAccessSection.statusActive": { + "defaultMessage": "Active" + }, + "system.admin.admin.MarketplaceAccessSection.statusBlocked": { + "defaultMessage": "Blocked" + }, + "system.admin.admin.MarketplaceAccessSection.disable": { + "defaultMessage": "Block" + }, + "system.admin.admin.MarketplaceAccessSection.reEnable": { + "defaultMessage": "Unblock" + }, + "system.admin.admin.MarketplaceAccessSection.disableSuccess": { + "defaultMessage": "Access blocked for this user." + }, + "system.admin.admin.MarketplaceAccessSection.disableFailure": { + "defaultMessage": "Failed to block access." + }, + "system.admin.admin.MarketplaceAccessSection.reEnableSuccess": { + "defaultMessage": "Access unblocked for this user." + }, + "system.admin.admin.MarketplaceAccessSection.reEnableFailure": { + "defaultMessage": "Failed to unblock access." + }, + "system.admin.admin.MarketplaceAccessSection.searchPlaceholder": { + "defaultMessage": "Search by name or email" + }, + "system.admin.admin.MarketplaceAccessSection.dormantHeading": { + "defaultMessage": "Dormant blocks ({count})" + }, + "system.admin.admin.MarketplaceAccessSection.dormantExplanation": { + "defaultMessage": "These people are blocked but no rule currently grants them access. The block denies nothing today — but it would take effect again if a rule starts matching them, so clear it if it is no longer wanted." + }, + "system.admin.admin.MarketplaceAccessSection.clearBlock": { + "defaultMessage": "Clear block" + }, + "system.admin.admin.MarketplaceAllowlistIndex.addRule": { + "defaultMessage": "Add access rule" + }, + "system.admin.admin.MarketplaceAllowlistIndex.eligibility": { + "defaultMessage": "Available to course managers & owners (of any course) and instance instructors & administrators (of any instance). They must also match one of the rules below." + }, + "system.admin.admin.MarketplaceAllowlistIndex.fetchFailure": { + "defaultMessage": "Failed to load marketplace access rules." + }, + "system.admin.admin.MarketplaceAllowlistIndex.createSuccess": { + "defaultMessage": "Access rule added." + }, + "system.admin.admin.MarketplaceAllowlistIndex.createFailure": { + "defaultMessage": "Failed to add access rule." + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess": { + "defaultMessage": "Access rule removed." + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteFailure": { + "defaultMessage": "Failed to remove access rule." + }, + "system.admin.admin.MarketplaceAllowlistIndex.openSuccess": { + "defaultMessage": "Marketplace opened to all course managers." + }, + "system.admin.admin.MarketplaceAllowlistIndex.openFailure": { + "defaultMessage": "Failed to open the marketplace to everyone." + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess": { + "defaultMessage": "Marketplace restricted to the scoped rules." + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictFailure": { + "defaultMessage": "Failed to restrict the marketplace." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle": { + "defaultMessage": "Access is limited to the rules below." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle": { + "defaultMessage": "The marketplace is open to all eligible staff: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.toggleLabel": { + "defaultMessage": "Open to everyone" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle": { + "defaultMessage": "Open marketplace to everyone?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody": { + "defaultMessage": "This makes the marketplace visible to all eligible staff: course managers/owners and instance instructors/administrators. You can restrict it again at any time; your scoped rules are kept." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle": { + "defaultMessage": "Restrict to scoped rules?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody": { + "defaultMessage": "The marketplace will again be limited to the rules below. Eligible staff not covered by a rule will lose access." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen": { + "defaultMessage": "Open to everyone" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict": { + "defaultMessage": "Restrict" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.title": { + "defaultMessage": "Add marketplace access rule" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.ruleType": { + "defaultMessage": "Rule type" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeUser": { + "defaultMessage": "Specific eligible user" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance": { + "defaultMessage": "All eligible users in an instance" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain": { + "defaultMessage": "All eligible users with an email domain" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.userEmail": { + "defaultMessage": "Eligible user email" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.eligibilityHint": { + "defaultMessage": "Eligible users refer to course managers & owners (of any course) and instance instructors & administrators (of any instance)." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.instanceId": { + "defaultMessage": "Instance" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain": { + "defaultMessage": "Email domain (e.g. schools.gov.sg)" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.next": { + "defaultMessage": "Next" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.back": { + "defaultMessage": "Back" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.confirmAdd": { + "defaultMessage": "Confirm add" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.counts": { + "defaultMessage": "Grants access to {matched, plural, one {# eligible user} other {# eligible users}}" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsOfMatched": { + "defaultMessage": "Grants access to {granted} of {matched, plural, one {# eligible user} other {# eligible users}}" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsExistingClause": { + "defaultMessage": "{existing} already had access" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsBlockedClause": { + "defaultMessage": "{blocked} blocked individually" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.noMatches": { + "defaultMessage": "This rule matches nobody eligible right now." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.openToEveryone": { + "defaultMessage": "The marketplace is currently open to everyone; this rule takes effect only if you restrict access again." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.previewFailure": { + "defaultMessage": "Could not preview this rule." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerNew": { + "defaultMessage": "New" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerExisting": { + "defaultMessage": "Already has access" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerBlocked": { + "defaultMessage": "Blocked" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.managesCourses": { + "defaultMessage": "Manages {count, plural, one {# course} other {# courses}}" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colName": { + "defaultMessage": "Name" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colEligibleVia": { + "defaultMessage": "Eligible via" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colStatus": { + "defaultMessage": "Status" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.searchPlaceholder": { + "defaultMessage": "Search by name or email" + }, + "system.admin.admin.MarketplaceAllowlistTable.colType": { + "defaultMessage": "Type" + }, + "system.admin.admin.MarketplaceAllowlistTable.colTarget": { + "defaultMessage": "Grants access to" + }, + "system.admin.admin.MarketplaceAllowlistTable.colActions": { + "defaultMessage": "Actions" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeUser": { + "defaultMessage": "User" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeInstance": { + "defaultMessage": "Instance" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain": { + "defaultMessage": "Email domain" + }, + "system.admin.admin.MarketplaceAllowlistTable.deleteConfirm": { + "defaultMessage": "Remove this marketplace access rule?" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyTitle": { + "defaultMessage": "No access rules yet" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyHint": { + "defaultMessage": "The marketplace stays hidden from everyone except system administrators. Add a rule to grant access." + }, + "system.admin.admin.MarketplaceAllowlistTable.zeroMatchWarning": { + "defaultMessage": "No eligible staff currently match this rule, so it grants access to nobody." + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "Delete User" }, diff --git a/client/locales/ko.json b/client/locales/ko.json index a035bfb56b8..4c02c3b2b57 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -8747,6 +8747,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "도움 받기" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "마켓플레이스 접근" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "공지사항을 가져올 수 없습니다." }, @@ -8831,6 +8834,294 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "{field}이(가) {prevValue}에서 {newValue}로 변경되었습니다." }, + "system.admin.admin.MarketplaceAccessFilter.trigger": { + "defaultMessage": "필터" + }, + "system.admin.admin.MarketplaceAccessFilter.status": { + "defaultMessage": "상태" + }, + "system.admin.admin.MarketplaceAccessFilter.active": { + "defaultMessage": "활성" + }, + "system.admin.admin.MarketplaceAccessFilter.blocked": { + "defaultMessage": "차단됨" + }, + "system.admin.admin.MarketplaceAccessFilter.allowedByRule": { + "defaultMessage": "허용 규칙" + }, + "system.admin.admin.MarketplaceAccessFilter.clearAll": { + "defaultMessage": "모두 지우기" + }, + "system.admin.admin.MarketplaceAccessSection.heading": { + "defaultMessage": "접근 권한이 있는 사용자" + }, + "system.admin.admin.MarketplaceAccessSection.summary": { + "defaultMessage": "총 접근 가능 인원: {count} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.summaryWithBlocked": { + "defaultMessage": "총 접근 가능 인원: {count} · 총 차단 인원: {blocked} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.filteredCounts": { + "defaultMessage": "필터링됨: 접근 가능 {count} · 차단 {blocked}" + }, + "system.admin.admin.MarketplaceAccessSection.modeOpen": { + "defaultMessage": "모두에게 공개" + }, + "system.admin.admin.MarketplaceAccessSection.modeScoped": { + "defaultMessage": "위 규칙으로 제한됨" + }, + "system.admin.admin.MarketplaceAccessSection.fetchFailure": { + "defaultMessage": "마켓플레이스 접근 목록을 불러오지 못했습니다." + }, + "system.admin.admin.MarketplaceAccessSection.colName": { + "defaultMessage": "이름" + }, + "system.admin.admin.MarketplaceAccessSection.colEmail": { + "defaultMessage": "이메일" + }, + "system.admin.admin.MarketplaceAccessSection.colEligibleVia": { + "defaultMessage": "적격 사유" + }, + "system.admin.admin.MarketplaceAccessSection.colAllowedBy": { + "defaultMessage": "허용 근거" + }, + "system.admin.admin.MarketplaceAccessSection.colStatus": { + "defaultMessage": "상태" + }, + "system.admin.admin.MarketplaceAccessSection.colActions": { + "defaultMessage": "작업" + }, + "system.admin.admin.MarketplaceAccessSection.managesCourses": { + "defaultMessage": "{count, plural, one {#개 과정 관리 중} other {#개 과정 관리 중}}" + }, + "system.admin.admin.MarketplaceAccessSection.instanceInstructor": { + "defaultMessage": "인스턴스 강사" + }, + "system.admin.admin.MarketplaceAccessSection.instanceAdministrator": { + "defaultMessage": "인스턴스 관리자" + }, + "system.admin.admin.MarketplaceAccessSection.allowedEveryone": { + "defaultMessage": "모든 사용자" + }, + "system.admin.admin.MarketplaceAccessSection.allowedNothing": { + "defaultMessage": "일치하는 규칙 없음" + }, + "system.admin.admin.MarketplaceAccessSection.systemAdmin": { + "defaultMessage": "시스템 관리자" + }, + "system.admin.admin.MarketplaceAccessSection.typeUser": { + "defaultMessage": "사용자" + }, + "system.admin.admin.MarketplaceAccessSection.typeInstance": { + "defaultMessage": "인스턴스" + }, + "system.admin.admin.MarketplaceAccessSection.typeEmailDomain": { + "defaultMessage": "이메일 도메인" + }, + "system.admin.admin.MarketplaceAccessSection.statusActive": { + "defaultMessage": "활성" + }, + "system.admin.admin.MarketplaceAccessSection.statusBlocked": { + "defaultMessage": "차단됨" + }, + "system.admin.admin.MarketplaceAccessSection.disable": { + "defaultMessage": "차단" + }, + "system.admin.admin.MarketplaceAccessSection.reEnable": { + "defaultMessage": "차단 해제" + }, + "system.admin.admin.MarketplaceAccessSection.disableSuccess": { + "defaultMessage": "이 사용자의 접근이 차단되었습니다." + }, + "system.admin.admin.MarketplaceAccessSection.disableFailure": { + "defaultMessage": "접근 차단에 실패했습니다." + }, + "system.admin.admin.MarketplaceAccessSection.reEnableSuccess": { + "defaultMessage": "이 사용자의 접근 차단이 해제되었습니다." + }, + "system.admin.admin.MarketplaceAccessSection.reEnableFailure": { + "defaultMessage": "접근 차단 해제에 실패했습니다." + }, + "system.admin.admin.MarketplaceAccessSection.searchPlaceholder": { + "defaultMessage": "이름 또는 이메일 검색" + }, + "system.admin.admin.MarketplaceAccessSection.dormantHeading": { + "defaultMessage": "휴면 차단 ({count})" + }, + "system.admin.admin.MarketplaceAccessSection.dormantExplanation": { + "defaultMessage": "이 사용자들은 차단되어 있지만, 현재 어떤 규칙도 이들에게 접근 권한을 부여하지 않습니다. 이 차단은 현재로서는 아무것도 막고 있지 않지만, 이후 어떤 규칙이 이들과 일치하게 되면 다시 효력을 발휘하므로, 더 이상 필요하지 않다면 차단을 해제하세요." + }, + "system.admin.admin.MarketplaceAccessSection.clearBlock": { + "defaultMessage": "차단 제거" + }, + "system.admin.admin.MarketplaceAllowlistIndex.addRule": { + "defaultMessage": "접근 규칙 추가" + }, + "system.admin.admin.MarketplaceAllowlistIndex.eligibility": { + "defaultMessage": "모든 과정의 관리자 및 소유자, 그리고 모든 인스턴스의 강사 및 관리자가 사용할 수 있습니다. 단, 아래 규칙 중 하나와도 일치해야 합니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.fetchFailure": { + "defaultMessage": "마켓플레이스 접근 규칙을 불러오지 못했습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.createSuccess": { + "defaultMessage": "접근 규칙이 추가되었습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.createFailure": { + "defaultMessage": "접근 규칙 추가에 실패했습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess": { + "defaultMessage": "접근 규칙이 제거되었습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteFailure": { + "defaultMessage": "접근 규칙 제거에 실패했습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.openSuccess": { + "defaultMessage": "마켓플레이스가 모든 과정 관리자에게 공개되었습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.openFailure": { + "defaultMessage": "마켓플레이스를 모두에게 공개하지 못했습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess": { + "defaultMessage": "마켓플레이스가 범위가 지정된 규칙으로 제한되었습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictFailure": { + "defaultMessage": "마켓플레이스를 제한하지 못했습니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle": { + "defaultMessage": "접근이 아래 규칙으로 제한됩니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle": { + "defaultMessage": "마켓플레이스가 모든 자격 있는 직원(과정 관리자/소유자 및 인스턴스 강사/관리자)에게 열려 있습니다. 아래 규칙은 유지되지만 비활성 상태입니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.toggleLabel": { + "defaultMessage": "모두에게 공개" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle": { + "defaultMessage": "마켓플레이스를 모두에게 공개하시겠습니까?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody": { + "defaultMessage": "이렇게 하면 마켓플레이스가 모든 자격 있는 직원(과정 관리자/소유자 및 인스턴스 강사/관리자)에게 표시됩니다. 언제든지 다시 제한할 수 있으며, 범위가 지정된 규칙은 유지됩니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle": { + "defaultMessage": "범위가 지정된 규칙으로 제한하시겠습니까?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody": { + "defaultMessage": "마켓플레이스가 다시 아래 규칙으로 제한됩니다. 규칙에 해당하지 않는 자격 있는 직원은 접근 권한을 잃게 됩니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen": { + "defaultMessage": "모두에게 공개" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict": { + "defaultMessage": "제한" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.title": { + "defaultMessage": "마켓플레이스 접근 규칙 추가" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.ruleType": { + "defaultMessage": "규칙 유형" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeUser": { + "defaultMessage": "특정 자격 있는 직원" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance": { + "defaultMessage": "인스턴스 내 모든 자격 있는 직원" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain": { + "defaultMessage": "특정 이메일 도메인의 모든 자격 있는 직원" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.userEmail": { + "defaultMessage": "자격 있는 직원 이메일" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.instanceId": { + "defaultMessage": "인스턴스" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain": { + "defaultMessage": "이메일 도메인 (예: schools.gov.sg)" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.next": { + "defaultMessage": "다음" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.back": { + "defaultMessage": "뒤로" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.confirmAdd": { + "defaultMessage": "추가 확인" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.counts": { + "defaultMessage": "{matched}명의 자격 있는 직원에게 접근 권한을 부여합니다" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsOfMatched": { + "defaultMessage": "{matched}명의 자격 있는 직원 중 {granted}명에게 접근 권한을 부여합니다" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsExistingClause": { + "defaultMessage": "{existing}명은 이미 접근 권한이 있었습니다" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsBlockedClause": { + "defaultMessage": "{blocked}명은 개별적으로 차단되었습니다" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.noMatches": { + "defaultMessage": "현재 이 규칙과 일치하는 자격 있는 직원이 없습니다." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.openToEveryone": { + "defaultMessage": "마켓플레이스가 현재 모두에게 공개되어 있습니다. 이 규칙은 접근을 다시 제한할 경우에만 적용됩니다." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.previewFailure": { + "defaultMessage": "이 규칙을 미리 볼 수 없습니다." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerNew": { + "defaultMessage": "신규" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerExisting": { + "defaultMessage": "이미 접근 권한 있음" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerBlocked": { + "defaultMessage": "차단됨" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.managesCourses": { + "defaultMessage": "{count, plural, one {#개 과정} other {#개 과정}} 관리" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colName": { + "defaultMessage": "이름" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colEligibleVia": { + "defaultMessage": "자격 경로" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colStatus": { + "defaultMessage": "상태" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.searchPlaceholder": { + "defaultMessage": "이름 또는 이메일로 검색" + }, + "system.admin.admin.MarketplaceAllowlistTable.colType": { + "defaultMessage": "유형" + }, + "system.admin.admin.MarketplaceAllowlistTable.colTarget": { + "defaultMessage": "접근 권한 대상" + }, + "system.admin.admin.MarketplaceAllowlistTable.colActions": { + "defaultMessage": "작업" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeUser": { + "defaultMessage": "사용자" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeInstance": { + "defaultMessage": "인스턴스" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain": { + "defaultMessage": "이메일 도메인" + }, + "system.admin.admin.MarketplaceAllowlistTable.deleteConfirm": { + "defaultMessage": "이 마켓플레이스 접근 규칙을 제거하시겠습니까?" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyTitle": { + "defaultMessage": "아직 접근 규칙이 없습니다" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyHint": { + "defaultMessage": "시스템 관리자를 제외한 모든 사용자에게 마켓플레이스가 숨겨진 상태로 유지됩니다. 규칙을 추가하여 접근 권한을 부여하세요." + }, + "system.admin.admin.MarketplaceAllowlistTable.zeroMatchWarning": { + "defaultMessage": "현재 이 규칙과 일치하는 자격 있는 직원이 없어 아무에게도 접근 권한이 부여되지 않습니다." + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "사용자 삭제" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 62d7829973e..263dbc1ebf5 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -8741,6 +8741,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "获取帮助" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "市场访问" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "无法获取公告" }, @@ -8825,6 +8828,294 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "已将 {field} 从 {prevValue} 重命名为 {newValue}" }, + "system.admin.admin.MarketplaceAccessFilter.trigger": { + "defaultMessage": "筛选" + }, + "system.admin.admin.MarketplaceAccessFilter.status": { + "defaultMessage": "状态" + }, + "system.admin.admin.MarketplaceAccessFilter.active": { + "defaultMessage": "活跃" + }, + "system.admin.admin.MarketplaceAccessFilter.blocked": { + "defaultMessage": "已屏蔽" + }, + "system.admin.admin.MarketplaceAccessFilter.allowedByRule": { + "defaultMessage": "允许规则" + }, + "system.admin.admin.MarketplaceAccessFilter.clearAll": { + "defaultMessage": "全部清除" + }, + "system.admin.admin.MarketplaceAccessSection.heading": { + "defaultMessage": "拥有访问权限的用户" + }, + "system.admin.admin.MarketplaceAccessSection.summary": { + "defaultMessage": "拥有访问权限总数:{count} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.summaryWithBlocked": { + "defaultMessage": "拥有访问权限总数:{count} · 屏蔽总数:{blocked} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.filteredCounts": { + "defaultMessage": "筛选结果:可访问 {count} · 已屏蔽 {blocked}" + }, + "system.admin.admin.MarketplaceAccessSection.modeOpen": { + "defaultMessage": "对所有人开放" + }, + "system.admin.admin.MarketplaceAccessSection.modeScoped": { + "defaultMessage": "仅限于上方规则" + }, + "system.admin.admin.MarketplaceAccessSection.fetchFailure": { + "defaultMessage": "无法加载市场访问权限列表。" + }, + "system.admin.admin.MarketplaceAccessSection.colName": { + "defaultMessage": "姓名" + }, + "system.admin.admin.MarketplaceAccessSection.colEmail": { + "defaultMessage": "电子邮件" + }, + "system.admin.admin.MarketplaceAccessSection.colEligibleVia": { + "defaultMessage": "资格来源" + }, + "system.admin.admin.MarketplaceAccessSection.colAllowedBy": { + "defaultMessage": "允许依据" + }, + "system.admin.admin.MarketplaceAccessSection.colStatus": { + "defaultMessage": "状态" + }, + "system.admin.admin.MarketplaceAccessSection.colActions": { + "defaultMessage": "操作" + }, + "system.admin.admin.MarketplaceAccessSection.managesCourses": { + "defaultMessage": "{count, plural, one {管理 # 门课程} other {管理 # 门课程}}" + }, + "system.admin.admin.MarketplaceAccessSection.instanceInstructor": { + "defaultMessage": "实例教师" + }, + "system.admin.admin.MarketplaceAccessSection.instanceAdministrator": { + "defaultMessage": "实例管理员" + }, + "system.admin.admin.MarketplaceAccessSection.allowedEveryone": { + "defaultMessage": "每个人" + }, + "system.admin.admin.MarketplaceAccessSection.allowedNothing": { + "defaultMessage": "没有匹配的规则" + }, + "system.admin.admin.MarketplaceAccessSection.systemAdmin": { + "defaultMessage": "系统管理员" + }, + "system.admin.admin.MarketplaceAccessSection.typeUser": { + "defaultMessage": "用户" + }, + "system.admin.admin.MarketplaceAccessSection.typeInstance": { + "defaultMessage": "实例" + }, + "system.admin.admin.MarketplaceAccessSection.typeEmailDomain": { + "defaultMessage": "电子邮件域名" + }, + "system.admin.admin.MarketplaceAccessSection.statusActive": { + "defaultMessage": "活跃" + }, + "system.admin.admin.MarketplaceAccessSection.statusBlocked": { + "defaultMessage": "已屏蔽" + }, + "system.admin.admin.MarketplaceAccessSection.disable": { + "defaultMessage": "屏蔽" + }, + "system.admin.admin.MarketplaceAccessSection.reEnable": { + "defaultMessage": "取消屏蔽" + }, + "system.admin.admin.MarketplaceAccessSection.disableSuccess": { + "defaultMessage": "已屏蔽该用户的访问权限。" + }, + "system.admin.admin.MarketplaceAccessSection.disableFailure": { + "defaultMessage": "屏蔽访问权限失败。" + }, + "system.admin.admin.MarketplaceAccessSection.reEnableSuccess": { + "defaultMessage": "已取消屏蔽该用户的访问权限。" + }, + "system.admin.admin.MarketplaceAccessSection.reEnableFailure": { + "defaultMessage": "取消屏蔽访问权限失败。" + }, + "system.admin.admin.MarketplaceAccessSection.searchPlaceholder": { + "defaultMessage": "搜索姓名或电子邮件" + }, + "system.admin.admin.MarketplaceAccessSection.dormantHeading": { + "defaultMessage": "休眠屏蔽({count})" + }, + "system.admin.admin.MarketplaceAccessSection.dormantExplanation": { + "defaultMessage": "这些用户已被屏蔽,但目前没有任何规则授予他们访问权限。该屏蔽目前不会阻止任何事情——但如果日后有规则与他们匹配,它将再次生效,因此如果不再需要,请清除该屏蔽。" + }, + "system.admin.admin.MarketplaceAccessSection.clearBlock": { + "defaultMessage": "清除屏蔽" + }, + "system.admin.admin.MarketplaceAllowlistIndex.addRule": { + "defaultMessage": "添加访问规则" + }, + "system.admin.admin.MarketplaceAllowlistIndex.eligibility": { + "defaultMessage": "任何课程的管理员及拥有者,以及任何实例的教师及管理员均可使用,但仍须匹配以下规则之一。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.fetchFailure": { + "defaultMessage": "加载市场访问规则失败。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.createSuccess": { + "defaultMessage": "访问规则已添加。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.createFailure": { + "defaultMessage": "添加访问规则失败。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess": { + "defaultMessage": "访问规则已移除。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteFailure": { + "defaultMessage": "移除访问规则失败。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.openSuccess": { + "defaultMessage": "市场已向所有课程管理员开放。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.openFailure": { + "defaultMessage": "未能将市场向所有人开放。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess": { + "defaultMessage": "市场已限制为已设定范围的规则。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictFailure": { + "defaultMessage": "未能限制市场。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle": { + "defaultMessage": "访问权限仅限于以下规则。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle": { + "defaultMessage": "市场目前向所有符合条件的职员开放:课程管理员/拥有者以及实例教师/管理员。以下规则会被保留,但暂不生效。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.toggleLabel": { + "defaultMessage": "对所有人开放" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle": { + "defaultMessage": "要将市场向所有人开放吗?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody": { + "defaultMessage": "这将使市场对所有符合条件的职员可见:课程管理员/拥有者以及实例教师/管理员。你可以随时重新限制访问,已设定范围的规则会被保留。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle": { + "defaultMessage": "要限制为已设定范围的规则吗?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody": { + "defaultMessage": "市场将再次仅限于以下规则。未被任何规则覆盖的符合条件职员将失去访问权限。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen": { + "defaultMessage": "对所有人开放" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict": { + "defaultMessage": "限制" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.title": { + "defaultMessage": "添加市场访问规则" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.ruleType": { + "defaultMessage": "规则类型" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeUser": { + "defaultMessage": "特定符合条件的职员" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance": { + "defaultMessage": "某个实例中的所有符合条件职员" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain": { + "defaultMessage": "拥有特定邮箱域名的所有符合条件职员" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.userEmail": { + "defaultMessage": "符合条件职员的邮箱" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.instanceId": { + "defaultMessage": "实例" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain": { + "defaultMessage": "邮箱域名(例如 schools.gov.sg)" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.next": { + "defaultMessage": "下一步" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.back": { + "defaultMessage": "返回" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.confirmAdd": { + "defaultMessage": "确认添加" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.counts": { + "defaultMessage": "为 {matched} 名符合条件的职员授予访问权限" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsOfMatched": { + "defaultMessage": "在 {matched} 名符合条件职员中,为 {granted} 名授予访问权限" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsExistingClause": { + "defaultMessage": "{existing} 人已拥有访问权限" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsBlockedClause": { + "defaultMessage": "{blocked} 人被单独屏蔽" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.noMatches": { + "defaultMessage": "此规则目前未匹配到任何符合条件的职员。" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.openToEveryone": { + "defaultMessage": "市场目前对所有人开放;此规则仅在你重新限制访问后才会生效。" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.previewFailure": { + "defaultMessage": "无法预览此规则。" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerNew": { + "defaultMessage": "新" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerExisting": { + "defaultMessage": "已拥有访问权限" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerBlocked": { + "defaultMessage": "已屏蔽" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.managesCourses": { + "defaultMessage": "管理 {count, plural, one {# 门课程} other {# 门课程}}" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colName": { + "defaultMessage": "姓名" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colEligibleVia": { + "defaultMessage": "资格来源" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colStatus": { + "defaultMessage": "状态" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.searchPlaceholder": { + "defaultMessage": "按姓名或邮箱搜索" + }, + "system.admin.admin.MarketplaceAllowlistTable.colType": { + "defaultMessage": "类型" + }, + "system.admin.admin.MarketplaceAllowlistTable.colTarget": { + "defaultMessage": "授权对象" + }, + "system.admin.admin.MarketplaceAllowlistTable.colActions": { + "defaultMessage": "操作" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeUser": { + "defaultMessage": "用户" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeInstance": { + "defaultMessage": "实例" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain": { + "defaultMessage": "邮箱域名" + }, + "system.admin.admin.MarketplaceAllowlistTable.deleteConfirm": { + "defaultMessage": "要移除此市场访问规则吗?" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyTitle": { + "defaultMessage": "尚无访问规则" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyHint": { + "defaultMessage": "除系统管理员外,市场对所有人保持隐藏。添加规则以授予访问权限。" + }, + "system.admin.admin.MarketplaceAllowlistTable.zeroMatchWarning": { + "defaultMessage": "目前没有符合条件的职员匹配此规则,因此不会授予任何人访问权限。" + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "删除用户" }, diff --git a/config/locales/en/activerecord/attributes.yml b/config/locales/en/activerecord/attributes.yml index f5586333528..8869d328563 100644 --- a/config/locales/en/activerecord/attributes.yml +++ b/config/locales/en/activerecord/attributes.yml @@ -15,6 +15,13 @@ en: weight: 'Order' course/assessment/category/title: default: 'Assessments' + # Admins read these attribute names verbatim: the allow-list controller renders + # `errors.full_messages.to_sentence` straight into a toast, so without these entries a + # validation failure surfaces as the raw i18n key. + course/assessment/marketplace/allowlist_rule: + user_id: 'User' + instance_id: 'Instance' + email_domain: 'Email domain' course/assessment/question: weight: 'Order' course/assessment/submission: diff --git a/config/locales/ko/activerecord/attributes.yml b/config/locales/ko/activerecord/attributes.yml index 6696c1e3611..34ab52f9d39 100644 --- a/config/locales/ko/activerecord/attributes.yml +++ b/config/locales/ko/activerecord/attributes.yml @@ -15,6 +15,10 @@ ko: weight: '순서' course/assessment/category/title: default: '평가' + course/assessment/marketplace/allowlist_rule: + user_id: '사용자' + instance_id: '인스턴스' + email_domain: '이메일 도메인' course/assessment/question: weight: '순서' course/assessment/submission: diff --git a/config/locales/zh/activerecord/attributes.yml b/config/locales/zh/activerecord/attributes.yml index ab0470c80aa..875cd5ba325 100644 --- a/config/locales/zh/activerecord/attributes.yml +++ b/config/locales/zh/activerecord/attributes.yml @@ -15,6 +15,10 @@ zh: weight: '权重' course/assessment/category/title: default: '评估' + course/assessment/marketplace/allowlist_rule: + user_id: '用户' + instance_id: '实例' + email_domain: '电子邮箱域名' course/assessment/question: weight: '权重' course/assessment/submission: diff --git a/config/routes.rb b/config/routes.rb index 026a51fdecf..e40eeb54d47 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -109,6 +109,13 @@ get '/' => 'admin#index' get 'deployment_info' => 'admin#deployment_info' resources :announcements, only: [:index, :create, :update, :destroy] + resources :marketplace_allowlist_rules, only: [:index, :create, :destroy] do + # Dry run: reports who a prospective rule would let in. POST because it carries a body, + # not because it mutates - the action never saves. + post :preview, on: :collection + end + get 'marketplace_access' => 'marketplace_access#index' + resources :marketplace_access_blocks, only: [:create, :destroy] resources :instances, only: [:index, :create, :update, :destroy] resources :users, only: [:index, :update, :destroy] resources :courses, only: [:index, :destroy] diff --git a/db/migrate/20260720154800_create_course_assessment_marketplace_allowlist_rules.rb b/db/migrate/20260720154800_create_course_assessment_marketplace_allowlist_rules.rb new file mode 100644 index 00000000000..35350554e54 --- /dev/null +++ b/db/migrate/20260720154800_create_course_assessment_marketplace_allowlist_rules.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +class CreateCourseAssessmentMarketplaceAllowlistRules < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_allowlist_rules do |t| + t.integer :rule_type, null: false + + t.references :user, + foreign_key: { to_table: :users }, + null: true, + index: true + + t.references :instance, + foreign_key: true, + null: true, + index: true + + t.string :email_domain, null: true + + t.timestamps + end + + add_index :course_assessment_marketplace_allowlist_rules, + :email_domain + + add_index :course_assessment_marketplace_allowlist_rules, + :user_id, + unique: true, + where: "rule_type = 0", + name: "index_marketplace_allowlist_rules_one_per_user" + + add_index :course_assessment_marketplace_allowlist_rules, + :instance_id, + unique: true, + where: "rule_type = 1", + name: "index_marketplace_allowlist_rules_one_per_instance" + + add_index :course_assessment_marketplace_allowlist_rules, + :email_domain, + unique: true, + where: "rule_type = 2", + name: "index_marketplace_allowlist_rules_one_per_email_domain" + + add_index :course_assessment_marketplace_allowlist_rules, + :rule_type, + unique: true, + where: "rule_type = 3", + name: "index_marketplace_allowlist_rules_one_everyone" + end +end diff --git a/db/migrate/20260722000001_rename_course_assessment_submissions_to_attempts.rb b/db/migrate/20260722000001_rename_course_assessment_submissions_to_attempts.rb new file mode 100644 index 00000000000..01b442379b7 --- /dev/null +++ b/db/migrate/20260722000001_rename_course_assessment_submissions_to_attempts.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Step 1 of the Attempt base-record migration +# (docs/superpowers/specs/2026-07-22-attempt-base-record-design.md). +# +# `course_assessment_submissions` is already mostly the attempts table — seven of its eleven +# columns are attempt-level. Rather than create a new base table and remap the largest tables in +# the schema, we rename this one into the base role; Phase 1b extracts the small course-coupled +# part back out under the old name. +# +# `rename_table` is metadata-only in PostgreSQL. Every index and foreign key follows automatically, +# including the inbound ones from course_assessment_answers, submission_questions, +# submission_logs, and question_bundle_assignments. +class RenameCourseAssessmentSubmissionsToAttempts < ActiveRecord::Migration[7.2] + def change + rename_table :course_assessment_submissions, :course_assessment_attempts + end +end diff --git a/db/migrate/20260722000002_rename_answers_submission_id_to_attempt_id.rb b/db/migrate/20260722000002_rename_answers_submission_id_to_attempt_id.rb new file mode 100644 index 00000000000..b44e1bbb919 --- /dev/null +++ b/db/migrate/20260722000002_rename_answers_submission_id_to_attempt_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Step 2 of the Attempt base-record migration. Metadata-only in PostgreSQL; the index and the +# foreign key to course_assessment_attempts both follow the rename automatically. +class RenameAnswersSubmissionIdToAttemptId < ActiveRecord::Migration[7.2] + def change + rename_column :course_assessment_answers, :submission_id, :attempt_id + end +end diff --git a/db/migrate/20260722000003_rename_submission_questions_submission_id_to_attempt_id.rb b/db/migrate/20260722000003_rename_submission_questions_submission_id_to_attempt_id.rb new file mode 100644 index 00000000000..86cfb300426 --- /dev/null +++ b/db/migrate/20260722000003_rename_submission_questions_submission_id_to_attempt_id.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Step 3 of the Attempt base-record migration. Metadata-only; index and foreign key follow. +class RenameSubmissionQuestionsSubmissionIdToAttemptId < ActiveRecord::Migration[7.2] + def change + rename_column :course_assessment_submission_questions, :submission_id, :attempt_id + end +end diff --git a/db/migrate/20260722000004_create_course_assessment_submissions_extension_table.rb b/db/migrate/20260722000004_create_course_assessment_submissions_extension_table.rb new file mode 100644 index 00000000000..c8ca305253e --- /dev/null +++ b/db/migrate/20260722000004_create_course_assessment_submissions_extension_table.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Phase 1b step: extracts the course-coupled columns off course_assessment_attempts into their own +# small table, reusing the name `course_assessment_submissions` that Phase 1a's table rename freed +# up. See docs/superpowers/specs/2026-07-22-attempt-base-record-design.md §4. +# +# Built under a temporary name and renamed at the end of this migration so that +# `course_assessment_submissions_tmp` never appears as a real historical name in schema.rb — only +# the final `course_assessment_submissions` does, now denoting a different (small) table than it +# did pre-Phase-1a. +class CreateCourseAssessmentSubmissionsExtensionTable < ActiveRecord::Migration[7.2] + def up + create_table :course_assessment_submissions_tmp, id: :serial do |t| + t.integer :attempt_id, null: false + t.integer :publisher_id + t.string :session_id, limit: 255 + t.datetime :last_graded_time, precision: nil + t.timestamps precision: nil, null: false + end + + add_index :course_assessment_submissions_tmp, :attempt_id, unique: true, + name: 'unique_course_assessment_submissions_attempt_id' + add_foreign_key :course_assessment_submissions_tmp, :course_assessment_attempts, + column: :attempt_id, name: 'fk_course_assessment_submissions_attempt_id' + add_foreign_key :course_assessment_submissions_tmp, :users, column: :publisher_id, + name: 'fk_course_assessment_submissions_publisher_id' + + # Row-touching backfill: one Submission row per pre-existing Attempt row. At this point in the + # branch's history every Attempt IS a real course submission (previews land in Phase 2), so + # this is a 1:1, no-filter copy. + execute <<~SQL.squish + INSERT INTO course_assessment_submissions_tmp + (attempt_id, publisher_id, session_id, last_graded_time, created_at, updated_at) + SELECT id, publisher_id, session_id, last_graded_time, created_at, updated_at + FROM course_assessment_attempts + SQL + + rename_table :course_assessment_submissions_tmp, :course_assessment_submissions + end + + def down + rename_table :course_assessment_submissions, :course_assessment_submissions_tmp + drop_table :course_assessment_submissions_tmp + end +end diff --git a/db/schema.rb b/db/schema.rb index 7eeadc353c1..5243d271c75 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_07_000002) do +ActiveRecord::Schema[7.2].define(version: 2026_07_22_000004) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -205,7 +205,7 @@ create_table "course_assessment_answers", id: :serial, force: :cascade do |t| t.integer "actable_id" t.string "actable_type", limit: 255 - t.integer "submission_id", null: false + t.integer "attempt_id", null: false t.integer "question_id", null: false t.string "workflow_state", limit: 255, null: false t.datetime "submitted_at", precision: nil @@ -219,9 +219,28 @@ t.string "last_session_id" t.bigint "client_version" t.index ["actable_type", "actable_id"], name: "index_course_assessment_answers_actable", unique: true + t.index ["attempt_id"], name: "fk__course_assessment_answers_submission_id" t.index ["grader_id"], name: "fk__course_assessment_answers_grader_id" t.index ["question_id"], name: "fk__course_assessment_answers_question_id" - t.index ["submission_id"], name: "fk__course_assessment_answers_submission_id" + end + + create_table "course_assessment_attempts", id: :serial, force: :cascade do |t| + t.integer "assessment_id", null: false + t.string "workflow_state", limit: 255, null: false + t.integer "creator_id", null: false + t.integer "updater_id", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.integer "publisher_id" + t.datetime "published_at", precision: nil + t.string "session_id", limit: 255 + t.datetime "submitted_at", precision: nil + t.datetime "last_graded_time", precision: nil, default: "2021-10-24 14:11:56" + t.index ["assessment_id", "creator_id"], name: "unique_assessment_id_and_creator_id", unique: true + t.index ["assessment_id"], name: "fk__course_assessment_submissions_assessment_id" + t.index ["creator_id"], name: "fk__course_assessment_submissions_creator_id" + t.index ["publisher_id"], name: "fk__course_assessment_submissions_publisher_id" + t.index ["updater_id"], name: "fk__course_assessment_submissions_updater_id" end create_table "course_assessment_categories", id: :serial, force: :cascade do |t| @@ -270,6 +289,15 @@ t.index ["question_id"], name: "index_course_assessment_live_feedbacks_on_question_id" end + create_table "course_assessment_marketplace_access_blocks", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "creator_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "idx_on_creator_id_becaf2e041" + t.index ["user_id"], name: "index_course_assessment_marketplace_access_blocks_on_user_id", unique: true + end + create_table "course_assessment_marketplace_adoptions", force: :cascade do |t| t.bigint "listing_id", null: false t.bigint "destination_course_id", null: false @@ -286,6 +314,22 @@ t.index ["updater_id"], name: "fk__cama_updater_id" end + create_table "course_assessment_marketplace_allowlist_rules", force: :cascade do |t| + t.integer "rule_type", null: false + t.bigint "user_id" + t.bigint "instance_id" + t.string "email_domain" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email_domain"], name: "idx_on_email_domain_6577b88d4e" + t.index ["email_domain"], name: "index_marketplace_allowlist_rules_one_per_email_domain", unique: true, where: "(rule_type = 2)" + t.index ["instance_id"], name: "idx_on_instance_id_77af5cff27" + t.index ["instance_id"], name: "index_marketplace_allowlist_rules_one_per_instance", unique: true, where: "(rule_type = 1)" + t.index ["rule_type"], name: "index_marketplace_allowlist_rules_one_everyone", unique: true, where: "(rule_type = 3)" + t.index ["user_id"], name: "index_course_assessment_marketplace_allowlist_rules_on_user_id" + t.index ["user_id"], name: "index_marketplace_allowlist_rules_one_per_user", unique: true, where: "(rule_type = 0)" + end + create_table "course_assessment_marketplace_listings", force: :cascade do |t| t.bigint "assessment_id", null: false t.boolean "published", default: false, null: false @@ -590,32 +634,23 @@ end create_table "course_assessment_submission_questions", id: :serial, force: :cascade do |t| - t.integer "submission_id", null: false + t.integer "attempt_id", null: false t.integer "question_id", null: false t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.index ["question_id"], name: "fk__course_assessment_submission_questions_question_id" - t.index ["submission_id", "question_id"], name: "idx_course_assessment_submission_questions_on_sub_and_qn", unique: true - t.index ["submission_id"], name: "fk__course_assessment_submission_questions_submission_id" + t.index ["attempt_id", "question_id"], name: "idx_course_assessment_submission_questions_on_sub_and_qn", unique: true + t.index ["attempt_id"], name: "fk__course_assessment_submission_questions_submission_id" end create_table "course_assessment_submissions", id: :serial, force: :cascade do |t| - t.integer "assessment_id", null: false - t.string "workflow_state", limit: 255, null: false - t.integer "creator_id", null: false - t.integer "updater_id", null: false - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false + t.integer "attempt_id", null: false t.integer "publisher_id" - t.datetime "published_at", precision: nil t.string "session_id", limit: 255 - t.datetime "submitted_at", precision: nil - t.datetime "last_graded_time", precision: nil, default: "2021-10-24 14:11:56" - t.index ["assessment_id", "creator_id"], name: "unique_assessment_id_and_creator_id", unique: true - t.index ["assessment_id"], name: "fk__course_assessment_submissions_assessment_id" - t.index ["creator_id"], name: "fk__course_assessment_submissions_creator_id" - t.index ["publisher_id"], name: "fk__course_assessment_submissions_publisher_id" - t.index ["updater_id"], name: "fk__course_assessment_submissions_updater_id" + t.datetime "last_graded_time", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["attempt_id"], name: "unique_course_assessment_submissions_attempt_id", unique: true end create_table "course_assessment_tabs", id: :serial, force: :cascade do |t| @@ -1966,8 +2001,12 @@ add_foreign_key "course_assessment_answer_scribing_scribbles", "course_assessment_answer_scribings", column: "answer_id", name: "fk_course_assessment_answer_scribing_scribbles_answer_id" add_foreign_key "course_assessment_answer_scribing_scribbles", "users", column: "creator_id", name: "fk_course_assessment_answer_scribing_scribbles_creator_id" add_foreign_key "course_assessment_answers", "course_assessment_questions", column: "question_id", name: "fk_course_assessment_answers_question_id" - add_foreign_key "course_assessment_answers", "course_assessment_submissions", column: "submission_id", name: "fk_course_assessment_answers_submission_id" + add_foreign_key "course_assessment_answers", "course_assessment_attempts", column: "attempt_id", name: "fk_course_assessment_answers_submission_id" add_foreign_key "course_assessment_answers", "users", column: "grader_id", name: "fk_course_assessment_answers_grader_id" + add_foreign_key "course_assessment_attempts", "course_assessments", column: "assessment_id", name: "fk_course_assessment_submissions_assessment_id" + add_foreign_key "course_assessment_attempts", "users", column: "creator_id", name: "fk_course_assessment_submissions_creator_id" + add_foreign_key "course_assessment_attempts", "users", column: "publisher_id", name: "fk_course_assessment_submissions_publisher_id" + add_foreign_key "course_assessment_attempts", "users", column: "updater_id", name: "fk_course_assessment_submissions_updater_id" add_foreign_key "course_assessment_categories", "courses", name: "fk_course_assessment_categories_course_id" add_foreign_key "course_assessment_categories", "users", column: "creator_id", name: "fk_course_assessment_categories_creator_id" add_foreign_key "course_assessment_categories", "users", column: "updater_id", name: "fk_course_assessment_categories_updater_id" @@ -1978,11 +2017,15 @@ add_foreign_key "course_assessment_live_feedbacks", "course_assessment_questions", column: "question_id" add_foreign_key "course_assessment_live_feedbacks", "course_assessments", column: "assessment_id" add_foreign_key "course_assessment_live_feedbacks", "users", column: "creator_id" + add_foreign_key "course_assessment_marketplace_access_blocks", "users" + add_foreign_key "course_assessment_marketplace_access_blocks", "users", column: "creator_id" add_foreign_key "course_assessment_marketplace_adoptions", "course_assessment_marketplace_listings", column: "listing_id", name: "fk_course_assessment_marketplace_adoptions_listing_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "course_assessments", column: "duplicated_assessment_id", name: "fk_cama_duplicated_assessment_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "courses", column: "destination_course_id", name: "fk_cama_destination_course_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "creator_id", name: "fk_course_assessment_marketplace_adoptions_creator_id" add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "updater_id", name: "fk_course_assessment_marketplace_adoptions_updater_id" + add_foreign_key "course_assessment_marketplace_allowlist_rules", "instances" + add_foreign_key "course_assessment_marketplace_allowlist_rules", "users" add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_listings", "users", column: "creator_id", name: "fk_course_assessment_marketplace_listings_creator_id" add_foreign_key "course_assessment_marketplace_listings", "users", column: "publisher_id", name: "fk_course_assessment_marketplace_listings_publisher_id" @@ -1990,7 +2033,7 @@ add_foreign_key "course_assessment_plagiarism_checks", "course_assessments", column: "assessment_id", name: "fk_course_assessment_plagiarism_checks_assessment_id" add_foreign_key "course_assessment_plagiarism_checks", "jobs", name: "fk_course_assessment_plagiarism_checks_job_id", on_delete: :nullify add_foreign_key "course_assessment_question_bundle_assignments", "course_assessment_question_bundles", column: "bundle_id" - add_foreign_key "course_assessment_question_bundle_assignments", "course_assessment_submissions", column: "submission_id" + add_foreign_key "course_assessment_question_bundle_assignments", "course_assessment_attempts", column: "submission_id" add_foreign_key "course_assessment_question_bundle_assignments", "course_assessments", column: "assessment_id" add_foreign_key "course_assessment_question_bundle_assignments", "users" add_foreign_key "course_assessment_question_bundle_questions", "course_assessment_question_bundles", column: "bundle_id" @@ -2027,13 +2070,11 @@ add_foreign_key "course_assessment_skills", "users", column: "updater_id", name: "fk_course_assessment_skills_updater_id" add_foreign_key "course_assessment_skills_question_assessments", "course_assessment_skills", column: "skill_id" add_foreign_key "course_assessment_skills_question_assessments", "course_question_assessments", column: "question_assessment_id" - add_foreign_key "course_assessment_submission_logs", "course_assessment_submissions", column: "submission_id", name: "fk_course_assessment_submission_logs_submission_id" + add_foreign_key "course_assessment_submission_logs", "course_assessment_attempts", column: "submission_id", name: "fk_course_assessment_submission_logs_submission_id" add_foreign_key "course_assessment_submission_questions", "course_assessment_questions", column: "question_id", name: "fk_course_assessment_submission_questions_question_id" - add_foreign_key "course_assessment_submission_questions", "course_assessment_submissions", column: "submission_id", name: "fk_course_assessment_submission_questions_submission_id" - add_foreign_key "course_assessment_submissions", "course_assessments", column: "assessment_id", name: "fk_course_assessment_submissions_assessment_id" - add_foreign_key "course_assessment_submissions", "users", column: "creator_id", name: "fk_course_assessment_submissions_creator_id" + add_foreign_key "course_assessment_submission_questions", "course_assessment_attempts", column: "attempt_id", name: "fk_course_assessment_submission_questions_submission_id" + add_foreign_key "course_assessment_submissions", "course_assessment_attempts", column: "attempt_id", name: "fk_course_assessment_submissions_attempt_id" add_foreign_key "course_assessment_submissions", "users", column: "publisher_id", name: "fk_course_assessment_submissions_publisher_id" - add_foreign_key "course_assessment_submissions", "users", column: "updater_id", name: "fk_course_assessment_submissions_updater_id" add_foreign_key "course_assessment_tabs", "course_assessment_categories", column: "category_id", name: "fk_course_assessment_tabs_category_id" add_foreign_key "course_assessment_tabs", "users", column: "creator_id", name: "fk_course_assessment_tabs_creator_id" add_foreign_key "course_assessment_tabs", "users", column: "updater_id", name: "fk_course_assessment_tabs_updater_id" diff --git a/lib/tasks/db/insert_discussion_topics.rake b/lib/tasks/db/insert_discussion_topics.rake index 095c673e78d..13851612801 100644 --- a/lib/tasks/db/insert_discussion_topics.rake +++ b/lib/tasks/db/insert_discussion_topics.rake @@ -15,12 +15,12 @@ namespace :db do # submission_id and question_id are used to group submission_questions where there are # multiple answers acting as old discussion topics. course_sq_tuples = connection.exec_query(<<-SQL) - SELECT cac.course_id AS course_id, casq.id AS sq_id, casq.submission_id AS submission_id, + SELECT cac.course_id AS course_id, casq.id AS sq_id, casq.attempt_id AS submission_id, casq.question_id AS question_id, MAX(cdt.created_at) AS created_at, MAX(cdt.updated_at) AS updated_at FROM course_assessment_submission_questions casq - INNER JOIN course_assessment_submissions cas - ON cas.id = casq.submission_id + INNER JOIN course_assessment_attempts cas + ON cas.id = casq.attempt_id INNER JOIN course_assessments ca ON ca.id = cas.assessment_id INNER JOIN course_assessment_tabs cat @@ -28,10 +28,10 @@ namespace :db do INNER JOIN course_assessment_categories cac ON cac.id = cat.category_id LEFT JOIN course_assessment_answers caa - ON (caa.submission_id = casq.submission_id AND caa.question_id = casq.question_id) + ON (caa.attempt_id = casq.attempt_id AND caa.question_id = casq.question_id) LEFT JOIN course_discussion_topics cdt ON (cdt.actable_id = caa.id AND cdt.actable_type = 'Course::Assessment::Answer') - GROUP BY cac.course_id, casq.id, casq.submission_id, casq.question_id + GROUP BY cac.course_id, casq.id, casq.attempt_id, casq.question_id SQL puts 'DROP UNIQUE index' diff --git a/lib/tasks/db/insert_submission_questions.rake b/lib/tasks/db/insert_submission_questions.rake index 04c0aade7b8..3ad479dee24 100644 --- a/lib/tasks/db/insert_submission_questions.rake +++ b/lib/tasks/db/insert_submission_questions.rake @@ -9,7 +9,7 @@ namespace :db do # for all submission/question pairs. submission_question_tuples = connection.exec_query(<<-SQL) SELECT cac.course_id AS course_id, cas.id AS submission_id, caq.id AS question_id - FROM course_assessment_submissions cas + FROM course_assessment_attempts cas INNER JOIN course_assessments ca ON ca.id = cas.assessment_id INNER JOIN course_assessment_questions caq @@ -36,7 +36,7 @@ namespace :db do connection.exec_query(<<-SQL) INSERT INTO course_assessment_submission_questions - (submission_id, + (attempt_id, question_id, created_at, updated_at) @@ -49,10 +49,10 @@ namespace :db do # This deletes the later version of the row. connection.exec_query(<<-SQL) DELETE FROM course_assessment_submission_questions a - USING (SELECT MIN(ctid) AS ctid, submission_id, question_id - FROM course_assessment_submission_questions GROUP BY submission_id, question_id + USING (SELECT MIN(ctid) AS ctid, attempt_id, question_id + FROM course_assessment_submission_questions GROUP BY attempt_id, question_id HAVING COUNT(*)>1) b - WHERE a.submission_id = b.submission_id AND a.question_id = b.question_id + WHERE a.attempt_id = b.attempt_id AND a.question_id = b.question_id AND a.ctid <> b.ctid SQL @@ -60,7 +60,7 @@ namespace :db do connection.exec_query(<<-SQL) CREATE UNIQUE INDEX idx_course_assessment_submission_questions_on_sub_and_qn ON course_assessment_submission_questions USING btree - (submission_id, question_id) + (attempt_id, question_id) SQL end end diff --git a/lib/tasks/db/migrate_comments.rake b/lib/tasks/db/migrate_comments.rake index 278aba7ab29..342111d2b13 100644 --- a/lib/tasks/db/migrate_comments.rake +++ b/lib/tasks/db/migrate_comments.rake @@ -12,7 +12,7 @@ namespace :db do INNER JOIN course_assessment_answers caa ON caa.id = cdt.actable_id INNER JOIN course_assessment_submission_questions casq - ON casq.submission_id = caa.submission_id AND casq.question_id = caa.question_id + ON casq.attempt_id = caa.attempt_id AND casq.question_id = caa.question_id INNER JOIN course_discussion_topics cdt2 ON casq.id = cdt2.actable_id AND cdt2.actable_type = 'Course::Assessment::SubmissionQuestion' diff --git a/spec/controllers/concerns/course/assessment/live_feedback/message_concern_spec.rb b/spec/controllers/concerns/course/assessment/live_feedback/message_concern_spec.rb index e084b525191..73c03d3f3e7 100644 --- a/spec/controllers/concerns/course/assessment/live_feedback/message_concern_spec.rb +++ b/spec/controllers/concerns/course/assessment/live_feedback/message_concern_spec.rb @@ -20,7 +20,7 @@ class self::DummyController < ApplicationController let!(:answer) { submission.answers.where(actable_type: 'Course::Assessment::Answer::Programming').first } let!(:question) { answer.question } let!(:submission_question) do - Course::Assessment::SubmissionQuestion.create!(submission_id: submission.id, question_id: question.id) + Course::Assessment::SubmissionQuestion.create!(attempt_id: submission.id, question_id: question.id) end let!(:codaveri_thread_id) { SecureRandom.hex(12) } diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index 9c02a8ac2db..bdf1398de0e 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -12,6 +12,8 @@ before { controller_sign_in(controller, manager.user) } describe 'GET #index' do + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + let!(:published) { create(:course_assessment_marketplace_listing, published: true) } let!(:unpublished) { create(:course_assessment_marketplace_listing, published: false) } @@ -69,12 +71,117 @@ end end end + describe 'GET #index visibility gate' do + subject { get :index, params: { course_id: course.id, format: :json } } + + # The suite runs with `use_transactional_fixtures = false` (see spec/rails_helper.rb), so rows + # persist across examples/runs. `:everyone` is a DB-enforced singleton (one row allowed), so any + # leftover row here would either block a later `create(:everyone)` with a uniqueness error or + # spuriously widen access in a sibling example. Mirrors the cleanup in + # spec/models/course/assessment/marketplace/allowlist_rule_spec.rb. + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + context 'when the manager is not on the allow-list' do + before { controller_sign_in(controller, manager.user) } + + it 'denies access' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when an allow-list rule matches the manager' do + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + controller_sign_in(controller, manager.user) + end + + it 'permits access' do + expect { subject }.not_to raise_exception + end + end + + context 'when the user is a system administrator' do + let(:admin) { create(:administrator) } + before do + create(:course_manager, course: course, user: admin) + controller_sign_in(controller, admin) + end + + it 'permits access without an allow-list rule' do + expect { subject }.not_to raise_exception + end + end + + context "when an 'everyone' rule exists" do + before do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + controller_sign_in(controller, manager.user) + end + + it 'permits a manager who has no matching scoped rule' do + expect { subject }.not_to raise_exception + end + end + + context "when an 'everyone' rule exists but the user is a student" do + let(:student) { create(:course_student, course: course).user } + before do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + controller_sign_in(controller, student) + end + + it 'still denies a non-manager (the manager gate holds)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when the user is an observer here but manages another course' do + let(:roamer) { create(:course_observer, course: course).user } + before do + create(:course_manager, course: create(:course), user: roamer) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: roamer) + controller_sign_in(controller, roamer) + end + + it 'permits browsing (access is per-person, not per-current-course role)' do + expect { subject }.not_to raise_exception + end + end + + context 'when the user manages another course but is not on the allow-list' do + let(:roamer) { create(:course_observer, course: course).user } + before do + create(:course_manager, course: create(:course), user: roamer) + controller_sign_in(controller, roamer) + end + + it 'denies access (allow-list is still required even for a manager elsewhere)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when an allow-listed user manages no course' do + let(:pupil) { create(:course_student, course: course).user } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: pupil) + controller_sign_in(controller, pupil) + end + + it 'denies access (must manage at least one course)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + end + describe 'POST #duplicate' do # `have_enqueued_job` requires the :test adapter; the test env defaults to :background_thread. # `run_rescue` re-enables handle_access_denied so AccessDenied renders 403 rather than # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). run_rescue + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + with_active_job_queue_adapter(:test) do let!(:listing) { create(:course_assessment_marketplace_listing, published: true) } let!(:tab) { course.assessment_categories.first.tabs.first } @@ -116,6 +223,8 @@ # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). run_rescue + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + let!(:listing) do assessment = create(:assessment, course: create(:course)) create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) @@ -175,6 +284,7 @@ ActsAsTenant.with_tenant(home_instance) do course = create(:course) manager = create(:course_manager, course: course) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) controller_sign_in(controller, manager.user) # Point the request at the home instance's host so `deduce_tenant` resolves it (this # describe is outside `with_tenant`, which would otherwise set the host header for us). diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb index b6d35b3839c..de6d5949df3 100644 --- a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -35,7 +35,10 @@ let(:destination_course) { create(:course) } let(:manager) { create(:course_manager, course: destination_course).user } - before { controller_sign_in(controller, manager) } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager) + controller_sign_in(controller, manager) + end it 'serializes the question across instances' do get :show, as: :json, params: { diff --git a/spec/controllers/course/assessment/submission/answer/answers_controller_spec.rb b/spec/controllers/course/assessment/submission/answer/answers_controller_spec.rb index 845449bbc8e..15f89dff59f 100644 --- a/spec/controllers/course/assessment/submission/answer/answers_controller_spec.rb +++ b/spec/controllers/course/assessment/submission/answer/answers_controller_spec.rb @@ -67,7 +67,7 @@ let(:submission) { create(:submission, :published, assessment: assessment, creator: submitter) } let(:answer) { submission.answers.first } let!(:submission_question) do - create(:submission_question, :with_post, submission_id: answer.submission_id, question_id: answer.question_id) + create(:submission_question, :with_post, attempt_id: answer.attempt_id, question_id: answer.question_id) end describe '#show' do diff --git a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb index 5aa00a954e3..50d1dc45e34 100644 --- a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb +++ b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb @@ -151,40 +151,56 @@ end end - describe '#update_grade' do - subject do - post :update, params: { - course_id: course, assessment_id: assessment2, id: graded_submission, - submission: { - answers: [{ id: answer.id, grade: grade }] - }, - format: :json - } - end - - context 'when update fails' do - let(:grade) { nil } - before do - subject + # `graded_submission` builds via the `:graded` factory trait, which passes through + # `:submitted` on its way there — briefly triggering `Attempt#after_save :auto_grade_submission, + # if: :submitted?`, which enqueues a real `Submission::AutoGradingJob` (there's a programming + # question among `:with_all_question_types`, per `graded_submission.answers.third`'s own + # comment below). Under the test env's default `:background_thread` adapter (see + # `spec/support/active_job.rb` / `app/CLAUDE.md`), that job runs concurrently with the rest of + # this example — and can win the race against the `post :update` below, re-grading (and + # transitioning) the very answer this example is asserting on before the request reaches it. + # This pre-existing race (the job enqueue is unchanged by Step 2's split) became far more likely + # to manifest post-split — the Attempt/Submission split adds enough extra latency to the main + # thread's own request handling to give the background job time to complete first. Wrapping + # with the repo's own `with_active_job_queue_adapter(:test)` helper (already used the same way + # for `Submission#finalise!`'s own auto-grading-job example) makes the job just enqueue, + # not execute, removing the race outright — not a behaviour change to the code under test. + with_active_job_queue_adapter(:test) do + describe '#update_grade' do + subject do + post :update, params: { + course_id: course, assessment_id: assessment2, id: graded_submission, + submission: { + answers: [{ id: answer.id, grade: grade }] + }, + format: :json + } end - it { is_expected.to have_http_status(:bad_request) } - end + context 'when update fails' do + let(:grade) { nil } + before do + subject + end - context 'when update grade is called, even when answer is not valid' do - let(:grade) { 0 } - let(:answer) { graded_submission.answers.third } # programming answer - let(:max_file_size) { 2.kilobytes } - let(:invalid_content) { 'a' * (max_file_size + 1) } - before do - stub_const('Course::Assessment::Answer::Programming::MAX_TOTAL_FILE_SIZE', max_file_size) - file = answer.actable.files.first - file.content = invalid_content - file.save!(validate: false) - subject + it { is_expected.to have_http_status(:bad_request) } end - it { is_expected.to have_http_status(:ok) } + context 'when update grade is called, even when answer is not valid' do + let(:grade) { 0 } + let(:answer) { graded_submission.answers.third } # programming answer + let(:max_file_size) { 2.kilobytes } + let(:invalid_content) { 'a' * (max_file_size + 1) } + before do + stub_const('Course::Assessment::Answer::Programming::MAX_TOTAL_FILE_SIZE', max_file_size) + file = answer.actable.files.first + file.content = invalid_content + file.save!(validate: false) + subject + end + + it { is_expected.to have_http_status(:ok) } + end end end @@ -263,7 +279,7 @@ let!(:answer) { submission.answers.where(actable_type: 'Course::Assessment::Answer::Programming').first } let!(:question) { answer.question } let!(:submission_question) do - Course::Assessment::SubmissionQuestion.create!(submission_id: submission.id, question_id: question.id) + Course::Assessment::SubmissionQuestion.create!(attempt_id: submission.id, question_id: question.id) end let!(:thread_id) { SecureRandom.hex(12) } let!(:thread_status) { 'active' } diff --git a/spec/controllers/course/assessment/submission_question/submission_questions_controller_spec.rb b/spec/controllers/course/assessment/submission_question/submission_questions_controller_spec.rb index aceef115a4c..4e81fe28af6 100644 --- a/spec/controllers/course/assessment/submission_question/submission_questions_controller_spec.rb +++ b/spec/controllers/course/assessment/submission_question/submission_questions_controller_spec.rb @@ -13,7 +13,7 @@ let(:submission) { create(:submission, :graded, assessment: assessment, creator: submitter.user) } let(:answer) { submission.answers.first } let!(:submission_question) do - create(:submission_question, :with_post, submission_id: answer.submission_id, question_id: answer.question_id) + create(:submission_question, :with_post, attempt_id: answer.attempt_id, question_id: answer.question_id) end describe '#all_answers' do @@ -22,7 +22,7 @@ get :all_answers, format: :json, params: { course_id: course, id: assessment, - submission_id: answer.submission_id, + submission_id: answer.attempt_id, question_id: answer.question_id } end diff --git a/spec/controllers/course/assessment_marketplace_component_spec.rb b/spec/controllers/course/assessment_marketplace_component_spec.rb index 6fa1c1f6aab..78cd6db53f5 100644 --- a/spec/controllers/course/assessment_marketplace_component_spec.rb +++ b/spec/controllers/course/assessment_marketplace_component_spec.rb @@ -15,7 +15,10 @@ context 'when the user can access the marketplace (course manager)' do let(:user) { create(:course_manager, course: course).user } - before { controller_sign_in(controller, user) } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + controller_sign_in(controller, user) + end it 'exposes an admin sidebar item pointing at the marketplace' do item = subject.sidebar_items.find { |i| i[:key] == :admin_marketplace } diff --git a/spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb b/spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb new file mode 100644 index 00000000000..b320410020f --- /dev/null +++ b/spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAccessBlocksController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before do + Course::Assessment::Marketplace::AccessBlock.delete_all + controller_sign_in(controller, admin) + end + + describe 'POST #create' do + it 'blocks the user and returns the block id' do + target = create(:user) + expect do + post :create, format: :json, params: { user_id: target.id } + end.to change { Course::Assessment::Marketplace::AccessBlock.count }.by(1) + expect(response).to have_http_status(:ok) + expect(response.parsed_body['userId']).to eq(target.id) + expect(response.parsed_body['id']).to be_present + end + + it 'rejects a duplicate block for the same user' do + target = create(:user) + create(:course_assessment_marketplace_access_block, user: target) + expect do + post :create, format: :json, params: { user_id: target.id } + end.not_to(change { Course::Assessment::Marketplace::AccessBlock.count }) + expect(response).to have_http_status(:bad_request) + end + end + + describe 'DELETE #destroy' do + it 'removes the block' do + block = create(:course_assessment_marketplace_access_block) + expect do + delete :destroy, format: :json, params: { id: block.id } + end.to change { Course::Assessment::Marketplace::AccessBlock.count }.by(-1) + expect(response).to have_http_status(:ok) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + post :create, format: :json, params: { user_id: create(:user).id } + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/controllers/system/admin/marketplace_access_controller_spec.rb b/spec/controllers/system/admin/marketplace_access_controller_spec.rb new file mode 100644 index 00000000000..8d753cfaf6c --- /dev/null +++ b/spec/controllers/system/admin/marketplace_access_controller_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAccessController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + # LOWER() and no '@' anchor: the uniqueness index is on `lower(email)` while SQL LIKE is + # case-sensitive, so an anchored, case-sensitive pattern leaves rows behind that collide on + # the next run. + User::Email.where('LOWER(email) LIKE ?', '%schools.gov.sg').delete_all + controller_sign_in(controller, admin) + end + + describe 'GET #index' do + render_views + + it 'lists eligible users with annotations and a summary' do + manager = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + + get :index, format: :json + expect(response).to have_http_status(:ok) + + row = response.parsed_body['users'].find { |u| u['id'] == manager.user.id } + expect(row).to be_present + expect(row['allowedByRules'].map { |r| r['ruleType'] }).to eq(['user']) + expect(row['courseCount']).to eq(1) + expect(row['blocked']).to be(false) + expect(row['systemAdmin']).to be(false) + # System admins are always listed and always count as having access; the test DB accumulates + # them across runs (nothing rolls back), so the total is relative to however many exist. + expect(response.parsed_body['summary']['totalWithAccess']).to eq(1 + User.administrator.count) + expect(response.parsed_body['summary']['openToEveryone']).to be(false) + end + + it 'flags a blocked user with a blockId' do + manager = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + block = create(:course_assessment_marketplace_access_block, user: manager.user) + + get :index, format: :json + row = response.parsed_body['users'].find { |u| u['id'] == manager.user.id } + expect(row['blocked']).to be(true) + expect(row['blockId']).to eq(block.id) + expect(response.parsed_body['summary']['totalWithAccess']).to eq(User.administrator.count) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + get :index, format: :json + expect(response).to have_http_status(:forbidden) + end + end + describe 'GET #index serialization' do + render_views + + it 'serializes every matching rule with its label, and the blocked total' do + user = create(:user, email: 'listed@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + user_rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: user) + domain_rule = create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + create(:course_assessment_marketplace_access_block, user: user) + + get :index, format: :json + + expect(response).to have_http_status(:ok) + row = response.parsed_body['users'].find { |u| u['id'] == user.id } + expect(row).not_to be_nil + expect(row['allowedByRules']).to contain_exactly( + { 'id' => user_rule.id, 'ruleType' => 'user', 'labelValue' => user.name }, + { 'id' => domain_rule.id, 'ruleType' => 'email_domain', + 'labelValue' => 'schools.gov.sg' } + ) + expect(response.parsed_body['summary']['totalBlocked']).to eq(1) + end + + it 'serializes an instance rule with the instance name as its label' do + other_instance = create(:instance) + user = create(:user) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + + get :index, format: :json + + row = response.parsed_body['users'].find { |u| u['id'] == user.id } + expect(row['allowedByRules']).to eq( + ['id' => rule.id, 'ruleType' => 'instance', 'labelValue' => other_instance.name] + ) + end + + it 'serializes an empty rule list for a user listed only because they are blocked' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_access_block, user: cu.user) + + get :index, format: :json + + row = response.parsed_body['users'].find { |u| u['id'] == cu.user.id } + expect(row['allowedByRules']).to eq([]) + expect(row['blocked']).to be(true) + end + + it 'serializes a system admin who manages nothing and matches no rule' do + admin = create(:administrator) + + get :index, format: :json + + row = response.parsed_body['users'].find { |u| u['id'] == admin.id } + expect(row).not_to be_nil + expect(row['systemAdmin']).to be(true) + expect(row['allowedByRules']).to eq([]) + expect(row['courseCount']).to eq(0) + end + end + end +end diff --git a/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb new file mode 100644 index 00000000000..26984015d5c --- /dev/null +++ b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAllowlistRulesController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before { controller_sign_in(controller, admin) } + + describe 'POST #create' do + # Email-domain rules are unique per domain and specs commit, so the row this example creates + # would collide with itself on the next run. Clear it first. + before do + Course::Assessment::Marketplace::AllowlistRule. + rule_type_email_domain.where(email_domain: 'schools.gov.sg').delete_all + end + + subject do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'email_domain', email_domain: 'schools.gov.sg' } + } + end + + it 'creates an email-domain rule' do + expect { subject }. + to change { Course::Assessment::Marketplace::AllowlistRule.count }.by(1) + expect(response).to have_http_status(:ok) + end + end + + describe 'POST #create for a user rule by email' do + render_views + # No transactional fixtures / DatabaseCleaner here (see GET #index note), so a user with this + # hardcoded email can persist from an earlier run and collide on email uniqueness. Clear it. + before { User::Email.where(email: 'teacher@school.edu').delete_all } + + it 'resolves a confirmed email to the owning user and creates a user rule' do + target = create(:user, email: 'teacher@school.edu') + expect do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'user', email: 'teacher@school.edu' } + } + end.to change { Course::Assessment::Marketplace::AllowlistRule.rule_type_user.count }.by(1) + expect(response).to have_http_status(:ok) + expect(Course::Assessment::Marketplace::AllowlistRule.rule_type_user.last.user).to eq(target) + end + + it 'serializes the resolved user\'s email as userEmail in the rendered rule' do + create(:user, email: 'teacher@school.edu') + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'user', email: 'teacher@school.edu' } + } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['userEmail']).to eq('teacher@school.edu') + end + + it 'rejects an email that matches no user' do + expect do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'user', email: 'nobody@nowhere.test' } + } + end.not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['errors']).to include('No user with that email.') + end + end + + describe 'GET #index' do + render_views + # This suite runs with `use_transactional_fixtures = false` and no DatabaseCleaner, so rows + # created by earlier local runs of this factory persist in the dev/test DB; scope to a clean + # slate here so the size assertion below is deterministic. + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) + end + + it 'lists the rules' do + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['rules'].size).to eq(1) + end + end + + describe 'GET #index everyone-mode reporting' do + render_views + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) + end + + it 'reports everyoneRuleId null and lists only scoped rules when no everyone rule exists' do + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['everyoneRuleId']).to be_nil + expect(response.parsed_body['rules'].size).to eq(1) + end + + it 'reports everyoneRuleId and excludes the everyone rule from the list' do + everyone = create(:course_assessment_marketplace_allowlist_rule, :everyone) + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['everyoneRuleId']).to eq(everyone.id) + expect(response.parsed_body['rules'].map { |r| r['ruleType'] }).not_to include('everyone') + expect(response.parsed_body['rules'].size).to eq(1) + end + end + + describe "POST #create with rule_type 'everyone'" do + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + it 'opens the marketplace to everyone' do + expect do + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + end.to change { Course::Assessment::Marketplace::AllowlistRule.rule_type_everyone.count }.by(1) + expect(response).to have_http_status(:ok) + end + + it 'rejects a second everyone rule' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect do + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + end.not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + expect(response).to have_http_status(:bad_request) + end + + it 'surfaces the uniqueness error when rejected' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + expect(response.parsed_body['errors']).to include('already been taken') + end + end + + describe 'DELETE #destroy' do + let!(:rule) { create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) } + + it 'removes the rule' do + expect { delete :destroy, format: :json, params: { id: rule.id } }. + to change { Course::Assessment::Marketplace::AllowlistRule.count }.by(-1) + expect(response).to have_http_status(:ok) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + get :index, format: :json + expect(response).to have_http_status(:forbidden) + end + end + + describe 'POST #preview' do + render_views + + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + # LOWER() and no '@' anchor — see the note in Task 1's spec: an anchored, case-sensitive + # pattern misses rows the `lower(email)` unique index will still collide on. + User::Email.where('LOWER(email) LIKE ?', '%preview.test').delete_all + end + + def preview(params) + post :preview, format: :json, params: { allowlist_rule: params } + end + + it 'counts eligible staff a domain rule would match, and how many are new' do + newcomer = create(:user, email: 'newcomer@preview.test') + create(:course_manager, course: create(:course), user: newcomer) + existing = create(:user, email: 'existing@preview.test') + create(:course_manager, course: create(:course), user: existing) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: existing) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['matchedCount']).to eq(2) + expect(body['newCount']).to eq(1) + expect(body['blockedCount']).to eq(0) + expect(body['openToEveryone']).to be(false) + expect(body['users'].map { |u| u['id'] }).to contain_exactly(newcomer.id, existing.id) + expect(body['users'].find { |u| u['id'] == existing.id }['alreadyHasAccess']).to be(true) + expect(body['users'].find { |u| u['id'] == newcomer.id }['alreadyHasAccess']).to be(false) + end + + it 'persists nothing' do + create(:course_manager, course: create(:course), + user: create(:user, email: 'dryrun@preview.test')) + + expect { preview(rule_type: 'email_domain', email_domain: 'preview.test') }. + not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + end + + it 'excludes users who are not baseline-eligible' do + create(:course_student, course: create(:course), + user: create(:user, email: 'student@preview.test')) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response.parsed_body['matchedCount']).to eq(0) + end + + it 'counts a blocked match but never as new' do + blocked = create(:user, email: 'blocked@preview.test') + create(:course_manager, course: create(:course), user: blocked) + create(:course_assessment_marketplace_access_block, user: blocked) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + body = response.parsed_body + expect(body['matchedCount']).to eq(1) + expect(body['newCount']).to eq(0) + # Counted separately from the already-has-access remainder: the UI names the two groups + # apart, and a blocked user is held back by their own block, not by prior access. + expect(body['blockedCount']).to eq(1) + expect(body['users'].first['blocked']).to be(true) + end + + it 'lists blocked, then already-cleared, then newly granted matches' do + # Named so the alphabetical order the query starts from is the exact REVERSE of the + # expected one; without the grouping this example would still pass on a name-sorted list. + blocked = create(:user, name: 'Zoe Blocked', email: 'zoe@preview.test') + create(:course_manager, course: create(:course), user: blocked) + create(:course_assessment_marketplace_access_block, user: blocked) + existing = create(:user, name: 'Mabel Existing', email: 'mabel@preview.test') + create(:course_manager, course: create(:course), user: existing) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: existing) + newcomer = create(:user, name: 'Adam New', email: 'adam@preview.test') + create(:course_manager, course: create(:course), user: newcomer) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response.parsed_body['users'].map { |u| u['id'] }). + to eq([blocked.id, existing.id, newcomer.id]) + end + + it 'reports zero new when the marketplace is already open to everyone' do + create(:course_manager, course: create(:course), + user: create(:user, email: 'open@preview.test')) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + body = response.parsed_body + expect(body['openToEveryone']).to be(true) + expect(body['matchedCount']).to eq(1) + expect(body['newCount']).to eq(0) + end + + it 'returns zero matches for a user rule whose target is not eligible' do + create(:user, email: 'nobody@preview.test') # manages nothing + + preview(rule_type: 'user', email: 'nobody@preview.test') + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['matchedCount']).to eq(0) + end + + it 'rejects an email matching no user' do + preview(rule_type: 'user', email: 'ghost@preview.test') + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['errors']).to include('No user with that email.') + end + + it 'rejects a rule that already exists' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'preview.test') + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response).to have_http_status(:bad_request) + # Attribute name omitted: StubbedI18nBackend returns the raw key for + # `activerecord.attributes.*`, so full_messages can never render "Email domain" here. + expect(response.parsed_body['errors']).to include('already has the same rule.') + end + + it 'denies a non-administrator' do + controller_sign_in(controller, create(:user)) + expect { preview(rule_type: 'email_domain', email_domain: 'preview.test') }. + to raise_exception(CanCan::AccessDenied) + end + end + end +end diff --git a/spec/factories/course_assessment_attempts.rb b/spec/factories/course_assessment_attempts.rb new file mode 100644 index 00000000000..e54c14cc182 --- /dev/null +++ b/spec/factories/course_assessment_attempts.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_attempt, class: Course::Assessment::Attempt, aliases: [:attempt] do + assessment { create(:assessment, :with_mcq_question, course: create(:course)) } + end +end diff --git a/spec/factories/course_assessment_marketplace_access_blocks.rb b/spec/factories/course_assessment_marketplace_access_blocks.rb new file mode 100644 index 00000000000..471edf6f26a --- /dev/null +++ b/spec/factories/course_assessment_marketplace_access_blocks.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_access_block, + class: 'Course::Assessment::Marketplace::AccessBlock' do + association :user + association :creator, factory: :user + end +end diff --git a/spec/factories/course_assessment_marketplace_allowlist_rules.rb b/spec/factories/course_assessment_marketplace_allowlist_rules.rb new file mode 100644 index 00000000000..ae96fb09113 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_allowlist_rules.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_allowlist_rule, + class: 'Course::Assessment::Marketplace::AllowlistRule' do + # Default to a self-contained email-domain rule so the bare factory is valid under + # `factory_bot:lint`. Traits below override `rule_type` (and supply any needed association). + rule_type { :email_domain } + # Unique per invocation. Specs commit (use_transactional_fixtures is false), and email-domain + # rules are now unique per domain, so a hardcoded default would collide with the row committed + # by the previous run. + sequence(:email_domain) { |n| "domain-#{n}-#{SecureRandom.hex(3)}.test" } + + trait :for_user do + rule_type { :user } + association :user + end + trait :for_instance do + rule_type { :instance } + association :instance + end + trait :for_email_domain do + rule_type { :email_domain } + end + trait :everyone do + rule_type { :everyone } + end + end +end diff --git a/spec/factories/course_assessment_submissions.rb b/spec/factories/course_assessment_submissions.rb index aa857b2799b..88cc2ec40ae 100644 --- a/spec/factories/course_assessment_submissions.rb +++ b/spec/factories/course_assessment_submissions.rb @@ -7,13 +7,39 @@ grader { User.stamper } auto_grade { true } # Used only with any of the submitted or finalised traits. creator + assessment { create(:assessment, :with_mcq_question, course: course) } end - assessment { create(:assessment, :with_mcq_question, course: course) } + + # `assessment`/`creator` above build the backing Attempt, not Submission's own (nonexistent) + # columns — this preserves every existing `create(:submission, assessment: foo)` call site's + # literal syntax (FactoryBot treats transient/real attribute overrides identically), while no + # longer trying to assign a nonexistent `submission.assessment=`. + # + # `creator: creator` must be threaded through explicitly: a bare `creator` inside `transient do + # ... end` is NOT actually inert here — `:creator` is a registered alias of the `:user` factory + # (spec/factories/users.rb), so FactoryBot resolves it as an implicit `Attribute::Association`, + # which the gem *always* builds with `ignored: false` regardless of being declared inside + # `transient` (`factory_bot/attribute/association.rb` hardcodes `super(name, false)`). Pre-split, + # that meant `submission.creator = ` landed directly on Submission's own + # (real, userstamp) `creator` column. Post-split, Submission has no `creator=` writer of its own + # (only a delegated reader) — the same assignment now silently falls through to + # `acts_as`'s `method_missing` and sets `experience_points_record.creator` instead (harmless + # duplication of the final `after(:build)` hook below, but it leaves `attempt.creator` unset). + # Passing `creator:` straight into the Attempt's own build restores the pre-split guarantee that + # `create(:submission, creator: X)` actually makes `submission.creator == X` — required for + # `validate_consistent_user` (`course_user.user == creator`) to ever pass. Mechanical fix, + # reproduced via `FactoryBot.build(:submission, creator: X, course_user: cu_for_X).valid?` → + # `experience_points_record inconsistent_user` (attempt.creator was nil, not X). + attempt { association(:course_assessment_attempt, assessment: assessment, creator: creator) } points_awarded { nil } trait :attempting do after(:build) do |submission| - submission.answers = submission.assessment.questions.attempt(submission) + # `Answer#submission` now targets `Course::Assessment::Attempt` (the FK repoint, Step 2d) — + # `.attempt(submission)` here must be given the Attempt, not the Submission, or building + # the new answer raises `ActiveRecord::AssociationTypeMismatch`. Mechanical fix; the + # question-attempt logic itself is otherwise unchanged. + submission.answers = submission.assessment.questions.attempt(submission.attempt) # These are the first answers, so set their `current_answer` flag. submission.answers.map do |answer| answer.current_answer = true @@ -62,7 +88,7 @@ trait :attempting_with_past_answers do attempting after(:build) do |submission| - answers = submission.assessment.questions.attempt(submission) + answers = submission.assessment.questions.attempt(submission.attempt) answers.map do |answer| answer.current_answer = false answer.save! @@ -74,7 +100,7 @@ trait :with_past_answers do after(:build) do |submission| - old_answers = submission.assessment.questions.attempt(submission) + old_answers = submission.assessment.questions.attempt(submission.attempt) old_answers.map do |answer| answer.created_at = Time.zone.now - 1.day answer.finalise! @@ -82,7 +108,7 @@ end submission.answers << old_answers - new_answers = submission.assessment.questions.attempt(submission) + new_answers = submission.assessment.questions.attempt(submission.attempt) new_answers.map do |answer| answer.current_answer = true answer.finalise! diff --git a/spec/factories/instances.rb b/spec/factories/instances.rb index c28441455dd..e4f4ce155ae 100644 --- a/spec/factories/instances.rb +++ b/spec/factories/instances.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true FactoryBot.define do - base_time = Time.zone.now.to_i + # Unique per process — see the note in user_emails.rb; host and name are both unique-constrained. + run_id = "#{Time.zone.now.to_i}-#{SecureRandom.hex(3)}" sequence :host do |n| - "local-#{base_time}-#{n}.lvh.me" + "local-#{run_id}-#{n}.lvh.me" end factory :instance do - sequence(:name) { |n| "Instance-#{base_time}-#{n}" } + sequence(:name) { |n| "Instance-#{run_id}-#{n}" } host trait :with_learning_map_component_enabled do diff --git a/spec/factories/user_emails.rb b/spec/factories/user_emails.rb index a29f8d66e57..6801d742fca 100644 --- a/spec/factories/user_emails.rb +++ b/spec/factories/user_emails.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true FactoryBot.define do - base_time = Time.zone.now.to_i + # Unique per process. Specs commit (use_transactional_fixtures is false), so a bare timestamp + # collides whenever two rspec processes start within the same second, and the second process then + # fails User::Email's uniqueness validation. The timestamp is kept for tracing leaked rows. + run_id = "#{Time.zone.now.to_i}-#{SecureRandom.hex(3)}" sequence :email do |n| - "user_#{n}@domain-#{base_time}-name.com" + "user_#{n}@domain-#{run_id}-name.com" end factory :user_email, class: User::Email.name do diff --git a/spec/models/course/assessment/answer_spec.rb b/spec/models/course/assessment/answer_spec.rb index 20e66cf84d8..aac7c8a7bdb 100644 --- a/spec/models/course/assessment/answer_spec.rb +++ b/spec/models/course/assessment/answer_spec.rb @@ -288,12 +288,25 @@ describe '#can_read_grade?' do let(:ability) { instance_double(Ability) } let(:answer) { create(:course_assessment_answer) } + # `can_read_grade?` is called with `self` as the receiver. For the base `:course_assessment_answer` + # factory, `answer` already IS that receiver; for the MultipleResponse/TextResponse factories + # below (which build the `acts_as :answer` actable directly, not the base `Answer`), calling + # `answer.can_read_grade?` falls through `acts_as`'s own `method_missing` to + # `answer.acting_as.send(:can_read_grade?, ...)` — so `self` inside the method is + # `answer.acting_as`, not `answer` itself. `.try(:acting_as)` resolves either case correctly + # (the base Answer doesn't respond to `acting_as` at all, since it uses the OTHER `acts_as` + # macro direction — `actable`, not `acts_as :answer`). + let(:can_read_grade_receiver) { answer.try(:acting_as) || answer } let(:submission) { answer.submission } let(:assessment) { submission.assessment } let(:show_mcq_answer) { false } before do - allow(ability).to receive(:can?).with(:grade, submission).and_return(false) + # `can_read_grade?` routes through `ability.can?(:grade, self)` (the answer), not + # `ability.can?(:grade, submission)` — `submission` now resolves to an Attempt post-repoint, + # and CanCan's `can :grade, Course::Assessment::Answer, submission: { assessment: ... }` + # rule matches on the Answer subject, not its Attempt (design spike §5.2, Step 2d). + allow(ability).to receive(:can?).with(:grade, can_read_grade_receiver).and_return(false) allow(submission).to receive(:published?).and_return(false) allow(assessment).to receive(:autograded?).and_return(false) allow(assessment).to receive(:allow_partial_submission).and_return(false) @@ -309,7 +322,7 @@ end context 'when the ability can grade the submission' do - before { allow(ability).to receive(:can?).with(:grade, submission).and_return(true) } + before { allow(ability).to receive(:can?).with(:grade, can_read_grade_receiver).and_return(true) } it 'returns true' do expect(answer.can_read_grade?(ability)).to be(true) diff --git a/spec/models/course/assessment/attempt_submission_backfill_migration_spec.rb b/spec/models/course/assessment/attempt_submission_backfill_migration_spec.rb new file mode 100644 index 00000000000..66466242ed5 --- /dev/null +++ b/spec/models/course/assessment/attempt_submission_backfill_migration_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true +require 'rails_helper' +require Rails.root.join('db/migrate/20260722000004_create_course_assessment_submissions_extension_table') + +RSpec.describe CreateCourseAssessmentSubmissionsExtensionTable, type: :model do + let(:instance) { Instance.default } + + # Inserts one row straight into course_assessment_attempts (the big Phase-1a table, untouched by + # this migration) and returns its id — a "pre-existing attempt" for the backfill to copy. Raw SQL + # rather than the factory so each course-coupled column can be given an exact, asserted value, and + # so the spec does not depend on the submission model/factory (still mid-rename until Task 2). + # Every course_assessment_attempts NOT NULL column is set; the columns the migration copies + # (publisher_id, session_id, last_graded_time, created_at, updated_at) are parameters. + def insert_attempt(assessment:, creator:, publisher_id: nil, session_id: nil, # rubocop:disable Metrics/ParameterLists + last_graded_time: nil, created_at: Time.zone.now, updated_at: Time.zone.now) + sql = <<-SQL.squish + INSERT INTO course_assessment_attempts + (assessment_id, workflow_state, creator_id, updater_id, publisher_id, published_at, + session_id, submitted_at, last_graded_time, created_at, updated_at) + VALUES + (?, 'submitted', ?, ?, ?, NULL, ?, now(), ?, ?, ?) + RETURNING id + SQL + ActiveRecord::Base.connection.select_value( + ActiveRecord::Base.sanitize_sql_array( + [sql, assessment.id, creator.id, creator.id, publisher_id, session_id, + last_graded_time, created_at, updated_at] + ) + ).to_i + end + + # This spec drives the migration's own `down`/`up`. RSpec's `maintain_test_schema!` always brings + # the test DB to schema.rb's FINAL state, so `course_assessment_submissions` already exists when an + # example starts; the `around` hook drops it (`down`) BEFORE each example so the body can test `up` + # creating + backfilling it from scratch, then restores it afterwards. + # + # Consequence (caught by the plan-time Test Review Gate, and the reason no example here uses a + # `change {}` matcher): since `down` has already dropped the table when the body begins, an + # example must NOT write `expect { up }.to change { SELECT ... FROM course_assessment_submissions }` + # — `change` evaluates its value block once BEFORE `up`, i.e. against a table that does not exist + # yet, raising `PG::UndefinedTable`. Every example therefore calls `up` first, sets + # `@migration_reapplied`, then asserts on the resulting rows. Row counts are scoped to a + # freshly-created `attempt_id`, so they are deterministically 1 after `up` and safe under this + # repo's "nothing rolls back between examples" convention. + # + # `@migration_reapplied` guards against double-calling `up` (once in the example, once in the + # `ensure`), which would crash trying to rename onto a table that already exists. + around do |example| + described_class.new.down + example.run + ensure + described_class.new.up unless @migration_reapplied + end + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:creator) { create(:course_student, course: course).user } + + it 'backfills one submission row per pre-existing attempt, copying every ' \ + 'course-coupled column with its exact value' do + publisher = create(:course_student, course: course).user + graded_time = Time.zone.parse('2026-01-02 03:04:05') + created_time = Time.zone.parse('2020-06-15 08:00:00') + updated_time = Time.zone.parse('2021-07-16 09:30:00') + attempt_id = insert_attempt(assessment: assessment, creator: creator, + publisher_id: publisher.id, session_id: 'a-fake-session-id', + last_graded_time: graded_time, + created_at: created_time, updated_at: updated_time) + + described_class.new.up + @migration_reapplied = true + + rows = ActiveRecord::Base.connection.select_all( + "SELECT * FROM course_assessment_submissions WHERE attempt_id = #{attempt_id}" + ).to_a + expect(rows.size).to eq(1) + row = rows.first + expect(row['publisher_id'].to_i).to eq(publisher.id) + expect(row['session_id']).to eq('a-fake-session-id') + expect(row['last_graded_time']).to eq(graded_time) + expect(row['created_at']).to eq(created_time) + expect(row['updated_at']).to eq(updated_time) + end + + it 'copies a NULL publisher_id through as NULL' do + attempt_id = insert_attempt(assessment: assessment, creator: creator, + publisher_id: nil, session_id: 'null-publisher-session', + last_graded_time: Time.zone.now) + + described_class.new.up + @migration_reapplied = true + + row = ActiveRecord::Base.connection.select_one( + "SELECT * FROM course_assessment_submissions WHERE attempt_id = #{attempt_id}" + ) + expect(row['publisher_id']).to be_nil + end + + it 'backfills every pre-existing attempt, not only the most recently created one' do + older_id = insert_attempt(assessment: assessment, creator: creator, session_id: 'older') + newer_creator = create(:course_student, course: course).user + newer_id = insert_attempt(assessment: assessment, creator: newer_creator, session_id: 'newer') + + described_class.new.up + @migration_reapplied = true + + counts = [older_id, newer_id].map do |id| + ActiveRecord::Base.connection.select_value( + "SELECT COUNT(*) FROM course_assessment_submissions WHERE attempt_id = #{id}" + ).to_i + end + expect(counts).to eq([1, 1]) + end + + it 'enforces one submission row per attempt (unique attempt_id)' do + attempt_id = insert_attempt(assessment: assessment, creator: creator, session_id: 'unique') + + described_class.new.up + @migration_reapplied = true + + expect do + ActiveRecord::Base.connection.execute( + 'INSERT INTO course_assessment_submissions (attempt_id, created_at, updated_at) ' \ + "VALUES (#{attempt_id}, now(), now())" + ) + end.to raise_error(ActiveRecord::RecordNotUnique) + end + end +end diff --git a/spec/models/course/assessment/marketplace/access_block_spec.rb b/spec/models/course/assessment/marketplace/access_block_spec.rb new file mode 100644 index 00000000000..bb97b9405ee --- /dev/null +++ b/spec/models/course/assessment/marketplace/access_block_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AccessBlock, type: :model do + let!(:instance) { Instance.default } + + before { described_class.delete_all } + + with_tenant(:instance) do + describe 'validations' do + it 'is valid with a user and creator' do + block = build(:course_assessment_marketplace_access_block) + expect(block).to be_valid + end + + it 'rejects a second block for the same user' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + duplicate = build(:course_assessment_marketplace_access_block, user: user) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + end + + describe '.blocked?' do + it 'is false for a nil user' do + expect(described_class.blocked?(nil)).to be(false) + end + + it 'is false when the user has no block' do + expect(described_class.blocked?(create(:user))).to be(false) + end + + it 'is true when the user has a block' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + expect(described_class.blocked?(user)).to be(true) + end + end + + describe '.blocked_user_ids' do + it 'returns the user ids of all blocks' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + expect(described_class.blocked_user_ids).to contain_exactly(user.id) + end + end + + describe 'when the admin who issued the block is destroyed' do + # `creator_id` is NOT NULL and FKs to users, so this raised PG::ForeignKeyViolation. The block + # must survive — it is a decision about the BLOCKED person, not about its author — so + # authorship is reassigned to the Deleted user rather than the row being destroyed. + it 'keeps the block and reassigns it to the Deleted user' do + creator = create(:administrator) + block = create(:course_assessment_marketplace_access_block, creator: creator) + + expect { ActsAsTenant.without_tenant { creator.destroy } }. + not_to(change { described_class.count }) + expect(block.reload.creator_id).to eq(User::DELETED_USER_ID) + end + end + + describe 'when the blocked user is destroyed' do + # The blocks table has an FK to users with no ON DELETE, so without a `dependent:` association + # on User the admin panel's delete-user action dies with PG::ForeignKeyViolation. + it 'destroys the block instead of raising a foreign-key violation' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + + expect { ActsAsTenant.without_tenant { user.destroy } }. + to change { described_class.count }.by(-1) + end + end + end +end diff --git a/spec/models/course/assessment/marketplace/access_list_query_spec.rb b/spec/models/course/assessment/marketplace/access_list_query_spec.rb new file mode 100644 index 00000000000..fa233a2563d --- /dev/null +++ b/spec/models/course/assessment/marketplace/access_list_query_spec.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AccessListQuery, type: :model do + let!(:instance) { Instance.default } + + # Specs here commit (use_transactional_fixtures is false repo-wide), so rows from previous runs + # persist. User::Email additionally enforces uniqueness, so the allow-listed-domain addresses below + # must be cleared too or re-creating them raises RecordInvalid. + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + # LOWER() and no '@' anchor: the uniqueness index is on `lower(email)` while SQL LIKE is + # case-sensitive, so an anchored, case-sensitive pattern silently leaves rows behind that + # collide on the next run (this bit Task 1). + User::Email.where('LOWER(email) LIKE ?', '%schools.gov.sg').delete_all + end + + with_tenant(:instance) do + it 'excludes a baseline user when no rule matches them' do + create(:course_manager, course: create(:course)) # manager, but no allow-list rule + # System admins are listed unconditionally (they bypass every gate), and the test DB always + # holds at least the seeded one — so this asserts on the non-admin rows. + expect(described_class.new.rows.reject(&:system_admin?)).to be_empty + end + + it 'includes a manager cleared by a user rule, annotated with course count and rule' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row).not_to be_nil + expect(row.course_count).to eq(1) + expect(row.instance_role).to be_nil + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['user']) + expect(row.blocked?).to be(false) + end + + it 'includes an instance instructor (managing no course) under an everyone rule' do + user = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + create(:course_assessment_marketplace_allowlist_rule, :everyone) + + row = described_class.new.rows.find { |r| r.user == user } + expect(row).not_to be_nil + expect(row.course_count).to eq(0) + expect(row.instance_role).to eq('instructor') + # An everyone rule is a page-level mode, not a per-row reason: rows carry no scoped rules. + expect(row.allowed_by_rules).to be_empty + end + + it 'includes a manager cleared by an email-domain rule' do + user = create(:user, email: 'teacher@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + row = described_class.new.rows.find { |r| r.user == user } + expect(row).not_to be_nil + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['email_domain']) + end + + it 'excludes a manager whose only allow-listed-domain email is unconfirmed' do + user = create(:user) # has a confirmed primary email at a non-matching domain + create(:course_manager, course: create(:course), user: user) + create(:user_email, :unconfirmed, email: 'pending@schools.gov.sg', + user: user, primary: false) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + expect(described_class.new.rows.map(&:user)).not_to include(user) + end + + it 'includes a manager cleared by an instance rule' do + cu = create(:course_manager, course: create(:course)) + # cu.user has a normal InstanceUser in the default instance via the after_create callback, + # so an instance rule for the default instance clears them. + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: instance) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row).not_to be_nil + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['instance']) + end + + it 'does not include a non-baseline user even when a user rule targets them' do + cu = create(:course_student, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + expect(described_class.new.rows.map(&:user)).not_to include(cu.user) + end + + it 'keeps a blocked user in the list, flagged with the block id' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + block = create(:course_assessment_marketplace_access_block, user: cu.user) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row.blocked?).to be(true) + expect(row.block_id).to eq(block.id) + end + + # Without this, dropping the `instance_id` filter from RuleMatchQuery#matched_instance_members + # would still pass every other example — the instance-rule test above uses only one instance. + it 'does not clear a user via an instance rule scoped to a different instance' do + rule_instance = create(:instance) + member_instance = create(:instance) + cu = create(:course_manager, course: create(:course)) + ActsAsTenant.with_tenant(member_instance) do + create(:instance_user, :instructor, user: cu.user, instance: member_instance) + end + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: rule_instance) + + expect(described_class.new.rows.map(&:user)).not_to include(cu.user) + end + + it 'lists every rule matching a user, not just the highest-precedence one' do + user = create(:user, email: 'both@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + user_rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: user) + domain_rule = create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + row = described_class.new.rows.find { |r| r.user == user } + expect(row.allowed_by_rules.map(&:id)).to contain_exactly(user_rule.id, domain_rule.id) + end + + it 'orders a row\'s rules by rule id' do + user = create(:user, email: 'ordered@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + first = create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + second = create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + + row = described_class.new.rows.find { |r| r.user == user } + expect(row.allowed_by_rules.map(&:id)).to eq([first.id, second.id]) + end + + it 'lists a blocked user whose matching rule was removed, so the block stays reachable' do + cu = create(:course_manager, course: create(:course)) + block = create(:course_assessment_marketplace_access_block, user: cu.user) + # No allow-list rule matches them at all — without the block they would not be listed. + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row).not_to be_nil + expect(row.allowed_by_rules).to be_empty + expect(row.block_id).to eq(block.id) + expect(row.blocked?).to be(true) + end + + it 'lists a blocked user who is no longer baseline-eligible at all' do + user = create(:user) # manages nothing, staff nowhere + create(:course_assessment_marketplace_access_block, user: user) + + row = described_class.new.rows.find { |r| r.user == user } + expect(row).not_to be_nil + expect(row.course_count).to eq(0) + expect(row.instance_role).to be_nil + end + + describe '#allowed_user_ids' do + it 'returns baseline users cleared by a rule, and excludes uncleared ones' do + cleared = create(:course_manager, course: create(:course)) + uncleared = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: cleared.user) + + ids = described_class.new.allowed_user_ids + expect(ids).to include(cleared.user.id) + expect(ids).not_to include(uncleared.user.id) + end + + it 'still counts a blocked user as allowed — a block is not an allow-list decision' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + create(:course_assessment_marketplace_access_block, user: cu.user) + + expect(described_class.new.allowed_user_ids).to include(cu.user.id) + end + + # Guards the `everyone?` branch: without it, collapsing the method to `rules_by_user.keys` + # would silently regress open-to-everyone into "only explicitly matched users". + it 'includes every baseline user when an everyone rule exists, not only rule-matched ones' do + first = create(:course_manager, course: create(:course)) + second = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + + ids = described_class.new.allowed_user_ids + expect(ids).to include(first.user.id, second.user.id) + end + end + + describe 'system administrators' do + it 'lists an admin who manages nothing and matches no rule' do + admin = create(:administrator) + + row = described_class.new.rows.find { |r| r.user == admin } + expect(row).not_to be_nil + expect(row.system_admin?).to be(true) + expect(row.allowed_by_rules).to be_empty + end + + it 'keeps listing an admin as blocked when a block exists' do + # A block row cannot actually revoke a sysadmin's bypass, but an orphaned one must stay + # visible and clearable — same contract as any other blocked user. + admin = create(:administrator) + create(:course_assessment_marketplace_access_block, user: admin) + + row = described_class.new.rows.find { |r| r.user == admin } + expect(row.system_admin?).to be(true) + expect(row.blocked?).to be(true) + end + + it 'still records the rules that match an admin' do + admin = create(:administrator) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: admin) + + row = described_class.new.rows.find { |r| r.user == admin } + expect(row.system_admin?).to be(true) + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['user']) + end + + it 'does not mark a non-admin as one' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row.system_admin?).to be(false) + end + end + + describe '#summary' do + it 'counts effective access and blocked separately, and reports the mode' do + active = create(:course_manager, course: create(:course)) + blocked = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: active.user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: blocked.user) + create(:course_assessment_marketplace_access_block, user: blocked.user) + + # Admins are always listed and always count as having access, and the test DB carries at + # least the seeded one, so the expectation is relative to however many exist. + admins = User.administrator.count + summary = described_class.new.summary + expect(summary[:total_with_access]).to eq(1 + admins) + expect(summary[:total_blocked]).to eq(1) + expect(summary[:open_to_everyone]).to be(false) + end + + it 'reports open_to_everyone when an everyone rule exists' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.new.summary[:open_to_everyone]).to be(true) + end + end + end +end diff --git a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb new file mode 100644 index 00000000000..4de6e87d06b --- /dev/null +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AllowlistRule, type: :model do + let!(:instance) { Instance.default } + + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + User::Email.delete_all + end + + with_tenant(:instance) do + describe 'validations' do + it 'requires user for a user rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: nil) + expect(rule).not_to be_valid + expect(rule.errors[:user]).to be_present + end + + it 'requires email_domain for an email_domain rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: nil) + expect(rule).not_to be_valid + expect(rule.errors[:email_domain]).to be_present + end + + it 'requires instance for an instance rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: nil) + expect(rule).not_to be_valid + expect(rule.errors[:instance]).to be_present + end + + it 'is valid as an everyone rule with no target fields' do + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(rule).to be_valid + end + + it 'allows only one everyone rule' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + duplicate = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:rule_type]).to be_present + end + end + + describe '.grants_access?' do + it 'is false for a nil user' do + expect(described_class.grants_access?(nil)).to be(false) + end + + it 'is false when no rule matches' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'nomatch.example') + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches an explicit user rule' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'matches an instance rule when the user belongs to that instance' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: instance) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not match an instance rule for another instance under the current tenant' do + user = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) { InstanceUser.create!(user: user) } + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, + instance: other_instance) + # `user.instance_users` is tenant-scoped (acts_as_tenant), so an instance rule grants + # access only while browsing the allow-listed instance — membership elsewhere is invisible. + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches an email-domain rule case-insensitively' do + user_email = create(:user_email, email: 'testuser@Schools.GOV.sg') + user = user_email.user + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not match a different email domain' do + user_email = create(:user_email, email: 'testuser@other.edu') + user = user_email.user + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches any user when an everyone rule exists' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'is false for a nil user even when an everyone rule exists' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.grants_access?(nil)).to be(false) + end + + it 'keeps granting a user rule after the user replaces their email (access is by user_id, not email)' do + user = create(:user) + original_email = user.email + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + expect(described_class.grants_access?(user)).to be(true) + + # Retire the original email and attach a brand-new one — the same person, different address. + user.emails.where(email: original_email).delete_all + create(:user_email, user: user, email: 'moved@newdomain.example') + user.reload + + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not grant access via a different user\'s rule after this user replaces their email' do + user = create(:user) + other_user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: other_user) + + user.emails.where(email: user.email).delete_all + create(:user_email, user: user, email: 'moved@newdomain.example') + user.reload + + expect(described_class.grants_access?(user)).to be(false) + end + end + + describe 'email resolution for a user rule' do + it 'resolves a confirmed email to the owning user' do + target = create(:user, email: 'teacher@school.edu') + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: 'teacher@school.edu') + expect(rule).to be_valid + expect(rule.user).to eq(target) + end + + it 'is case-insensitive on the entered email' do + target = create(:user, email: 'teacher@school.edu') + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: ' Teacher@School.EDU ') + expect(rule).to be_valid + expect(rule.user).to eq(target) + end + + it 'is invalid with a clear message when no user has that email' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: 'nobody@nowhere.test') + expect(rule).not_to be_valid + expect(rule.errors.full_messages).to include('No user with that email.') + end + + it 'does not also add a user-presence error when the email fails to resolve' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: 'nobody@nowhere.test') + expect(rule).not_to be_valid + expect(rule.errors.full_messages).to eq(['No user with that email.']) + end + end + + describe 'duplicate rules' do + it 'rejects a second user rule for the same user' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: user) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to include('already has the same rule.') + end + + it 'allows a user rule for a different user' do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: create(:user)) + expect(build(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: create(:user))).to be_valid + end + + it 'rejects a second instance rule for the same instance' do + other_instance = create(:instance) + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:instance_id]).to include('already has the same rule.') + end + + it 'rejects a second email-domain rule for the same domain' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'dupes.test') + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'dupes.test') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:email_domain]).to include('already has the same rule.') + end + + it 'treats a differently-cased domain as the same rule' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'dupes.test') + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: ' DUPES.TEST ') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:email_domain]).to include('already has the same rule.') + end + + it 'normalizes the stored domain to stripped lowercase' do + rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: ' MiXeD.TEST ') + expect(rule.reload.email_domain).to eq('mixed.test') + end + + # A user rule whose email resolves to nobody keeps user_id NULL, and Rails checks uniqueness + # as `user_id IS NULL` — which matches every instance and email-domain rule unless the check + # is scoped to rule_type. Unscoped, the admin gets a bogus "already has the same rule." stacked on + # top of the real reason. (Verified by mutation: dropping `scope: :rule_type` fails this.) + it 'does not report a duplicate for an unresolvable email when other rule types exist' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: create(:instance)) + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: nil, email: 'nobody@nowhere.test') + + expect(rule).not_to be_valid + expect(rule.errors[:user_id]).to be_empty + expect(rule.errors[:base]).to include('No user with that email.') + end + end + + describe 'when the targeted user is destroyed' do + # Same FK trap as the access-blocks table: a user rule pins the user row, so deleting an + # allow-listed user from the admin panel raised PG::ForeignKeyViolation. + it 'destroys the rule instead of raising a foreign-key violation' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + + expect { ActsAsTenant.without_tenant { user.destroy } }. + to change { described_class.count }.by(-1) + end + + it 'leaves rules that do not target that user alone' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'keeps.test') + # Created under the tenant, destroyed without one (as the admin panel does): building a + # user inside `without_tenant` fails its own `instance_users` validation. + bystander = create(:user) + + expect { ActsAsTenant.without_tenant { bystander.destroy } }. + not_to(change { described_class.count }) + end + end + end +end diff --git a/spec/models/course/assessment/marketplace/rule_match_query_spec.rb b/spec/models/course/assessment/marketplace/rule_match_query_spec.rb new file mode 100644 index 00000000000..9fb78a59b0f --- /dev/null +++ b/spec/models/course/assessment/marketplace/rule_match_query_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::RuleMatchQuery, type: :model do + let!(:instance) { Instance.default } + + # Specs commit (use_transactional_fixtures is false repo-wide), so rows from previous runs persist + # and User::Email enforces uniqueness. Clear the fixed-domain addresses this file creates. + # + # The pattern must be LOWER()'d and must not anchor the '@': the uniqueness index is on + # `lower(email)` while SQL LIKE is case-sensitive, and one example deliberately uses a SUBDOMAIN + # (someone@sub.match-query.test). A '%@match-query.test' pattern misses both, leaving rows behind + # that collide on the next run. + before do + User::Email.where('LOWER(email) LIKE ?', '%match-query.test').delete_all + end + + with_tenant(:instance) do + describe 'a user rule' do + it 'matches only the targeted user, and only within the candidate set' do + target = create(:user) + other = create(:user) + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: target) + + expect(described_class.new(rule).user_ids_within([target.id, other.id])). + to eq(Set[target.id]) + end + + it 'returns nothing when the targeted user is outside the candidate set' do + target = create(:user) + other = create(:user) + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: target) + + expect(described_class.new(rule).user_ids_within([other.id])).to be_empty + end + end + + describe 'an instance rule' do + it 'matches candidates belonging to that instance, across tenants' do + member = create(:user) + outsider = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: member, instance: other_instance) + end + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + + expect(described_class.new(rule).user_ids_within([member.id, outsider.id])). + to eq(Set[member.id]) + end + end + + describe 'an email-domain rule' do + it 'matches a candidate holding a confirmed email at that domain' do + user = create(:user, email: 'teacher@match-query.test') + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'match-query.test') + + expect(described_class.new(rule).user_ids_within([user.id])).to eq(Set[user.id]) + end + + it 'matches case-insensitively on both the rule and the address' do + user = create(:user, email: 'head@match-query.test') + # Force the stored address to mixed case directly. `create(:user, email: 'HEAD@...')` + # raises RecordNotUnique even on a fresh address: the write path inserts both the given + # and the normalized form, and the two collide under the `lower(email)` unique index. + # Legacy rows can still hold mixed case, and the query's LOWER(SPLIT_PART(...)) on the + # address side exists for exactly them — so this is the only way to reach that branch. + User::Email.where(user_id: user.id). + where('LOWER(email) = ?', 'head@match-query.test'). + update_all(email: 'HEAD@MATCH-QUERY.TEST') + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'Match-Query.TEST') + + expect(described_class.new(rule).user_ids_within([user.id])).to eq(Set[user.id]) + end + + it 'ignores an unconfirmed address at that domain' do + user = create(:user) # confirmed primary email at a non-matching domain + create(:user_email, :unconfirmed, email: 'pending@match-query.test', + user: user, primary: false) + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'match-query.test') + + expect(described_class.new(rule).user_ids_within([user.id])).to be_empty + end + + it 'does not match a different domain that merely shares a suffix' do + user = create(:user, email: 'someone@sub.match-query.test') + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'match-query.test') + + expect(described_class.new(rule).user_ids_within([user.id])).to be_empty + end + end + + describe 'an everyone rule' do + it 'matches the whole candidate set' do + a = create(:user) + b = create(:user) + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + + expect(described_class.new(rule).user_ids_within([a.id, b.id])).to eq(Set[a.id, b.id]) + end + end + + it 'returns an empty set for an empty candidate list without querying' do + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.new(rule).user_ids_within([])).to be_empty + end + + it 'treats an unsaved rule identically to a persisted one' do + target = create(:user) + unsaved = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: target) + persisted = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: target) + + expect(described_class.new(unsaved).user_ids_within([target.id])). + to eq(described_class.new(persisted).user_ids_within([target.id])) + end + end +end diff --git a/spec/models/course/assessment/submission_spec.rb b/spec/models/course/assessment/submission_spec.rb index 7da9fe68604..7bbd678aee6 100644 --- a/spec/models/course/assessment/submission_spec.rb +++ b/spec/models/course/assessment/submission_spec.rb @@ -2,13 +2,29 @@ require 'rails_helper' RSpec.describe Course::Assessment::Submission do - it { is_expected.to belong_to(:assessment).without_validating_presence } - it { is_expected.to have_many(:answers).dependent(:destroy) } - it { is_expected.to have_many(:multiple_response_answers).through(:answers) } - it { is_expected.to have_many(:text_response_answers).through(:answers) } - it { is_expected.to have_many(:programming_answers).through(:answers) } - it { is_expected.to have_many(:forum_post_response_answers).through(:answers) } - it { is_expected.to accept_nested_attributes_for(:answers) } + # `:assessment`/`:answers` are no longer real associations on Submission — they moved to + # `Course::Assessment::Attempt` (Step 2a/2c) and are reached here only via `delegate ... to: + # :attempt`. The literal reflection shape this task deliberately changes (same reasoning as + # `spec/models/course/assessment_spec.rb`'s `have_many(:submissions)` update, Step 2l): assert the + # delegation instead of a `belong_to`/`have_many` that no longer exists on this class. + # + # `.without_validating_presence`: matches the pre-split assertion's own choice for `:assessment` + # (line above, before this task). Without it, shoulda's matcher builds a bare instance with + # `attempt: nil` to verify the association is required — which cascades into + # `Course::ExperiencePointsRecord#validate_limit_exp_points_on_association` (an unrelated, + # pre-existing validation reached via `acts_as`'s autosave-association-validation) reading + # `submission.assessment.base_exp`, and crashes on the nil assessment. The pre-split spec sidestepped + # this exact fragility for the exact same underlying reason. + it { is_expected.to belong_to(:attempt).without_validating_presence } + it { should delegate_method(:assessment).to(:attempt) } + it { should delegate_method(:answers).to(:attempt) } + it { should delegate_method(:answers=).to(:attempt).with_arguments([]) } + # `multiple_response_answers`/`text_response_answers`/`programming_answers`/ + # `forum_post_response_answers`/`accepts_nested_attributes_for(:answers)` had no delegate call + # site anywhere in the app (grepped) even before this task — they are removed capabilities on + # Submission, not renamed ones, now living on `Attempt` only (its own spec is out of this task's + # scope). Per the repo's "no vacuous absence tests" convention, deleted outright rather than + # replaced with a `not_to`. let(:instance) { Instance.default } with_tenant(:instance) do @@ -338,8 +354,19 @@ with_active_job_queue_adapter(:test) do it 'creates a new auto grading job' do - submission.finalise! - expect { submission.save }.to \ + # Pre-split, the `workflow` gem's deferred-persistence adapter + # (`lib/extensions/deferred_workflow_state_persistence`) only writes `workflow_state` in + # memory during `finalise!`'s transition hook — a separate, caller-supplied `.save` + # afterward was what actually persisted it and fired `auto_grade_submission`'s + # `if: :saved_change_to_workflow_state?` guard. Post-split, `Submission#finalise!` wraps + # `attempt.finalise!` and its own `save!` in one transaction (Step 2c's explicit design + # decision, so a partial failure can't leave the two rows inconsistent) — `attempt`'s + # pending workflow_state change is persisted via `autosave: true` as part of THIS SAME + # `save!`, so the job now fires during `finalise!` itself; a caller no longer needs (or + # benefits from) a separate trailing `.save`. Intentional behavioural change from the + # design's own atomicity decision, not a regression — the guarantee this example protects + # (finalising enqueues the job exactly once) still holds, just one call earlier. + expect { submission.finalise! }.to \ have_enqueued_job(Course::Assessment::Submission::AutoGradingJob).exactly(:once) end end @@ -780,7 +807,16 @@ def unsubmit_and_save_subject describe '#send_submit_notification' do subject do - submission1.save + # `workflow_state_before_last_save` is delegated to `attempt` (Step 2c). This `.save` + # exists purely to make that guard read `'attempting'` (simulating "we're right after an + # attempting-state save", the precondition `send_submit_notification` checks) — pre-split, + # a bare `submission1.save` achieved this because `submission1` WAS the attempt (a no-op + # save on an unchanged record reports its dirty-tracking as before==after==current value). + # Post-split, saving `submission1` (the small table) no longer touches `attempt` at all + # when `attempt` itself has no pending changes (a `belongs_to ..., autosave: true` only + # cascades a save when the association target is dirty or new) — save `attempt` directly + # to get the same precondition. + submission1.attempt.save submission1.updater = user1 submission1.send(:send_submit_notification) end @@ -809,10 +845,24 @@ def unsubmit_and_save_subject end it 'updates the last_graded_time' do + # `on_dependent_status_change` (Submission, still) only ever *assigns* + # `last_graded_time` in memory on the associated Submission — it never saves it (both + # pre- and post-split); persisting it has always required a later, separate save of that + # same Submission object. Pre-split, that "later save" fell out incidentally: the + # single-table Submission stayed dirty from the `:graded` factory trait's own + # `mark!` → `publish_answers` cascade, and this example's `before` block's + # `subject.publish!; subject.save!` happened to be what flushed it — before `answer.grade + # = 0` (this example's own, actual trigger) had done anything at all (`answer.save!`, + # the real trigger, runs on the NEXT line). Post-split, `Submission#publish!`'s own + # atomicity (Step 2c) flushes that same pending value earlier (during the `before` block + # itself), so by the time this example starts, there is nothing new for the `before` + # block's save to report — the assertion needs to move to what it was actually meant to + # verify: that saving the answer with its new grade updates and persists + # `last_graded_time`, checked after the save that is supposed to cause it. answer.grade = 0 - expect(subject.saved_changes).to include(:last_graded_time) answer.save! subject.save! + expect(subject.saved_changes).to include(:last_graded_time) end end end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 1dabaf126c8..4564124a913 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -19,6 +19,7 @@ context 'when the user is a course manager' do let(:course_user) { create(:course_manager, course: course) } let(:user) { course_user.user } + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) } it { is_expected.to be_able_to(:access_marketplace, course) } it { is_expected.not_to be_able_to(:publish_to_marketplace, build(:assessment)) } @@ -37,5 +38,79 @@ let(:user) { course_user.user } it { is_expected.not_to be_able_to(:access_marketplace, course) } end + + context 'when the user is a course manager but is not allow-listed' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + # Load-bearing at the ability level: without the explicit `cannot`, the blanket + # `can :manage, Course` a manager holds would satisfy `:access_marketplace`. + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + + context 'when the user is an observer here but manages another course (person-level access)' do + let(:course_user) { create(:course_observer, course: course) } + let(:user) { course_user.user } + before do + create(:course_manager, course: create(:course), user: user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + end + + it { is_expected.to be_able_to(:access_marketplace, course) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } + it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + end + + context 'when an allow-listed user manages no course at all' do + let(:course_user) { create(:course_observer, course: course) } + let(:user) { course_user.user } + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) } + + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + + context 'when the user is an instance instructor who manages no course but is allow-listed' do + let!(:course_user) { create(:course_observer, course: course) } + let!(:user) { course_user.user } + before do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + end + + # Proves the second baseline branch: eligible via instance role, not via managing a course. + it { is_expected.to be_able_to(:access_marketplace, course) } + end + + context 'when the user is an instance instructor who manages no course and is not allow-listed' do + let!(:course_user) { create(:course_observer, course: course) } + let!(:user) { course_user.user } + before do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + end + + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + + context 'when an eligible, allow-listed manager is individually blocked' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + create(:course_assessment_marketplace_access_block, user: user) + end + + it { is_expected.not_to be_able_to(:access_marketplace, course) } + + it 'regains access once the block is removed' do + Course::Assessment::Marketplace::AccessBlock.where(user_id: user.id).delete_all + expect(Ability.new(user, course, course_user)).to be_able_to(:access_marketplace, course) + end + end end end diff --git a/spec/models/course/assessment_spec.rb b/spec/models/course/assessment_spec.rb index 6d8b480816a..3d644644b5c 100644 --- a/spec/models/course/assessment_spec.rb +++ b/spec/models/course/assessment_spec.rb @@ -11,7 +11,8 @@ it { is_expected.to have_many(:programming_questions).through(:questions) } it { is_expected.to have_many(:scribing_questions).through(:questions) } it { is_expected.to have_many(:forum_post_response_questions).through(:questions) } - it { is_expected.to have_many(:submissions).dependent(:destroy) } + it { is_expected.to have_many(:attempts).dependent(:destroy) } + it { is_expected.to have_many(:submissions).through(:attempts) } it { is_expected.to have_many(:conditions) } it { is_expected.to have_many(:assessment_conditions).dependent(:destroy) } it { is_expected.to have_one(:duplication_traceable).dependent(:destroy) } diff --git a/spec/models/course/condition/assessment_spec.rb b/spec/models/course/condition/assessment_spec.rb index f8ebf654dd3..50f9c5133c8 100644 --- a/spec/models/course/condition/assessment_spec.rb +++ b/spec/models/course/condition/assessment_spec.rb @@ -115,11 +115,16 @@ end end - context 'when the submission is published' do + context 'when a published submission is re-graded' do let(:submission_traits) { [:published] } it 'evaluate_conditional_for the affected course_user' do expect(Course::Condition::Assessment). to receive(:evaluate_conditional_for).with(submission.course_user) + # A re-grade updates last_graded_time (Submission's own column). Re-evaluation should + # still fire exactly once. (Pre-split, a bare no-op `submission.save!` fired here only + # because the factory left the single row dirty — an artifact; re-evaluating when + # nothing changed is not meaningful, so the trigger is a real re-grade instead.) + submission.last_graded_time = Time.zone.now submission.save! end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index b7109e39b9d..0e8b01d6684 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -44,6 +44,67 @@ end end + describe '#course_manager_or_owner?' do + let(:user) { create(:user) } + + it 'is true when the user manages a course' do + create(:course_manager, course: create(:course), user: user) + expect(user.course_manager_or_owner?).to be(true) + end + + it 'is true when the user owns a course' do + create(:course_owner, course: create(:course), user: user) + expect(user.course_manager_or_owner?).to be(true) + end + + it 'is true when the user manages a course in a different instance' do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:course_manager, course: create(:course), user: user) + end + expect(user.course_manager_or_owner?).to be(true) + end + + it 'is false when the user only has non-manager course roles' do + create(:course_student, course: create(:course), user: user) + create(:course_observer, course: create(:course), user: user) + expect(user.course_manager_or_owner?).to be(false) + end + + it 'is false when the user is in no course' do + expect(user.course_manager_or_owner?).to be(false) + end + end + + describe '#instance_instructor_or_administrator?' do + # Eager: a lazy `let` would first run `create(:user)` inside the `with_tenant(other_instance)` + # block below, and `after_create :create_instance_user` would then give the user a normal + # InstanceUser in *that* instance — colliding with the instructor/administrator one we create. + let!(:user) { create(:user) } + + it 'is true when the user is an instructor in some instance' do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + expect(user.instance_instructor_or_administrator?).to be(true) + end + + it 'is true when the user is an administrator in some instance' do + another_instance = create(:instance) + ActsAsTenant.with_tenant(another_instance) do + create(:instance_administrator, user: user, instance: another_instance) + end + expect(user.instance_instructor_or_administrator?).to be(true) + end + + it 'is false when the user is only a normal instance member' do + # `create(:user)` already gives a normal InstanceUser in the default instance via the + # after_create callback; there is no instructor/administrator membership anywhere. + expect(user.instance_instructor_or_administrator?).to be(false) + end + end + describe '#emails' do let(:user) { create(:user, emails_count: 5) } it 'unsets other email as primary when a new email is assigned' do