diff --git a/app/controllers/components/course/assessment_marketplace_component.rb b/app/controllers/components/course/assessment_marketplace_component.rb new file mode 100644 index 00000000000..e6d75500119 --- /dev/null +++ b/app/controllers/components/course/assessment_marketplace_component.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +class Course::AssessmentMarketplaceComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def self.display_name + 'Assessment Marketplace' + end + + def sidebar_items + return [] unless can?(:access_marketplace, current_course) + + [ + key: :admin_marketplace, + icon: :marketplace, + type: :admin, + weight: 6, + path: course_marketplace_path(current_course) + ] + end +end diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb index a54d4dae4fe..3e282fd4674 100644 --- a/app/controllers/components/course/gradebook_component.rb +++ b/app/controllers/components/course/gradebook_component.rb @@ -16,13 +16,11 @@ def main_sidebar_items return [] unless can?(:read_gradebook, current_course) [ - { - key: self.class.key, - icon: :gradebook, - type: :normal, - weight: 9, - path: course_gradebook_path(current_course) - } + key: self.class.key, + icon: :gradebook, + type: :normal, + weight: 9, + path: course_gradebook_path(current_course) ] end @@ -30,12 +28,10 @@ def settings_sidebar_items return [] unless can?(:manage_gradebook_settings, current_course) [ - { - key: self.class.key, - type: :settings, - weight: 14, - path: course_admin_gradebook_path(current_course) - } + key: self.class.key, + type: :settings, + weight: 14, + path: course_admin_gradebook_path(current_course) ] end end 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..ded5fd9d092 100644 --- a/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb +++ b/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb @@ -3,8 +3,10 @@ module Course::Assessment::LiveFeedback::ThreadConcern extend ActiveSupport::Concern def safe_create_and_save_thread_info + # `@submission` is the extension (`@assessment.submissions.find`), whose own id differs from the + # attempt id that `submission_questions.submission_id` references. Match on the attempt id. submission_question = Course::Assessment::SubmissionQuestion.where( - submission_id: @submission, question_id: @answer.question + submission_id: @submission.attempt_id, question_id: @answer.question ).first submission_question.with_lock do 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..bc40a21fcea 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,10 @@ 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` lives on the base (`course_assessment_submissions`), not on Submission's own + # table. Unqualified, `pluck` is ambiguous — `acts_as :experience_points_record` also joins + # `course_experience_points_records`, which also has `creator_id`. Qualify explicitly. + existing_submission_user_ids = @assessment.submissions.pluck('course_assessment_submissions.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 +79,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 +97,10 @@ 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` is a Submission (extension), whose own `id` is an independent serial, NOT the + # attempt id that `answers.submission_id` references. Use `cm_submission.attempt_id` (the + # extension's FK to its attempt) to find the answers. + answers = Course::Assessment::Answer.includes(:question).where(submission_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..4354f771ff3 100644 --- a/app/controllers/concerns/course/statistics/counts_concern.rb +++ b/app/controllers/concerns/course/statistics/counts_concern.rb @@ -11,6 +11,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 + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) @@ -27,6 +28,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 + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) @@ -46,6 +48,7 @@ def num_late_students_hash 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 + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id JOIN course_users cu ON cu.user_id = cas.creator_id WHERE @@ -65,6 +68,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 + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.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/grades_concern.rb b/app/controllers/concerns/course/statistics/grades_concern.rb index 5918006f91a..17ef5390612 100644 --- a/app/controllers/concerns/course/statistics/grades_concern.rb +++ b/app/controllers/concerns/course/statistics/grades_concern.rb @@ -10,6 +10,7 @@ def grade_statistics_hash FROM ( SELECT cas.creator_id, cas.assessment_id, SUM(caa.grade) AS grade FROM course_assessment_submissions cas + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id JOIN course_assessment_answers caa ON cas.id = caa.submission_id WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 06d090f1da7..2065fe50ab4 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -43,6 +43,8 @@ def answer_statistics_hash course_assessment_answers caa_inner JOIN course_assessment_submissions cas_inner ON caa_inner.submission_id = cas_inner.id + INNER JOIN + course_assessment_submission_details cad_inner ON cad_inner.attempt_id = cas_inner.id WHERE cas_inner.assessment_id = #{assessment_params[:id]} ) AS caa_ranked @@ -57,6 +59,7 @@ def answer_statistics_hash COUNT(*) AS attempt_count FROM course_assessment_answers caa JOIN course_assessment_submissions cas ON caa.submission_id = cas.id + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id WHERE cas.assessment_id = #{assessment_params[:id]} AND caa.workflow_state != 'attempting' GROUP BY caa.question_id, caa.submission_id ) diff --git a/app/controllers/concerns/course/statistics/times_concern.rb b/app/controllers/concerns/course/statistics/times_concern.rb index 37f2c819651..d66d6a1de40 100644 --- a/app/controllers/concerns/course/statistics/times_concern.rb +++ b/app/controllers/concerns/course/statistics/times_concern.rb @@ -11,6 +11,7 @@ def duration_statistics_hash 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 + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.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/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/marketplace/controller.rb b/app/controllers/course/assessment/marketplace/controller.rb new file mode 100644 index 00000000000..489a3ec7cd8 --- /dev/null +++ b/app/controllers/course/assessment/marketplace/controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::Controller < Course::ComponentController + # display_graded_test_types is defined in Course::Assessment::AssessmentsHelper; the marketplace + # preview views reuse it, but Rails only auto-includes a controller's own matching helper. + helper Course::Assessment::AssessmentsHelper + + private + + def component + current_component_host[:course_assessment_marketplace_component] + end +end diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb new file mode 100644 index 00000000000..eec9f66166f --- /dev/null +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::ListingsController < Course::Assessment::Marketplace::Controller + before_action :authorize_access! + + def index + ActsAsTenant.without_tenant do + # Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on + # the acting-as record. The source course is deliberately NOT preloaded: the MVP exposes no + # attribution, so nothing in the view reaches for it. + @listings = Course::Assessment::Marketplace::Listing.published. + includes(assessment: :lesson_plan_item).to_a + @adoption_counts = adoption_counts(@listings.map(&:id)) + @question_counts = question_counts(@listings.map(&:assessment_id)) + @destination_tabs = destination_tabs + end + end + + def duplicate + listings = authorized_listings + job = Course::Assessment::Marketplace::DuplicationJob.perform_later( + listings.map(&:id), current_course, duplicate_params[:destination_tab_id].to_i, + current_user: current_user + ).job + render partial: 'jobs/submitted', locals: { job: job } + end + + def show + ActsAsTenant.without_tenant do + @listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment).find_by(id: params[:id]) + raise CanCan::AccessDenied unless @listing + + @assessment = @listing.assessment + authorize!(:preview_in_marketplace, @assessment) + @destination_tabs = destination_tabs + render 'show' + end + end + + private + + def authorize_access! + authorize!(:access_marketplace, current_course) + end + + def adoption_counts(listing_ids) + Course::Assessment::Marketplace::Adoption. + where(listing_id: listing_ids).group(:listing_id). + distinct.count(:destination_course_id) + end + + def question_counts(assessment_ids) + # reorder(nil) strips QuestionAssessment's `default_scope { order(weight: :asc) }`; without it + # the injected `ORDER BY weight` breaks the grouped aggregate (PG::GroupingError — weight is + # neither grouped nor aggregated). + Course::QuestionAssessment. + where(assessment_id: assessment_ids).reorder(nil).group(:assessment_id). + distinct.count(:question_id) + end + + def destination_tabs + current_course.assessment_categories.includes(:tabs).flat_map do |category| + category.tabs.map do |tab| + { id: tab.id, title: tab.title, category_id: category.id, category_title: category.title } + end + end + end + + def authorized_listings + listings = ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]).includes(:assessment) + end + raise CanCan::AccessDenied if listings.empty? + + listings.each { |listing| authorize!(:duplicate_from_marketplace, listing.assessment) } + authorize!(:duplicate_to, current_course) + listings + end + + def duplicate_params + params.permit(:destination_tab_id, listing_ids: []) + end +end diff --git a/app/controllers/course/assessment/marketplace/questions_controller.rb b/app/controllers/course/assessment/marketplace/questions_controller.rb new file mode 100644 index 00000000000..91f4f066993 --- /dev/null +++ b/app/controllers/course/assessment/marketplace/questions_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::QuestionsController < Course::Assessment::Marketplace::Controller + before_action :authorize_access! + + def show + ActsAsTenant.without_tenant do + listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment). + find_by(id: params[:listing_id]) + raise CanCan::AccessDenied unless listing + + @assessment = listing.assessment + authorize!(:preview_in_marketplace, @assessment) + + @question = @assessment.questions.includes(:actable).find(params[:id]) + @question_assessment = @question.question_assessments.find_by!(assessment: @assessment) + render 'show' # rendered inside without_tenant so actable associations resolve cross-instance + end + end + + private + + def authorize_access! + authorize!(:access_marketplace, current_course) + end +end diff --git a/app/controllers/course/assessment/marketplace_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb new file mode 100644 index 00000000000..50e4a5e200f --- /dev/null +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +class Course::Assessment::MarketplaceListingsController < Course::Assessment::Controller + before_action :authorize_publish_to_marketplace! + + def create + listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(assessment: @assessment) + now = Time.zone.now + listing.published = true + listing.first_published_at ||= now + listing.last_published_at = now + listing.publisher ||= current_user + if listing.save + render json: { published: true }, status: :ok + else + render json: { errors: listing.errors.full_messages }, status: :unprocessable_content + end + end + + def destroy + listing = @assessment.marketplace_listing + if listing&.update(published: false) + head :ok + else + head :unprocessable_content + end + end + + private + + # Publishing is admin-only. `authorize!(:publish_to_marketplace, @assessment)` alone is + # insufficient: teaching staff hold `can :manage, Course::Assessment` over their own course's + # assessments (assessment_ability.rb:189), and CanCan's `:manage` wildcard subsumes every + # custom action — including `:publish_to_marketplace`. Gate explicitly on administrator status. + def authorize_publish_to_marketplace! + authorize!(:publish_to_marketplace, @assessment) + raise CanCan::AccessDenied unless current_user&.administrator? + end + + def component + current_component_host[:course_assessments_component] + 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..4554639502e 100644 --- a/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb +++ b/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -class Course::Assessment::Submission::Answer::Programming::AnnotationsController < \ +class Course::Assessment::Submission::Answer::Programming::AnnotationsController < Course::Assessment::Submission::Answer::Programming::Controller include Signals::EmissionConcern @@ -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.attempt.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..ad09e9bcf3e 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 @@ -423,7 +423,10 @@ 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` lives on the base (`course_assessment_submissions`), not on Submission's own + # table. Unqualified, `pluck` is ambiguous — `acts_as :experience_points_record` also joins + # `course_experience_points_records`, which also has `creator_id`. Qualify explicitly. + user_ids_with_submission = existing_submissions.pluck('course_assessment_submissions.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..401189e340f 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.attempt.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..5216dc83498 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 an Attempt id — the wire key `submissionId` carries + # the attempt's id, not the extension table's own id. Find by attempt, then navigate to the real + # Submission for `authorize!`, whose `can :read, ...Submission` rules match on subject class. + @submission = @assessment.attempts.find(all_answers_params[:submission_id]).submission + # A preview attempt has no Submission extension row; treat it as not found here rather + # than relying on the incidental `authorize!(:read, nil)` denial. + raise ActiveRecord::RecordNotFound if @submission.nil? + authorize!(:read, @submission) @submission_question = @submission. submission_questions. diff --git a/app/controllers/course/assessment/submissions_controller.rb b/app/controllers/course/assessment/submissions_controller.rb index bcb764ee5c3..edefd04142b 100644 --- a/app/controllers/course/assessment/submissions_controller.rb +++ b/app/controllers/course/assessment/submissions_controller.rb @@ -74,7 +74,7 @@ def load_submissions @submissions = Course::Assessment::Submission.by_users(student_ids). ordered_by_submitted_date.accessible_by(current_ability). calculated(:grade). - includes(:answers, experience_points_record: { course_user: [:course, :groups] }) + includes({ attempt: :answers }, experience_points_record: { course_user: [:course, :groups] }) end # Load pending submissions, either for the entire course, or for my students only. 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/statistics/aggregate_controller.rb b/app/controllers/course/statistics/aggregate_controller.rb index 306984f30e6..9eae6b6c76c 100644 --- a/app/controllers/course/statistics/aggregate_controller.rb +++ b/app/controllers/course/statistics/aggregate_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true # This is named aggregate controller as naming this as course controller leads to name conflict issues -class Course::Statistics::AggregateController < Course::Statistics::Controller +class Course::Statistics::AggregateController < Course::Statistics::Controller # rubocop:disable Metrics/ClassLength before_action :preload_levels, only: [:all_students, :course_performance] include Course::Statistics::TimesConcern include Course::Statistics::GradesConcern @@ -65,7 +65,7 @@ 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 @@ -73,6 +73,9 @@ def fetch_course_get_help_data(start_date, end_date) 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 + -- `s.id` is the attempt's id (what the response's `submissionId` carries), which is a different + -- id space from the extension table's own serial `id`. Join to the extension via its `attempt_id`. + INNER JOIN course_assessment_submission_details 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 @@ -147,6 +150,8 @@ def correctness_hash ON ca.tab_id = tab.id INNER JOIN course_assessment_submissions cas ON cas.assessment_id = ca.id + INNER JOIN course_assessment_submission_details cad + ON cad.attempt_id = cas.id INNER JOIN course_assessment_answers caa ON caa.submission_id = cas.id INNER JOIN course_assessment_questions caq @@ -169,7 +174,7 @@ def correctness_hash id SQL ) - query.map { |u| [u.id, u.correctness] }.to_h + query.to_h { |u| [u.id, u.correctness] } end def fetch_all_assessment_related_statistics_hash diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index d158d64a013..38e806c4b57 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -21,7 +21,12 @@ 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 (it's Attempt-only, reached via + # the delegate), so querying it directly here raises `PG::UndefinedColumn`. `Attempt` carries + # `assessment_id`, `grade`, and `grader_ids` natively/as `calculated`, and this action's jbuilder + # only reads columns present on Attempt, so query Attempt directly. + submissions = Course::Assessment::Attempt.unscoped. + joins(:submission). where(assessment_id: assessment_params[:id]). calculated(:grade, :grader_ids) @course_users_hash = preload_course_users_hash(current_course) @@ -39,7 +44,10 @@ 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. + joins(:submission). preload(creator: :course_users). where(assessment_id: assessment_params[:id]). calculated(:grade) @@ -52,8 +60,12 @@ def ancestor_statistics def live_feedback_statistics @assessment = Course::Assessment.unscoped.includes(:questions). find(assessment_params[:id]) - @submissions = Course::Assessment::Submission.unscoped. - select(:id, :creator_id, :workflow_state). + # id/creator_id/workflow_state all live on Attempt; this action only reads those three columns + # (unscoped + a narrow .select), so querying Attempt directly is equivalent (every course-member + # attempt has exactly one submission). + @submissions = Course::Assessment::Attempt.unscoped. + joins(:submission). + select('course_assessment_submissions.id', :creator_id, :workflow_state). where(assessment_id: assessment_params[:id]) create_submission_question_id_hash(@assessment.questions) @@ -64,7 +76,8 @@ 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.joins(:submission). + where(assessment_id: assessment_params[:id], creator_id: user_id) @question = Course::Assessment::Question.find(params[:question_id]) create_submission_question_id_hash([@question]) @@ -238,7 +251,7 @@ def feedback_messages_cte(student_ids, submission_question_ids) def feedback_answers_cte <<-SQL SELECT - a.submission_id, + a.submission_id AS submission_id, a.question_id, a.created_at, a.grade, diff --git a/app/controllers/system/admin/get_help_controller.rb b/app/controllers/system/admin/get_help_controller.rb index 3123c13496b..7348b1463d5 100644 --- a/app/controllers/system/admin/get_help_controller.rb +++ b/app/controllers/system/admin/get_help_controller.rb @@ -52,6 +52,7 @@ def fetch_system_get_help_data(start_date, end_date) 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_submission_details sd ON sd.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..0728a6d056a 100644 --- a/app/controllers/system/admin/instance/get_help_controller.rb +++ b/app/controllers/system/admin/instance/get_help_controller.rb @@ -50,6 +50,7 @@ def fetch_instance_get_help_data(start_date, end_date) 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_submission_details sd ON sd.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/answer/base_auto_grading_job.rb b/app/jobs/course/assessment/answer/base_auto_grading_job.rb index f4fb54b3b0f..deb5bb952dc 100644 --- a/app/jobs/course/assessment/answer/base_auto_grading_job.rb +++ b/app/jobs/course/assessment/answer/base_auto_grading_job.rb @@ -43,8 +43,12 @@ def perform_tracked(answer, redirect_to_path = nil) Course::Assessment::Answer::AutoGradingService.grade(answer) end - if update_exp?(answer.submission) - Course::Assessment::Submission::CalculateExpService.update_exp(answer.submission) + # `answer.submission` is the Attempt base; EXP (awarder/awarded_at/points_awarded) lives on its + # Submission extension. Recompute against the extension (nil for a preview attempt, which has no + # EXP and must be skipped). + submission = answer.attempt.submission + if submission && update_exp?(submission) + Course::Assessment::Submission::CalculateExpService.update_exp(submission) end end diff --git a/app/jobs/course/assessment/marketplace/duplication_job.rb b/app/jobs/course/assessment/marketplace/duplication_job.rb new file mode 100644 index 00000000000..0463ef6aca5 --- /dev/null +++ b/app/jobs/course/assessment/marketplace/duplication_job.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::DuplicationJob < ApplicationJob + include TrackableJob + include Rails.application.routes.url_helpers + + queue_as :duplication + + protected + + def perform_tracked(listing_ids, destination_course, destination_tab_id, options = {}) + current_user = options[:current_user] + ActsAsTenant.without_tenant do + listings = Course::Assessment::Marketplace::Listing.published.where(id: listing_ids) + listings.each do |listing| + copy = duplicate_listing(listing, destination_course, current_user) + reparent_into_tab(copy, destination_course, destination_tab_id) + record_adoption(listing, destination_course, copy, current_user) + end + redirect_to course_assessments_url(destination_course, + category: destination_course.assessment_categories.first.id, + tab: destination_tab_id, + host: destination_course.instance.host) + end + end + + private + + def duplicate_listing(listing, destination_course, current_user) + source = listing.assessment + Course::Duplication::ObjectDuplicationService.duplicate_objects( + source.course, destination_course, source, current_user: current_user + ) + end + + def reparent_into_tab(copy, destination_course, destination_tab_id) + target_tab = destination_course.assessment_categories. + flat_map(&:tabs).find { |tab| tab.id == destination_tab_id } + return unless target_tab && copy.tab_id != target_tab.id + + copy.tab = target_tab + copy.folder.parent = target_tab.category.folder + copy.save! + end + + def record_adoption(listing, destination_course, copy, current_user) + Course::Assessment::Marketplace::Adoption.create!( + listing: listing, + destination_course: destination_course, + duplicated_assessment: copy, + creator: current_user, + updater: current_user + ) + 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 new file mode 100644 index 00000000000..45dc1e7922b --- /dev/null +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +module Course::AssessmentMarketplaceAbilityComponent + include AbilityHost::Component + + def define_permissions + allow_admins_publish_to_marketplace if user&.administrator? + # 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 + + def allow_managers_access_marketplace + can :access_marketplace, Course, id: course.id + can :duplicate_from_marketplace, Course::Assessment do |assessment| + assessment.marketplace_listing&.published? || false + end + can :preview_in_marketplace, Course::Assessment do |assessment| + assessment.marketplace_listing&.published? || false + end + end +end \ No newline at end of file diff --git a/app/models/concerns/course/assessment/coerces_submission_to_attempt.rb b/app/models/concerns/course/assessment/coerces_submission_to_attempt.rb new file mode 100644 index 00000000000..7d5f5420a96 --- /dev/null +++ b/app/models/concerns/course/assessment/coerces_submission_to_attempt.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +# Lets a caller assign a `Course::Assessment::Submission` (the extension) to a `submission` +# association whose real target is the `Course::Assessment::Attempt` base. The `belongs_to` writer +# strictly checks `record.is_a?(reflection.klass)`, so a `Submission` would otherwise raise +# `ActiveRecord::AssociationTypeMismatch`. Coerce it to its `Attempt` so every caller works +# unchanged. Shared verbatim by Answer, SubmissionQuestion, and QuestionBundleAssignment. +module Course::Assessment::CoercesSubmissionToAttempt + extend ActiveSupport::Concern + + def submission=(value) + value = value.attempt if value.is_a?(Course::Assessment::Submission) + super + end +end diff --git a/app/models/concerns/course/assessment/new_submission_concern.rb b/app/models/concerns/course/assessment/new_submission_concern.rb index b4841c97dff..45dca6ce37f 100644 --- a/app/models/concerns/course/assessment/new_submission_concern.rb +++ b/app/models/concerns/course/assessment/new_submission_concern.rb @@ -12,7 +12,10 @@ 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` references the attempt base, so it must hold an + # Attempt id — not the extension table's own (different) id. `attempt_id` is the extension's + # own FK column to its attempt. + 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..d43a6ded4ad 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,43 @@ 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 submission-less (preview) Attempt has no Submission, + # so `submission` is `nil` and these become no-ops. The real bodies are defined on + # Course::Assessment::Submission (see submission.rb). + # + # `assign_zero_experience_points` is shared by `finalise` and `resubmit_programming`; + # `after_resubmit_programming_hook` exists as a separate hook for the second call site, which has + # no single seam it can share with `after_finalise_hook`. + 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 +178,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..1f200e9581a 100644 --- a/app/models/concerns/course_user/staff_concern.rb +++ b/app/models/concerns/course_user/staff_concern.rb @@ -28,12 +28,16 @@ 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). - where('course_assessment_submissions.publisher_id = ?', user_id). + # `publisher_id` is Submission's own column (the one that publishing writes to); + # `course_assessment_submissions.publisher_id` is a stale residual column on the base. Qualify + # by the extension table. `published_at`/`submitted_at` are Attempt-only, hence the join above. + where('course_assessment_submission_details.publisher_id = ?', user_id). where('course_users.course_id = ?', course_id). - pluck(:published_at, :submitted_at). + pluck('course_assessment_submissions.published_at', 'course_assessment_submissions.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 7bde6a0d4bc..c00685a9f64 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -35,10 +35,16 @@ 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 + # An assessment's "submissions" are only its course-member submissions: a submission-less (preview) + # attempt is excluded by the join, with no filter needed. Read call sites are unaffected. + has_many :submissions, through: :attempts, source: :submission has_many :question_assessments, class_name: 'Course::QuestionAssessment', inverse_of: :assessment, dependent: :destroy @@ -82,6 +88,8 @@ class Course::Assessment < ApplicationRecord has_one :gradebook_assessment_contribution, class_name: 'Course::Gradebook::AssessmentContribution', dependent: :destroy, inverse_of: :assessment + has_one :marketplace_listing, class_name: 'Course::Assessment::Marketplace::Listing', + inverse_of: :assessment, dependent: :destroy has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback', inverse_of: :assessment, dependent: :destroy has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy @@ -128,8 +136,12 @@ 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 + # `assessment` is a delegated method on Submission, not a real FK column, so `.where(assessment:)` + # would raise `PG::UndefinedColumn`. `.by_user(user)` is a subquery (not a join — see its own + # comment), so join `:attempt` explicitly here to filter by `assessment_id`. + submissions = Course::Assessment::Submission.by_user(user).joins(:attempt). + where(course_assessment_submissions: { assessment_id: distinct(false).pluck(:id) }). + ordered_by_date all.to_a.tap do |result| preloader = ActiveRecord::Associations::Preloader.new(records: result, @@ -179,6 +191,27 @@ 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 does not work now that `submissions` is a + # `has_many :through` association — 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..90ba7a1b43a 100644 --- a/app/models/course/assessment/answer.rb +++ b/app/models/course/assessment/answer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Course::Assessment::Answer < ApplicationRecord include Workflow + actable optional: true, inverse_of: :answer workflow do @@ -52,8 +53,19 @@ 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 + # Association name kept as `:submission` for call-site compatibility, but it targets the attempt + # base record: `submission_id` identifies an Attempt row (the base), not a Submission row. + belongs_to :submission, class_name: 'Course::Assessment::Attempt', inverse_of: :answers, + foreign_key: 'submission_id' + include Course::Assessment::CoercesSubmissionToAttempt + belongs_to :question, class_name: 'Course::Assessment::Question', inverse_of: nil + + # `attempt` is the accurate name for what `:submission` returns — the Attempt base record. Prefer it in + # new code: `answer.attempt.submission` reads clearly where `answer.submission.submission` stuttered (the + # FK column is misleadingly named `submission_id`). Reader-only alias; the association stays `:submission` + # for existing call sites. + alias_method :attempt, :submission 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 @@ -126,7 +138,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. + submission.published? || ability.can?(:grade, self) || (submission.assessment.autograded? && !submission.assessment.allow_partial_submission) || ( submission.assessment.autograded? && diff --git a/app/models/course/assessment/answer/programming_ability.rb b/app/models/course/assessment/answer/programming_ability.rb index 25d414408e0..4303f21201d 100644 --- a/app/models/course/assessment/answer/programming_ability.rb +++ b/app/models/course/assessment/answer/programming_ability.rb @@ -13,7 +13,7 @@ def allow_create_programming_files can :create_programming_files, Course::Assessment::Answer::Programming do |programming_answer| multiple_file_submission?(programming_answer.question) && creator?(programming_answer.submission) && - can_update_submission?(programming_answer.submission) && + can_update_submission?(programming_answer.attempt.submission) && current_answer?(programming_answer) end end @@ -22,7 +22,7 @@ def allow_destroy_programming_files can :destroy_programming_file, Course::Assessment::Answer::Programming do |programming_answer| multiple_file_submission?(programming_answer.question) && creator?(programming_answer.submission) && - can_update_submission?(programming_answer.submission) && + can_update_submission?(programming_answer.attempt.submission) && current_answer?(programming_answer) end end diff --git a/app/models/course/assessment/answer/programming_file_annotation.rb b/app/models/course/assessment/answer/programming_file_annotation.rb index 63788ce7ac0..5ed6903bf6b 100644 --- a/app/models/course/assessment/answer/programming_file_annotation.rb +++ b/app/models/course/assessment/answer/programming_file_annotation.rb @@ -17,8 +17,13 @@ class Course::Assessment::Answer::ProgrammingFileAnnotation < ApplicationRecord # where.has { file.answer.answer.submission.creator_id.in(user_id) }. # joining { discussion_topic }.selecting { discussion_topic.id } unscoped. - joins(file: { answer: { answer: :submission } }). - where(Course::Assessment::Submission.arel_table[:creator_id].in(user_id)). + # The innermost `:submission` joins the Attempt base (post-split); the nested `:submission` + # (Attempt's `has_one :submission`) inner-joins the extension table, restricting to real + # submissions so a preview attempt's annotations never leak here. `creator_id` lives on + # Course::Assessment::Attempt post-repoint — `Course::Assessment::Submission.arel_table` now + # points at the column-less extension table, so the reference must use Attempt. + joins(file: { answer: { answer: { submission: :submission } } }). + where(Course::Assessment::Attempt.arel_table[:creator_id].in(user_id)). joins(:discussion_topic). select(Course::Discussion::Topic.arel_table[:id]) end) diff --git a/app/models/course/assessment/assessment_ability.rb b/app/models/course/assessment/assessment_ability.rb index 2a45749429a..b1de118d28a 100644 --- a/app/models/course/assessment/assessment_ability.rb +++ b/app/models/course/assessment/assessment_ability.rb @@ -21,9 +21,14 @@ def assessment_course_hash { tab: { category: { course_id: course.id } } } end + # The condition identifying a user's own attempt-in-progress, expressed against the Attempt base + # (which owns `workflow_state` and `creator_id` post-split; `creator_id` is the owner because + # `validate_consistent_user` pins it to the submission's course_user). Wrap it in `attempt:` for a + # `Submission` subject, or use it directly under `submission:` for an `Answer`/`Answer::TextResponse` + # subject, since that association now reaches the Attempt. def assessment_submission_attempting_hash(user) { workflow_state: 'attempting' }.tap do |result| - result.reverse_merge!(experience_points_record: { course_user: { user_id: user.id } }) if user + result[:creator_id] = user.id if user end end @@ -77,7 +82,7 @@ def allow_create_assessment_submission experience_points_record: { course_user: { user_id: user.id } } can [:update, :generate_live_feedback, :save_live_feedback, :create_live_feedback_chat, :fetch_live_feedback_status], - Course::Assessment::Submission, assessment_submission_attempting_hash(user) + Course::Assessment::Submission, attempt: assessment_submission_attempting_hash(user) end def allow_update_own_assessment_answer @@ -141,19 +146,19 @@ def allow_staff_read_observe_access_and_attempt_assessment def allow_staff_read_assessment_submissions can :view_all_submissions, Course::Assessment, assessment_course_hash - can :read, Course::Assessment::Submission, assessment: assessment_course_hash + can :read, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_staff_read_assessment_tests - can :read_tests, Course::Assessment::Submission, assessment: assessment_course_hash + can :read_tests, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_staff_update_category_grades - can :update_category_grades, Course::Assessment::Submission, assessment: assessment_course_hash + can :update_category_grades, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_staff_update_category_explanations - can :update_category_explanations, Course::Assessment::Submission, assessment: assessment_course_hash + can :update_category_explanations, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_staff_read_submission_questions @@ -165,7 +170,7 @@ def allow_staff_read_submission_answers end def allow_staff_delete_own_assessment_submission - can :delete_submission, Course::Assessment::Submission, creator_id: user.id + can :delete_submission, Course::Assessment::Submission, attempt: { creator_id: user.id } end def define_teaching_staff_assessment_permissions @@ -217,7 +222,7 @@ def allow_manage_questions def allow_teaching_staff_grade_assessment_submissions can [:update, :reload_answer, :grade, :reevaluate_answer, :generate_feedback], - Course::Assessment::Submission, assessment: assessment_course_hash + Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } can :grade, Course::Assessment::Answer, submission: { assessment: assessment_course_hash } end @@ -225,7 +230,7 @@ def allow_teaching_staff_grade_assessment_submissions def allow_teaching_staff_interact_with_live_feedback can [:generate_live_feedback, :save_live_feedback, :create_live_feedback_chat, :fetch_live_feedback_status, :fetch_live_feedback_chat], - Course::Assessment::Submission, assessment: assessment_course_hash + Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_teaching_staff_manage_assessment_annotations @@ -289,7 +294,7 @@ def allow_manager_fetch_submissions_from_koditsu # Only managers and above are allowed to delete assessment submissions def allow_manager_delete_assessment_submissions can :delete_all_submissions, Course::Assessment, assessment_course_hash - can :delete_submission, Course::Assessment::Submission, assessment: assessment_course_hash + can :delete_submission, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_manager_update_assessment_answer diff --git a/app/models/course/assessment/attempt.rb b/app/models/course/assessment/attempt.rb new file mode 100644 index 00000000000..b63644ff1a3 --- /dev/null +++ b/app/models/course/assessment/attempt.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true +class Course::Assessment::Attempt < ApplicationRecord + # The `course_assessment_submissions` table IS the attempt base record: it was kept under its + # historical name (rather than renamed to `course_assessment_attempts`) so the change is purely + # additive and safe under rolling deploys. `Attempt`'s Rails-default table name + # (`course_assessment_attempts`) does not exist; point it at the real base table. + self.table_name = 'course_assessment_submissions' + # ApplicationUserstampConcern's `inherited` hook ran `add_userstamp_associations({})` against + # `course_assessment_attempts` (the nonexistent default) before the line above took effect, so it + # added no creator/updater associations → creator_id/updater_id NOT NULL violation on insert. + # Re-run now that table_name is correct — the base has both columns, so this ADDS them back. + add_userstamp_associations({}) + 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: 'submission_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: 'submission_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.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) + + # @!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 preview attempts, `.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_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 + 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_submissions` — 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.rb b/app/models/course/assessment/marketplace.rb new file mode 100644 index 00000000000..235cbc69e93 --- /dev/null +++ b/app/models/course/assessment/marketplace.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Course::Assessment::Marketplace + def self.table_name_prefix + 'course_assessment_marketplace_' + 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/adoption.rb b/app/models/course/assessment/marketplace/adoption.rb new file mode 100644 index 00000000000..dac5a616290 --- /dev/null +++ b/app/models/course/assessment/marketplace/adoption.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::Adoption < ApplicationRecord + belongs_to :listing, class_name: 'Course::Assessment::Marketplace::Listing', inverse_of: :adoptions + belongs_to :destination_course, class_name: 'Course', inverse_of: false + belongs_to :duplicated_assessment, class_name: 'Course::Assessment', inverse_of: false + + validates :duplicated_assessment_id, uniqueness: true + validates :creator, presence: true + validates :updater, presence: true +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/listing.rb b/app/models/course/assessment/marketplace/listing.rb new file mode 100644 index 00000000000..0722ac64925 --- /dev/null +++ b/app/models/course/assessment/marketplace/listing.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::Listing < ApplicationRecord + belongs_to :assessment, class_name: 'Course::Assessment', inverse_of: :marketplace_listing + belongs_to :publisher, class_name: 'User', inverse_of: false + has_many :adoptions, class_name: 'Course::Assessment::Marketplace::Adoption', + inverse_of: :listing, dependent: :destroy + + validates :assessment_id, uniqueness: true + validates :publisher, presence: true + validates :creator, presence: true + validates :updater, presence: true + + scope :published, -> { where(published: true) } + + def adoption_count + adoptions.distinct.count(:destination_course_id) + 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..751e86dbb27 100644 --- a/app/models/course/assessment/question_bundle_assignment.rb +++ b/app/models/course/assessment/question_bundle_assignment.rb @@ -3,8 +3,10 @@ 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 + include Course::Assessment::CoercesSubmissionToAttempt + belongs_to :question_bundle, class_name: 'Course::Assessment::QuestionBundle', foreign_key: :bundle_id, inverse_of: :question_bundle_assignments diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index cb5df4c80f6..51999630432 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -1,151 +1,154 @@ # frozen_string_literal: true class Course::Assessment::Submission < ApplicationRecord - include Workflow + # Submission is a small course-coupled extension of the attempt base record. The base owns the + # `course_assessment_submissions` table (which is this model's Rails-default table name); point + # Submission at its own extension table instead. + self.table_name = 'course_assessment_submission_details' + # This table has no creator_id/updater_id; identity is delegated to `attempt` and this row is + # never stamped (record_userstamp is disabled below). ApplicationUserstampConcern's `inherited` + # hook nonetheless added *required* creator/updater belongs_to, because this model's default table + # name resolves to `course_assessment_submissions` — which does have those columns — before the + # line above repoints us to the column-less extension. Drop those associations and their presence + # validations; the delegated readers below resolve creator/updater through the attempt. + %i[creator updater].each do |name| + _validate_callbacks.dup.each do |callback| + filter = callback.filter + next unless filter.respond_to?(:attributes) && filter.attributes.include?(name) + + skip_callback(:validate, callback.kind, filter) + end + _reflections.delete(name.to_s) + reflections.delete(name.to_s) + end 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? - - 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 - - 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 - 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 - + # Stamping belongs entirely to Attempt (the base). Without this, `activerecord-userstamp`'s + # globally-registered `before_validation :set_creator_attribute` still fires here: `acts_as`'s + # `ReflectionsWithActsAs#_reflections` merges in the acting-as `experience_points_record`'s own + # `:creator` reflection wherever Submission has none of its own, so `reflect_on_association(:creator)` + # returns non-nil and the callback calls `creator` on self — resolving to the DELEGATED reader (not + # a real column) and raising `ActiveSupport::DelegationError` whenever `attempt` is nil (e.g. a bare + # `Course::Assessment::Submission.new`). This table was never stamped, so disabling it loses nothing. + self.record_userstamp = false + + belongs_to :attempt, class_name: 'Course::Assessment::Attempt', inverse_of: :submission, + autosave: true + # `publisher_id` is a real column on this extension table. `after_publish_hook`/`after_unsubmit_hook` + # below both assign `self.publisher =`, which needs this association to exist. 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] + # Attempt-level surface exposed on Submission through delegation, so existing `submission.` call + # sites keep working after the split. Notes on the less-obvious members: + # + # - `create_new_answers` (from AnswersConcern, mixed into Attempt) and + # `saved_change_to_workflow_state?`/`workflow_state_before_last_save` must be delegated: `workflow_state` + # is not a column on this extension table, so Rails generates no dirty-tracking methods here, yet + # the `after_save` callbacks from TodoConcern/CikgoTaskCompletionConcern/NotificationConcern (still + # included on Submission) and the controller's `signals` guard read them. Delegation reads the + # right value: by the time `finalise!`/`publish!`/etc. call `save!` on Submission, the attempt's + # dirty state from the `attempt.!` call just above it is still fresh. + # - `creator=`/`updater=` writers, not just readers: without them `submission.creator = x` falls + # through `acts_as`'s `method_missing` and writes `experience_points_record.creator` (the wrong row). + # - `allow_nil: true`: a Submission with no `attempt` only exists transiently (a bare `.new`). The + # `belongs_to :attempt` above already enforces attempt presence; the delegate need not also raise + # `ActiveSupport::DelegationError` for that transient case. + 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 + + # Mirror Attempt's calculated attributes so `@assessment.submissions.calculated(:grade, …)` (used by + # the gradebook/download/statistics services) works on the extension relation. Attempt correlates + # each subquery on its own `id`; here we correlate on this extension table's `attempt_id`, which + # equals that base id. The delegated instance readers above still return these values per-record; + # registering the calculated attributes restores the batch-load form the services rely on. calculated :graded_at, (lambda do Course::Assessment::Answer.unscope(:order). - where('course_assessment_answers.submission_id = course_assessment_submissions.id'). + where('course_assessment_answers.submission_id = course_assessment_submission_details.attempt_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') + where('course_assessment_submission_logs.submission_id = course_assessment_submission_details.attempt_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 + where('course_assessment_answers.submission_id = course_assessment_submission_details.attempt_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 + where('course_assessment_answers.submission_id = course_assessment_submission_details.attempt_id AND course_assessment_answers.current_answer = true'). select('ARRAY_REMOVE(ARRAY_AGG(DISTINCT(course_assessment_answers.grader_id)), NULL)') end) + Course::Assessment::Answer.after_save do |answer| + Course::Assessment::Submission.on_dependent_status_change(answer) + end + + # On the old single table, `last_graded_time` had a DB column default (a frozen historical + # timestamp — the well-known Rails `add_column ... default: Time.now` gotcha). The extension table + # does not carry that default, so populate it in Ruby on creation to keep the `presence: true` + # validation below satisfiable. + before_validation :ensure_last_graded_time + + validate :validate_consistent_user, on: :create + validate :validate_awarded_attributes, if: :published? + validates :last_graded_time, presence: true + + # `belongs_to :attempt, autosave: true` (above) makes Rails validate the associated Attempt as part + # of validating Submission and, when invalid, copy its messages onto `errors[:attempt]` (prefixed + # "Attempt ..."). The attempt's uniqueness error is added to `:base`; re-home the cascaded copy back + # under `:base` (unprefixed) so the controller's `errors.full_messages.to_sentence` rescue renders + # it to the student exactly as before. Presentation only — the save is rejected either way. + 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 a `has_many :through` + # that already joins the base table to reach `submission`. An additional explicit `joins(:attempt)` + # would join the base table a second time under another alias; a later `.pluck(:creator_id)` (a + # column on the base but not on Submission's own table) then becomes `PG::AmbiguousColumn`. The + # subquery form adds no second join, so it is safe whether called directly 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 +158,30 @@ 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). Uses Submission's own `created_at` + # rather than the attempt's: today every submission row is created in the same request as its + # attempt, so the two are indistinguishable. Revisit if a submission can ever be created later + # than its attempt. 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 :confirmed, -> { joins(:attempt).merge(Course::Assessment::Attempt.confirmed) } - scope :pending_for_grading, (lambda do - where(workflow_state: [:submitted, :graded]). - joins(:assessment). - where('course_assessments.autograded = ?', false) - end) + scope :pending_for_grading, -> { joins(:attempt).merge(Course::Assessment::Attempt.pending_for_grading) } + + # `with__state` scopes are generated by the `workflow-activerecord` gem on whichever class + # calls `workflow do ... end` — that is Attempt, not Submission. Re-expose them here (as joins) for + # the call sites that use `submissions.with__state`; they cannot be covered by the instance + # `delegate` list above because they are class-level scopes. + 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 +190,196 @@ 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_submissions: { 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 + # Not delegated like the members above, because `.grade_summary` (below) is a raw `find_by_sql` on + # this class that SELECTs an ad-hoc `assessment_id` column onto its result rows. Those rows have no + # `attempt_id` and so cannot read `attempt`; prefer the raw-SQL-loaded attribute when present, + # otherwise delegate to the attempt. + 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 (the association name is `:submission`, its + # `class_name` is Attempt). `last_graded_time` is a course-coupled column that lives only on the + # real Submission, reached via the attempt's `has_one :submission`. + answer.attempt.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. + 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_submissions cas + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id + 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 + ) 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 because `alias_method` resolves its target when + # evaluated: `finalise!`/`publish!`/`unsubmit!` are plain methods defined on this class (not + # delegated, unlike `mark!`/`unmark!`), so aliasing them earlier raises `NameError` at class load. + alias_method :finalise=, :finalise! + alias_method :mark=, :mark! + alias_method :unmark=, :unmark! + alias_method :publish=, :publish! + alias_method :unsubmit=, :unsubmit! + + # --- Hook bodies invoked by Attempt's WorkflowEventConcern for the EXP-specific / course-coupled + # work of each workflow event. Public (not protected) because they are called with an explicit + # receiver (`submission&.after_x_hook`) from a different object (the Attempt instance), which + # `protected` would forbid since Attempt and Submission are unrelated 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 `send_email` argument. Also folds in the + # former `assign_experience_points` before_validation's only real effect + # (`points_awarded ||= draft_points_awarded`), whose guard was only ever true on a publish event. + 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?) } + # `assign_zero_experience_points` runs on both `finalise` and `resubmit_programming`. + # `resubmit_programming` clears points (reusing `after_unsubmit_hook`) and re-finalises current + # answers first, so it gets its own hook rather than overloading `after_finalise_hook`. + 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 +391,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 +398,18 @@ 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 key for a nested `:base` error is the dotted + # `:'attempt.base'` (association name + attribute), not a bare `:attempt`. + 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..fd08155022b 100644 --- a/app/models/course/assessment/submission/log.rb +++ b/app/models/course/assessment/submission/log.rb @@ -1,8 +1,14 @@ # 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 maps to + # `course_assessment_submission_details`, that derivation yields the nonexistent + # `course_assessment_submission_detail_logs`. Pin the real table name explicitly. + 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..659c9a58fc2 100644 --- a/app/models/course/assessment/submission_question.rb +++ b/app/models/course/assessment/submission_question.rb @@ -8,11 +8,21 @@ class Course::Assessment::SubmissionQuestion < ApplicationRecord 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? } } - belongs_to :submission, class_name: 'Course::Assessment::Submission', + # Association is `:submission` but its target is the `Attempt` base; the FK column stays + # `submission_id` (the base table/columns were not renamed — additive split). `foreign_key` is + # stated explicitly to document that the column is `submission_id`, not `attempt_id`. + belongs_to :submission, class_name: 'Course::Assessment::Attempt', foreign_key: 'submission_id', inverse_of: :submission_questions + include Course::Assessment::CoercesSubmissionToAttempt + belongs_to :question, class_name: 'Course::Assessment::Question', inverse_of: :submission_questions + # `attempt` is the accurate name for what `:submission` returns — the Attempt base record. Prefer it in + # new code (e.g. `@submission_question.attempt.submission`); the association stays `:submission` for + # existing call sites. Reader-only alias. + alias_method :attempt, :submission + has_many :threads, class_name: 'Course::Assessment::LiveFeedback::Thread', inverse_of: :submission_question, dependent: :destroy after_initialize :set_course, if: :new_record? @@ -25,8 +35,12 @@ class Course::Assessment::SubmissionQuestion < ApplicationRecord # where.has { submission.creator_id.in(user_id) }. # joining { discussion_topic }.selecting { discussion_topic.id } unscoped. - joins(:submission). - where(Course::Assessment::Submission.arel_table[:creator_id].in(user_id)). + # SubmissionQuestion `:submission` joins the Attempt base (which includes previews); the nested + # `:submission` (Attempt's `has_one :submission`) inner-joins the extension table, restricting + # to real submissions so a preview's submission_questions never leak here. `creator_id` lives on + # Course::Assessment::Attempt post-repoint, so the arel_table reference must match. + joins(submission: :submission). + where(Course::Assessment::Attempt.arel_table[:creator_id].in(user_id)). joins(:discussion_topic). select(Course::Discussion::Topic.arel_table[:id]) end) diff --git a/app/models/course/condition/assessment.rb b/app/models/course/condition/assessment.rb index 4ed34f3e143..af87447ff6c 100644 --- a/app/models/course/condition/assessment.rb +++ b/app/models/course/condition/assessment.rb @@ -1,11 +1,30 @@ # frozen_string_literal: true -class Course::Condition::Assessment < ApplicationRecord +# The class observes two tables (Attempt + Submission) via 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 change: `workflow_state` + # lives on Attempt, `last_graded_time` on Submission. Registering both halves on + # `Submission.after_save` and reading `submission.saved_change_to_workflow_state?` (delegated to + # `attempt`) goes stale across separate Submission saves: the dirty flag reflects `attempt`'s own + # most recent save, so a later unrelated `submission.save!` still reports the old transition as + # freshly changed and double-fires. Reading `attempt.saved_change_to_workflow_state?` from an + # `after_save` on `Attempt` itself avoids this. + 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 +71,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). Guard so it still results in + # a single re-evaluation: 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 +108,17 @@ 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` lives on Attempt; `.confirmed` already wraps this exact set of states + # (`[:submitted, :graded, :published]`) through `:attempt`. + 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 on Submission, not real associations, so + # `eager_load(:answers, assessment: :questions)` on it raises `ActiveRecord::ConfigurationError`. + # Both live on `:attempt`, so eager-load through it. + 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..751b1de6abb 100644 --- a/app/models/course_user.rb +++ b/app/models/course_user.rb @@ -118,8 +118,11 @@ 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 a delegated method on Submission, not a real association, so `joins(assessment:)` + # raises `ActiveRecord::ConfigurationError`. Join through `:attempt` first (a real association), + # matching `Submission.from_course`'s `joins(attempt: { assessment: { tab: :category } })`. Course::Assessment::Submission.select('count(*)'). - joins(assessment: { tab: :category }). + joins(attempt: { assessment: { tab: :category } }). where('course_assessment_submissions.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] }) 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..0643e165c34 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.attempt.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..d9fc871f194 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.attempt.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/csv_download_service.rb b/app/services/course/assessment/submission/csv_download_service.rb index 3407367e33a..3b1a98e949b 100644 --- a/app/services/course/assessment/submission/csv_download_service.rb +++ b/app/services/course/assessment/submission/csv_download_service.rb @@ -34,8 +34,8 @@ def generate def generate_csv submissions = @assessment.submissions.by_users(course_users.pluck(:user_id)). - includes(:assessment, { answers: { actable: [:options, :files] }, - experience_points_record: :course_user }) + includes(attempt: [:assessment, answers: { actable: [:options, :files] }], + experience_points_record: :course_user) submissions_hash = submissions.to_h { |submission| [submission.creator_id, submission] } csv_file_path = File.join(@base_dir, "#{Pathname.normalize_filename(@assessment.title)}.csv") CSV.open(csv_file_path, 'w') do |csv| diff --git a/app/services/course/assessment/submission/ssid_zip_download_service.rb b/app/services/course/assessment/submission/ssid_zip_download_service.rb index 429d2241efc..5de6eb8ddd3 100644 --- a/app/services/course/assessment/submission/ssid_zip_download_service.rb +++ b/app/services/course/assessment/submission/ssid_zip_download_service.rb @@ -44,7 +44,7 @@ def cleanup_entries # Downloads each submission to its own folder in the base directory. def download_to_base_dir submissions = @assessment.submissions.confirmed.by_users(course_user_ids(@assessment)). - includes(:answers, experience_points_record: :course_user) + includes({ attempt: :answers }, experience_points_record: :course_user) submissions.find_each do |submission| folder_name = "#{submission.id}_#{submission.course_user.name}" submission_dir = create_folder(@base_dir, folder_name) diff --git a/app/services/course/assessment/submission/update_service.rb b/app/services/course/assessment/submission/update_service.rb index 7db382b251b..bc10744809f 100644 --- a/app/services/course/assessment/submission/update_service.rb +++ b/app/services/course/assessment/submission/update_service.rb @@ -78,8 +78,11 @@ 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` targets `Course::Assessment::Attempt`, so assigning a + # `Course::Assessment::Submission` here raises `ActiveRecord::AssociationTypeMismatch`. + # `@submission.attempt` is the real association to hand it. 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/services/course/assessment/submission/zip_download_service.rb b/app/services/course/assessment/submission/zip_download_service.rb index c290b9fc4bc..f8e90989fe3 100644 --- a/app/services/course/assessment/submission/zip_download_service.rb +++ b/app/services/course/assessment/submission/zip_download_service.rb @@ -18,7 +18,7 @@ def initialize(current_course_user, assessment, course_user_type) # Downloads each submission to its own folder in the base directory. def download_to_base_dir submissions = @assessment.submissions.by_users(course_user_ids). - includes(:answers, experience_points_record: :course_user) + includes({ attempt: :answers }, experience_points_record: :course_user) submissions.find_each do |submission| submission_dir = create_folder(@base_dir, submission.course_user.name) download_answers(submission, submission_dir) diff --git a/app/services/course/skills_mastery_preload_service.rb b/app/services/course/skills_mastery_preload_service.rb index 284c03075c9..7933980a01a 100644 --- a/app/services/course/skills_mastery_preload_service.rb +++ b/app/services/course/skills_mastery_preload_service.rb @@ -63,8 +63,10 @@ def skills_by_branch def grade_by_skill @grade_by_skill ||= begin grade_by_skill = Hash.new(0) + # `belonging_to_submissions` filters answers by `submission_id`, which references the attempt + # (base) id, not the extension's own id. Pluck `attempt_id` so the answers actually match. submission_ids = Course::Assessment::Submission.by_user(@course_user.user.id). - from_course(@course).with_published_state.pluck(:id) + from_course(@course).with_published_state.pluck(:attempt_id) answers = Course::Assessment::Answer.belonging_to_submissions(submission_ids).current_answers. includes(question: { question_assessments: :skills }) answers.each do |answer| diff --git a/app/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index d22f4b56bf4..b801cc43559 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -77,8 +77,12 @@ json.permissions do json.canManage can_manage json.canObserve can_observe json.canInviteToKoditsu can?(:invite_to_koditsu, assessment) + json.canPublishToMarketplace((can?(:publish_to_marketplace, @assessment) && current_user&.administrator?) || false) end +json.isPublishedToMarketplace @assessment.marketplace_listing&.published? || false +json.marketplaceListingUrl course_assessment_marketplace_listing_path(current_course, @assessment) + unless can_attempt not_started_for_user = assessment_not_started(assessment.time_for(current_course_user)) json.willStartAt assessment.time_for(current_course_user).start_at if not_started_for_user diff --git a/app/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder new file mode 100644 index 00000000000..ead481dbe7a --- /dev/null +++ b/app/views/course/assessment/marketplace/listings/index.json.jbuilder @@ -0,0 +1,19 @@ +# frozen_string_literal: true +json.canAccess true +json.listings @listings do |listing| + assessment = listing.assessment + json.id listing.id + json.assessmentId assessment.id + json.title assessment.title + json.questionCount(@question_counts[assessment.id] || 0) + json.adoptions(@adoption_counts[listing.id] || 0) + json.firstPublishedAt listing.first_published_at + json.previewUrl course_listing_path(current_course, listing) + json.duplicateUrl duplicate_course_listings_path(current_course) +end +json.destinationTabs @destination_tabs do |tab| + json.id tab[:id] + json.title tab[:title] + json.categoryId tab[:category_id] + json.categoryTitle tab[:category_title] +end diff --git a/app/views/course/assessment/marketplace/listings/show.json.jbuilder b/app/views/course/assessment/marketplace/listings/show.json.jbuilder new file mode 100644 index 00000000000..92d6b4f7305 --- /dev/null +++ b/app/views/course/assessment/marketplace/listings/show.json.jbuilder @@ -0,0 +1,49 @@ +# frozen_string_literal: true +json.id @assessment.id +json.title @assessment.title +json.description format_ckeditor_rich_text(@assessment.description) + +# The current course's category/tab structure, so the duplicate confirmation dialog can offer the +# destination tab picker from the listing detail page (the listing itself lives in another course). +json.destinationTabs @destination_tabs do |tab| + json.id tab[:id] + json.title tab[:title] + json.categoryId tab[:category_id] + json.categoryTitle tab[:category_title] +end + +json.gradingMode @assessment.autograded? ? 'autograded' : 'manual' +json.baseExp @assessment.base_exp if @assessment.base_exp > 0 +json.bonusExp @assessment.time_bonus_exp if @assessment.time_bonus_exp > 0 +json.showMcqMrqSolution @assessment.show_mcq_mrq_solution +json.showRubricToStudents @assessment.show_rubric_to_students +json.gradedTestCases display_graded_test_types(@assessment) + +questions = @assessment.questions.includes(:actable) + +# Group by the human-readable type (e.g. "Multiple Choice", "Text Response Question") so the +# breakdown matches the per-question chips and the wording of the real assessment show page, +# instead of raw actable class names ("MultipleResponse"). +json.typeCounts questions.group_by(&:question_type_readable).transform_values(&:size) + +json.questions questions do |question| + json.id question.id + json.title question.title + json.description format_ckeditor_rich_text(question.description) + json.staffOnlyComments format_ckeditor_rich_text(question.staff_only_comments) + json.maximumGrade question.maximum_grade + # Human-readable label for the type chip, mirroring _question_assessment.json.jbuilder. The + # renderer dispatch lives on the detail endpoint (which keeps the demodulized discriminator). + json.type question.question_type_readable + json.unautogradable !question.auto_gradable? + + if question.actable_type == 'Course::Assessment::Question::MultipleResponse' + mrq = question.actable + json.mcqMrqType mrq.multiple_choice? ? 'mcq' : 'mrq' # multiple_choice? is aliased to any_correct? + json.options mrq.options do |option| + json.id option.id + json.option format_ckeditor_rich_text(option.option) + json.correct option.correct + end + end +end diff --git a/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder new file mode 100644 index 00000000000..5a526830eb1 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true +json.maxPosts question.max_posts +json.hasTextResponse question.has_text_response diff --git a/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder new file mode 100644 index 00000000000..03518cfbc46 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true +json.gradingScheme question.grading_scheme +json.options question.options do |option| + json.id option.id + json.option format_ckeditor_rich_text(option.option) + json.correct option.correct + json.explanation format_ckeditor_rich_text(option.explanation) + json.weight option.weight +end diff --git a/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder new file mode 100644 index 00000000000..acd4127d3b1 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder @@ -0,0 +1,21 @@ +# frozen_string_literal: true +json.languageName question.language&.name +json.memoryLimit question.memory_limit +json.timeLimit question.time_limit + +json.templateFiles question.template_files do |file| + json.filename file.filename + json.content file.content +end + +grouped = question.test_cases.group_by(&:test_case_type) +{ 'publicTestCases' => 'public_test', + 'privateTestCases' => 'private_test', + 'evaluationTestCases' => 'evaluation_test' }.each do |key, type| + json.set! key, (grouped[type] || []) do |tc| + json.identifier tc.identifier + json.expression tc.expression + json.expected tc.expected + json.hint tc.hint + end +end diff --git a/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder new file mode 100644 index 00000000000..e0428d204b7 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true +json.categories question.categories do |category| + json.name category.name + json.isBonus category.is_bonus_category + json.criteria category.criterions do |criterion| + json.grade criterion.grade + json.explanation format_ckeditor_rich_text(criterion.explanation) + end +end diff --git a/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder new file mode 100644 index 00000000000..ff701b44713 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true +# Verified against app/views/course/assessment/question/scribing/_scribing_question.json.jbuilder: +# scribing exposes its image via `attachment_reference.generate_public_url`, guarded by presence. +json.imageUrl question.attachment_reference&.generate_public_url diff --git a/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder new file mode 100644 index 00000000000..4f508b70d3c --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true +json.hideText question.hide_text +json.isAttachmentRequired question.is_attachment_required +json.maxAttachments question.max_attachments +json.maxAttachmentSize question.max_attachment_size +json.isComprehension question.is_comprehension +json.solutions question.solutions do |solution| + json.solutionType solution.solution_type + json.solution format_ckeditor_rich_text(solution.solution) + json.grade solution.grade + json.explanation format_ckeditor_rich_text(solution.explanation) +end diff --git a/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder new file mode 100644 index 00000000000..ca1fcb85e8e --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true +# Voice questions have no type-specific setup fields; the base prompt is shown by the shell. +# `json.merge!({})` forces the enclosing `json.detail do … end` block to serialize as an empty +# object `{}`. Without it the block's scope stays blank and jbuilder emits `null` instead. +json.merge!({}) diff --git a/app/views/course/assessment/marketplace/questions/show.json.jbuilder b/app/views/course/assessment/marketplace/questions/show.json.jbuilder new file mode 100644 index 00000000000..555d4a7607e --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/show.json.jbuilder @@ -0,0 +1,30 @@ +# frozen_string_literal: true +detail_partials = { + 'Course::Assessment::Question::MultipleResponse' => 'multiple_response', + 'Course::Assessment::Question::Programming' => 'programming', + 'Course::Assessment::Question::TextResponse' => 'text_response', + 'Course::Assessment::Question::RubricBasedResponse' => 'rubric_based_response', + 'Course::Assessment::Question::ForumPostResponse' => 'forum_post_response', + 'Course::Assessment::Question::VoiceResponse' => 'voice_response', + 'Course::Assessment::Question::Scribing' => 'scribing' +} + +json.id @question.id +json.title @question.title +json.defaultTitle @question_assessment.default_title(@question_assessment.question_number) +json.description format_ckeditor_rich_text(@question.description) +json.staffOnlyComments format_ckeditor_rich_text(@question.staff_only_comments) +json.maximumGrade @question.maximum_grade +# `type` is the demodulized discriminator that drives the frontend renderer dispatch; keep it stable. +json.type @question.actable_type.demodulize +# `displayType` is the human-readable label shown in the detail header chip (mirrors the card). +json.displayType @question.question_type_readable + +partial = detail_partials[@question.actable_type] +if partial + json.detail do + json.partial! "course/assessment/marketplace/questions/details/#{partial}", question: @question.actable + end +else + json.detail nil +end diff --git a/app/views/notifiers/course/assessment/answer/comment_notifier/annotated/user_notifications/email.html.slim b/app/views/notifiers/course/assessment/answer/comment_notifier/annotated/user_notifications/email.html.slim index 5eb2571fb2a..1c72b1a8634 100644 --- a/app/views/notifiers/course/assessment/answer/comment_notifier/annotated/user_notifications/email.html.slim +++ b/app/views/notifiers/course/assessment/answer/comment_notifier/annotated/user_notifications/email.html.slim @@ -2,9 +2,10 @@ - annotation = post.topic.actable - answer = annotation.file.answer - question = answer.question -- submission = answer.submission +- attempt = answer.submission +- submission = attempt.submission - course_user = submission.course_user -- assessment = submission.assessment +- assessment = attempt.assessment - question_assessment = assessment.question_assessments.find_by!(question: question) - course = assessment.course - host = course.instance.host 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/course/Marketplace.ts b/client/app/api/course/Marketplace.ts new file mode 100644 index 00000000000..8f1f4bf7ea8 --- /dev/null +++ b/client/app/api/course/Marketplace.ts @@ -0,0 +1,53 @@ +import { AxiosResponse } from 'axios'; + +import { DestinationTab, MarketplaceListing } from 'course/marketplace/types'; + +import BaseCourseAPI from './Base'; + +export default class MarketplaceAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/marketplace`; + } + + publishListing(assessmentId: number): Promise { + return this.client.post( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`, + ); + } + + removeListing(assessmentId: number): Promise { + return this.client.delete( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`, + ); + } + + index(): Promise< + AxiosResponse<{ + listings: MarketplaceListing[]; + destinationTabs: DestinationTab[]; + canAccess: boolean; + }> + > { + return this.client.get(this.#urlPrefix); + } + + duplicate( + listingIds: number[], + destinationTabId: number | null, + ): Promise { + return this.client.post(`${this.#urlPrefix}/listings/duplicate`, { + listing_ids: listingIds, + ...(destinationTabId ? { destination_tab_id: destinationTabId } : {}), + }); + } + + fetchListing(id: number): Promise { + return this.client.get(`${this.#urlPrefix}/listings/${id}`); + } + + fetchQuestion(listingId: number, questionId: number): Promise { + return this.client.get( + `${this.#urlPrefix}/listings/${listingId}/questions/${questionId}`, + ); + } +} diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 355a5878c53..8087e014bc7 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -18,6 +18,7 @@ import LeaderboardAPI from './Leaderboard'; import LearningMapAPI from './LearningMap'; import LessonPlanAPI from './LessonPlan'; import LevelAPI from './Level'; +import MarketplaceAPI from './Marketplace'; import MaterialFoldersAPI from './MaterialFolders'; import MaterialsAPI from './Materials'; import PersonalTimesAPI from './PersonalTimes'; @@ -55,6 +56,7 @@ const CourseAPI = { learningMap: new LearningMapAPI(), lessonPlan: new LessonPlanAPI(), level: new LevelAPI(), + marketplace: new MarketplaceAPI(), materials: new MaterialsAPI(), materialFolders: new MaterialFoldersAPI(), personalTimes: new PersonalTimesAPI(), diff --git a/client/app/api/system/Admin.ts b/client/app/api/system/Admin.ts index 40eac58adc4..13243af9a80 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 { + AllowlistRulePreviewData, + MarketplaceAccessData, +} from 'types/system/marketplaceAccess'; +import { + AllowlistRuleData, + AllowlistRuleFormData, +} from 'types/system/marketplaceAllowlist'; 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/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx index 3d9b1be88a9..4a9f30ad51e 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx @@ -13,6 +13,8 @@ import { AssessmentDeleteResult, } from 'types/course/assessment/assessments'; +import PublishToMarketplaceButton from 'course/marketplace/components/PublishToMarketplaceButton'; +import marketplaceTranslations from 'course/marketplace/translations'; import DeleteButton from 'lib/components/core/buttons/DeleteButton'; import { PromptText } from 'lib/components/core/dialogs/Prompt'; import Link from 'lib/components/core/Link'; @@ -37,6 +39,9 @@ const AssessmentShowHeader = ( const { t } = useTranslation(); const [deleting, setDeleting] = useState(false); const [inviting, setInviting] = useState(false); + const [publishedToMarketplace, setPublishedToMarketplace] = useState( + assessment.isPublishedToMarketplace, + ); const navigate = useNavigate(); const handleDelete = (): Promise => { @@ -72,7 +77,14 @@ const AssessmentShowHeader = ( onClick={handleDelete} title={t(translations.sureDeletingAssessment)} > - {t(translations.deletingThisAssessment)} + + {t(translations.deletingThisAssessment)} + {assessment.isPublishedToMarketplace && ( + + {t(marketplaceTranslations.deleteWarning)} + + )} + {assessment.title} {t(translations.deleteAssessmentWarning)} @@ -146,6 +158,16 @@ const AssessmentShowHeader = ( )} + {assessment.permissions.canPublishToMarketplace && ( + + )} + {assessment.actionButtonUrl && ( in the delete Prompt whose +// message contains this phrase, rendered only when `isPublishedToMarketplace`. +const MARKETPLACE_WARNING = /removes it from the marketplace/i; + +describe('', () => { + it('warns that deletion removes the marketplace listing when the assessment is listed', async () => { + const page = render( + , + ); + + // First query awaits the i18n LoadingIndicator; subsequent getBy* are sync. + fireEvent.click(await page.findByLabelText('Delete Assessment')); // opens the delete Prompt + expect(page.getByText(MARKETPLACE_WARNING)).toBeVisible(); + }); + + it('shows no marketplace warning when the assessment is not listed', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByLabelText('Delete Assessment')); // delete Prompt still opens + expect(page.queryByText(MARKETPLACE_WARNING)).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx new file mode 100644 index 00000000000..a0a78741b2e --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx @@ -0,0 +1,27 @@ +import { Button } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; + +interface Props { + canImport: boolean; + tabId: number; +} + +const ImportAssessmentsButton = ({ + canImport, + tabId, +}: Props): JSX.Element | null => { + const { t } = useTranslation(); + if (!canImport) return null; + + return ( + + + + ); +}; + +export default ImportAssessmentsButton; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx new file mode 100644 index 00000000000..413b2b75274 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx @@ -0,0 +1,19 @@ +import { render } from 'test-utils'; + +import ImportAssessmentsButton from '../ImportAssessmentsButton'; + +it('links to the marketplace with the given tab as from_tab when the user can import', async () => { + const page = render(); + const link = await page.findByRole('link', { name: 'Import Assessments' }); + expect(link).toHaveAttribute( + 'href', + expect.stringContaining('/marketplace?from_tab=42'), + ); +}); + +it('renders nothing when the user cannot import', () => { + const page = render(); + expect( + page.queryByRole('link', { name: 'Import Assessments' }), + ).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx index fae862370ab..82a28038ba7 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx @@ -9,6 +9,7 @@ import Preload from 'lib/components/wrappers/Preload'; import { fetchAssessments } from '../../operations/assessments'; import AssessmentsTable from './AssessmentsTable'; +import ImportAssessmentsButton from './ImportAssessmentsButton'; import NewAssessmentFormButton from './NewAssessmentFormButton'; const AssessmentsIndex = (): JSX.Element => { @@ -30,17 +31,23 @@ const AssessmentsIndex = (): JSX.Element => { + <> + + + ) } title={data.display.category.title} diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 65286db93ae..8e225e47cbc 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -2043,6 +2043,10 @@ const translations = defineMessages({ id: 'course.assessment.question.programming.liveFeedbackNotSupported', defaultMessage: 'Get Help is not supported for {languageName}.', }, + importAssessments: { + id: 'course.assessment.AssessmentsIndex.importAssessments', + defaultMessage: 'Import Assessments', + }, }); export default translations; diff --git a/client/app/bundles/course/duplication/components/DuplicationAssessmentTree.tsx b/client/app/bundles/course/duplication/components/DuplicationAssessmentTree.tsx new file mode 100644 index 00000000000..5ee4c24b35c --- /dev/null +++ b/client/app/bundles/course/duplication/components/DuplicationAssessmentTree.tsx @@ -0,0 +1,143 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Tooltip } from 'react-tooltip'; +import { Card, CardContent } from '@mui/material'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; +import useTranslation from 'lib/hooks/useTranslation'; + +import TypeBadge from './TypeBadge'; +import UnpublishedIcon from './UnpublishedIcon'; + +export interface DuplicationTreeCategory { + id: number; + title: string; +} +export interface DuplicationTreeTab { + id: number; + title: string; +} +export interface DuplicationTreeAssessment { + id: number; + title: string; +} +export interface DuplicationAssessmentTreeNode { + category: DuplicationTreeCategory | null; + tabs: Array<{ + tab: DuplicationTreeTab | null; + assessments: DuplicationTreeAssessment[]; + }>; +} + +interface Props { + nodes: DuplicationAssessmentTreeNode[]; +} + +// IDs kept identical to the strings previously defined in AssessmentsListing / +// DuplicateItemsConfirmation so locales/en.json needs no re-translation. +const translations = defineMessages({ + defaultCategory: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultCategory', + defaultMessage: 'Default Category', + }, + defaultTab: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultTab', + defaultMessage: 'Default Tab', + }, + itemUnpublished: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.itemUnpublished', + defaultMessage: + 'Items are duplicated as unpublished when duplicating to an existing course.', + }, +}); + +const DuplicationAssessmentTree: FC = ({ nodes }) => { + const { t } = useTranslation(); + + const renderAssessmentRow = ( + assessment: DuplicationTreeAssessment, + ): JSX.Element => ( + + + + {assessment.title} + + } + /> + ); + + const renderTabTree = ( + tab: DuplicationTreeTab | null, + assessments: DuplicationTreeAssessment[], + ): JSX.Element => ( +
+ {tab ? ( + + + {tab.title} + + } + /> + ) : ( + + )} + {assessments.map(renderAssessmentRow)} +
+ ); + + const renderNode = ( + node: DuplicationAssessmentTreeNode, + index: number, + ): JSX.Element => ( + + + {node.category ? ( + + + {node.category.title} + + } + /> + ) : ( + + )} + {node.tabs.map(({ tab, assessments }) => + renderTabTree(tab, assessments), + )} + + + ); + + if (nodes.length === 0) return null; + + return ( + <> + {nodes.map(renderNode)} + {t(translations.itemUnpublished)} + + ); +}; + +export default DuplicationAssessmentTree; diff --git a/client/app/bundles/course/duplication/components/TypeBadge/index.tsx b/client/app/bundles/course/duplication/components/TypeBadge/index.tsx index 1f4add68ec4..d1eeb171df3 100644 --- a/client/app/bundles/course/duplication/components/TypeBadge/index.tsx +++ b/client/app/bundles/course/duplication/components/TypeBadge/index.tsx @@ -45,15 +45,18 @@ const translations: Record = }, }); -const TypeBadge: FC<{ text?: string; itemType: DuplicableItemType }> = ({ - text, - itemType, -}) => { +const TypeBadge: FC<{ + text?: string; + itemType: DuplicableItemType; + dense?: boolean; +}> = ({ text, itemType, dense = false }) => { const { t } = useTranslation(); return ( diff --git a/client/app/bundles/course/duplication/components/__test__/DuplicationAssessmentTree.test.tsx b/client/app/bundles/course/duplication/components/__test__/DuplicationAssessmentTree.test.tsx new file mode 100644 index 00000000000..313ec577019 --- /dev/null +++ b/client/app/bundles/course/duplication/components/__test__/DuplicationAssessmentTree.test.tsx @@ -0,0 +1,47 @@ +import { render } from 'test-utils'; + +import DuplicationAssessmentTree from '../DuplicationAssessmentTree'; + +it('renders category, tab and assessment rows with badges', async () => { + const page = render( + , + ); + + // I18nProvider shows a LoadingIndicator until locale messages async-load; + // await the first query to render past it, then the rest are synchronous. + expect(await page.findByText('Missions')).toBeVisible(); + expect(page.getByText('Assignments')).toBeVisible(); + expect(page.getByText('Mission 1')).toBeVisible(); + expect(page.getByText('Category')).toBeVisible(); + expect(page.getByText('Tab')).toBeVisible(); + expect(page.getByText('Assessment')).toBeVisible(); +}); + +it('renders disabled default placeholders when category/tab are null', async () => { + const page = render( + , + ); + + expect(await page.findByText('Default Category')).toBeVisible(); + expect(page.getByText('Default Tab')).toBeVisible(); + expect(page.getByText('Mission 1')).toBeVisible(); +}); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx index 4799c28d3b3..4967ea8acd1 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx @@ -1,135 +1,25 @@ import { FC } from 'react'; -import { defineMessages } from 'react-intl'; -import { Card, CardContent, ListSubheader } from '@mui/material'; +import { ListSubheader } from '@mui/material'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import DuplicationAssessmentTree, { + DuplicationAssessmentTreeNode, +} from 'course/duplication/components/DuplicationAssessmentTree'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { DuplicationAssessmentData, - DuplicationCategoryData, DuplicationTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; -import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -const translations = defineMessages({ - defaultCategory: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultCategory', - defaultMessage: 'Default Category', - }, - defaultTab: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultTab', - defaultMessage: 'Default Tab', - }, -}); - const AssessmentsListing: FC = () => { const { assessmentsComponent: categories, selectedItems } = useAppSelector( selectDuplicationStore, ); const { t } = useTranslation(); - const renderAssessmentRow = ( - assessment: DuplicationAssessmentData, - ): JSX.Element => ( - - - - {assessment.title} - - } - /> - ); - - const renderTabRow = (tab: DuplicationTabData): JSX.Element => ( - - - {tab.title} - - } - /> - ); - - const renderCategoryRow = ( - category: DuplicationCategoryData, - ): JSX.Element => ( - - - {category.title} - - } - /> - ); - - const renderTabTree = ( - tab: DuplicationTabData | null, - children: DuplicationAssessmentData[], - ): JSX.Element => ( -
- {tab ? ( - renderTabRow(tab) - ) : ( - - )} - {children.length > 0 && children.map(renderAssessmentRow)} -
- ); - - const renderCategoryCard = ( - category: DuplicationCategoryData | null, - orphanTabs: DuplicationTabData[], - orphanAssessments: DuplicationAssessmentData[], - ): JSX.Element => { - const tabsTrees = (tabs: DuplicationTabData[]): JSX.Element[] => - tabs.map((tab) => renderTabTree(tab, tab.assessments)); - - return ( - - - {category ? ( - renderCategoryRow(category) - ) : ( - - )} - {orphanAssessments.length > 0 && - renderTabTree(null, orphanAssessments)} - {orphanTabs.length > 0 && tabsTrees(orphanTabs)} - {category && tabsTrees(category.tabs)} - - - ); - }; - - // Identifies connected subtrees of selected categories, tabs and assessments. - const categoriesTrees: DuplicationCategoryData[] = []; + const categoriesTrees: DuplicationCategoryLike[] = []; const tabTrees: DuplicationTabData[] = []; const assessmentTrees: DuplicationAssessmentData[] = []; @@ -156,16 +46,48 @@ const AssessmentsListing: FC = () => { const orphanTreesCount = tabTrees.length + assessmentTrees.length; if (orphanTreesCount + categoriesTrees.length < 1) return null; + const nodes: DuplicationAssessmentTreeNode[] = [ + ...categoriesTrees.map((category) => ({ + category: { id: category.id, title: category.title }, + tabs: category.tabs.map((tab) => ({ + tab: { id: tab.id, title: tab.title }, + assessments: tab.assessments, + })), + })), + ...(orphanTreesCount > 0 + ? [ + { + category: null, + tabs: [ + // Orphan assessments render first (matches prior output order), + // then orphan tabs. + ...(assessmentTrees.length > 0 + ? [{ tab: null, assessments: assessmentTrees }] + : []), + ...tabTrees.map((tab) => ({ + tab: { id: tab.id, title: tab.title }, + assessments: tab.assessments, + })), + ], + }, + ] + : []), + ]; + return ( <> {t(componentTranslations.course_assessments_component)} - {categoriesTrees.map((category) => renderCategoryCard(category, [], []))} - {orphanTreesCount > 0 && - renderCategoryCard(null, tabTrees, assessmentTrees)} + ); }; +interface DuplicationCategoryLike { + id: number; + title: string; + tabs: DuplicationTabData[]; +} + export default AssessmentsListing; diff --git a/client/app/bundles/course/marketplace/__test__/fromTab.test.ts b/client/app/bundles/course/marketplace/__test__/fromTab.test.ts new file mode 100644 index 00000000000..d5d5bce8acd --- /dev/null +++ b/client/app/bundles/course/marketplace/__test__/fromTab.test.ts @@ -0,0 +1,29 @@ +import { readFromTab, withFromTab } from '../fromTab'; + +describe('withFromTab', () => { + it('appends from_tab as the first query param when the path has none', () => { + expect(withFromTab('/courses/1/marketplace', '42')).toBe( + '/courses/1/marketplace?from_tab=42', + ); + }); + + it('appends from_tab with & when the path already has a query string', () => { + expect(withFromTab('/p/1?foo=bar', '42')).toBe('/p/1?foo=bar&from_tab=42'); + }); + + it('returns the path unchanged when from_tab is null', () => { + expect(withFromTab('/courses/1/marketplace', null)).toBe( + '/courses/1/marketplace', + ); + }); +}); + +describe('readFromTab', () => { + it('extracts from_tab from a search string', () => { + expect(readFromTab('?from_tab=42&x=1')).toBe('42'); + }); + + it('returns null when from_tab is absent', () => { + expect(readFromTab('?x=1')).toBeNull(); + }); +}); diff --git a/client/app/bundles/course/marketplace/__test__/handles.test.ts b/client/app/bundles/course/marketplace/__test__/handles.test.ts new file mode 100644 index 00000000000..8438e419dd1 --- /dev/null +++ b/client/app/bundles/course/marketplace/__test__/handles.test.ts @@ -0,0 +1,77 @@ +import { Location } from 'react-router-dom'; + +import { CrumbPath } from 'lib/hooks/router/dynamicNest'; + +import { listingHandle, marketplaceHandle } from '../handles'; +import { fetchListing } from '../operations'; + +// The handles always return a `{ getData }` request (never a bare title/null), so narrow the +// DataHandle union to read getData directly. +interface WithGetData { + getData: () => T; +} + +jest.mock('../operations'); + +const asMatch = ( + pathname: string, + params: Record = {}, +): { id: string; pathname: string; params: typeof params; data: unknown } => ({ + id: '', + pathname, + params, + data: undefined, +}); + +const asLocation = (search: string): Location => ({ + pathname: '', + search, + hash: '', + state: null, + key: '', +}); + +describe('marketplaceHandle', () => { + it('links the crumb to the marketplace path carrying from_tab', () => { + const handle = marketplaceHandle( + asMatch('/courses/1/marketplace'), + asLocation('?from_tab=42'), + ) as WithGetData; + + expect(handle.getData()).toEqual({ + content: { + title: expect.anything(), + url: '/courses/1/marketplace?from_tab=42', + }, + }); + }); + + it('links the crumb to the bare marketplace path when there is no from_tab', () => { + const handle = marketplaceHandle( + asMatch('/courses/1/marketplace'), + asLocation(''), + ) as WithGetData; + + expect(handle.getData()).toEqual({ + content: { title: expect.anything(), url: '/courses/1/marketplace' }, + }); + }); +}); + +describe('listingHandle', () => { + it('resolves the listing title and links the crumb carrying from_tab', async () => { + (fetchListing as jest.Mock).mockResolvedValue({ title: 'Graph Theory' }); + + const handle = listingHandle( + asMatch('/courses/1/marketplace/listings/7', { listingId: '7' }), + asLocation('?from_tab=42'), + ) as WithGetData>; + + await expect(handle.getData()).resolves.toEqual({ + content: { + title: 'Graph Theory', + url: '/courses/1/marketplace/listings/7?from_tab=42', + }, + }); + }); +}); diff --git a/client/app/bundles/course/marketplace/components/DestinationTabPicker.tsx b/client/app/bundles/course/marketplace/components/DestinationTabPicker.tsx new file mode 100644 index 00000000000..b0f90d854ca --- /dev/null +++ b/client/app/bundles/course/marketplace/components/DestinationTabPicker.tsx @@ -0,0 +1,93 @@ +import { FC } from 'react'; +import { + Card, + CardContent, + FormControlLabel, + Radio, + RadioGroup, +} from '@mui/material'; + +import TypeBadge from 'course/duplication/components/TypeBadge'; + +import { DestinationTab } from '../types'; + +interface Group { + categoryId: number; + categoryTitle: string; + tabs: DestinationTab[]; +} + +interface DestinationTabPickerProps { + tabs: DestinationTab[]; + value: number | null; + onChange: (tabId: number) => void; +} + +// Group tabs by category in first-seen order (the controller already emits categories then their +// tabs in display order, so this preserves that without re-sorting). Robust to a category's tabs +// arriving non-contiguously. +const groupByCategory = (tabs: DestinationTab[]): Group[] => { + const groups: Group[] = []; + const indexByCategory = new Map(); + tabs.forEach((tab) => { + const existing = indexByCategory.get(tab.categoryId); + if (existing === undefined) { + indexByCategory.set(tab.categoryId, groups.length); + groups.push({ + categoryId: tab.categoryId, + categoryTitle: tab.categoryTitle, + tabs: [tab], + }); + } else { + groups[existing].tabs.push(tab); + } + }); + return groups; +}; + +const DestinationTabPicker: FC = ({ + tabs, + value, + onChange, +}) => { + const groups = groupByCategory(tabs); + + return ( + + + onChange(Number(e.target.value))} + value={value != null ? String(value) : ''} + > + {groups.map((group) => ( +
+
+ + {group.categoryTitle} +
+ {group.tabs.map((tab) => ( + } + label={ + + + {tab.title} + + } + value={String(tab.id)} + /> + ))} +
+ ))} +
+
+
+ ); +}; + +export default DestinationTabPicker; diff --git a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx new file mode 100644 index 00000000000..06ec2ea5c5a --- /dev/null +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from 'react'; +import { Tooltip } from 'react-tooltip'; +import { Card, CardContent, ListSubheader } from '@mui/material'; + +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import Prompt from 'lib/components/core/dialogs/Prompt'; +import Link from 'lib/components/core/Link'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { duplicateListings } from '../operations'; +import translations from '../translations'; +import { DestinationTab, MarketplaceListing } from '../types'; + +import DestinationTabPicker from './DestinationTabPicker'; + +interface Props { + listings: Pick[]; + destinationTabs: DestinationTab[]; + initialDestinationTabId: number | null; + destinationCourse: { title: string; url: string }; + open: boolean; + onClose: () => void; +} + +const DuplicateConfirmation = ({ + listings, + destinationTabs, + initialDestinationTabId, + destinationCourse, + open, + onClose, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [submitting, setSubmitting] = useState(false); + + // The selected tab defaults to the `from_tab` the user launched from, but only when it names a + // real tab in this course; otherwise the course's first tab. So exactly one existing tab is + // always selected, and an unknown/absent from_tab yields null (backend then defaults) rather than + // a phantom id. + const resolveInitial = (): number | null => { + if ( + initialDestinationTabId != null && + destinationTabs.some((tab) => tab.id === initialDestinationTabId) + ) { + return initialDestinationTabId; + } + return destinationTabs[0]?.id ?? null; + }; + + const [selectedTabId, setSelectedTabId] = useState( + resolveInitial(), + ); + + // The pages keep this component mounted and only flip `open`, so `selectedTabId` outlives a close + // — re-seed it each time the dialog opens, or a tab the user picked and then walked away from + // would still be selected next time. + // + // Deps are `[open]` on purpose. Adding `destinationTabs` would compare it by identity, so any + // parent re-render passing a fresh array would re-fire this and reset the radio out from under a + // user mid-decision. Reopening is the only moment the selection should be re-seeded. + useEffect(() => { + if (!open) return; + setSelectedTabId(resolveInitial()); + }, [open]); + + const confirm = async (): Promise => { + setSubmitting(true); + await duplicateListings( + listings.map((l) => l.id), + selectedTabId, + // This is pollJob's *completion* callback — the job has finished by now. `redirectUrl` points + // at the destination tab; it is optional on JobCompleted, so the link is conditional. + (redirectUrl) => { + toast.success( + <> + {t(translations.duplicateCompleted, { n: listings.length })} + {redirectUrl && ( + <> + {' '} + + {t(translations.viewDuplicatedAssessment)} + + + )} + , + ); + setSubmitting(false); + onClose(); + }, + () => { + toast.error(t(translations.duplicateFailed, { n: listings.length })); + setSubmitting(false); + }, + ); + }; + + return ( + + + {t(translations.destinationCourse)} + + + + + {destinationCourse.title} + + + + + + {t(translations.pickDestinationTab)} + + + + {t(translations.duplicating)} + + + {listings.map((listing) => ( +
+ + + {listing.title} +
+ ))} +
+
+ + {t(translations.itemUnpublished)} + +
+ ); +}; + +export default DuplicateConfirmation; diff --git a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx new file mode 100644 index 00000000000..f016ac17033 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Button } from '@mui/material'; +import { AssessmentData } from 'types/course/assessment/assessments'; + +import CourseAPI from 'api/course'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../translations'; + +interface Props { + assessment: Pick< + AssessmentData, + 'id' | 'isPublishedToMarketplace' | 'permissions' + >; + onChange: (published: boolean) => void; +} + +const PublishToMarketplaceButton = ({ + assessment, + onChange, +}: Props): JSX.Element | null => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const listed = assessment.isPublishedToMarketplace; + + if (!assessment.permissions.canPublishToMarketplace) return null; + + const confirm = async (): Promise => { + setSubmitting(true); + try { + if (listed) { + await CourseAPI.marketplace.removeListing(assessment.id); + toast.success(t(translations.removed)); + onChange(false); + } else { + await CourseAPI.marketplace.publishListing(assessment.id); + toast.success(t(translations.published)); + onChange(true); + } + setOpen(false); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + setOpen(false)} + open={open} + primaryColor={listed ? 'error' : 'primary'} + primaryLabel={t(listed ? translations.remove : translations.publish)} + title={t( + listed + ? translations.removeConfirmTitle + : translations.publishConfirmTitle, + )} + > + + {t( + listed + ? translations.removeConfirmBody + : translations.publishConfirmBody, + )} + + + + ); +}; + +export default PublishToMarketplaceButton; diff --git a/client/app/bundles/course/marketplace/components/__test__/DestinationTabPicker.test.tsx b/client/app/bundles/course/marketplace/components/__test__/DestinationTabPicker.test.tsx new file mode 100644 index 00000000000..b78449abfbf --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/DestinationTabPicker.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render } from 'test-utils'; + +import DestinationTabPicker from '../DestinationTabPicker'; + +const tabs = [ + { id: 10, title: 'Tutorials', categoryId: 1, categoryTitle: 'Week 3' }, + { id: 11, title: 'Problem Sets', categoryId: 1, categoryTitle: 'Week 3' }, + { id: 20, title: 'Lab', categoryId: 2, categoryTitle: 'Week 4' }, +]; + +it('renders an empty radio group when there are no tabs', async () => { + const page = render( + , + ); + + expect(await page.findByRole('radiogroup')).toBeEmptyDOMElement(); +}); + +it('groups tabs under one header per category and renders a radio per tab', async () => { + const page = render( + , + ); + + // I18nProvider (TypeBadge uses it) async-loads messages, so await the first query. + expect(await page.findByText('Week 3')).toBeVisible(); + expect(page.getByText('Week 4')).toBeVisible(); + // The two Week 3 tabs share a single header. + expect(page.getAllByText('Week 3')).toHaveLength(1); + expect(page.getAllByRole('radio')).toHaveLength(3); + // Headers are badged as categories, radios as tabs. RTL's text matcher only sees an element's + // direct text-node children, so TypeBadge's Typography matches 'Category'/'Tab' on its own. + expect(page.getAllByText('Category')).toHaveLength(2); + expect(page.getAllByText('Tab')).toHaveLength(3); +}); + +// The fixture is interleaved AND in descending categoryId order, so first-seen order and any +// sorted order disagree — the flat `tabs` fixture above cannot tell them apart. +it('groups a category under its first-seen header when its tabs arrive non-contiguously', async () => { + const interleavedTabs = [ + { id: 20, title: 'Lab', categoryId: 2, categoryTitle: 'Week 4' }, + { id: 10, title: 'Tutorials', categoryId: 1, categoryTitle: 'Week 3' }, + { id: 21, title: 'Recitation', categoryId: 2, categoryTitle: 'Week 4' }, + ]; + + const page = render( + , + ); + + // Week 4's two tabs are split by a Week 3 tab, but still share one header. + expect(await page.findAllByText('Week 4')).toHaveLength(1); + expect(page.getAllByText('Week 3')).toHaveLength(1); + // Week 4 is seen first, so its group (and both its tabs) comes first. + expect( + page.getAllByRole('radio').map((radio) => radio.getAttribute('value')), + ).toEqual(['20', '21', '10']); +}); + +it('marks the tab whose id equals value as checked', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('radio', { name: /Problem Sets/ }), + ).toBeChecked(); + expect(page.getByRole('radio', { name: /Tutorials/ })).not.toBeChecked(); + expect(page.getByRole('radio', { name: /Lab/ })).not.toBeChecked(); +}); + +it('checks no tab when value is null', async () => { + const page = render( + , + ); + + expect(await page.findAllByRole('radio')).toHaveLength(3); + page + .getAllByRole('radio') + .forEach((radio) => expect(radio).not.toBeChecked()); +}); + +it('fires onChange with the numeric tab id when another tab is chosen', async () => { + const onChange = jest.fn(); + const page = render( + , + ); + + fireEvent.click(await page.findByRole('radio', { name: /Lab/ })); + + expect(onChange).toHaveBeenCalledWith(20); +}); + +it('does not move the selection itself when a tab is clicked', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByRole('radio', { name: /Lab/ })); + + // Controlled: the parent still says 11, so the checkmark must not move. + expect(page.getByRole('radio', { name: /Problem Sets/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Lab/ })).not.toBeChecked(); +}); diff --git a/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx b/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx new file mode 100644 index 00000000000..afbac3070aa --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx @@ -0,0 +1,494 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor } from 'test-utils'; +import TestApp from 'utilities/TestApp'; + +import GlobalAPI from 'api'; +import CourseAPI from 'api/course'; +import toast from 'lib/hooks/toast'; + +import DuplicateConfirmation from '../DuplicateConfirmation'; + +// The toast message is a ReactNode (it carries a link), so capture it and render it rather than +// mounting a ToastContainer. +jest.mock('lib/hooks/toast', () => ({ success: jest.fn(), error: jest.fn() })); + +const mock = createMockAdapter(CourseAPI.marketplace.client); +// pollJob polls the *jobs* endpoint, which lives on a different axios client to the marketplace API. +const jobsMock = createMockAdapter(GlobalAPI.jobs.client); + +beforeEach(() => { + mock.reset(); + jobsMock.reset(); + jest.clearAllMocks(); +}); + +const LISTING_TITLE = 'Recursion Drills'; +const listings = [{ id: 1, title: LISTING_TITLE }]; +const url = `/courses/${global.courseId}/marketplace/listings/duplicate`; +const course = { title: 'Enrollable Course', url: '/courses/4' }; +const REDIRECT_URL = '/courses/4/assessments?category=5&tab=42'; +const destinationTabs = [ + { id: 41, title: 'Tutorials', categoryId: 5, categoryTitle: 'Missions' }, + { id: 42, title: 'Assignments', categoryId: 5, categoryTitle: 'Missions' }, +]; + +const props = { + destinationCourse: course, + destinationTabs, + initialDestinationTabId: 42, + listings, + onClose: jest.fn(), +}; + +const successToastTexts = (): string[] => + (toast.success as unknown as jest.Mock).mock.calls.map(([message]) => { + if (typeof message === 'string') return message; + + const children = (message as { props?: { children?: unknown } }).props + ?.children; + if (Array.isArray(children)) { + return children.filter((child) => typeof child === 'string').join(''); + } + + return typeof children === 'string' ? children : ''; + }); + +it('forgets an abandoned selection and re-seeds the initial tab when reopened', async () => { + const page = render(); + + expect(await page.findByRole('radio', { name: /Assignments/ })).toBeChecked(); + + // The user picks a different tab, then dismisses the dialog without confirming. + fireEvent.click(page.getByRole('radio', { name: /Tutorials/ })); + expect(page.getByRole('radio', { name: /Tutorials/ })).toBeChecked(); + + // The page keeps this component mounted and only flips `open`, so `selectedTabId` outlives the + // close — which is the entire reason the re-seeding effect exists. + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + page.rerender( + + + , + ); + + // Reopening starts from the tab the user launched from, not the choice they walked away from. + expect(await page.findByRole('radio', { name: /Assignments/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Tutorials/ })).not.toBeChecked(); +}); + +it('keeps the user’s selection across a re-render while the dialog stays open', async () => { + const page = render(); + + fireEvent.click(await page.findByRole('radio', { name: /Tutorials/ })); + expect(page.getByRole('radio', { name: /Tutorials/ })).toBeChecked(); + + // A parent re-render must not re-seed the selection out from under the user mid-decision. + page.rerender( + + + , + ); + + expect(await page.findByRole('radio', { name: /Tutorials/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Assignments/ })).not.toBeChecked(); +}); + +it('shows the destination course, the tab picker, and the duplicating list', async () => { + const page = render( + , + ); + + // I18nProvider shows a LoadingIndicator until locale messages async-load; await the first query + // to render past it, then the rest are synchronous. + expect(await page.findByText('Duplicate items?')).toBeVisible(); + + expect(page.getByText('Destination Course')).toBeVisible(); + expect(page.getByRole('link', { name: 'Enrollable Course' })).toHaveAttribute( + 'href', + '/courses/4', + ); + + expect(page.getByText('Pick destination tab')).toBeVisible(); + expect(page.getByText('Missions')).toBeVisible(); + expect(page.getByText('Tutorials')).toBeVisible(); + expect(page.getByText('Assignments')).toBeVisible(); + + expect(page.getByText('Duplicating')).toBeVisible(); + expect(page.getByText(LISTING_TITLE)).toBeVisible(); +}); + +it('stacks destination tabs vertically with large category and tab text', async () => { + const page = render( + , + ); + + expect(await page.findByText('Duplicate items?')).toBeVisible(); + + expect(page.getByText('Missions').closest('div')).toHaveClass('text-xl'); + + const assignments = page.getByRole('radio', { name: /Assignments/ }); + const tutorials = page.getByRole('radio', { name: /Tutorials/ }); + + expect(assignments.closest('label')?.parentElement).toHaveClass( + 'flex', + 'flex-col', + 'items-start', + ); + expect(assignments.closest('label')).toHaveClass('text-xl'); + expect(tutorials.closest('label')).toHaveClass('text-xl'); +}, 10000); + +// Pins the ⊘ icon and its wiring to the tooltip. NOT the tooltip copy: react-tooltip v5 renders +// nothing until shown, and hovering the anchor does not mount its content under jsdom (verified — +// `fireEvent.mouseEnter` + `findByText` on the message times out). So assert the wiring, which is +// what a dropped `tooltipId` or a dropped would break. +it('badges each item as an assessment and marks it as arriving unpublished', async () => { + const page = render( + , + ); + + expect(await page.findByText(LISTING_TITLE)).toBeVisible(); + expect(page.getByText('Assessment')).toBeVisible(); + expect(page.getByTestId('BlockIcon')).toHaveAttribute( + 'data-tooltip-id', + 'itemUnpublished', + ); +}); + +it('pre-selects the tab the user came from', async () => { + const page = render( + , + ); + + expect(await page.findByRole('radio', { name: /Assignments/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Tutorials/ })).not.toBeChecked(); +}); + +it('falls back to the first tab when the initial id is not a real tab', async () => { + const page = render( + , + ); + + expect(await page.findByRole('radio', { name: /Tutorials/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Assignments/ })).not.toBeChecked(); +}); + +it('falls back to the first tab when entered without a from_tab', async () => { + const page = render( + , + ); + + expect(await page.findByRole('radio', { name: /Tutorials/ })).toBeChecked(); +}); + +it('posts the pre-selected destination tab on confirm', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + const page = render( + , + ); + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + listing_ids: [1], + destination_tab_id: 42, + }); +}); + +it('posts the newly chosen tab after the user changes the selection', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + const page = render( + , + ); + + fireEvent.click(await page.findByRole('radio', { name: /Tutorials/ })); + fireEvent.click(page.getByRole('button', { name: /Duplicate/ })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + listing_ids: [1], + destination_tab_id: 41, + }); +}); + +it('omits the destination tab entirely when the course has no tabs to pick from', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + + const page = render( + , + ); + + expect(await page.findByText('Recursion Drills')).toBeVisible(); + expect(page.queryAllByRole('radio')).toHaveLength(0); + + fireEvent.click(page.getByRole('button', { name: /Duplicate/ })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + const body = JSON.parse(mock.history.post[0].data); + expect(body).toMatchObject({ listing_ids: [1] }); + // There is no tab to name, so the key must be absent and the backend picks its own default. + expect(body).not.toHaveProperty('destination_tab_id'); +}); + +it('duplicates every selected listing and pluralises the completion toast', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock.onGet('/jobs/9').reply(200, { status: 'completed' }); + + const page = render( + , + ); + + expect(await page.findByText(LISTING_TITLE)).toBeVisible(); + expect(page.getByText('Graph Traversals')).toBeVisible(); + + fireEvent.click(page.getByRole('button', { name: /Duplicate/ })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + listing_ids: [1, 2], + }); + + await waitFor( + () => expect(successToastTexts()).toContain('Assessments duplicated.'), + { timeout: 6000 }, + ); +}, 10000); + +// The toast fires from pollJob's COMPLETION callback, so it must not claim the work has merely +// "started" — and it must surface the redirectUrl that callback receives, which the dialog used to +// throw away, leaving the user with no idea where the duplicate landed. +it('reports completion and links to where the assessment landed', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock + .onGet('/jobs/9') + .reply(200, { status: 'completed', redirectUrl: REDIRECT_URL }); + + const page = render( + , + ); + + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + + // pollJob polls every 2s — longer than waitFor's 1s default. + await waitFor(() => expect(toast.success).toHaveBeenCalled(), { + timeout: 6000, + }); + + const message = (toast.success as unknown as jest.Mock).mock.calls[0][0]; + const toasted = render(
{message}
); + + expect(await toasted.findByText(/Assessment duplicated\./)).toBeVisible(); + expect(toasted.queryByText(/started/i)).not.toBeInTheDocument(); + expect( + toasted.getByRole('link', { name: 'View assessment' }), + ).toHaveAttribute('href', REDIRECT_URL); +}, 10000); + +it('closes itself once the duplication completes', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock + .onGet('/jobs/9') + .reply(200, { status: 'completed', redirectUrl: REDIRECT_URL }); + const onClose = jest.fn(); + + const page = render( + , + ); + + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + + // The dialog must dismiss itself on completion — the toast (with its link) is what remains. + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1), { + timeout: 6000, + }); +}, 10000); + +it('omits the link when the job returns no redirect url', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock.onGet('/jobs/9').reply(200, { status: 'completed' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + + await waitFor(() => expect(toast.success).toHaveBeenCalled(), { + timeout: 6000, + }); + + const message = (toast.success as unknown as jest.Mock).mock.calls[0][0]; + const toasted = render(
{message}
); + + expect(await toasted.findByText(/Assessment duplicated\./)).toBeVisible(); + expect( + toasted.queryByRole('link', { name: 'View assessment' }), + ).not.toBeInTheDocument(); +}, 10000); + +// Guards the reworded failure copy — the old string was a malformed gerund +// ("Duplicating assessment failed."). +it('reports a failed duplication in plain language', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock.onGet('/jobs/9').reply(200, { status: 'errored' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + + await waitFor(() => expect(toast.error).toHaveBeenCalled(), { + timeout: 6000, + }); + + expect(toast.error).toHaveBeenCalledWith( + 'Could not duplicate the assessment.', + ); + expect(toast.success).not.toHaveBeenCalled(); +}, 10000); + +it('locks the dialog while the job runs, then unlocks it without closing if the job fails', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock.onGet('/jobs/9').reply(200, { status: 'errored' }); + const onClose = jest.fn(); + + const page = render( + , + ); + + const duplicate = await page.findByRole('button', { name: /Duplicate/ }); + fireEvent.click(duplicate); + + // `Prompt` applies `disabled` to the cancel button as well as the primary one, so an in-flight + // job can be neither double-submitted nor abandoned halfway. + expect(duplicate).toBeDisabled(); + expect(page.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + fireEvent.click(duplicate); + + await waitFor(() => expect(toast.error).toHaveBeenCalled(), { + timeout: 6000, + }); + + expect(mock.history.post).toHaveLength(1); + + // A failed job must leave the dialog open and usable, so the user can retry. + await waitFor(() => expect(duplicate).toBeEnabled()); + expect(page.getByRole('button', { name: 'Cancel' })).toBeEnabled(); + expect(onClose).not.toHaveBeenCalled(); +}, 10000); diff --git a/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx b/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx new file mode 100644 index 00000000000..525bfec613e --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx @@ -0,0 +1,74 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import PublishToMarketplaceButton from '../PublishToMarketplaceButton'; + +const confirmInDialog = async ( + page: ReturnType, + name: RegExp, +): Promise => { + const dialog = await page.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name })); +}; + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +const assessmentAt = ( + isPublishedToMarketplace: boolean, + canPublishToMarketplace = true, +): never => + ({ + id: 5, + isPublishedToMarketplace, + permissions: { canPublishToMarketplace }, + }) as never; + +const url = `/courses/${global.courseId}/assessments/5/marketplace_listing`; + +it('renders nothing when the user cannot publish', () => { + const page = render( + , + ); + expect(page.queryByText('Publish to Marketplace')).not.toBeInTheDocument(); + expect(page.queryByText('Remove from Marketplace')).not.toBeInTheDocument(); +}); + +it('publishes after confirming and reports published=true', async () => { + mock.onPost(url).reply(200, { published: true }); + const onChange = jest.fn(); + const page = render( + , + ); + + // findByText: test-utils wraps the tree in a translations Suspense whose fallback is a + // LoadingIndicator; the trigger button only exists after messages resolve. + fireEvent.click(await page.findByText('Publish to Marketplace')); // trigger button + await confirmInDialog(page, /Publish to Marketplace/); // primary button inside the Prompt + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(onChange).toHaveBeenCalledWith(true); +}); + +it('removes after confirming when already listed, reports published=false', async () => { + mock.onDelete(url).reply(200); + const onChange = jest.fn(); + const page = render( + , + ); + + fireEvent.click(await page.findByText('Remove from Marketplace')); // trigger button + await confirmInDialog(page, /Remove from Marketplace/); // primary button inside the Prompt + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(onChange).toHaveBeenCalledWith(false); +}); diff --git a/client/app/bundles/course/marketplace/fromTab.ts b/client/app/bundles/course/marketplace/fromTab.ts new file mode 100644 index 00000000000..81d260005fe --- /dev/null +++ b/client/app/bundles/course/marketplace/fromTab.ts @@ -0,0 +1,15 @@ +// `from_tab` is the assessment tab the user came from when they clicked "Import assessments". +// It rides along in the URL through the whole browse flow (index → listing → question preview and +// back via breadcrumbs) so duplication imports into that origin tab no matter how the user +// navigates. Every intra-marketplace link routes its path through `withFromTab` so the param is +// never silently dropped. +export const FROM_TAB_PARAM = 'from_tab'; + +export const readFromTab = (search: string): string | null => + new URLSearchParams(search).get(FROM_TAB_PARAM); + +export const withFromTab = (path: string, fromTab: string | null): string => { + if (!fromTab) return path; + const separator = path.includes('?') ? '&' : '?'; + return `${path}${separator}${FROM_TAB_PARAM}=${fromTab}`; +}; diff --git a/client/app/bundles/course/marketplace/handles.ts b/client/app/bundles/course/marketplace/handles.ts new file mode 100644 index 00000000000..7b8ccc3a9ff --- /dev/null +++ b/client/app/bundles/course/marketplace/handles.ts @@ -0,0 +1,49 @@ +import { getIdFromUnknown } from 'utilities'; + +import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +import { readFromTab, withFromTab } from './fromTab'; +import { fetchListing, fetchQuestion } from './operations'; +import translations from './translations'; + +// Both crumbs link to their own route's pathname, but carry the browse flow's `from_tab` forward +// so returning to the marketplace/listing preserves the origin-tab context (see ./fromTab). +export const marketplaceHandle: DataHandle = (match, location) => { + const fromTab = readFromTab(location.search); + return { + getData: (): CrumbPath => ({ + // Descriptor title; Breadcrumbs runs t() on it. + content: { + title: translations.pageTitle, + url: withFromTab(match.pathname, fromTab), + }, + }), + }; +}; + +export const listingHandle: DataHandle = (match, location) => { + const listingId = getIdFromUnknown(match.params?.listingId); + if (!listingId) throw new Error(`Invalid listing id: ${listingId}`); + const fromTab = readFromTab(location.search); + return { + getData: async (): Promise => ({ + content: { + title: (await fetchListing(listingId)).title, + url: withFromTab(match.pathname, fromTab), + }, + }), + }; +}; + +export const questionHandle: DataHandle = (match) => { + const listingId = getIdFromUnknown(match.params?.listingId); + const questionId = getIdFromUnknown(match.params?.questionId); + if (!listingId || !questionId) + throw new Error('Invalid marketplace question route'); + return { + getData: async (): Promise => { + const q = await fetchQuestion(listingId, questionId); + return q.title ? `${q.defaultTitle}: ${q.title}` : q.defaultTitle; + }, + }; +}; diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts new file mode 100644 index 00000000000..ff0303ce4a8 --- /dev/null +++ b/client/app/bundles/course/marketplace/operations.ts @@ -0,0 +1,52 @@ +import CourseAPI from 'api/course'; +import pollJob from 'lib/helpers/jobHelpers'; + +import { + ListingPreviewData, + MarketplaceIndexData, + QuestionPreviewData, +} from './types'; + +export const fetchListings = async (): Promise => { + const response = await CourseAPI.marketplace.index(); + return { + listings: (response.data.listings ?? + []) as MarketplaceIndexData['listings'], + destinationTabs: (response.data.destinationTabs ?? + []) as MarketplaceIndexData['destinationTabs'], + }; +}; + +export const duplicateListings = async ( + listingIds: number[], + destinationTabId: number | null, + onSuccess: (redirectUrl?: string) => void, + onFailure: () => void, +): Promise => { + const response = await CourseAPI.marketplace.duplicate( + listingIds, + destinationTabId, + ); + pollJob( + response.data.jobUrl, + (data) => onSuccess(data.redirectUrl), + onFailure, + 2000, + ); +}; + +export const fetchListing = async (id: number): Promise => { + const response = await CourseAPI.marketplace.fetchListing(id); + return response.data as ListingPreviewData; +}; + +export const fetchQuestion = async ( + listingId: number, + questionId: number, +): Promise => { + const response = await CourseAPI.marketplace.fetchQuestion( + listingId, + questionId, + ); + return response.data as QuestionPreviewData; +}; diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewAssessmentDetails.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewAssessmentDetails.tsx new file mode 100644 index 00000000000..e6c8ae03943 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewAssessmentDetails.tsx @@ -0,0 +1,51 @@ +import { TableBody, TableCell, TableRow } from '@mui/material'; + +// Reuse the assessment show-page's own message descriptors so wording (and locale entries) stay +// identical to AssessmentShow/AssessmentDetails.tsx — no duplicate marketplace keys. +import translations from 'course/assessment/translations'; +import TableContainer from 'lib/components/core/layouts/TableContainer'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { ListingPreviewData } from '../../types'; + +interface Props { + for: ListingPreviewData; +} + +const row = (head: string, value: React.ReactNode): JSX.Element => ( + + {head} + {value} + +); + +const PreviewAssessmentDetails = ({ for: a }: Props): JSX.Element => { + const { t } = useTranslation(); + return ( + + + {row( + t(translations.gradingMode), + a.gradingMode === 'autograded' + ? t(translations.autograded) + : t(translations.manuallyGraded), + )} + {a.baseExp != null && + row(t(translations.baseExp), a.baseExp.toString())} + {a.bonusExp != null && + row(t(translations.bonusExp), a.bonusExp.toString())} + {row( + t(translations.showMcqMrqSolution), + a.showMcqMrqSolution ? '✅' : '❌', + )} + {row( + t(translations.showRubricToStudents), + a.showRubricToStudents ? '✅' : '❌', + )} + {row(t(translations.gradedTestCases), a.gradedTestCases)} + + + ); +}; + +export default PreviewAssessmentDetails; diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx new file mode 100644 index 00000000000..0d0f400a001 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { + EditNote, + ExpandLess, + ExpandMore, + VisibilityOutlined, +} from '@mui/icons-material'; +import { + Alert, + Button, + Chip, + Collapse, + IconButton, + Radio, + Tooltip, + Typography, +} from '@mui/material'; + +// Reuse the assessment show/editor descriptors (type chip, showOptions/hideOptions, staff-only +// comments) so the card is visually identical to AssessmentShow/Question.tsx minus its controls. +import translations from 'course/assessment/translations'; +import Checkbox from 'lib/components/core/buttons/Checkbox'; +import Link from 'lib/components/core/Link'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import { getCourseId } from 'lib/helpers/url-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { withFromTab } from '../../fromTab'; +import previewTranslations from '../../translations'; +import { PreviewQuestionSummary } from '../../types'; + +interface Props { + of: PreviewQuestionSummary; + index: number; + listingId: string; + fromTab?: string | null; +} + +const PreviewQuestionCard = ({ + of: q, + index, + listingId, + fromTab = null, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + const detailUrl = withFromTab( + `/courses/${getCourseId()}/marketplace/listings/${listingId}/questions/${q.id}`, + fromTab, + ); + + return ( +
+
+
+ + {index + 1} + +
+ +
+ {q.title} + +
+ + + {q.unautogradable && ( + + )} +
+
+ + + + + + + + +
+ +
+ {q.description && } + + {q.options && q.options.length > 0 && ( +
+ + + + {q.options.map((choice) => ( + + ))} + +
+ )} + + {q.staffOnlyComments && ( + + + + } + severity="info" + > + + + )} +
+
+ ); +}; + +export default PreviewQuestionCard; diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx new file mode 100644 index 00000000000..c687b47d634 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx @@ -0,0 +1,210 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import ListingPreview from '../index'; + +const mockNavigate = jest.fn(); + +// `TestApp` mounts the component directly inside a `MemoryRouter` with no matching +// ``, so `useParams()` would otherwise be empty and the page +// would fetch `.../listings/NaN`. Mock it to supply the route param, mirroring +// survey/pages/ResponseIndex/__test__. `useNavigate` is spied so the back button's +// navigate() target can be asserted (Page renders backTo as a navigate() button, not a link). +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: (): typeof mockNavigate => mockNavigate, + useParams: (): { listingId: string; courseId: string } => ({ + listingId: '7', + courseId: global.courseId.toString(), + }), +})); + +beforeEach(() => mockNavigate.mockClear()); + +// The Duplicate Assessment button needs the destination course, which the page reads from the +// course outlet context. There is no CourseLayout outlet in the test, so mock the hook (mirrors +// MarketplaceIndex/__test__). +jest.mock('../../../../container/CourseLoader', () => ({ + useCourseContext: (): { courseTitle: string; courseUrl: string } => ({ + courseTitle: 'Test Course', + courseUrl: `/courses/${global.courseId}`, + }), +})); + +// NOTE: do NOT jest.mock('../../../operations') — this bundle mocks the axios adapter and lets the +// real fetchListing run. Auto-mocking operations makes fetchListing return undefined, and Preload's +// `while` callback then does `undefined.then` → "Cannot read properties of undefined (reading 'then')". +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +const LISTING_TITLE = 'Published, All Question Types'; + +it('renders the read-only assessment config', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: '

Awesome description 5

', + gradingMode: 'manual', + baseExp: 1000, + bonusExp: 1000, + showMcqMrqSolution: true, + showRubricToStudents: false, + gradedTestCases: 'Public, Private', + // Backend now serializes human-readable type labels (question_type_readable). + typeCounts: { 'Multiple Choice': 1 }, + questions: [ + { + id: 17, + title: 'The awesome question 17', + description: '

Look at this awesome question

', + staffOnlyComments: '

Deep pedagogical insight.

', + maximumGrade: 2, + type: 'Multiple Choice', + unautogradable: false, + mcqMrqType: 'mcq', + options: [ + { id: 1, option: 'true', correct: true }, + { id: 2, option: 'false', correct: false }, + ], + }, + ], + }); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + + // Description renders in the bordered card, not as bare text. + expect(screen.getByText('Awesome description 5')).toBeVisible(); + // Properties table reuses AssessmentShow's labels. + expect(screen.getByText('Grading mode')).toBeVisible(); + // Type chip + summary breakdown both use the readable label. + expect(screen.getAllByText(/Multiple Choice/).length).toBeGreaterThan(0); + // Author's staff-only notes surface for adopters to judge intent. + expect(screen.getByText('Deep pedagogical insight.')).toBeVisible(); + // Top-right action opens the duplicate flow. + expect( + screen.getByRole('button', { name: 'Duplicate Assessment' }), + ).toBeVisible(); + // The card title is plain text now; the eye icon links into the per-question detail route. + expect(screen.getByText('The awesome question 17')).toBeVisible(); + expect( + screen.getByRole('link', { name: 'View question details' }), + ).toHaveAttribute('href', expect.stringContaining('questions/17')); +}); + +it('carries from_tab into the per-question detail links', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: '

desc

', + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: { 'Multiple Choice': 1 }, + questions: [ + { + id: 17, + title: 'The awesome question 17', + description: '', + staffOnlyComments: '', + maximumGrade: 2, + type: 'Multiple Choice', + unautogradable: false, + mcqMrqType: 'mcq', + options: [], + }, + ], + }); + + render(, { at: [`${url}?from_tab=42`] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + expect( + screen.getByRole('link', { name: 'View question details' }), + ).toHaveAttribute('href', expect.stringContaining('from_tab=42')); +}); + +it('navigates back to the marketplace carrying from_tab', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: '

desc

', + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + + render(, { at: [`${url}?from_tab=42`] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByTestId('ArrowBackIconButton')); + expect(mockNavigate).toHaveBeenCalledWith( + `/courses/${global.courseId}/marketplace?from_tab=42`, + ); +}); + +it('renders a back button to the marketplace index', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: '

desc

', + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + // Page renders the back affordance as an IconButton with this testid when `backTo` is set. + expect(screen.getByTestId('ArrowBackIconButton')).toBeInTheDocument(); +}); + +it('marks the page title as a preview', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: '

desc

', + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + // A "Preview" chip sits beside the title so the read-only listing detail page is never mistaken + // for the real assessment it mirrors. + expect(screen.getByText('Preview')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx new file mode 100644 index 00000000000..5e6c9a04abd --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { ContentCopy } from '@mui/icons-material'; +import { Button, Chip, Paper } from '@mui/material'; + +// Reuse the assessment show page's "Questions" heading so wording + locales stay identical. +import assessmentTranslations from 'course/assessment/translations'; +import { useCourseContext } from 'course/container/CourseLoader'; +import DescriptionCard from 'lib/components/core/DescriptionCard'; +import Page from 'lib/components/core/layouts/Page'; +import Subsection from 'lib/components/core/layouts/Subsection'; +import Preload from 'lib/components/wrappers/Preload'; +import useTranslation from 'lib/hooks/useTranslation'; + +import DuplicateConfirmation from '../../components/DuplicateConfirmation'; +import { withFromTab } from '../../fromTab'; +import { fetchListing } from '../../operations'; +import translations from '../../translations'; +import { ListingPreviewData } from '../../types'; + +import PreviewAssessmentDetails from './PreviewAssessmentDetails'; +import PreviewQuestionCard from './PreviewQuestionCard'; + +const ListingPreview = (): JSX.Element => { + const { listingId } = useParams(); + const { t } = useTranslation(); + const { courseTitle, courseUrl } = useCourseContext(); + const [params] = useSearchParams(); + // `from_tab` rides in from the marketplace index so the duplicate lands in the tab the user came + // from; null when they reached the preview directly, which DuplicateConfirmation renders fine. + const fromTab = params.get('from_tab'); + const destinationTabId = parseInt(fromTab ?? '', 10) || null; + const [duplicating, setDuplicating] = useState(false); + + return ( + } + while={(): Promise => fetchListing(Number(listingId))} + > + {(listing): JSX.Element => ( + setDuplicating(true)} + startIcon={} + variant="contained" + > + {t(translations.duplicateAssessment)} + + } + backTo={withFromTab(`${courseUrl}/marketplace`, fromTab)} + className="space-y-5" + title={ + + {listing.title} + + + } + > + {listing.description && ( + + )} + + + + +
+ {Object.entries(listing.typeCounts).map(([type, n]) => ( + + ))} +
+ + + {listing.questions.map((question, index) => ( + + ))} + +
+ + setDuplicating(false)} + open={duplicating} + /> +
+ )} +
+ ); +}; + +export default ListingPreview; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx new file mode 100644 index 00000000000..416bd9b2865 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx @@ -0,0 +1,183 @@ +import { useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { + ContentCopy, + StorefrontOutlined, + VisibilityOutlined, +} from '@mui/icons-material'; +import { + Button, + IconButton, + MenuItem, + TextField, + Tooltip, + Typography, +} from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import { formatLongDate } from 'lib/moment'; + +import { withFromTab } from '../../fromTab'; +import translations from '../../translations'; +import { MarketplaceListing } from '../../types'; + +type SortMode = 'adoptions' | 'newest'; + +interface Props { + fromTab?: string | null; + listings: MarketplaceListing[]; + onDuplicate: (rows: MarketplaceListing[]) => void; +} + +const MarketplaceTable = ({ + fromTab = null, + listings, + onDuplicate, +}: Props): JSX.Element => { + const { formatMessage: t } = useIntl(); + const [sortMode, setSortMode] = useState('adoptions'); + + const sorted = useMemo(() => { + const copy = [...listings]; + if (sortMode === 'newest') { + copy.sort((a, b) => + (b.firstPublishedAt ?? '').localeCompare(a.firstPublishedAt ?? ''), + ); + } else { + copy.sort((a, b) => b.adoptions - a.adoptions); + } + return copy; + }, [listings, sortMode]); + + const columns: ColumnTemplate[] = [ + { + of: 'title', + title: t(translations.colTitle), + searchable: true, + cell: (l) => l.title, + }, + { + of: 'questionCount', + title: t(translations.colQuestions), + cell: (l) => l.questionCount, + }, + { + of: 'adoptions', + title: t(translations.colAdoptions), + cell: (l) => l.adoptions, + }, + { + of: 'firstPublishedAt', + title: t(translations.colPublished), + cell: (l) => formatLongDate(l.firstPublishedAt), + }, + { + id: 'actions', + title: t(translations.colActions), + cell: (l) => ( +
+ + + + + + + onDuplicate([l])} + size="small" + > + + + +
+ ), + }, + ]; + + // Rendered in BOTH toolbar states (idle `buttons` and active `activeToolbar`), + // because `buttons` are hidden once a row is selected. Value is controlled by + // parent state, so remounting across states preserves the chosen sort. + const sortControl = ( + setSortMode(e.target.value as SortMode)} + select + size="small" + value={sortMode} + > + {t(translations.sortMostAdopted)} + {t(translations.sortNewest)} + + ); + + // Idle state: disabled, same position/style as the active button (must not move). + const idleDuplicateButton = ( + + ); + + const emptyState = ( +
+ + + {t( + listings.length === 0 + ? translations.emptyNoListings + : translations.emptyNoMatch, + )} + +
+ ); + + return ( + l.id.toString()} + indexing={{ rowSelectable: true, hideSelectAll: true }} + pagination={{ initialPageSize: 20, rowsPerPage: [10, 20, 50] }} + renderEmpty={emptyState} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (l, filter): boolean => + !filter || l.title.toLowerCase().includes(filter.toLowerCase()), + }, + }} + toolbar={{ + show: true, + keepNative: true, + buttons: [sortControl, idleDuplicateButton], + activeToolbar: (rows) => ( +
+ {sortControl} + +
+ ), + }} + /> + ); +}; + +export default MarketplaceTable; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx new file mode 100644 index 00000000000..f7defddc1c2 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx @@ -0,0 +1,182 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, waitFor } from 'test-utils'; + +import { MarketplaceListing } from '../../../types'; +import MarketplaceTable from '../MarketplaceTable'; + +// Sort keys disagree so order is meaningful: Graph Theory is most-adopted, Recursion newest. +const GRAPH_THEORY = 'Graph Theory'; +const LISTINGS: MarketplaceListing[] = [ + { + id: 1, + assessmentId: 10, + title: 'Recursion Drills', + questionCount: 8, + adoptions: 5, + firstPublishedAt: '2026-06-01T00:00:00Z', + previewUrl: '/p/1', + duplicateUrl: '/d', + }, + { + id: 2, + assessmentId: 11, + title: GRAPH_THEORY, + questionCount: 3, + adoptions: 12, + firstPublishedAt: '2026-01-01T00:00:00Z', + previewUrl: '/p/2', + duplicateUrl: '/d', + }, +]; + +it('shows a disabled "Select to duplicate" button when nothing is selected', async () => { + const page = render( + , + ); + // findBy: test-utils wraps the tree in a translations Suspense (LoadingIndicator fallback). + const idle = await page.findByRole('button', { name: 'Select to duplicate' }); + expect(idle).toBeDisabled(); +}); + +it('renders Preview and Duplicate as icon buttons with one-word tooltips/labels', async () => { + const onDuplicate = jest.fn(); + const page = render( + , + ); + + const previews = await page.findAllByLabelText('Preview'); + previews.forEach((el) => expect(el).not.toHaveAttribute('target')); + expect(previews.map((el) => el.getAttribute('href'))).toEqual( + expect.arrayContaining(['/p/1', '/p/2']), + ); + + const duplicates = await page.findAllByLabelText('Duplicate'); + // Default sort = adoptions desc → Graph Theory (12) is the first row. + fireEvent.click(duplicates[0]); + expect(onDuplicate).toHaveBeenCalledWith([ + expect.objectContaining({ title: GRAPH_THEORY }), + ]); +}); + +it('carries from_tab into the preview links when set', async () => { + const page = render( + , + ); + const previews = await page.findAllByLabelText('Preview'); + expect(previews.map((el) => el.getAttribute('href'))).toEqual( + expect.arrayContaining(['/p/1?from_tab=42', '/p/2?from_tab=42']), + ); +}); + +it('renders one checkbox per row and no select-all header checkbox', async () => { + const page = render( + , + ); + await page.findByText(GRAPH_THEORY); + + // Only per-row checkboxes — the select-all header checkbox is removed. + expect(page.getAllByRole('checkbox')).toHaveLength(LISTINGS.length); +}); + +it('keeps the search bar visible and shows an enabled count button on selection', async () => { + const onDuplicate = jest.fn(); + const page = render( + , + ); + await page.findByText(GRAPH_THEORY); + + // Data-row checkboxes follow any header checkbox — click the last one to select a row. + const checkboxes = page.getAllByRole('checkbox'); + fireEvent.click(checkboxes[checkboxes.length - 1]); + + // Regression for the vanishing-search bug: search must remain after selection. + expect(page.getByPlaceholderText('Search by title')).toBeVisible(); + + const bulk = page.getByRole('button', { name: 'Duplicate 1 assessment' }); + expect(bulk).toBeEnabled(); + fireEvent.click(bulk); + expect(onDuplicate).toHaveBeenCalledTimes(1); + expect(onDuplicate.mock.calls[0][0]).toHaveLength(1); +}); + +it('paginates to the default page size of 20', async () => { + const many: MarketplaceListing[] = Array.from({ length: 25 }, (_, i) => ({ + id: i + 1, + assessmentId: 100 + i, + title: `Listing ${String(i).padStart(2, '0')}`, + questionCount: 1, + adoptions: 25 - i, // Listing 00 highest → page 1; Listing 24 lowest → page 2 + firstPublishedAt: '2026-01-01T00:00:00Z', + previewUrl: `/p/${i}`, + duplicateUrl: '/d', + })); + + const page = render( + , + ); + await page.findByText('Listing 00'); + expect(page.queryByText('Listing 24')).not.toBeInTheDocument(); +}); + +it('shows a no-match message when the search filters everything, keeping the search bar', async () => { + const page = render( + , + ); + await page.findByText(GRAPH_THEORY); + + // userEvent (not fireEvent) for the search field — React 18 startTransition. + await userEvent.type(page.getByPlaceholderText('Search by title'), 'zzzzz'); + + await waitFor(() => + expect(page.getByText('No assessments match your search.')).toBeVisible(), + ); + // The search bar must remain so the user can clear the query. + expect(page.getByPlaceholderText('Search by title')).toBeVisible(); +}); + +it('shows an empty-marketplace message when there are no listings at all', async () => { + const page = render( + , + ); + expect( + await page.findByText( + 'No assessments have been published to the marketplace yet.', + ), + ).toBeVisible(); +}); + +it('shows the published date, formatted', async () => { + const page = render( + , + ); + await page.findByText(GRAPH_THEORY); + // formatLongDate('2026-06-01T00:00:00Z') under TZ=Asia/Singapore → '01 Jun 2026'. + expect(page.getByText('01 Jun 2026')).toBeVisible(); + expect(page.getByText('01 Jan 2026')).toBeVisible(); +}); + +it('sorts by published date (not adoptions) when Newest is selected', async () => { + const onDuplicate = jest.fn(); + const page = render( + , + ); + await page.findByText(GRAPH_THEORY); + + // Drive the MUI select-mode "Sort by" TextField (idiom mirrored from the sibling + // MarketplaceIndex test): mouseDown the labelled control, then click the option. + fireEvent.mouseDown(page.getByLabelText('Sort by')); + fireEvent.click(page.getByRole('option', { name: 'Newest' })); + + // Recursion Drills has the most recent firstPublishedAt (2026-06) despite fewer adoptions, + // so it must lead. Icon buttons render in row order, so the first Duplicate button belongs + // to the first row. + const duplicates = await page.findAllByLabelText('Duplicate'); + fireEvent.click(duplicates[0]); + expect(onDuplicate).toHaveBeenCalledWith([ + expect.objectContaining({ title: 'Recursion Drills' }), + ]); +}); diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx new file mode 100644 index 00000000000..f09510b46e3 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx @@ -0,0 +1,119 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import MarketplaceIndex from '../index'; + +jest.mock('../../../../container/CourseLoader', () => ({ + useCourseContext: (): { courseTitle: string; courseUrl: string } => ({ + courseTitle: 'Test Course', + courseUrl: '/courses/4', + }), +})); + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +// Fixture chosen so the two sort keys DISAGREE: Graph Theory is most-adopted but oldest; +// Recursion Drills is fewer adoptions but newest. This lets the sort tests prove the mode +// actually changes order rather than passing on a coincidental tie. +const LISTINGS = [ + { + id: 1, + assessmentId: 10, + title: 'Recursion Drills', + questionCount: 8, + adoptions: 5, + firstPublishedAt: '2026-06-01T00:00:00Z', + previewUrl: '/p/1', + duplicateUrl: '/d', + }, + { + id: 2, + assessmentId: 11, + title: 'Graph Theory', + questionCount: 3, + adoptions: 12, + firstPublishedAt: '2026-01-01T00:00:00Z', + previewUrl: '/p/2', + duplicateUrl: '/d', + }, +]; + +const url = `/courses/${global.courseId}/marketplace`; +const renderPage = async (page): Promise => { + await waitFor(() => expect(page.getByText('Graph Theory')).toBeVisible()); +}; + +it('renders published listings sorted by most adopted by default', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [url] }); + await renderPage(page); + + const rows = page.getAllByRole('row'); + // Graph Theory (12 adoptions) precedes Recursion Drills (5) by default. + expect(rows[1]).toHaveTextContent('Graph Theory'); +}); + +it('re-sorts by newest when the sort mode changes', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [url] }); + await renderPage(page); + + // Open the MUI "Sort by" select and choose Newest. + // NOTE (executor): confirm the exact idiom for driving a MUI `select`-mode TextField + // against an existing table test (e.g. mouseDown the combobox, then click the option). + fireEvent.mouseDown(page.getByLabelText('Sort by')); + fireEvent.click(page.getByRole('option', { name: 'Newest' })); + + await waitFor(() => { + const rows = page.getAllByRole('row'); + // Recursion Drills (2026-06) is newest and must now lead. + expect(rows[1]).toHaveTextContent('Recursion Drills'); + }); +}); + +it('filters rows by the title search', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [url] }); + await renderPage(page); + + // Search field must be driven with userEvent (React 18 startTransition) — see client/CLAUDE-testing.md. + await userEvent.type(page.getByPlaceholderText('Search by title'), 'Graph'); + + await waitFor(() => + expect(page.queryByText('Recursion Drills')).not.toBeInTheDocument(), + ); + expect(page.getByText('Graph Theory')).toBeVisible(); +}); + +it('carries from_tab into the preview links', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [`${url}?from_tab=7`] }); + await renderPage(page); + + const previews = page.getAllByLabelText('Preview'); + expect(previews.map((el) => el.getAttribute('href'))).toEqual( + expect.arrayContaining(['/p/1?from_tab=7', '/p/2?from_tab=7']), + ); +}); + +it('opens the confirmation with the resolved destination tab', async () => { + mock.onGet(url).reply(200, { + listings: LISTINGS, + canAccess: true, + destinationTabs: [ + { id: 7, title: 'Assignments', categoryId: 3, categoryTitle: 'Missions' }, + ], + }); + const page = render(, { at: [`${url}?from_tab=7`] }); + await renderPage(page); + + fireEvent.click(page.getAllByLabelText('Duplicate')[0]); + + expect(await page.findByText('Test Course')).toBeVisible(); + expect(page.getByText('Missions')).toBeVisible(); + expect(page.getByText('Assignments')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx new file mode 100644 index 00000000000..346fe0fb91a --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useSearchParams } from 'react-router-dom'; + +import { useCourseContext } from 'course/container/CourseLoader'; +import Page from 'lib/components/core/layouts/Page'; +import Preload from 'lib/components/wrappers/Preload'; + +import DuplicateConfirmation from '../../components/DuplicateConfirmation'; +import { fetchListings } from '../../operations'; +import translations from '../../translations'; +import { MarketplaceListing } from '../../types'; + +import MarketplaceTable from './MarketplaceTable'; + +const MarketplaceIndex = (): JSX.Element => { + const { formatMessage: t } = useIntl(); + const { courseTitle, courseUrl } = useCourseContext(); + const [params] = useSearchParams(); + const fromTab = params.get('from_tab'); + const destinationTabId = parseInt(fromTab ?? '', 10) || null; + const [pending, setPending] = useState([]); + + return ( + } while={fetchListings}> + {({ listings, destinationTabs }): JSX.Element => ( + + + setPending([])} + open={pending.length > 0} + /> + + )} + + ); +}; + +export default MarketplaceIndex; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/__test__/index.test.tsx new file mode 100644 index 00000000000..82654cf2411 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/__test__/index.test.tsx @@ -0,0 +1,57 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import QuestionPreview from '../index'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: (): { + listingId: string; + questionId: string; + courseId: string; + } => ({ + listingId: '7', + questionId: '3', + courseId: global.courseId.toString(), + }), +})); + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +it('renders the question and dispatches to the type-specific renderer', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7/questions/3`; + mock.onGet(url).reply(200, { + id: 3, + title: 'Sorting in Python', + defaultTitle: 'Question 1', + description: '

Implement sort

', + staffOnlyComments: '', + maximumGrade: 10, + type: 'Programming', + displayType: 'Programming', + detail: { + languageName: 'Python 3.10', + memoryLimit: 32, + timeLimit: 10, + templateFiles: [{ filename: 'main.py', content: 'print(1)' }], + publicTestCases: [], + privateTestCases: [], + evaluationTestCases: [], + }, + }); + + render(, { at: [url] }); + + await waitFor(() => + expect(screen.getByDisplayValue('Sorting in Python')).toBeVisible(), + ); + // The human-readable type chip (displayType) renders beside the Title field. + expect(screen.getByText('Programming')).toBeVisible(); + // Shell renders the reused "Grading" section + "Maximum grade" label around the renderer. + expect(screen.getByText('Grading')).toBeVisible(); + expect(screen.getByText('Maximum grade')).toBeVisible(); + expect(screen.getByTestId('renderer-Programming')).toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/index.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/index.tsx new file mode 100644 index 00000000000..da46296283f --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/index.tsx @@ -0,0 +1,99 @@ +import { useParams } from 'react-router-dom'; +import { EditNote } from '@mui/icons-material'; +import { Chip, TextField, Typography } from '@mui/material'; + +import assessmentTranslations from 'course/assessment/translations'; +import Page from 'lib/components/core/layouts/Page'; +import Section from 'lib/components/core/layouts/Section'; +import Subsection from 'lib/components/core/layouts/Subsection'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import Preload from 'lib/components/wrappers/Preload'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { fetchQuestion } from '../../operations'; +import { QuestionPreviewData } from '../../types'; + +import ForumPostResponse from './renderers/ForumPostResponse'; +import MultipleResponse from './renderers/MultipleResponse'; +import Programming from './renderers/Programming'; +import RubricBasedResponse from './renderers/RubricBasedResponse'; +import Scribing from './renderers/Scribing'; +import TextResponse from './renderers/TextResponse'; +import { RendererProps } from './renderers/types'; +import VoiceResponse from './renderers/VoiceResponse'; + +const RENDERERS: Record JSX.Element | null> = { + MultipleResponse, + Programming, + TextResponse, + RubricBasedResponse, + ForumPostResponse, + VoiceResponse, + Scribing, +}; + +const QuestionPreview = (): JSX.Element => { + const { t } = useTranslation(); + const { listingId, questionId } = useParams(); + return ( + } + while={(): Promise => + fetchQuestion(Number(listingId), Number(questionId)) + } + > + {(question): JSX.Element => { + const Renderer = RENDERERS[question.type]; + return ( + +
+ + {question.displayType && ( + + )} + {question.description && ( + + + + )} + {question.staffOnlyComments && ( + } + subtitle={t(assessmentTranslations.staffOnlyCommentsHint)} + title={t(assessmentTranslations.staffOnlyComments)} + > + + + )} +
+ +
+
+ + {t(assessmentTranslations.maximumGrade)} + + {question.maximumGrade} +
+
+ + {Renderer ? : null} +
+ ); + }} +
+ ); +}; + +export default QuestionPreview; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/ForumPostResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/ForumPostResponse.tsx new file mode 100644 index 00000000000..19c44243bd9 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/ForumPostResponse.tsx @@ -0,0 +1,38 @@ +import { Typography } from '@mui/material'; + +// Reuse the forum-post editor field labels (max posts, text response) from +// course/assessment/translations. +import translations from 'course/assessment/translations'; +import Section from 'lib/components/core/layouts/Section'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type ForumPostDetail = Extract< + QuestionPreviewData['detail'], + { maxPosts: number } +>; + +const ForumPostResponse = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as ForumPostDetail; + return ( +
+
+ + {t(translations.maxPosts)}: {detail.maxPosts} + + + {t(translations.textResponse)}: {detail.hasTextResponse ? '✅' : '❌'} + +
+
+ ); +}; + +export default ForumPostResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/MultipleResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/MultipleResponse.tsx new file mode 100644 index 00000000000..7f284736fe1 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/MultipleResponse.tsx @@ -0,0 +1,47 @@ +import { Radio } from '@mui/material'; + +// Reuse the assessment editor's own field labels (same wording + locale entries) instead of +// minting marketplace-local duplicates. `choices` lives in course/assessment/translations. +import translations from 'course/assessment/translations'; +import Checkbox from 'lib/components/core/buttons/Checkbox'; +import Section from 'lib/components/core/layouts/Section'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { RendererProps } from './types'; + +const MultipleResponse = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as Extract< + typeof question.detail, + { gradingScheme: string } + >; + const isMcq = detail.gradingScheme === 'any_correct'; + return ( +
+
+
+ {detail.options.map((choice) => ( +
+ + {choice.explanation && ( + + )} +
+ ))} +
+
+
+ ); +}; + +export default MultipleResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Programming.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Programming.tsx new file mode 100644 index 00000000000..8d77708c059 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Programming.tsx @@ -0,0 +1,127 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +// Reuse the programming-editor field labels (Language/limits, Templates, Test cases, and the +// Expression/Expected/Hint table headers) from course/assessment/translations. +import translations from 'course/assessment/translations'; +import Section from 'lib/components/core/layouts/Section'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { ProgrammingTestCase, QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type ProgrammingDetail = Extract< + QuestionPreviewData['detail'], + { templateFiles: unknown } +>; + +interface TestCaseTableProps { + title: string; + rows: ProgrammingTestCase[]; +} + +const TestCaseTable = ({ + title, + rows, +}: TestCaseTableProps): JSX.Element | null => { + const { t } = useTranslation(); + if (!rows.length) return null; + return ( +
+ {title} +
+
+ + + {t(translations.expression)} + {t(translations.expected)} + {t(translations.hint)} + + + + {rows.map((tc) => ( + + {tc.expression} + {tc.expected} + {tc.hint} + + ))} + +
+ + + ); +}; + +const LabeledRow = ({ + label, + value, +}: { + label: string; + value: string | number; +}): JSX.Element => ( +
+ + {label} + + {value} +
+); + +const Programming = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as ProgrammingDetail; + return ( +
+
+ + + +
+ +
+ {detail.templateFiles.map((file) => ( +
+ {file.filename} +
+              {file.content}
+            
+
+ ))} +
+ +
+ + + +
+
+ ); +}; + +export default Programming; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx new file mode 100644 index 00000000000..d20f88fa8e1 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx @@ -0,0 +1,74 @@ +import { + Chip, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +// Field labels (Rubric heading, Grade, Explanation) come from course/assessment/translations; +// only the "Bonus" category chip has no equivalent there and lives in the marketplace translations. +import translations from 'course/assessment/translations'; +import Section from 'lib/components/core/layouts/Section'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import useTranslation from 'lib/hooks/useTranslation'; + +import previewTranslations from '../../../translations'; +import { QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type RubricDetail = Extract< + QuestionPreviewData['detail'], + { categories: unknown } +>; + +const RubricBasedResponse = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as RubricDetail; + return ( +
+
+ {detail.categories.map((category) => ( +
+
+ {category.name} + {category.isBonus && ( + + )} +
+
+ + + + {t(translations.grade)} + {t(translations.explanation)} + + + + {category.criteria.map((criterion) => ( + + {criterion.grade} + + + + + ))} + +
+
+
+ ))} +
+
+ ); +}; + +export default RubricBasedResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Scribing.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Scribing.tsx new file mode 100644 index 00000000000..53344d8820c --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Scribing.tsx @@ -0,0 +1,43 @@ +import { Typography } from '@mui/material'; + +import Section from 'lib/components/core/layouts/Section'; +import useTranslation from 'lib/hooks/useTranslation'; + +// The "cannot be previewed" empty state is marketplace-preview-specific (the cross-instance +// attachment-URL limitation); no assessment-editor label matches, so it lives in the local keys. +import translations from '../../../translations'; +import { QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type ScribingDetail = Extract< + QuestionPreviewData['detail'], + { imageUrl: string | null } +>; + +const Scribing = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as ScribingDetail; + // A scribing question has no field labels of its own — the background image (or its empty-state + // note) is the whole content. Render it in a title-less Section so it still aligns under the lg=9 + // content column like every other section. + return ( +
+
+ {detail.imageUrl ? ( + {question.title} + ) : ( + + {t(translations.noPreviewImage)} + + )} +
+
+ ); +}; + +export default Scribing; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/TextResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/TextResponse.tsx new file mode 100644 index 00000000000..4433e6c29d3 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/TextResponse.tsx @@ -0,0 +1,69 @@ +import { Chip, Typography } from '@mui/material'; + +// Reuse the text-response editor field labels (Attachment settings, Max attachments, Solutions, +// Grade, Explanation, Comprehension) from course/assessment/translations. +import translations from 'course/assessment/translations'; +import Section from 'lib/components/core/layouts/Section'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type TextResponseDetail = Extract< + QuestionPreviewData['detail'], + { solutions: unknown } +>; + +const TextResponse = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as TextResponseDetail; + const showAttachments = detail.maxAttachments > 0; + const showSolutions = detail.solutions.length > 0; + // The comprehension marker rides at the top of the first rendered section so it stays inside the + // lg=9 content column (a bare chip above the sections would misalign). + const comprehensionChip = detail.isComprehension ? ( + + ) : null; + return ( +
+ {showAttachments && ( +
+ {comprehensionChip} + + {t(translations.maxAttachments)}: {detail.maxAttachments} + +
+ )} + + {showSolutions && ( +
+ {!showAttachments && comprehensionChip} + {detail.solutions.map((solution, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+ + + {t(translations.grade)}: {solution.grade} + + {solution.explanation && ( + + )} +
+ ))} +
+ )} +
+ ); +}; + +export default TextResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/VoiceResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/VoiceResponse.tsx new file mode 100644 index 00000000000..163fd4c6648 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/VoiceResponse.tsx @@ -0,0 +1,8 @@ +import { RendererProps } from './types'; + +// Voice questions carry no type-specific setup — the prompt is the base description and the max +// grade are already rendered by the shell's "Question details" and "Grading" sections. Mirroring the +// native edit UI (which shows nothing extra for voice), this renderer contributes no section. +const VoiceResponse = (_props: RendererProps): JSX.Element | null => null; + +export default VoiceResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/ForumPostResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/ForumPostResponse.test.tsx new file mode 100644 index 00000000000..9138a20f971 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/ForumPostResponse.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import ForumPostResponse from '../ForumPostResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Discuss', + defaultTitle: 'Question 1', + description: '

Post in the forum

', + staffOnlyComments: '', + maximumGrade: 3, + type: 'ForumPostResponse', + displayType: 'Forum Post Response', + detail: { maxPosts: 3, hasTextResponse: true }, +}; + +it('renders the required post count and the text-response requirement', async () => { + render(); + + // maxPosts is interpolated into a line → match the number within it. + expect(await screen.findByText(/3/)).toBeVisible(); + // hasTextResponse true → the text-response-required line shows. + expect(screen.getByText(/text response/i)).toBeVisible(); + // Requirements now live under the reused "Additional Settings" section. + expect(screen.getByText('Additional Settings')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/MultipleResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/MultipleResponse.test.tsx new file mode 100644 index 00000000000..000cfe7a50e --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/MultipleResponse.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import MultipleResponse from '../MultipleResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Capital of France', + defaultTitle: 'Question 1', + description: '

Pick one

', + staffOnlyComments: '', + maximumGrade: 1, + type: 'MultipleResponse', + displayType: 'Multiple Choice', + detail: { + gradingScheme: 'any_correct', // MCQ → single-select (Radio) + options: [ + { + id: 1, + option: '

Paris

', + correct: true, + explanation: '

Correct!

', + weight: 1, + }, + { + id: 2, + option: '

London

', + correct: false, + explanation: '

Wrong city

', + weight: 0, + }, + ], + }, +}; + +it('renders each choice, marks the correct one, and shows explanations', async () => { + render(); + + expect(await screen.findByText('Paris')).toBeVisible(); + expect(screen.getByText('London')).toBeVisible(); + expect(screen.getByText('Correct!')).toBeVisible(); + // Options now live under the reused "Choices" section. + expect(screen.getByTestId('renderer-MultipleResponse')).toBeInTheDocument(); + expect(screen.getByText('Choices')).toBeVisible(); + + // gradingScheme 'any_correct' → MCQ → Radio inputs, correct option checked. + const radios = screen.getAllByRole('radio'); + expect(radios[0]).toBeChecked(); + expect(radios[1]).not.toBeChecked(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Programming.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Programming.test.tsx new file mode 100644 index 00000000000..34feb414929 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Programming.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import Programming from '../Programming'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Sorting in Python', + defaultTitle: 'Question 1', + description: '

Implement sort

', + staffOnlyComments: '', + maximumGrade: 10, + type: 'Programming', + displayType: 'Programming', + detail: { + languageName: 'Python 3.10', + memoryLimit: 32, + timeLimit: 10, + templateFiles: [{ filename: 'main.py', content: 'print(1)' }], + publicTestCases: [ + { + identifier: 'pub_1', + expression: 'sort([3,1,2])', + expected: '[1,2,3]', + hint: 'ascending', + }, + ], + privateTestCases: [ + { + identifier: 'priv_1', + expression: 'sort([])', + expected: '[]', + hint: '', + }, + ], + evaluationTestCases: [], + }, +}; + +it('renders the language, template file, and public/private test-case tables', async () => { + render(); + + expect(await screen.findByText('main.py')).toBeVisible(); + expect(screen.getByText('print(1)')).toBeVisible(); + expect(screen.getByText(/Python 3\.10/)).toBeVisible(); // interpolated into the summary line + expect(screen.getByText('sort([3,1,2])')).toBeVisible(); // public bucket + expect(screen.getByText('sort([])')).toBeVisible(); // private bucket + // Content is grouped under the reused Templates / Test cases sections. + expect(screen.getByText('Templates')).toBeVisible(); + expect(screen.getByText('Test cases')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/RubricBasedResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/RubricBasedResponse.test.tsx new file mode 100644 index 00000000000..d83326adb9c --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/RubricBasedResponse.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import RubricBasedResponse from '../RubricBasedResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Essay', + defaultTitle: 'Question 1', + description: '

Write an essay

', + staffOnlyComments: '', + maximumGrade: 7, + type: 'RubricBasedResponse', + displayType: 'Rubric-Based Response', + detail: { + categories: [ + { + name: 'Clarity', + isBonus: false, + criteria: [{ grade: 5, explanation: '

Very clear

' }], + }, + { + name: 'Extra credit', + isBonus: true, + criteria: [{ grade: 2, explanation: '

Nice touch

' }], + }, + ], + }, +}; + +it('renders each category, its criteria, and a bonus marker', async () => { + render(); + + expect(await screen.findByText('Clarity')).toBeVisible(); + expect(screen.getByText('Extra credit')).toBeVisible(); + expect(screen.getByText('Very clear')).toBeVisible(); + expect(screen.getByText('Nice touch')).toBeVisible(); + // isBonus category → a "Bonus" chip/label (match the chosen `bonus` translation). + expect(screen.getByText(/bonus/i)).toBeVisible(); + // Categories now live under the reused "Rubric" section. + expect(screen.getByText('Rubric')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Scribing.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Scribing.test.tsx new file mode 100644 index 00000000000..31751fca9af --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Scribing.test.tsx @@ -0,0 +1,39 @@ +import { render, screen, waitFor } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import Scribing from '../Scribing'; + +const base = { + id: 3, + title: 'Label the diagram', + defaultTitle: 'Question 1', + description: '

Annotate

', + staffOnlyComments: '', + maximumGrade: 4, + type: 'Scribing', + displayType: 'Scribing', +} as const; + +it('renders the background image when imageUrl is present', async () => { + const question: QuestionPreviewData = { + ...base, + detail: { imageUrl: 'https://example.test/diagram.png' }, + }; + const { container } = render(); + + await waitFor(() => + expect(container.querySelector('img')).toBeInTheDocument(), + ); + expect(container.querySelector('img')).toHaveAttribute( + 'src', + 'https://example.test/diagram.png', + ); +}); + +it('renders an empty-state note when imageUrl is null', async () => { + const question: QuestionPreviewData = { ...base, detail: { imageUrl: null } }; + render(); + + // No image → "not previewable" empty state (match `noPreviewImage`). + expect(await screen.findByText(/preview/i)).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/TextResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/TextResponse.test.tsx new file mode 100644 index 00000000000..6f3a705ec4b --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/TextResponse.test.tsx @@ -0,0 +1,43 @@ +// pages/QuestionPreview/renderers/__test__/TextResponse.test.tsx +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import TextResponse from '../TextResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Explain recursion', + defaultTitle: 'Question 1', + description: '

In your own words

', + staffOnlyComments: '', + maximumGrade: 8, + type: 'TextResponse', + displayType: 'Text Response', + detail: { + hideText: false, + isAttachmentRequired: true, + maxAttachments: 2, + maxAttachmentSize: null, + isComprehension: false, + solutions: [ + { + solutionType: 'exact_match', + solution: '

A function calling itself

', + grade: 8, + explanation: '

Model answer

', + }, + ], + }, +}; + +it('renders solutions and, when attachments are allowed, the attachment line', async () => { + render(); + + expect(await screen.findByText('A function calling itself')).toBeVisible(); + expect(screen.getByText('Model answer')).toBeVisible(); + // maxAttachments > 0 → attachments-allowed line (match the chosen translation). + expect(screen.getByText(/max number of attachments/i)).toBeVisible(); + // Content is grouped under the reused Attachment Settings / Solutions sections. + expect(screen.getByText('Attachment Settings')).toBeVisible(); + expect(screen.getByText('Solutions')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/VoiceResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/VoiceResponse.test.tsx new file mode 100644 index 00000000000..5ac86d1222e --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/VoiceResponse.test.tsx @@ -0,0 +1,28 @@ +import { render, screen, waitFor } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import VoiceResponse from '../VoiceResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Read aloud', + defaultTitle: 'Question 1', + description: '

Record yourself

', + staffOnlyComments: '', + maximumGrade: 5, + type: 'VoiceResponse', + displayType: 'Voice Response', + detail: {}, // voice carries no type-specific setup +}; + +it('contributes no type-specific section (prompt + grade live in the shell)', async () => { + const { container } = render(); + + // Wait out the I18nProvider's async loading spinner, then confirm the renderer itself added + // nothing — voice questions are carried entirely by the shell's "Question details"/"Grading". + // (`container` still holds provider chrome like the Toastify region, so assert on visible text.) + await waitFor(() => + expect(screen.queryByTestId('CircularProgress')).not.toBeInTheDocument(), + ); + expect(container.textContent).toBe(''); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/types.ts b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/types.ts new file mode 100644 index 00000000000..a5e013a6207 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/types.ts @@ -0,0 +1,5 @@ +import { QuestionPreviewData } from '../../../types'; + +export interface RendererProps { + question: QuestionPreviewData; +} diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts new file mode 100644 index 00000000000..3818d2785cd --- /dev/null +++ b/client/app/bundles/course/marketplace/translations.ts @@ -0,0 +1,162 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + publish: { + id: 'course.marketplace.publish', + defaultMessage: 'Publish to Marketplace', + }, + remove: { + id: 'course.marketplace.remove', + defaultMessage: 'Remove from Marketplace', + }, + publishConfirmTitle: { + id: 'course.marketplace.publishConfirmTitle', + defaultMessage: 'Publish to Marketplace?', + }, + publishConfirmBody: { + id: 'course.marketplace.publishConfirmBody', + defaultMessage: + 'This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title.', + }, + removeConfirmTitle: { + id: 'course.marketplace.removeConfirmTitle', + defaultMessage: 'Remove from Marketplace?', + }, + removeConfirmBody: { + id: 'course.marketplace.removeConfirmBody', + defaultMessage: + 'It will no longer appear in the marketplace. Existing copies are unaffected.', + }, + published: { + id: 'course.marketplace.publishedToast', + defaultMessage: 'Published to the marketplace.', + }, + removed: { + id: 'course.marketplace.removedToast', + defaultMessage: 'Removed from the marketplace.', + }, + deleteWarning: { + id: 'course.marketplace.deleteWarning', + defaultMessage: + 'This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected.', + }, + pageTitle: { + id: 'course.marketplace.pageTitle', + defaultMessage: 'Assessment Marketplace', + }, + colTitle: { id: 'course.marketplace.colTitle', defaultMessage: 'Title' }, + colQuestions: { + id: 'course.marketplace.colQuestions', + defaultMessage: 'Questions', + }, + colAdoptions: { + id: 'course.marketplace.colAdoptions', + defaultMessage: 'Adoptions', + }, + colActions: { + id: 'course.marketplace.colActions', + defaultMessage: 'Actions', + }, + colPublished: { + id: 'course.marketplace.colPublished', + defaultMessage: 'Published at', + }, + preview: { + id: 'course.marketplace.previewAction', + defaultMessage: 'Preview', + }, + previewBadge: { + id: 'course.marketplace.previewBadge', + defaultMessage: 'Preview', + }, + duplicateAssessment: { + id: 'course.marketplace.duplicateAssessment', + defaultMessage: 'Duplicate Assessment', + }, + viewDetails: { + id: 'course.marketplace.viewDetails', + defaultMessage: 'View question details', + }, + searchPlaceholder: { + id: 'course.marketplace.searchPlaceholder', + defaultMessage: 'Search by title', + }, + sortLabel: { id: 'course.marketplace.sortLabel', defaultMessage: 'Sort by' }, + sortMostAdopted: { + id: 'course.marketplace.sortMostAdopted', + defaultMessage: 'Most adopted', + }, + sortNewest: { id: 'course.marketplace.sortNewest', defaultMessage: 'Newest' }, + duplicateN: { + id: 'course.marketplace.duplicateN', + defaultMessage: + '{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}', + }, + confirmationQuestion: { + id: 'course.marketplace.confirmationQuestion', + defaultMessage: 'Duplicate items?', + }, + destinationCourse: { + id: 'course.marketplace.destinationCourse', + defaultMessage: 'Destination Course', + }, + pickDestinationTab: { + id: 'course.marketplace.pickDestinationTab', + defaultMessage: 'Pick destination tab', + }, + duplicating: { + id: 'course.marketplace.duplicating', + defaultMessage: 'Duplicating', + }, + // Reuses the duplication bundle's existing id verbatim so formatjs extract dedupes rather than + // minting a marketplace-only duplicate; marketplace renders the ⊘ unpublished tooltip itself now. + itemUnpublished: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.itemUnpublished', + defaultMessage: + 'Items are duplicated as unpublished when duplicating to an existing course.', + }, + duplicateConfirm: { + id: 'course.marketplace.duplicateConfirm', + defaultMessage: 'Duplicate', + }, + // Fired from pollJob's completion callback, so this reports what already happened. The old copy + // said "started", which was both malformed ("Duplicating assessment started.") and untrue. + duplicateCompleted: { + id: 'course.marketplace.duplicateCompleted', + defaultMessage: + '{n, plural, one {Assessment duplicated} other {Assessments duplicated}}.', + }, + duplicateFailed: { + id: 'course.marketplace.duplicateFailed', + defaultMessage: + '{n, plural, one {Could not duplicate the assessment} other {Could not duplicate the assessments}}.', + }, + viewDuplicatedAssessment: { + id: 'course.marketplace.viewDuplicatedAssessment', + defaultMessage: 'View assessment', + }, + selectToDuplicate: { + id: 'course.marketplace.selectToDuplicate', + defaultMessage: 'Select to duplicate', + }, + emptyNoListings: { + id: 'course.marketplace.emptyNoListings', + defaultMessage: + 'No assessments have been published to the marketplace yet.', + }, + emptyNoMatch: { + id: 'course.marketplace.emptyNoMatch', + defaultMessage: 'No assessments match your search.', + }, + // Preview-only copy with no equivalent in course/assessment/translations. Every other renderer + // label is reused from there; these three have no source and so live locally. + bonus: { + id: 'course.marketplace.bonus', + defaultMessage: 'Bonus', + }, + noPreviewImage: { + id: 'course.marketplace.noPreviewImage', + defaultMessage: + 'The background image for this question cannot be previewed here.', + }, +}); diff --git a/client/app/bundles/course/marketplace/types.ts b/client/app/bundles/course/marketplace/types.ts new file mode 100644 index 00000000000..c4ac6667af0 --- /dev/null +++ b/client/app/bundles/course/marketplace/types.ts @@ -0,0 +1,133 @@ +export interface MarketplaceListing { + id: number; + assessmentId: number; + title: string; + questionCount: number; + adoptions: number; + firstPublishedAt: string | null; + previewUrl: string; + duplicateUrl: string; +} + +export interface DestinationTab { + id: number; + title: string; + categoryId: number; + categoryTitle: string; +} + +export interface MarketplaceIndexData { + listings: MarketplaceListing[]; + destinationTabs: DestinationTab[]; +} + +export interface PreviewChoice { + id: number; + option: string; + correct: boolean; +} + +export interface PreviewQuestionSummary { + id: number; + title: string; + description: string; + staffOnlyComments: string; + maximumGrade: number; + type: string; + unautogradable: boolean; + mcqMrqType?: 'mcq' | 'mrq'; + options?: PreviewChoice[]; +} + +export interface ListingPreviewData { + id: number; + title: string; + description: string; + // The previewer's own category/tab structure, so the duplicate dialog can offer the destination + // tab picker from the listing detail page (the listing itself lives in another course). + destinationTabs: DestinationTab[]; + gradingMode: 'autograded' | 'manual'; + baseExp: number | null; + bonusExp: number | null; + showMcqMrqSolution: boolean; + showRubricToStudents: boolean; + gradedTestCases: string; + typeCounts: Record; + questions: PreviewQuestionSummary[]; +} + +export interface ProgrammingTestCase { + identifier: string; + expression: string; + expected: string; + hint: string; +} + +export interface QuestionPreviewData { + id: number; + title: string; + defaultTitle: string; + description: string; + staffOnlyComments: string; + maximumGrade: number; + // Discriminator. The demodulized actable class name from the backend, e.g. 'Programming'. + // It — NOT the shape of `detail` — decides which `detail` variant is present: the renderer + // dispatcher (QuestionPreview) switches on `type`, and each renderer narrows `detail` with a + // cast (the variants share no literal tag, so TS can't auto-discriminate them). One `type` + // string ⇒ exactly one `detail` variant below. + type: string; + // Human-readable type label for the header chip (e.g. 'Multiple Choice'). Display-only — the + // renderer dispatch keys off `type`, never this. + displayType: string; + // Present variant is fixed by `type` above: + detail: // type === 'MultipleResponse' — both MCQ and MRQ (gradingScheme 'any_correct' ⇒ MCQ / single + // answer, 'all_correct' ⇒ MRQ / multi-answer). `options` carries the answer key + explanations. + | { + gradingScheme: string; + options: (PreviewChoice & { explanation: string; weight: number })[]; + } + // type === 'Programming' — language, limits, template files, and the three test-case buckets + // (public visible to students, private/evaluation hidden). Any bucket may be empty. + | { + languageName: string; + memoryLimit: number | null; + timeLimit: number | null; + templateFiles: { filename: string; content: string }[]; + publicTestCases: ProgrammingTestCase[]; + privateTestCases: ProgrammingTestCase[]; + evaluationTestCases: ProgrammingTestCase[]; + } + // type === 'TextResponse' — covers plain Text Response, File Upload, AND comprehension (one + // actable, disambiguated by flags: isComprehension, and attachment fields for File Upload). + | { + hideText: boolean; + isAttachmentRequired: boolean; + maxAttachments: number; + maxAttachmentSize: number | null; + isComprehension: boolean; + solutions: { + solutionType: string; + solution: string; + grade: number; + explanation: string; + }[]; + } + // type === 'RubricBasedResponse' — grading rubric as categories → criteria (grade + explanation). + | { + categories: { + name: string; + isBonus: boolean; + criteria: { grade: number; explanation: string }[]; + }[]; + } + // type === 'ForumPostResponse' — how many forum posts are required + whether a text answer too. + | { maxPosts: number; hasTextResponse: boolean } + // type === 'VoiceResponse' — no type-specific setup; the whole prompt IS the base `description`, + // so `detail` is an empty object. + | Record + // type === 'Scribing' — the background image students annotate (null if not previewable + // cross-instance; see the attachment-URL limitation in the design spec). + | { imageUrl: string | null } + // Unknown / unsupported `type` — the dispatcher renders nothing. + | null; +} diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts index c52ce359071..f5af35441ce 100644 --- a/client/app/bundles/course/translations.ts +++ b/client/app/bundles/course/translations.ts @@ -51,6 +51,14 @@ const translations = defineMessages({ id: 'course.componentTitles.course_announcements_component', defaultMessage: 'Announcements', }, + course_assessment_marketplace_component: { + id: 'course.componentTitles.course_assessment_marketplace_component', + defaultMessage: 'Assessment Marketplace', + }, + admin_marketplace: { + id: 'course.courses.SidebarItem.admin.marketplace', + defaultMessage: 'Assessment Marketplace', + }, course_assessments_component: { id: 'course.componentTitles.course_assessments_component', defaultMessage: 'Assessments', 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..284dc26a9fa --- /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 matched by these rules', + }, + 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..925bb04ce5f --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx @@ -0,0 +1,950 @@ +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 NUS_LABEL = 'nus.edu.sg'; +const DORMANT_DAN = 'Dormant Dan'; +const ROOT_ADMIN = 'Root Admin'; +const EMAIL_NUS_LABEL = 'Email domain (nus.edu.sg)'; +const SYSTEM_ADMIN = 'System admin'; + +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_LABEL }, + ], + 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_LABEL }, + ], + 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_LABEL, +}; + +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 openFilter = async (page: ReturnType): Promise => { + fireEvent.click(page.getByRole('button', { name: 'Filter' })); + await page.findByRole('menu'); +}; + +const closeFilter = async (page: ReturnType): Promise => { + 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); +}; + +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_NUS_LABEL)).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_NUS_LABEL)).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_LABEL }, + { id: 11, ruleType: 'user', labelValue: 'Jane Tan' }, + ], + }, + ], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText(EMAIL_NUS_LABEL)).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_NUS_LABEL); + + 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_LABEL, + }, + ], + }, + ], + 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(); +}); + +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_NUS_LABEL }), + ).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_LABEL }, + { 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_LABEL }, + ], + 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_NUS_LABEL); + + // 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/lib/components/table/MuiTableAdapter/MuiTable.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx index c4e2957ad96..fa8cf81c7e9 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx @@ -20,6 +20,8 @@ const MuiTable = (props: TableProps): JSX.Element => {
+ {props.body.rows.length === 0 && props.body.renderEmpty} + {props.pagination && } ); diff --git a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts index 8ebb3127667..5465abf8af9 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts @@ -9,6 +9,7 @@ const buildTanStackColumns = ( columns: ColumnTemplate[], hasCheckboxes?: boolean | ((datum: D) => boolean), hasIndices?: boolean, + hideSelectAll?: boolean, ): BuiltColumns> => { const initialColumns: ColumnDef[] = []; @@ -27,11 +28,15 @@ const buildTanStackColumns = ( enableSorting: false, enableColumnFilter: false, enableGlobalFilter: false, - header: ({ table }): RowSelector => ({ - selected: table.getIsAllRowsSelected(), - indeterminate: table.getIsSomeRowsSelected(), - onChange: table.getToggleAllRowsSelectedHandler(), - }), + // A non-RowSelector header (null) renders an empty cell, dropping the + // select-all checkbox while the per-row `cell` checkboxes remain. + header: hideSelectAll + ? (): null => null + : ({ table }): RowSelector => ({ + selected: table.getIsAllRowsSelected(), + indeterminate: table.getIsSomeRowsSelected(), + onChange: table.getToggleAllRowsSelectedHandler(), + }), cell: ({ row }): RowSelector => ({ selected: row.getIsSelected(), disabled: !row.getCanSelect(), diff --git a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx index b6eca581e97..ae83d895f0d 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx +++ b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx @@ -47,6 +47,7 @@ const useTanStackTableBuilder = ( props.columns, props.indexing?.rowSelectable, props.indexing?.indices, + props.indexing?.hideSelectAll, ); const [columnFilters, setColumnFilters] = useState([]); @@ -337,6 +338,7 @@ const useTanStackTableBuilder = ( }, body: { rows: table.getRowModel().rows, + renderEmpty: props.renderEmpty, getCells: (row) => row.getVisibleCells(), // Use getRealColumnById (ID-based) not getRealColumn(index). getVisibleCells() skips hidden // columns, so its positional index diverges from getRealColumn's full-column-list index diff --git a/client/app/lib/components/table/adapters/Body.ts b/client/app/lib/components/table/adapters/Body.ts index 955602d0dd9..c507e536387 100644 --- a/client/app/lib/components/table/adapters/Body.ts +++ b/client/app/lib/components/table/adapters/Body.ts @@ -30,6 +30,7 @@ interface BodyProps { allFilteredSelected?: boolean; someFilteredSelected?: boolean; toggleAllFiltered?: () => void; + renderEmpty?: ReactNode; } export default BodyProps; diff --git a/client/app/lib/components/table/builder/TableTemplate.ts b/client/app/lib/components/table/builder/TableTemplate.ts index d6bba03f117..77c85288961 100644 --- a/client/app/lib/components/table/builder/TableTemplate.ts +++ b/client/app/lib/components/table/builder/TableTemplate.ts @@ -1,3 +1,5 @@ +import { ReactNode } from 'react'; + import ColumnPickerTemplate from './ColumnPickerTemplate'; import ColumnTemplate, { Data } from './ColumnTemplate'; import { @@ -17,6 +19,7 @@ interface TableTemplate { getRowClassName?: (datum: D) => string; getRowEqualityData?: (datum: D) => unknown; className?: string; + renderEmpty?: ReactNode; pagination?: PaginationTemplate; csvDownload?: CsvDownloadTemplate; search?: SearchTemplate; diff --git a/client/app/lib/components/table/builder/featureTemplates.ts b/client/app/lib/components/table/builder/featureTemplates.ts index 76aff602229..9eb1abf7079 100644 --- a/client/app/lib/components/table/builder/featureTemplates.ts +++ b/client/app/lib/components/table/builder/featureTemplates.ts @@ -33,6 +33,9 @@ export interface SearchTemplate { export interface IndexingTemplate { rowSelectable?: boolean | ((datum: D) => boolean); indices?: boolean; + // Hides the select-all checkbox in the row-selector column header while + // keeping the per-row checkboxes. No effect unless `rowSelectable` is set. + hideSelectAll?: boolean; } export interface FilterTemplate { diff --git a/client/app/lib/constants/icons.ts b/client/app/lib/constants/icons.ts index 9c1d328e12a..b29ab3d3a9d 100644 --- a/client/app/lib/constants/icons.ts +++ b/client/app/lib/constants/icons.ts @@ -49,6 +49,8 @@ import { StairsOutlined, Star, StarOutline, + Storefront, + StorefrontOutlined, SvgIconComponent, TableChart, TableChartOutlined, @@ -84,6 +86,7 @@ export const COURSE_COMPONENT_ICONS = { statistics: { outlined: InsertChartOutlined, filled: InsertChart }, experience: { outlined: StarOutline, filled: Star }, duplication: { outlined: FileCopyOutlined, filled: FileCopy }, + marketplace: { outlined: StorefrontOutlined, filled: Storefront }, levels: { outlined: StairsOutlined, filled: Stairs }, groups: { outlined: GroupsOutlined, filled: Groups }, skills: { outlined: OfflineBoltOutlined, filled: OfflineBolt }, diff --git a/client/app/lib/hooks/toast/toast.tsx b/client/app/lib/hooks/toast/toast.tsx index a94f377d104..bafb0c50af9 100644 --- a/client/app/lib/hooks/toast/toast.tsx +++ b/client/app/lib/hooks/toast/toast.tsx @@ -16,7 +16,9 @@ import { import { Typography } from '@mui/material'; import { produce } from 'immer'; -type Toaster = (message: string, options?: ToastOptions) => Id; +// `formattedMessage` already renders a ReactNode (and `PromisedToastMessages` already types its +// messages that way), so this only widens the type — nothing changes at runtime. +type Toaster = (message: ReactNode, options?: ToastOptions) => Id; interface PromisedToastMessages { pending?: ReactNode; diff --git a/client/app/routers/course/index.tsx b/client/app/routers/course/index.tsx index 13bc90b1aca..0abdd08ba4d 100644 --- a/client/app/routers/course/index.tsx +++ b/client/app/routers/course/index.tsx @@ -10,6 +10,7 @@ import forumsRouter from './forums'; import gradebookRouter from './gradebook'; import groupsRouter from './groups'; import lessonPlanRouter from './lessonPlan'; +import marketplaceRouter from './marketplace'; import materialsRouter from './materials'; import plagiarismRouter from './plagiarism'; import scholaisticRouter from './scholaistic'; @@ -45,6 +46,7 @@ const courseRouter: Translated = (t) => ({ gradebookRouter(t), groupsRouter(t), lessonPlanRouter(t), + marketplaceRouter(t), materialsRouter(t), plagiarismRouter(t), statisticsRouter(t), diff --git a/client/app/routers/course/marketplace.tsx b/client/app/routers/course/marketplace.tsx new file mode 100644 index 00000000000..a3c54571dc8 --- /dev/null +++ b/client/app/routers/course/marketplace.tsx @@ -0,0 +1,54 @@ +import { Navigate, RouteObject } from 'react-router-dom'; +import { WithRequired } from 'types'; + +import { Translated } from 'lib/hooks/useTranslation'; + +const marketplaceRouter: Translated = () => ({ + path: 'marketplace', + lazy: async () => ({ + handle: (await import('course/marketplace/handles')).marketplaceHandle, + }), + children: [ + { + index: true, + lazy: async () => ({ + Component: (await import('course/marketplace/pages/MarketplaceIndex')) + .default, + }), + }, + { + // `listings` on its own (no id) is not a real page — send it back to the + // marketplace index so it lands in the same place as `marketplace/`. + path: 'listings', + element: , + }, + { + path: 'listings/:listingId', + lazy: async () => ({ + handle: (await import('course/marketplace/handles')).listingHandle, + }), + children: [ + { + index: true, + lazy: async () => ({ + Component: (await import('course/marketplace/pages/ListingPreview')) + .default, + }), + }, + { + path: 'questions/:questionId', + lazy: async (): Promise> => { + const [{ default: Component }, { questionHandle }] = + await Promise.all([ + import('course/marketplace/pages/QuestionPreview'), + import('course/marketplace/handles'), + ]); + return { Component, handle: questionHandle }; + }, + }, + ], + }, + ], +}); + +export default marketplaceRouter; 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/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index 1fcdcb66319..f23d634b075 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -106,7 +106,10 @@ export interface AssessmentData extends AssessmentActionsData { canManage: boolean; canObserve: boolean; canInviteToKoditsu: boolean; + canPublishToMarketplace: boolean; }; + isPublishedToMarketplace: boolean; + marketplaceListingUrl: string; requirements: { title: string; satisfied?: boolean; 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 93611b2df81..6137b773a09 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -1493,6 +1493,9 @@ "course.assessment.assessments.sendReminderEmailSuccess": { "defaultMessage": "Closing assessment reminder emails have been successfully dispatched." }, + "course.assessment.AssessmentsIndex.importAssessments": { + "defaultMessage": "Import Assessments" + }, "course.assessment.create.createAsDraft": { "defaultMessage": "Create As Draft" }, @@ -4322,6 +4325,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "Announcements" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "Assessment Marketplace" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "Assessments" }, @@ -4580,6 +4586,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "Duplicate Data" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "Assessment Marketplace" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "Timeline Designer" }, @@ -6059,6 +6068,108 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "Experience points threshold cannot be 0" }, + "course.marketplace.publish": { + "defaultMessage": "Publish to Marketplace" + }, + "course.marketplace.remove": { + "defaultMessage": "Remove from Marketplace" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "Publish to Marketplace?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title." + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "Remove from Marketplace?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "It will no longer appear in the marketplace. Existing copies are unaffected." + }, + "course.marketplace.publishedToast": { + "defaultMessage": "Published to the marketplace." + }, + "course.marketplace.removedToast": { + "defaultMessage": "Removed from the marketplace." + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected." + }, + "course.marketplace.pageTitle": { + "defaultMessage": "Assessment Marketplace" + }, + "course.marketplace.colTitle": { + "defaultMessage": "Title" + }, + "course.marketplace.colQuestions": { + "defaultMessage": "Questions" + }, + "course.marketplace.colAdoptions": { + "defaultMessage": "Adoptions" + }, + "course.marketplace.colActions": { + "defaultMessage": "Actions" + }, + "course.marketplace.colPublished": { + "defaultMessage": "Published at" + }, + "course.marketplace.colPublisher": { + "defaultMessage": "Publisher" + }, + "course.marketplace.previewAction": { + "defaultMessage": "Preview" + }, + "course.marketplace.previewBadge": { + "defaultMessage": "Preview" + }, + "course.marketplace.searchPlaceholder": { + "defaultMessage": "Search by title" + }, + "course.marketplace.sortLabel": { + "defaultMessage": "Sort by" + }, + "course.marketplace.sortMostAdopted": { + "defaultMessage": "Most adopted" + }, + "course.marketplace.sortNewest": { + "defaultMessage": "Newest" + }, + "course.marketplace.duplicateN": { + "defaultMessage": "{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}" + }, + "course.marketplace.confirmationQuestion": { + "defaultMessage": "Duplicate items?" + }, + "course.marketplace.destinationCourse": { + "defaultMessage": "Destination Course" + }, + "course.marketplace.pickDestinationTab": { + "defaultMessage": "Pick destination tab" + }, + "course.marketplace.duplicating": { + "defaultMessage": "Duplicating" + }, + "course.marketplace.duplicateConfirm": { + "defaultMessage": "Duplicate" + }, + "course.marketplace.duplicateCompleted": { + "defaultMessage": "{n, plural, one {Assessment duplicated} other {Assessments duplicated}}." + }, + "course.marketplace.duplicateFailed": { + "defaultMessage": "{n, plural, one {Could not duplicate the assessment} other {Could not duplicate the assessments}}." + }, + "course.marketplace.viewDuplicatedAssessment": { + "defaultMessage": "View assessment" + }, + "course.marketplace.selectToDuplicate": { + "defaultMessage": "Select to duplicate" + }, + "course.marketplace.emptyNoListings": { + "defaultMessage": "No assessments have been published to the marketplace yet." + }, + "course.marketplace.emptyNoMatch": { + "defaultMessage": "No assessments match your search." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "Download has failed. Please try again later." }, @@ -8669,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" }, @@ -8753,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 matched by these rules" + }, + "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 2fb4e77cae7..4c02c3b2b57 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -1493,6 +1493,9 @@ "course.assessment.assessments.sendReminderEmailSuccess": { "defaultMessage": "평가 마감 알림 이메일이 성공적으로 발송되었습니다." }, + "course.assessment.AssessmentsIndex.importAssessments": { + "defaultMessage": "평가 가져오기" + }, "course.assessment.create.createAsDraft": { "defaultMessage": "드래프트로 생성" }, @@ -4304,6 +4307,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "공지 사항" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "평가 마켓플레이스" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "평가" }, @@ -4562,6 +4568,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "데이터 복제" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "평가 마켓플레이스" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "타임라인 디자이너" }, @@ -6023,6 +6032,99 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "경험치 기준은 0이 될 수 없습니다" }, + "course.marketplace.publish": { + "defaultMessage": "마켓플레이스에 게시" + }, + "course.marketplace.remove": { + "defaultMessage": "마켓플레이스에서 제거" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "마켓플레이스에 게시하시겠습니까?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "이 평가는 강좌 관리자가 찾아볼 수 있으며, 미리보기 및 복제할 수 있습니다. 이 평가의 자체 제목과 설명이 사용됩니다." + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "마켓플레이스에서 제거하시겠습니까?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "더 이상 마켓플레이스에 표시되지 않습니다. 기존 복사본은 영향을 받지 않습니다." + }, + "course.marketplace.publishedToast": { + "defaultMessage": "마켓플레이스에 게시되었습니다." + }, + "course.marketplace.removedToast": { + "defaultMessage": "마켓플레이스에서 제거되었습니다." + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "이 평가는 평가 마켓플레이스에 있습니다. 삭제하면 마켓플레이스에서 제거되고 채택 기록도 삭제됩니다. 다른 강좌의 기존 복사본은 영향을 받지 않습니다." + }, + "course.marketplace.pageTitle": { + "defaultMessage": "평가 마켓플레이스" + }, + "course.marketplace.colTitle": { + "defaultMessage": "제목" + }, + "course.marketplace.colQuestions": { + "defaultMessage": "문제" + }, + "course.marketplace.colAdoptions": { + "defaultMessage": "채택" + }, + "course.marketplace.colActions": { + "defaultMessage": "작업" + }, + "course.marketplace.colPublished": { + "defaultMessage": "게시 일시" + }, + "course.marketplace.colPublisher": { + "defaultMessage": "게시자" + }, + "course.marketplace.previewAction": { + "defaultMessage": "미리보기" + }, + "course.marketplace.searchPlaceholder": { + "defaultMessage": "제목으로 검색" + }, + "course.marketplace.sortLabel": { + "defaultMessage": "정렬 기준" + }, + "course.marketplace.sortMostAdopted": { + "defaultMessage": "가장 많이 채택됨" + }, + "course.marketplace.sortNewest": { + "defaultMessage": "최신순" + }, + "course.marketplace.duplicateN": { + "defaultMessage": "{n}개 평가 복제" + }, + "course.marketplace.confirmationQuestion": { + "defaultMessage": "항목을 복제하시겠습니까?" + }, + "course.marketplace.destinationCourse": { + "defaultMessage": "대상 강좌" + }, + "course.marketplace.assessmentsHeading": { + "defaultMessage": "평가" + }, + "course.marketplace.duplicateConfirm": { + "defaultMessage": "복제" + }, + "course.marketplace.duplicateStarted": { + "defaultMessage": "{n, plural, one {평가 복제가} other {평가 복제가}} 시작되었습니다." + }, + "course.marketplace.duplicateFailed": { + "defaultMessage": "{n, plural, one {평가 복제에} other {평가 복제에}} 실패했습니다." + }, + "course.marketplace.selectToDuplicate": { + "defaultMessage": "복제하려면 선택" + }, + "course.marketplace.emptyNoListings": { + "defaultMessage": "아직 마켓플레이스에 게시된 평가가 없습니다." + }, + "course.marketplace.emptyNoMatch": { + "defaultMessage": "검색과 일치하는 평가가 없습니다." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, @@ -8645,6 +8747,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "도움 받기" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "마켓플레이스 접근" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "공지사항을 가져올 수 없습니다." }, @@ -8729,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 97ace071541..263dbc1ebf5 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -1484,6 +1484,9 @@ "course.assessment.assessments.sendReminderEmailSuccess": { "defaultMessage": "已成功发送结束测验的提醒邮件。" }, + "course.assessment.AssessmentsIndex.importAssessments": { + "defaultMessage": "导入评估" + }, "course.assessment.create.createAsDraft": { "defaultMessage": "创建为草稿" }, @@ -4298,6 +4301,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "公告" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "评估市场" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "测验" }, @@ -4556,6 +4562,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "复制数据" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "평가 마켓플레이스" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "时间线设计工具" }, @@ -6017,6 +6026,99 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "经验值阈值不能为0" }, + "course.marketplace.publish": { + "defaultMessage": "发布到市场" + }, + "course.marketplace.remove": { + "defaultMessage": "从市场移除" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "发布到市场?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "课程管理员可以浏览此评估,并可预览和复制它。它会使用此评估自身的标题和描述。" + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "从市场移除?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "它将不再显示在市场中。现有副本不受影响。" + }, + "course.marketplace.publishedToast": { + "defaultMessage": "已发布到市场。" + }, + "course.marketplace.removedToast": { + "defaultMessage": "已从市场移除。" + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "此评估位于评估市场中。删除它会将其从市场移除,并删除其采用历史记录。其他课程中的现有副本不受影响。" + }, + "course.marketplace.pageTitle": { + "defaultMessage": "评估市场" + }, + "course.marketplace.colTitle": { + "defaultMessage": "标题" + }, + "course.marketplace.colQuestions": { + "defaultMessage": "问题" + }, + "course.marketplace.colAdoptions": { + "defaultMessage": "采用次数" + }, + "course.marketplace.colActions": { + "defaultMessage": "操作" + }, + "course.marketplace.colPublished": { + "defaultMessage": "发布时间" + }, + "course.marketplace.colPublisher": { + "defaultMessage": "发布者" + }, + "course.marketplace.previewAction": { + "defaultMessage": "预览" + }, + "course.marketplace.searchPlaceholder": { + "defaultMessage": "按标题搜索" + }, + "course.marketplace.sortLabel": { + "defaultMessage": "排序方式" + }, + "course.marketplace.sortMostAdopted": { + "defaultMessage": "采用最多" + }, + "course.marketplace.sortNewest": { + "defaultMessage": "最新" + }, + "course.marketplace.duplicateN": { + "defaultMessage": "复制 {n} 个评估" + }, + "course.marketplace.confirmationQuestion": { + "defaultMessage": "复制项目?" + }, + "course.marketplace.destinationCourse": { + "defaultMessage": "目标课程" + }, + "course.marketplace.assessmentsHeading": { + "defaultMessage": "评估" + }, + "course.marketplace.duplicateConfirm": { + "defaultMessage": "复制" + }, + "course.marketplace.duplicateStarted": { + "defaultMessage": "{n, plural, one {评估复制} other {评估复制}}已开始。" + }, + "course.marketplace.duplicateFailed": { + "defaultMessage": "{n, plural, one {评估复制} other {评估复制}}失败。" + }, + "course.marketplace.selectToDuplicate": { + "defaultMessage": "选择评估复制" + }, + "course.marketplace.emptyNoListings": { + "defaultMessage": "尚未有评估发布到市场。" + }, + "course.marketplace.emptyNoMatch": { + "defaultMessage": "没有符合搜索条件的评估。" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, @@ -8639,6 +8741,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "获取帮助" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "市场访问" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "无法获取公告" }, @@ -8723,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 e57841f9fbf..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] @@ -286,6 +293,8 @@ resources :mock_answers, on: :member, only: [:index, :create, :destroy] end + resource :marketplace_listing, only: [:create, :destroy] + namespace :question do resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] do post :generate, on: :collection @@ -611,6 +620,14 @@ get 'learn_settings', to: 'stories#learn_settings' get 'mission_control', to: 'stories#mission_control' end + + scope module: 'assessment/marketplace' do + get 'marketplace' => 'listings#index', as: :marketplace + resources :listings, only: [:show], path: 'marketplace/listings' do + post 'duplicate', on: :collection + resources :questions, only: [:show] + end + end end end diff --git a/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb new file mode 100644 index 00000000000..e1b094eb9ad --- /dev/null +++ b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb @@ -0,0 +1,30 @@ +class CreateCourseAssessmentMarketplaceListings < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_listings do |t| + t.references :assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_course_assessment_marketplace_listings_assessment_id', + on_delete: :cascade }, + index: { name: 'fk__course_assessment_marketplace_listings_assessment_id', + unique: true } + t.boolean :published, null: false, default: false + t.datetime :first_published_at + t.datetime :last_published_at + t.references :publisher, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_publisher_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_publisher_id' } + t.references :creator, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_creator_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_updater_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_listings, :published, + name: 'index_course_assessment_marketplace_listings_on_published' + end +end diff --git a/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb new file mode 100644 index 00000000000..1f7eee7c3d6 --- /dev/null +++ b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb @@ -0,0 +1,33 @@ +class CreateCourseAssessmentMarketplaceAdoptions < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_adoptions do |t| + t.references :listing, null: false, + foreign_key: { to_table: :course_assessment_marketplace_listings, + name: 'fk_course_assessment_marketplace_adoptions_listing_id', + on_delete: :cascade }, + index: { name: 'fk__course_assessment_marketplace_adoptions_listing_id' } + t.references :destination_course, null: false, + foreign_key: { to_table: :courses, + name: 'fk_cama_destination_course_id', + on_delete: :cascade }, + index: { name: 'fk__cama_destination_course_id' } + t.references :duplicated_assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_cama_duplicated_assessment_id', + on_delete: :cascade }, + index: { name: 'fk__cama_duplicated_assessment_id', + unique: true } + t.references :creator, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_adoptions_creator_id' }, + index: { name: 'fk__cama_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_adoptions_updater_id' }, + index: { name: 'fk__cama_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_adoptions, [:listing_id, :destination_course_id], + name: 'index_cama_on_listing_id_and_destination_course_id' + end +end 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..91e58838c79 --- /dev/null +++ b/db/migrate/20260720154800_create_course_assessment_marketplace_allowlist_rules.rb @@ -0,0 +1,55 @@ +# 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" + + create_table :course_assessment_marketplace_access_blocks do |t| + t.references :user, null: false, foreign_key: true, index: { unique: true } + t.references :creator, null: false, foreign_key: { to_table: :users } + t.timestamps + end + end +end diff --git a/db/migrate/20260723000001_create_course_assessment_submission_details.rb b/db/migrate/20260723000001_create_course_assessment_submission_details.rb new file mode 100644 index 00000000000..5b1b258c5c8 --- /dev/null +++ b/db/migrate/20260723000001_create_course_assessment_submission_details.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Extracts the course-coupled columns of the submission into their own +# small extension table WITHOUT renaming the base table. `Course::Assessment::Attempt` maps onto the +# existing `course_assessment_submissions` (the base) via `self.table_name`; `Submission` maps onto +# this new table. +# +# Purely additive: no DDL touches `course_assessment_submissions` (only READ for the backfill), so a +# rolling deploy's still-old worker is completely undisturbed. Reversible via `drop_table`. +# +# The course-coupled columns are deliberately left ALSO on the base table for now (duplicated) so the +# ~24 raw-SQL sites that read them keep working; a later cleanup migration drops them from the base. +class CreateCourseAssessmentSubmissionDetails < ActiveRecord::Migration[7.2] + # Non-transactional so each backfill batch commits on its own — a production-sized + # `course_assessment_submissions` must never ride in one giant transaction (long lock, WAL spike, + # statement_timeout). The trade-off is that `up` is no longer atomic, so every step below is made + # individually idempotent (`if_not_exists` + `WHERE NOT EXISTS`) and the whole migration is safe to + # re-run after a partial failure. + disable_ddl_transaction! + + BACKFILL_BATCH_SIZE = 5_000 + + def up + create_table :course_assessment_submission_details, id: :serial, if_not_exists: true 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_submission_details, :attempt_id, unique: true, if_not_exists: true, + name: 'unique_course_assessment_submission_details_attempt_id' + add_foreign_key :course_assessment_submission_details, :course_assessment_submissions, if_not_exists: true, + column: :attempt_id, name: 'fk_course_assessment_submission_details_attempt_id' + add_foreign_key :course_assessment_submission_details, :users, if_not_exists: true, + column: :publisher_id, name: 'fk_course_assessment_submission_details_publisher_id' + + backfill_details + end + + def down + drop_table :course_assessment_submission_details, if_exists: true + end + + private + + # One extension row per existing base row, copied 1:1 (every base row is a real submission — preview + # attempts don't exist yet). `WHERE NOT EXISTS` skips rows already backfilled, so this both fills a + # fresh table and reconciles a partially-filled one; batched + looped so no single statement scans + # the whole base table. Mirrors `rake db:backfill_submission_details`, which reruns this after the + # rolling deploy fully cuts over. + def backfill_details + loop do + inserted = execute(<<~SQL.squish).cmd_tuples + INSERT INTO course_assessment_submission_details + (attempt_id, publisher_id, session_id, last_graded_time, created_at, updated_at) + SELECT s.id, s.publisher_id, s.session_id, s.last_graded_time, s.created_at, s.updated_at + FROM course_assessment_submissions s + WHERE NOT EXISTS ( + SELECT 1 FROM course_assessment_submission_details d WHERE d.attempt_id = s.id + ) + ORDER BY s.id + LIMIT #{BACKFILL_BATCH_SIZE} + SQL + break if inserted == 0 + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c61308e6fbf..cd2174af4cd 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_06_25_000000) do +ActiveRecord::Schema[7.2].define(version: 2026_07_23_000001) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -270,6 +270,64 @@ 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 + t.bigint "duplicated_assessment_id", null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "fk__cama_creator_id" + t.index ["destination_course_id"], name: "fk__cama_destination_course_id" + t.index ["duplicated_assessment_id"], name: "fk__cama_duplicated_assessment_id", unique: true + t.index ["listing_id", "destination_course_id"], name: "index_cama_on_listing_id_and_destination_course_id" + t.index ["listing_id"], name: "fk__course_assessment_marketplace_adoptions_listing_id" + 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 + t.datetime "first_published_at" + t.datetime "last_published_at" + t.bigint "publisher_id", null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assessment_id"], name: "fk__course_assessment_marketplace_listings_assessment_id", unique: true + t.index ["creator_id"], name: "fk__course_assessment_marketplace_listings_creator_id" + t.index ["published"], name: "index_course_assessment_marketplace_listings_on_published" + t.index ["publisher_id"], name: "fk__course_assessment_marketplace_listings_publisher_id" + t.index ["updater_id"], name: "fk__course_assessment_marketplace_listings_updater_id" + end + create_table "course_assessment_plagiarism_checks", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false @@ -549,6 +607,16 @@ t.index ["skill_id"], name: "index_course_assessment_skills_question_assessments_on_skill_id" end + create_table "course_assessment_submission_details", id: :serial, force: :cascade 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.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["attempt_id"], name: "unique_course_assessment_submission_details_attempt_id", unique: true + end + create_table "course_assessment_submission_logs", id: :serial, force: :cascade do |t| t.integer "submission_id", null: false t.jsonb "request" @@ -1945,6 +2013,19 @@ 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" + add_foreign_key "course_assessment_marketplace_listings", "users", column: "updater_id", name: "fk_course_assessment_marketplace_listings_updater_id" 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" @@ -1985,6 +2066,8 @@ 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_details", "course_assessment_submissions", column: "attempt_id", name: "fk_course_assessment_submission_details_attempt_id" + add_foreign_key "course_assessment_submission_details", "users", column: "publisher_id", name: "fk_course_assessment_submission_details_publisher_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_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" diff --git a/lib/tasks/db/backfill_submission_details.rake b/lib/tasks/db/backfill_submission_details.rake new file mode 100644 index 00000000000..86c18154415 --- /dev/null +++ b/lib/tasks/db/backfill_submission_details.rake @@ -0,0 +1,34 @@ +# frozen_string_literal: true +namespace :db do + # Backfills a `course_assessment_submission_details` row for every base `course_assessment_submissions` + # row that lacks one. Idempotent (`WHERE NOT EXISTS`) and batched, so it is safe to run repeatedly. + # + # Run this AFTER a rolling deploy has fully cut over. The create-table migration's own backfill only + # covers rows that existed when it ran; still-old workers keep inserting detail-less base rows during + # the deploy window, and new code reads those as previews (`Attempt#preview?` is "no detail row"). + # This task reconciles them. + task backfill_submission_details: :environment do + ActsAsTenant.without_tenant do + batch_size = 5_000 + connection = ActiveRecord::Base.connection + total = 0 + loop do + inserted = connection.execute(<<~SQL.squish).cmd_tuples + INSERT INTO course_assessment_submission_details + (attempt_id, publisher_id, session_id, last_graded_time, created_at, updated_at) + SELECT s.id, s.publisher_id, s.session_id, s.last_graded_time, s.created_at, s.updated_at + FROM course_assessment_submissions s + WHERE NOT EXISTS ( + SELECT 1 FROM course_assessment_submission_details d WHERE d.attempt_id = s.id + ) + ORDER BY s.id + LIMIT #{batch_size} + SQL + total += inserted + puts "Backfilled #{total} submission detail row(s)..." if inserted > 0 + break if inserted == 0 + end + puts "Done. Backfilled #{total} missing submission detail row(s)." + end + end +end diff --git a/spec/controllers/concerns/course/assessment/live_feedback/thread_concern_spec.rb b/spec/controllers/concerns/course/assessment/live_feedback/thread_concern_spec.rb index 5c0a4cbb285..89da5d393d9 100644 --- a/spec/controllers/concerns/course/assessment/live_feedback/thread_concern_spec.rb +++ b/spec/controllers/concerns/course/assessment/live_feedback/thread_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: submission, question: question) + Course::Assessment::SubmissionQuestion.create!(submission: submission.attempt, question: question) end let!(:thread_info) { { 'id' => SecureRandom.hex(12), 'status' => 'active' } } diff --git a/spec/controllers/concerns/course/assessment/submission/koditsu/submissions_concern_spec.rb b/spec/controllers/concerns/course/assessment/submission/koditsu/submissions_concern_spec.rb index 3cf33b72302..be7b2fba415 100644 --- a/spec/controllers/concerns/course/assessment/submission/koditsu/submissions_concern_spec.rb +++ b/spec/controllers/concerns/course/assessment/submission/koditsu/submissions_concern_spec.rb @@ -103,8 +103,11 @@ class self::DummyController < ApplicationController expect(submissions[0].workflow_state).to eq('submitted') expect(submissions[1].workflow_state).to eq('submitted') - student_one_answers = submissions[0].answers - student_two_answers = submissions[1].answers + # `Answer`'s `default_scope { order(:created_at) }` has no tiebreak, and the two answers are + # inserted together (identical `created_at`), so `.answers` may return them in either order. + # Sort by `question_id` to assert on each question's answer deterministically. + student_one_answers = submissions[0].answers.sort_by(&:question_id) + student_two_answers = submissions[1].answers.sort_by(&:question_id) expect(student_one_answers[0].correct).to be_truthy expect(student_one_answers[1].correct).to be_falsey diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb new file mode 100644 index 00000000000..8c0b966a9ad --- /dev/null +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::AssessmentsController, type: :controller do + render_views + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:admin) { create(:administrator) } + + describe 'GET #show — marketplace fields' do + context 'as a system admin' do + before { controller_sign_in(controller, admin) } + + it 'grants the publish permission and reports not-yet-published' do + get :show, as: :json, params: { course_id: course, id: assessment } + body = JSON.parse(response.body) + expect(body['permissions']).to include('canPublishToMarketplace' => true) + expect(body).to include('isPublishedToMarketplace' => false) + expect(body['marketplaceListingUrl']).to be_present + end + + it 'reports isPublishedToMarketplace true once a published listing exists' do + create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + get :show, as: :json, params: { course_id: course, id: assessment } + expect(JSON.parse(response.body)).to include('isPublishedToMarketplace' => true) + end + end + + context 'as a course manager (non-admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + + it 'withholds the publish permission' do + get :show, as: :json, params: { course_id: course, id: assessment } + expect(JSON.parse(response.body)['permissions']).to include('canPublishToMarketplace' => false) + end + end + end + end +end diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb new file mode 100644 index 00000000000..bdf1398de0e --- /dev/null +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::ListingsController, type: :controller do + render_views # index.json.jbuilder output is asserted below — controller specs don't render views otherwise + + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:manager) { create(:course_manager, course: course) } + + 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) } + + it 'returns only published listings' do + get :index, params: { course_id: course, format: :json } + ids = response.parsed_body['listings'].map { |l| l['id'] } + expect(ids).to include(published.id) + expect(ids).not_to include(unpublished.id) + end + + it 'includes title, question count and adoptions, and canAccess' do + get :index, params: { course_id: course, format: :json } + expect(response.parsed_body['canAccess']).to be(true) + row = response.parsed_body['listings'].find { |l| l['id'] == published.id } + expect(row).to include('title', 'questionCount', 'adoptions', 'previewUrl', 'duplicateUrl') + end + + it 'includes the current course destination tabs with category names' do + get :index, params: { course_id: course, format: :json } + tabs = response.parsed_body['destinationTabs'] + expect(tabs).to be_present + default_tab = course.assessment_categories.first.tabs.first + row = tabs.find { |tab| tab['id'] == default_tab.id } + expect(row).to include( + 'id' => default_tab.id, + 'title' => default_tab.title, + 'categoryId' => default_tab.category.id, + 'categoryTitle' => default_tab.category.title + ) + end + + it 'reports the live distinct-course adoption count' do + listing = create(:course_assessment_marketplace_listing, published: true) + create(:course_assessment_marketplace_adoption, listing: listing, destination_course: create(:course)) + get :index, params: { course_id: course, format: :json } + row = response.parsed_body['listings'].find { |l| l['id'] == listing.id } + expect(row['adoptions']).to eq(1) + end + + it 'reports the actual question count for a listing (not the 0 fallback)' do + assessment_with_questions = create(:assessment, :with_mcq_question, question_count: 3, course: course) + listing = create(:course_assessment_marketplace_listing, published: true, assessment: assessment_with_questions) + get :index, params: { course_id: course, format: :json } + row = response.parsed_body['listings'].find { |l| l['id'] == listing.id } + expect(row['questionCount']).to eq(3) + end + + context 'as a student' do + let(:student) { create(:course_student, course: course).user } + before { controller_sign_in(controller, student) } + it 'is forbidden' do + expect do + get :index, params: { course_id: course, format: :json } + end.to raise_exception(CanCan::AccessDenied) + 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 } + + it 'enqueues a duplication job with the destination course + tab' do + expect do + post :duplicate, params: { + course_id: course, listing_ids: [listing.id], destination_tab_id: tab.id, format: :json + } + end.to have_enqueued_job(Course::Assessment::Marketplace::DuplicationJob). + with([listing.id], course, tab.id, current_user: manager.user) + expect(response.parsed_body['jobUrl']).to be_present + end + + context 'when the listing is unpublished' do + let!(:listing) { create(:course_assessment_marketplace_listing, published: false) } + it 'is forbidden and enqueues nothing' do + expect do + post :duplicate, params: { + course_id: course, listing_ids: [listing.id], destination_tab_id: tab.id, format: :json + } + end.not_to have_enqueued_job(Course::Assessment::Marketplace::DuplicationJob) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when no matching published listing exists (empty/unknown ids)' do + it 'is forbidden (renders 403 on the empty set)' do + post :duplicate, params: { + course_id: course, listing_ids: [-1], destination_tab_id: tab.id, format: :json + } + expect(response).to have_http_status(:forbidden) + end + end + end + end + describe 'GET #show (preview)' do + # `run_rescue` re-enables handle_access_denied so a denied preview 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) } + + let!(:listing) do + assessment = create(:assessment, course: create(:course)) + create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) + create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + end + + it 'renders the assessment config read-only' do + get :show, params: { course_id: course, id: listing.id, format: :json } + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body).to include('title', 'gradingMode', 'showMcqMrqSolution', 'showRubricToStudents', 'gradedTestCases') + # The listing preview reports the human-readable question type, matching the per-question chips. + readable_type = I18n.t('course.assessment.question.multiple_responses.question_type.multiple_choice') + expect(body['typeCounts']).to include(readable_type => 1) + + question = body['questions'].first + expect(question).to have_key('staffOnlyComments') + expect(question['type']).to eq(readable_type) + expect(question['unautogradable']).to be(false) + expect(question['mcqMrqType']).to eq('mcq') + expect(question['options']).to be_present + end + + it 'includes the current course destination tabs so the duplicate dialog can offer a picker' do + get :show, params: { course_id: course, id: listing.id, format: :json } + tabs = response.parsed_body['destinationTabs'] + expect(tabs).to be_present + default_tab = course.assessment_categories.first.tabs.first + row = tabs.find { |tab| tab['id'] == default_tab.id } + expect(row).to include( + 'id' => default_tab.id, + 'title' => default_tab.title, + 'categoryId' => default_tab.category.id, + 'categoryTitle' => default_tab.category.title + ) + end + + context 'when the listing is unpublished' do + let!(:listing) { create(:course_assessment_marketplace_listing, published: false) } + it 'is forbidden' do + get :show, params: { course_id: course, id: listing.id, format: :json } + expect(response).to have_http_status(:forbidden) + end + end + end + end + + # Cross-instance: a listing published in another instance is visible. + describe 'cross-instance visibility' do + let(:other_instance) { create(:instance) } + let(:home_instance) { create(:instance) } + + it 'lists listings from other instances' do + foreign = ActsAsTenant.with_tenant(other_instance) do + create(:course_assessment_marketplace_listing, published: true) + end + 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). + @request.headers['host'] = home_instance.host + get :index, params: { course_id: course, format: :json } + ids = response.parsed_body['listings'].map { |l| l['id'] } + expect(ids).to include(foreign.id) + end + end + end +end diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb new file mode 100644 index 00000000000..de6d5949df3 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::QuestionsController, type: :controller do + render_views + + let(:source_instance) { create(:instance) } + let(:destination_instance) { create(:instance) } + + # Source-side data lives in the source instance. The listing is cross-instance, so it is built + # with the tenant switched off — mirroring the controller's own without_tenant reads. These are + # outer-level lets: they run before with_tenant sets the destination tenant, and they don't rely + # on an ambient tenant because each sets its own explicitly. + let!(:source_assessment) do + ActsAsTenant.with_tenant(source_instance) do + course = create(:course, instance: source_instance) + assessment = create(:assessment, course: course) + create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) + assessment + end + end + let!(:listing) do + # NOTE: the factory has no :published trait — `published { true }` is a default attribute + # (spec/factories/course_assessment_marketplace_listings.rb). Do NOT pass `:published`. + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: source_assessment) + end + end + let(:question) { source_assessment.questions.first } + + # Destination-side data + the request run under the destination tenant. with_tenant (controller + # variant) sets ActsAsTenant.current_tenant AND the request host, so every tenant-scoped create + # below (Course, CourseUser) and the controller's own tenant deduction resolve to the destination. + with_tenant(:destination_instance) do + let(:destination_course) { create(:course) } + let(:manager) { create(:course_manager, course: destination_course).user } + + 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: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['id']).to eq(question.id) + expect(body['type']).to eq('MultipleResponse') + expect(body['detail']).to be_present + end + + it 'denies when the listing is unpublished' do + ActsAsTenant.without_tenant { listing.update!(published: false) } + expect do + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + end.to raise_exception(CanCan::AccessDenied) + end + + it 'serializes the MCQ answer key (options with correctness, explanation, weight)' do + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + detail = response.parsed_body['detail'] + expect(detail['gradingScheme']).to be_present + expect(detail['options'].first).to include('option', 'correct', 'explanation', 'weight') + end + + it 'serializes programming template files and test-case buckets' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create( + :course_assessment_question_programming, + assessment: assessment, + test_case_count: 1, + private_test_case_count: 1, + evaluation_test_case_count: 1 + ) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + detail = response.parsed_body['detail'] + expect(detail['languageName']).to be_present + expect(detail['templateFiles']).to be_present + expect(detail['publicTestCases'].first).to include('expression', 'expected', 'hint') + end + + it 'serializes text-response solutions and attachment settings' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_text_response, :exact_match_solution, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + detail = response.parsed_body['detail'] + expect(detail).to include('hideText', 'isAttachmentRequired', 'maxAttachments', 'isComprehension') + expect(detail['solutions'].first).to include('solution', 'grade') + end + + it 'serializes rubric categories and criteria' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_rubric_based_response, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + category = response.parsed_body['detail']['categories'].first + expect(category).to include('name', 'isBonus') + expect(category['criteria'].first).to include('grade', 'explanation') + end + + it 'serializes forum post requirements' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_forum_post_response, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + expect(response.parsed_body['detail']).to include('maxPosts', 'hasTextResponse') + end + + it 'serializes voice response with an empty detail object' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_voice_response, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + expect(response.parsed_body['type']).to eq('VoiceResponse') + expect(response.parsed_body['detail']).to eq({}) + end + + it 'serializes scribing with an imageUrl key (null when no attachment)' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_scribing, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + expect(response.parsed_body['type']).to eq('Scribing') + expect(response.parsed_body['detail']).to have_key('imageUrl') + expect(response.parsed_body['detail']['imageUrl']).to be_nil + end + end +end diff --git a/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb new file mode 100644 index 00000000000..ed42190bd36 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::MarketplaceListingsController, type: :controller do + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:admin) { create(:administrator) } + + before { controller_sign_in(controller, admin) } + + describe 'POST #create' do + subject { post :create, params: { course_id: course, assessment_id: assessment, format: :json } } + + it 'creates a published listing' do + expect { subject }.to change { Course::Assessment::Marketplace::Listing.count }.by(1) + listing = assessment.reload.marketplace_listing + expect(listing.published).to be(true) + expect(listing.first_published_at).to be_present + expect(listing.last_published_at).to be_present + expect(listing.publisher).to eq(admin) + end + + context 'when the assessment was previously published then removed (re-publish)' do + let!(:listing) do + create(:course_assessment_marketplace_listing, assessment: assessment, published: false, + first_published_at: 3.days.ago, last_published_at: 3.days.ago) + end + + it 'reuses the existing row, preserves first_published_at, bumps last_published_at' do + original_first = listing.first_published_at + expect { subject }.not_to(change { Course::Assessment::Marketplace::Listing.count }) + listing.reload + expect(listing.published).to be(true) + expect(listing.first_published_at).to be_within(1.second).of(original_first) # NOT overwritten + expect(listing.last_published_at).to be > original_first # bumped to now + end + end + + context 'when the user is a course manager (can read but not an admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + end + + describe 'DELETE #destroy' do + let!(:listing) { create(:course_assessment_marketplace_listing, assessment: assessment, published: true) } + + it 'soft-removes: keeps the row, sets published false' do + delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } + expect(listing.reload.published).to be(false) + expect(Course::Assessment::Marketplace::Listing.exists?(listing.id)).to be(true) + end + + context 'when the assessment has no marketplace listing' do + let(:unlisted_assessment) { create(:assessment, course: course) } + + it 'responds unprocessable' do + delete :destroy, params: { course_id: course, assessment_id: unlisted_assessment, format: :json } + expect(response).to have_http_status(:unprocessable_content) + end + end + + context 'when the user is a course manager (can read but not an admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + it 'is forbidden and leaves the listing published' do + expect do + delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } + end.to raise_exception(CanCan::AccessDenied) + expect(listing.reload.published).to be(true) + end + end + end + end +end diff --git a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb index 5aa00a954e3..f5af1eaae4c 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 itself is unchanged) became far more likely to manifest + # once the Attempt/Submission split added enough extra latency to the 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 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..68eccd9b8e8 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 @@ -85,6 +85,21 @@ expect(json_result['comments'].count).to eq(1) end end + + context 'when the submission_id refers to a preview attempt (an Attempt with no Submission)' do + let(:user) { create(:course_manager, course: course).user } + let!(:preview_attempt) { create(:course_assessment_attempt, assessment: assessment) } + before { controller_sign_in(controller, user) } + + it 'raises RecordNotFound rather than dereferencing the nil submission' do + expect do + get :all_answers, format: :json, params: { + course_id: course, id: assessment, + submission_id: preview_attempt.id, question_id: answer.question_id + } + end.to raise_exception(ActiveRecord::RecordNotFound) + end + end end end end diff --git a/spec/controllers/course/assessment_marketplace_component_spec.rb b/spec/controllers/course/assessment_marketplace_component_spec.rb new file mode 100644 index 00000000000..78cd6db53f5 --- /dev/null +++ b/spec/controllers/course/assessment_marketplace_component_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::AssessmentMarketplaceComponent do + controller(Course::Controller) {} # rubocop:disable Lint/EmptyBlock + + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + + subject do + controller.instance_variable_set(:@course, course) + described_class.new(controller) + end + + context 'when the user can access the marketplace (course manager)' do + let(:user) { create(:course_manager, course: course).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 } + expect(item).to be_present + expect(item[:type]).to eq(:admin) + expect(item[:icon]).to eq(:marketplace) + expect(item[:path]).to eq(course_marketplace_path(course)) + end + end + + context 'when the user cannot access the marketplace (course student)' do + let(:user) { create(:course_student, course: course).user } + before { controller_sign_in(controller, user) } + + it 'exposes no sidebar item' do + expect(subject.sidebar_items).to be_empty + end + end + end +end diff --git a/spec/controllers/course/statistics/aggregate_controller_spec.rb b/spec/controllers/course/statistics/aggregate_controller_spec.rb index 07ee104ab43..d88f8c1ade4 100644 --- a/spec/controllers/course/statistics/aggregate_controller_spec.rb +++ b/spec/controllers/course/statistics/aggregate_controller_spec.rb @@ -211,6 +211,28 @@ it { expect(subject).to be_successful } end + context 'with a preview attempt (a bare Attempt with no Submission extension) by a student' do + let(:user) { create(:course_manager, course: course).user } + let!(:preview_student) { create(:course_student, course: course) } + # A preview attempt is an Attempt row in course_assessment_submissions with NO + # course_assessment_submission_details (extension) row. `update_columns` puts it in a + # submitted state without going through the workflow/extension machinery, exactly mimicking + # a leaked preview. The statistics must count it as neither attempted nor submitted. + let!(:preview_attempt) do + create(:course_assessment_attempt, assessment: assessment, creator: preview_student.user). + tap { |attempt| attempt.update_columns(workflow_state: 'submitted', submitted_at: Time.zone.now) } + end + before { controller_sign_in(controller, user) } + + it 'excludes the preview from the attempted and submitted student counts' do + expect(subject).to be_successful + json_result = JSON.parse(response.body) + + expect(json_result['assessments'][0]['numAttempted']).to eq(3) + expect(json_result['assessments'][0]['numSubmitted']).to eq(2) + end + end + context 'when the course has no students' do let(:user) { create(:course_manager, course: course).user } before do diff --git a/spec/controllers/course/statistics/assessment_controller_spec.rb b/spec/controllers/course/statistics/assessment_controller_spec.rb index cb7de26527d..2fde0c8b817 100644 --- a/spec/controllers/course/statistics/assessment_controller_spec.rb +++ b/spec/controllers/course/statistics/assessment_controller_spec.rb @@ -126,6 +126,28 @@ end end + context 'when a student has only a preview attempt (a bare Attempt with no Submission extension)' do + let(:user) { create(:course_manager, course: course).user } + let!(:preview_student) { create(:course_student, course: course) } + # A preview attempt: an Attempt row in course_assessment_submissions with no extension row. + # It must NOT be surfaced as this student's submission — they should read as 'unstarted'. + let!(:preview_attempt) do + create(:course_assessment_attempt, assessment: assessment, creator: preview_student.user). + tap { |attempt| attempt.update_columns(workflow_state: 'graded', submitted_at: Time.zone.now) } + end + before { controller_sign_in(controller, user) } + + it 'shows the previewing student as unstarted, not as their preview attempt' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + preview_row = json_result.find { |row| row.dig('courseUser', 'id') == preview_student.id } + expect(preview_row).not_to be_nil + expect(preview_row['workflowState']).to eq('unstarted') + expect(preview_row['answers']).to be_nil + end + end + context 'when the administrator get the submission statistics data' do let(:administrator) { create(:administrator) } before { controller_sign_in(controller, administrator) } 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..43272af08b1 --- /dev/null +++ b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb @@ -0,0 +1,291 @@ +# 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: the uniqueness index is on `lower(email)` while SQL LIKE is + # case-sensitive, so an anchored, case-sensitive pattern misses rows the 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_adoptions.rb b/spec/factories/course_assessment_marketplace_adoptions.rb new file mode 100644 index 00000000000..912723e5b67 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_adoptions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_adoption, + class: Course::Assessment::Marketplace::Adoption do + listing { association :course_assessment_marketplace_listing } + destination_course { association :course } + duplicated_assessment { association :assessment, course: destination_course } + 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_marketplace_listings.rb b/spec/factories/course_assessment_marketplace_listings.rb new file mode 100644 index 00000000000..6ea0a819ffe --- /dev/null +++ b/spec/factories/course_assessment_marketplace_listings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_listing, + class: Course::Assessment::Marketplace::Listing do + transient do + course { nil } + end + assessment { association :assessment, course: course || create(:course) } + publisher { assessment.course.creator } + published { true } + first_published_at { Time.zone.now } + last_published_at { Time.zone.now } + end +end diff --git a/spec/factories/course_assessment_submission_logs.rb b/spec/factories/course_assessment_submission_logs.rb index 7dede5f2974..5ab6ee3f1cf 100644 --- a/spec/factories/course_assessment_submission_logs.rb +++ b/spec/factories/course_assessment_submission_logs.rb @@ -6,7 +6,11 @@ assessment { build(:assessment, course: course) } end - submission { build(:submission, assessment: assessment, course: course) } + # `Log#submission` targets the base `Course::Assessment::Attempt` (not the `Submission` detail + # extension), so this association must be given an Attempt or FactoryBot's lint raises + # `ActiveRecord::AssociationTypeMismatch`. Build through `:submission` (rather than a bare + # `:attempt`) so the Attempt gets a valid creator/course-user graph its validations require. + submission { build(:submission, assessment: assessment, course: course).attempt } request do { HTTP_X_FORWARDED_FOR: '192.168.123.45', diff --git a/spec/factories/course_assessment_submissions.rb b/spec/factories/course_assessment_submissions.rb index aa857b2799b..7e537aed5dd 100644 --- a/spec/factories/course_assessment_submissions.rb +++ b/spec/factories/course_assessment_submissions.rb @@ -7,13 +7,28 @@ 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 are transients that build the backing Attempt (Submission has no + # such columns of its own), preserving every existing `create(:submission, assessment: foo)` call + # site's syntax. + # + # `creator: creator` must be threaded into the Attempt build explicitly: `:creator` is a + # registered alias of the `:user` factory, so FactoryBot builds it as an association even inside + # `transient`. If left to fall onto Submission, the assignment goes through `acts_as`'s + # `method_missing` to `experience_points_record.creator` and leaves `attempt.creator` unset — + # which then fails `validate_consistent_user` (`course_user.user == creator`). Passing it into + # the Attempt guarantees `create(:submission, creator: X)` makes `submission.creator == 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` targets `Course::Assessment::Attempt`, so `.attempt(...)` must be given + # the Attempt, not the Submission, or building the answer raises + # `ActiveRecord::AssociationTypeMismatch`. + 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 +77,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 +89,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 +97,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/features/course/assessment/submission/log_spec.rb b/spec/features/course/assessment/submission/log_spec.rb index 613de002975..ef16c4ffddd 100644 --- a/spec/features/course/assessment/submission/log_spec.rb +++ b/spec/features/course/assessment/submission/log_spec.rb @@ -16,7 +16,7 @@ create(:submission, assessment: protected_assessment, creator: student) end let(:submission_logs) do - create_list(:course_assessment_submission_log, 5, submission: submission) + create_list(:course_assessment_submission_log, 5, submission: submission.attempt) end before { login_as(user, scope: :user) } diff --git a/spec/jobs/course/assessment/answer/auto_grading_job_spec.rb b/spec/jobs/course/assessment/answer/auto_grading_job_spec.rb index a2211486b82..44b47d48a20 100644 --- a/spec/jobs/course/assessment/answer/auto_grading_job_spec.rb +++ b/spec/jobs/course/assessment/answer/auto_grading_job_spec.rb @@ -58,6 +58,9 @@ initial_points = submission.points_awarded subject.perform_now(answer) + # The job grades through the Attempt and updates points on its extension row; the spec's + # `submission` (a separate extension instance) must be reloaded to observe the DB change. + submission.reload expect(answer).to be_graded expect(answer.grade).to eq(question.maximum_grade) correct_exp = assessment.base_exp + assessment.time_bonus_exp diff --git a/spec/jobs/course/assessment/answer/reduce_priority_auto_grading_job_spec.rb b/spec/jobs/course/assessment/answer/reduce_priority_auto_grading_job_spec.rb index dbbe8f3504e..90553ce109c 100644 --- a/spec/jobs/course/assessment/answer/reduce_priority_auto_grading_job_spec.rb +++ b/spec/jobs/course/assessment/answer/reduce_priority_auto_grading_job_spec.rb @@ -42,6 +42,9 @@ initial_points = submission.points_awarded subject.perform_now(answer) + # Points are updated on the extension row via the Attempt; reload the spec's separate + # `submission` instance to observe the DB change after the split. + submission.reload expect(answer).to be_graded expect(answer.grade).to eq(0) expect(submission.points_awarded).to eq(0) @@ -62,6 +65,9 @@ initial_points = submission.points_awarded subject.perform_now(answer) + # Points are updated on the extension row via the Attempt; reload the spec's separate + # `submission` instance to observe the DB change after the split. + submission.reload expect(answer).to be_graded expect(answer.grade).to eq(question.maximum_grade) correct_exp = assessment.base_exp + assessment.time_bonus_exp diff --git a/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb new file mode 100644 index 00000000000..1dfb4505036 --- /dev/null +++ b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::DuplicationJob, type: :job do + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:source_course) { create(:course) } + let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } + let(:listing) { create(:course_assessment_marketplace_listing, assessment: source_assessment, published: true) } + let(:destination_course) { create(:course) } + let(:destination_tab) { destination_course.assessment_categories.first.tabs.first } + let(:user) { create(:administrator) } + + def run + described_class.perform_now([listing.id], destination_course, destination_tab.id, current_user: user) + end + + it 'duplicates the assessment into the destination course' do + expect { run }.to change { destination_course.assessments.count }.by(1) + end + + it 'lands the copy in the chosen tab' do + run + copy = destination_course.assessments.order(:created_at).last + expect(copy.tab_id).to eq(destination_tab.id) + end + + it 'writes an adoption row for the copy' do + expect { run }.to change { Course::Assessment::Marketplace::Adoption.count }.by(1) + adoption = Course::Assessment::Marketplace::Adoption.last + expect(adoption.listing).to eq(listing) + expect(adoption.destination_course).to eq(destination_course) + end + + it 'counts the same destination course only once across two duplications' do + run + run + expect(listing.reload.adoption_count).to eq(1) + end + + it 'skips unpublished listings (job re-filters `.published`)' do + listing.update!(published: false) + expect { run }.not_to change(destination_course.assessments, :count) + # Relative, not `Adoption.count == 0`: the duplication path commits outside the example's + # transaction (rows persist across runs), so only the delta from `run` is meaningful here. + expect { run }.not_to change(Course::Assessment::Marketplace::Adoption, :count) + end + + it 'duplicates every listing when given several ids' do + other = create(:course_assessment_marketplace_listing, + assessment: create(:assessment, :with_mcq_question, course: source_course), published: true) + expect do + described_class.perform_now([listing.id, other.id], destination_course, destination_tab.id, current_user: user) + end.to change { destination_course.assessments.count }.by(2). + and change { Course::Assessment::Marketplace::Adoption.count }.by(2) + end + + # Grandchildren-excluded: only this job writes adoption rows. A plain course-to-course + # duplication of an already-adopted copy should not create a second-generation adoption. + it 'does not write an adoption for an ordinary ObjectDuplicationService copy' do + run + copy = destination_course.assessments.order(:created_at).last + third_course = create(:course) + expect do + Course::Duplication::ObjectDuplicationService.duplicate_objects( + destination_course, third_course, copy, current_user: user + ) + end.not_to change(Course::Assessment::Marketplace::Adoption, :count) + end + end +end diff --git a/spec/lib/tasks/db/backfill_submission_details_spec.rb b/spec/lib/tasks/db/backfill_submission_details_spec.rb new file mode 100644 index 00000000000..c8dc6821741 --- /dev/null +++ b/spec/lib/tasks/db/backfill_submission_details_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +require 'rails_helper' +require 'rake' + +RSpec.describe 'db:backfill_submission_details', type: :task do + before(:all) do + Rails.application.load_tasks unless Rake::Task.task_defined?('db:backfill_submission_details') + end + + def run_task + task = Rake::Task['db:backfill_submission_details'] + task.reenable + task.invoke + end + + def detail_count(attempt_id) + ActiveRecord::Base.connection.select_value( + "SELECT COUNT(*) FROM course_assessment_submission_details WHERE attempt_id = #{attempt_id}" + ).to_i + end + + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:student) { create(:course_student, course: course) } + + # A "base row with no extension" — a real submission whose detail row was dropped — stands in for + # a row a still-old worker inserted during the rolling-deploy window. `Attempt#preview?` reads it + # as a preview until the detail row is reconciled. + it 'creates a detail row for a base attempt that is missing one' do + submission = create(:course_assessment_submission, assessment: assessment, creator: student.user) + attempt_id = submission.attempt_id + Course::Assessment::Submission.where(attempt_id: attempt_id).delete_all + expect(detail_count(attempt_id)).to eq(0) + + run_task + + expect(detail_count(attempt_id)).to eq(1) + end + + it 'is idempotent — leaves an already-present detail row untouched' do + submission = create(:course_assessment_submission, assessment: assessment, creator: student.user) + + run_task + + expect(detail_count(submission.attempt_id)).to eq(1) + end + end +end diff --git a/spec/models/course/assessment/answer_spec.rb b/spec/models/course/assessment/answer_spec.rb index 20e66cf84d8..8dd7263471f 100644 --- a/spec/models/course/assessment/answer_spec.rb +++ b/spec/models/course/assessment/answer_spec.rb @@ -48,7 +48,7 @@ expect(subject.question.question_assessments.map(&:assessment)).not_to include(subject.submission.assessment) expect(subject.valid?).to be(false) expect(subject.errors[:question]).to include( - I18n.t('activerecord.errors.models.course/assessment/answer.attributes.question'\ + I18n.t('activerecord.errors.models.course/assessment/answer.attributes.question' \ '.consistent_assessment') ) end @@ -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` resolves to an Attempt, and CanCan's + # `can :grade, Course::Assessment::Answer, submission: { assessment: ... }` rule matches on + # the Answer subject, not its Attempt. + 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/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..ddabc899bc8 --- /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. + 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/adoption_spec.rb b/spec/models/course/assessment/marketplace/adoption_spec.rb new file mode 100644 index 00000000000..7b0e9c38488 --- /dev/null +++ b/spec/models/course/assessment/marketplace/adoption_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::Adoption, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + it { is_expected.to belong_to(:listing).class_name('Course::Assessment::Marketplace::Listing') } + it { is_expected.to belong_to(:destination_course).class_name('Course') } + it { is_expected.to belong_to(:duplicated_assessment).class_name('Course::Assessment') } + + it 'validates uniqueness of duplicated_assessment_id' do + existing = create(:course_assessment_marketplace_adoption) + dup = build(:course_assessment_marketplace_adoption, + duplicated_assessment: existing.duplicated_assessment) + expect(dup).not_to be_valid + end + + it 'is destroyed when its duplicated assessment is destroyed (DB cascade)' do + adoption = create(:course_assessment_marketplace_adoption) + adoption.duplicated_assessment.destroy + expect(described_class.exists?(adoption.id)).to be(false) + 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/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb new file mode 100644 index 00000000000..7eeb9a26f3c --- /dev/null +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::Listing, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + it { is_expected.to belong_to(:assessment).class_name('Course::Assessment') } + it { is_expected.to belong_to(:publisher).class_name('User') } + it do + is_expected.to have_many(:adoptions). + class_name('Course::Assessment::Marketplace::Adoption').dependent(:destroy) + end + + describe 'validations' do + subject { build(:course_assessment_marketplace_listing) } + + it { is_expected.to validate_presence_of(:publisher) } + + it 'validates uniqueness of assessment_id' do + existing = create(:course_assessment_marketplace_listing) + dup = build(:course_assessment_marketplace_listing, assessment: existing.assessment) + expect(dup).not_to be_valid + end + end + + describe '.published' do + it 'includes published listings and excludes unpublished ones' do + published = create(:course_assessment_marketplace_listing, published: true) + unpublished = create(:course_assessment_marketplace_listing, published: false) + expect(described_class.published).to include(published) + expect(described_class.published).not_to include(unpublished) + end + end + + describe '#adoption_count' do + subject { create(:course_assessment_marketplace_listing) } + + it 'counts distinct destination courses' do + course_a = create(:course) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: course_a) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: course_a) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: create(:course)) + expect(subject.adoption_count).to eq(2) + 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_details_backfill_migration_spec.rb b/spec/models/course/assessment/submission_details_backfill_migration_spec.rb new file mode 100644 index 00000000000..fbacb3432cc --- /dev/null +++ b/spec/models/course/assessment/submission_details_backfill_migration_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true +require 'rails_helper' +require Rails.root.join('db/migrate/20260723000001_create_course_assessment_submission_details') + +RSpec.describe CreateCourseAssessmentSubmissionDetails, type: :model do + let(:instance) { Instance.default } + + # Inserts one row straight into course_assessment_submissions (the base table, untouched by this + # migration) and returns its id — a "pre-existing submission" for the backfill to copy. Raw SQL + # rather than the submission factory for two reasons: the factory also creates the + # course_assessment_submission_details row that this backfill is meant to create, so it cannot + # seed a base row lacking one; and raw SQL lets each course-coupled column be given an exact, + # asserted value. Every base 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_base_submission(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_submissions + (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_submission_details` 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. + # + # Every example calls `up` first, sets `@migration_reapplied`, then asserts on the resulting rows — + # never a `change {}` matcher, since `change` would evaluate its value block against a not-yet-created + # table (`PG::UndefinedTable`). 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". + 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 detail row per pre-existing base submission, 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') + base_id = insert_base_submission(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_submission_details WHERE attempt_id = #{base_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 + base_id = insert_base_submission(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_submission_details WHERE attempt_id = #{base_id}" + ) + expect(row['publisher_id']).to be_nil + end + + it 'backfills every pre-existing base submission, not only the most recently created one' do + older_id = insert_base_submission(assessment: assessment, creator: creator, session_id: 'older') + newer_creator = create(:course_student, course: course).user + newer_id = insert_base_submission(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_submission_details WHERE attempt_id = #{id}" + ).to_i + end + expect(counts).to eq([1, 1]) + end + + it 'enforces one detail row per attempt (unique attempt_id)' do + base_id = insert_base_submission(assessment: assessment, creator: creator, session_id: 'unique') + + described_class.new.up + @migration_reapplied = true + + expect do + ActiveRecord::Base.connection.execute( + 'INSERT INTO course_assessment_submission_details (attempt_id, created_at, updated_at) ' \ + "VALUES (#{base_id}, now(), now())" + ) + end.to raise_error(ActiveRecord::RecordNotUnique) + end + + def detail_count(attempt_id) + ActiveRecord::Base.connection.select_value( + "SELECT COUNT(*) FROM course_assessment_submission_details WHERE attempt_id = #{attempt_id}" + ).to_i + end + + it 'is idempotent — re-running the backfill adds no duplicate detail row' do + base_id = insert_base_submission(assessment: assessment, creator: creator, session_id: 'idempotent') + + described_class.new.up + described_class.new.up + @migration_reapplied = true + + expect(detail_count(base_id)).to eq(1) + end + + it 'backfills only base rows still missing a detail row, leaving existing ones untouched' do + first_id = insert_base_submission(assessment: assessment, creator: creator, session_id: 'first') + described_class.new.up + + newer_creator = create(:course_student, course: course).user + second_id = insert_base_submission(assessment: assessment, creator: newer_creator, session_id: 'second') + described_class.new.up + @migration_reapplied = true + + expect([detail_count(first_id), detail_count(second_id)]).to eq([1, 1]) + end + + it 'backfills every base row even when they span multiple batches' do + stub_const('CreateCourseAssessmentSubmissionDetails::BACKFILL_BATCH_SIZE', 2) + ids = Array.new(3) do |i| + insert_base_submission(assessment: assessment, + creator: create(:course_student, course: course).user, session_id: "batch-#{i}") + end + + described_class.new.up + @migration_reapplied = true + + expect(ids.map { |id| detail_count(id) }).to eq([1, 1, 1]) + end + end +end diff --git a/spec/models/course/assessment/submission_spec.rb b/spec/models/course/assessment/submission_spec.rb index 7da9fe68604..45b2eab2954 100644 --- a/spec/models/course/assessment/submission_spec.rb +++ b/spec/models/course/assessment/submission_spec.rb @@ -2,13 +2,25 @@ 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 not real associations on Submission — they live on + # `Course::Assessment::Attempt` and are reached here only via `delegate ... to: :attempt`. Assert + # the `belongs_to :attempt` instead. + # + # `.without_validating_presence`: 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` (reached via `acts_as`'s + # autosave-association-validation) reading `submission.assessment.base_exp` and crashing on the nil + # assessment. + 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 @@ -51,7 +63,7 @@ expect(subject).not_to be_valid expect(subject.errors.messages[:experience_points_record]). to include(I18n. - t('activerecord.errors.models.course/assessment/submission.'\ + t('activerecord.errors.models.course/assessment/submission.' \ 'attributes.experience_points_record.inconsistent_user')) end end @@ -69,7 +81,7 @@ expect(subject).not_to be_valid expect(subject.errors.messages[:base]). to include(I18n. - t('activerecord.errors.models.course/assessment/submission.'\ + t('activerecord.errors.models.course/assessment/submission.' \ 'submission_already_exists')) end end @@ -85,7 +97,7 @@ expect(subject).not_to be_valid expect(subject.errors.messages[:experience_points_record]). to include(I18n. - t('activerecord.errors.models.course/assessment/submission.'\ + t('activerecord.errors.models.course/assessment/submission.' \ 'attributes.experience_points_record.absent_award_attributes')) end end @@ -96,7 +108,7 @@ expect(subject).not_to be_valid expect(subject.errors.messages[:experience_points_record]). to include(I18n. - t('activerecord.errors.models.course/assessment/submission.'\ + t('activerecord.errors.models.course/assessment/submission.' \ 'attributes.experience_points_record.absent_award_attributes')) end end @@ -338,8 +350,13 @@ with_active_job_queue_adapter(:test) do it 'creates a new auto grading job' do - submission.finalise! - expect { submission.save }.to \ + # `Submission#finalise!` wraps `attempt.finalise!` and its own `save!` in one transaction, + # so the attempt's pending `workflow_state` change is persisted (via `autosave: true`) as + # part of that same `save!`. `auto_grade_submission`'s `if: :saved_change_to_workflow_state?` + # guard therefore fires during `finalise!` itself — a caller no longer needs a separate + # trailing `.save` to enqueue the job. The guarantee (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 +797,15 @@ def unsubmit_and_save_subject describe '#send_submit_notification' do subject do - submission1.save + # `workflow_state_before_last_save` is delegated to `attempt`. This save on the attempt + # exists purely to make that guard read `'attempting'` (the precondition + # `send_submit_notification` checks): 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 +834,15 @@ def unsubmit_and_save_subject end it 'updates the last_graded_time' do + # `on_dependent_status_change` only *assigns* `last_graded_time` in memory on the + # associated Submission; persisting it requires a later save of that same Submission. This + # example therefore drives the actual trigger explicitly — set the answer's grade, save the + # answer (which fires the assignment), then save the submission — and asserts that the save + # persisted `last_graded_time`. 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 @@ -937,6 +967,29 @@ def unsubmit_and_save_subject ) expect(results).to be_empty end + + it 'excludes preview attempts (an Attempt with no Submission extension row)' do + real = create(:course_assessment_submission, :graded, + assessment: graded_assessment, creator: student.user) + real.answers.update_all(grade: 5.0, current_answer: true) + + # Build a normal graded submission for another student, then drop its extension row so only + # the base Attempt (with graded answers) remains — exactly a preview attempt. grade_summary + # must not sum it. + preview_student = create(:course_student, course: course) + preview = create(:course_assessment_submission, :graded, + assessment: graded_assessment, creator: preview_student.user) + preview.answers.update_all(grade: 7.0, current_answer: true) + Course::Assessment::Submission.where(attempt_id: preview.attempt_id).delete_all + + results = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id, preview_student.user_id], + assessment_ids: [graded_assessment.id] + ) + + expect(results.map(&:student_id)).to contain_exactly(student.user_id) + expect(results.map { |row| row.grade.to_f }).to eq([5.0]) + end end end end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb new file mode 100644 index 00000000000..4564124a913 --- /dev/null +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:listing) { create(:course_assessment_marketplace_listing, published: true) } + let(:published_assessment) { listing.assessment } + + subject { Ability.new(user, course, course_user) } + + context 'when the user is a system administrator' do + let(:user) { create(:administrator) } + let(:course_user) { nil } + it { is_expected.to be_able_to(:publish_to_marketplace, build(:assessment)) } + end + + 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)) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } + it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + + it 'cannot duplicate/preview an unpublished listing' do + unpublished = create(:course_assessment_marketplace_listing, published: false).assessment + expect(subject).not_to be_able_to(:duplicate_from_marketplace, unpublished) + expect(subject).not_to be_able_to(:preview_in_marketplace, unpublished) + end + end + + context 'when the user is a course student' do + let(:course_user) { create(:course_student, course: course) } + 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..7339f973274 100644 --- a/spec/models/course/condition/assessment_spec.rb +++ b/spec/models/course/condition/assessment_spec.rb @@ -40,7 +40,7 @@ it 'is not valid' do expect(subject).to_not be_valid expect(subject.errors[:assessment]).to include(I18n.t('activerecord.errors.models.' \ - 'course/condition/assessment'\ + 'course/condition/assessment' \ '.attributes.assessment.unique_dependency')) end end @@ -72,7 +72,7 @@ it 'is not valid' do expect(subject).to_not be_valid expect(subject.errors[:assessment]).to include(I18n.t('activerecord.errors.models.' \ - 'course/condition/assessment.'\ + 'course/condition/assessment.' \ 'attributes.assessment.cyclic_dependency')) end end @@ -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 diff --git a/spec/services/course/assessment/answer/ai_generated_post_service_spec.rb b/spec/services/course/assessment/answer/ai_generated_post_service_spec.rb index bca35e93ffd..84b089acdc2 100644 --- a/spec/services/course/assessment/answer/ai_generated_post_service_spec.rb +++ b/spec/services/course/assessment/answer/ai_generated_post_service_spec.rb @@ -67,7 +67,7 @@ let(:discussion_topic) { create(:course_discussion_topic) } it 'ensures the student and group managers are subscribed' do expect(discussion_topic).to receive(:ensure_subscribed_by).with(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| expect(discussion_topic).to receive(:ensure_subscribed_by).with(manager.user) end diff --git a/spec/services/course/assessment/submission/auto_grading_service_spec.rb b/spec/services/course/assessment/submission/auto_grading_service_spec.rb index 81096c90fd0..1774361d126 100644 --- a/spec/services/course/assessment/submission/auto_grading_service_spec.rb +++ b/spec/services/course/assessment/submission/auto_grading_service_spec.rb @@ -75,8 +75,9 @@ submission: submission) end before do - # Stub #auto_grade_submission so that job is not created upon save - allow(submission).to receive(:auto_grade_submission).and_return(true) + # Stub #auto_grade_submission so that job is not created upon save. It is an after_save + # callback on the Attempt base (where the workflow lives), so stub it there. + allow(submission.attempt).to receive(:auto_grade_submission).and_return(true) submission.finalise! submission.save! end @@ -93,7 +94,7 @@ context 'when submission is submitted before bonus end at' do before do - submission.update_column(:submitted_at, 4.days.ago) + submission.attempt.update_column(:submitted_at, 4.days.ago) subject.grade(submission) end @@ -105,7 +106,7 @@ context 'when submission is submitted between bonus end at and end at' do before do - submission.update_column(:submitted_at, 2.days.ago) + submission.attempt.update_column(:submitted_at, 2.days.ago) subject.grade(submission) end diff --git a/spec/support/userstamp.rb b/spec/support/userstamp.rb index 114d9431c29..03ae1c9c54d 100644 --- a/spec/support/userstamp.rb +++ b/spec/support/userstamp.rb @@ -1,5 +1,22 @@ # frozen_string_literal: true -ActsAsTenant.with_tenant(Instance.default) do - # Create a global stamper for this spec run - User.stamper = User.human_users.first +RSpec.configure do |config| + # Create a global stamper for this spec run. + # + # The stamper becomes the creator (and therefore the auto-built owner course_user) of courses + # created in specs, and mail-sending specs deliver to that owner — so the stamper MUST own a + # valid email. This suite commits without cleanup (use_transactional_fixtures is false, no + # DatabaseCleaner), so if any spec removes the seeded admin's email it stays removed; the next + # process's db:seed then recreates the admin as a *new* user, leaving the lowest-id human + # (User.human_users.first) permanently without an email. + # + # Resolve the stamper by the seeded admin email (matching db/seeds and seed.rake) so it always + # owns one, and do it in before(:suite) — this runs AFTER rails_helper's top-level db:seed, so + # the admin email is guaranteed present even after such a recreation. (Setting it at file-load + # time ran before db:seed and re-froze the stale, emailless user.) Fall back to the lowest-id + # human only if that email is somehow absent. + config.before(:suite) do + ActsAsTenant.with_tenant(Instance.default) do + User.stamper = User::Email.find_by_email('test@example.org')&.user || User.human_users.first + end + end end